diff --git a/.gitattributes b/.gitattributes index f6263094d01a2..3df26d6a42897 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,5 @@ ThirdPartyNotices.txt eol=crlf *.sh eol=lf *.rtf -text **/*.json linguist-language=jsonc + +test/componentFixtures/.screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f121b855a0d42..62d002fc4564b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -141,6 +141,7 @@ function f(x: number, y: string): void { } - You MUST deal with disposables by registering them immediately after creation for later disposal. Use helpers such as `DisposableStore`, `MutableDisposable` or `DisposableMap`. Do NOT register a disposable to the containing class if the object is created within a method that is called repeadedly to avoid leaks. Instead, return a `IDisposable` from such method and let the caller register it. - You MUST NOT use storage keys of another component only to make changes to that component. You MUST come up with proper API to change another component. - Use `IEditorService` to open editors instead of `IEditorGroupsService.activeGroup.openEditor` to ensure that the editor opening logic is properly followed and to avoid bypassing important features such as `revealIfOpened` or `preserveFocus`. +- Avoid using `bind()`, `call()` and `apply()` solely to control `this` or partially apply arguments; prefer arrow functions or closures to capture the necessary context, and use these methods only when required by an API or interoperability. ## Learnings - Minimize the amount of assertions in tests. Prefer one snapshot-style `assert.deepStrictEqual` over multiple precise assertions, as they are much more difficult to understand and to update. diff --git a/.github/skills/update-screenshots/SKILL.md b/.github/skills/update-screenshots/SKILL.md index f4cac151d61d8..46172cfee2d9b 100644 --- a/.github/skills/update-screenshots/SKILL.md +++ b/.github/skills/update-screenshots/SKILL.md @@ -38,17 +38,17 @@ Pick the most recent run that has a `screenshot-diff` artifact (runs where scree gh run download --name screenshot-diff --dir .tmp/screenshot-diff ``` -This downloads: -- `test/componentFixtures/.screenshots/current/` — the CI-captured screenshots -- `test/componentFixtures/.screenshots/report.json` — structured diff report -- `test/componentFixtures/.screenshots/report.md` — human-readable diff report +The artifact is uploaded from two paths (`test/componentFixtures/.screenshots/current/` and `test/componentFixtures/.screenshots/report/`), but GitHub Actions strips the common prefix. So the downloaded structure is: +- `current/` — the CI-captured screenshots (e.g. `current/baseUI/Buttons/Dark.png`) +- `report/report.json` — structured diff report +- `report/report.md` — human-readable diff report ### 3. Review the changes Show the user what changed by reading the markdown report: ```bash -cat .tmp/screenshot-diff/test/componentFixtures/.screenshots/report.md +cat .tmp/screenshot-diff/report/report.md ``` ### 4. Copy CI screenshots to baseline @@ -56,7 +56,7 @@ cat .tmp/screenshot-diff/test/componentFixtures/.screenshots/report.md ```bash # Remove old baselines and replace with CI screenshots rm -rf test/componentFixtures/.screenshots/baseline/ -cp -r .tmp/screenshot-diff/test/componentFixtures/.screenshots/current/ test/componentFixtures/.screenshots/baseline/ +cp -r .tmp/screenshot-diff/current/ test/componentFixtures/.screenshots/baseline/ ``` ### 5. Clean up diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index e5a91228a1419..d6f7edb1cbe88 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -12,6 +12,7 @@ permissions: contents: read pull-requests: write checks: write + statuses: write concurrency: group: screenshots-${{ github.event.pull_request.number || github.sha }} @@ -36,10 +37,18 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install build dependencies + run: npm ci + working-directory: build + - name: Install build/vite dependencies run: rm -f package-lock.json && npm install working-directory: build/vite + - name: Build vite + run: npm run build + working-directory: build/vite + - name: Install Playwright Chromium run: npx playwright install chromium @@ -51,10 +60,23 @@ jobs: run: | npx component-explorer screenshot:compare \ --project ./test/componentFixtures \ - --report ./test/componentFixtures/.screenshots/report.json \ - --report-markdown ./test/componentFixtures/.screenshots/report.md + --report ./test/componentFixtures/.screenshots/report continue-on-error: true + - name: Prepare explorer artifact + run: | + mkdir -p /tmp/explorer-artifact/screenshot-report + cp -r build/vite/dist/* /tmp/explorer-artifact/ + if [ -d test/componentFixtures/.screenshots/report ]; then + cp -r test/componentFixtures/.screenshots/report/* /tmp/explorer-artifact/screenshot-report/ + fi + + - name: Upload explorer artifact + uses: actions/upload-artifact@v4 + with: + name: component-explorer + path: /tmp/explorer-artifact/ + - name: Upload screenshot report if: steps.compare.outcome == 'failure' uses: actions/upload-artifact@v4 @@ -62,14 +84,13 @@ jobs: name: screenshot-diff path: | test/componentFixtures/.screenshots/current/ - test/componentFixtures/.screenshots/report.json - test/componentFixtures/.screenshots/report.md + test/componentFixtures/.screenshots/report/ - name: Set check title env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - REPORT="test/componentFixtures/.screenshots/report.json" + REPORT="test/componentFixtures/.screenshots/report/report.json" if [ -f "$REPORT" ]; then CHANGED=$(node -e "const r = require('./$REPORT'); console.log(r.summary.added + r.summary.removed + r.summary.changed)") TITLE="${CHANGED} screenshots changed" @@ -81,17 +102,25 @@ jobs: CHECK_RUN_ID=$(gh api "repos/${{ github.repository }}/commits/$SHA/check-runs" \ --jq '.check_runs[] | select(.name == "screenshots") | .id') + DETAILS_URL="https://hediet-ghartifactpreview.azurewebsites.net/${{ github.repository }}/run/${{ github.run_id }}/component-explorer/___explorer.html?report=./screenshot-report/report.json" + if [ -n "$CHECK_RUN_ID" ]; then gh api "repos/${{ github.repository }}/check-runs/$CHECK_RUN_ID" \ -X PATCH --input - <> $GITHUB_STEP_SUMMARY + if [ -f test/componentFixtures/.screenshots/report/report.md ]; then + cat test/componentFixtures/.screenshots/report/report.md >> $GITHUB_STEP_SUMMARY else echo "## Screenshots ✅" >> $GITHUB_STEP_SUMMARY echo "No visual changes detected." >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 7359fbeaa220c..9a9fdcadff97a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ vscode-telemetry-docs/ test-output.json test/componentFixtures/.screenshots/* !test/componentFixtures/.screenshots/baseline/ +dist diff --git a/build/next/index.ts b/build/next/index.ts index e8f5b1f72d1c1..b0120837efa26 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -10,7 +10,7 @@ import { promisify } from 'util'; import glob from 'glob'; import gulpWatch from '../lib/watch/index.ts'; import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from './nls-plugin.ts'; -import { convertPrivateFields, type ConvertPrivateFieldsResult } from './private-to-property.ts'; +import { convertPrivateFields, adjustSourceMap, type ConvertPrivateFieldsResult } from './private-to-property.ts'; import { getVersion } from '../lib/getVersion.ts'; import product from '../../product.json' with { type: 'json' }; import packageJson from '../../package.json' with { type: 'json' }; @@ -883,6 +883,8 @@ ${tslib}`, // Post-process and write all output files let bundled = 0; const mangleStats: { file: string; result: ConvertPrivateFieldsResult }[] = []; + // Map from JS file path to pre-mangle content + edits, for source map adjustment + const mangleEdits = new Map(); for (const { result } of buildResults) { if (!result.outputFiles) { continue; @@ -894,22 +896,26 @@ ${tslib}`, if (file.path.endsWith('.js') || file.path.endsWith('.css')) { let content = file.text; - // Apply NLS post-processing if enabled (JS only) - if (file.path.endsWith('.js') && doNls && indexMap.size > 0) { - content = postProcessNLS(content, indexMap, preserveEnglish); - } - - // Convert native #private fields to regular properties. + // Convert native #private fields to regular properties BEFORE NLS + // post-processing, so that the edit offsets align with esbuild's + // source map coordinate system (both reference the raw esbuild output). // Skip extension host bundles - they expose API surface to extensions // where true encapsulation matters more than the perf gain. if (file.path.endsWith('.js') && doManglePrivates && !isExtensionHostBundle(file.path)) { + const preMangleCode = content; const mangleResult = convertPrivateFields(content, file.path); content = mangleResult.code; if (mangleResult.editCount > 0) { mangleStats.push({ file: path.relative(path.join(REPO_ROOT, outDir), file.path), result: mangleResult }); + mangleEdits.set(file.path, { preMangleCode, edits: mangleResult.edits }); } } + // Apply NLS post-processing if enabled (JS only) + if (file.path.endsWith('.js') && doNls && indexMap.size > 0) { + content = postProcessNLS(content, indexMap, preserveEnglish); + } + // Rewrite sourceMappingURL to CDN URL if configured if (sourceMapBaseUrl) { const relativePath = path.relative(path.join(REPO_ROOT, outDir), file.path); @@ -924,8 +930,19 @@ ${tslib}`, } await fs.promises.writeFile(file.path, content); + } else if (file.path.endsWith('.map')) { + // Source maps may need adjustment if private fields were mangled + const jsPath = file.path.replace(/\.map$/, ''); + const editInfo = mangleEdits.get(jsPath); + if (editInfo) { + const mapJson = JSON.parse(file.text); + const adjusted = adjustSourceMap(mapJson, editInfo.preMangleCode, editInfo.edits); + await fs.promises.writeFile(file.path, JSON.stringify(adjusted)); + } else { + await fs.promises.writeFile(file.path, file.contents); + } } else { - // Write other files (source maps, assets) as-is + // Write other files (assets, etc.) as-is await fs.promises.writeFile(file.path, file.contents); } } diff --git a/build/next/nls-plugin.ts b/build/next/nls-plugin.ts index 56cb84fa33a06..7be3faccf2439 100644 --- a/build/next/nls-plugin.ts +++ b/build/next/nls-plugin.ts @@ -6,6 +6,7 @@ import * as esbuild from 'esbuild'; import * as path from 'path'; import * as fs from 'fs'; +import { SourceMapGenerator } from 'source-map'; import { TextModel, analyzeLocalizeCalls, @@ -160,10 +161,17 @@ export function postProcessNLS( // Transformation // ============================================================================ +interface NLSEdit { + line: number; // 0-based line in original source + startCol: number; // 0-based start column in original + endCol: number; // 0-based end column in original + newLength: number; // length of replacement text +} + function transformToPlaceholders( source: string, moduleId: string -): { code: string; entries: NLSEntry[] } { +): { code: string; entries: NLSEntry[]; edits: NLSEdit[] } { const localizeCalls = analyzeLocalizeCalls(source, 'localize'); const localize2Calls = analyzeLocalizeCalls(source, 'localize2'); @@ -176,10 +184,11 @@ function transformToPlaceholders( ); if (allCalls.length === 0) { - return { code: source, entries: [] }; + return { code: source, entries: [], edits: [] }; } const entries: NLSEntry[] = []; + const edits: NLSEdit[] = []; const model = new TextModel(source); // Process in reverse order to preserve positions @@ -201,14 +210,92 @@ function transformToPlaceholders( placeholder }); + const replacementText = `"${placeholder}"`; + + // Track the edit for source map generation (positions are in original source coords) + edits.push({ + line: call.keySpan.start.line, + startCol: call.keySpan.start.character, + endCol: call.keySpan.end.character, + newLength: replacementText.length, + }); + // Replace the key with the placeholder string - model.apply(call.keySpan, `"${placeholder}"`); + model.apply(call.keySpan, replacementText); } - // Reverse entries to match source order + // Reverse entries and edits to match source order entries.reverse(); + edits.reverse(); + + return { code: model.toString(), entries, edits }; +} + +/** + * Generates a source map that maps from the NLS-transformed source back to the + * original source. esbuild composes this with its own bundle source map so that + * the final source map points all the way back to the untransformed TypeScript. + */ +function generateNLSSourceMap( + originalSource: string, + filePath: string, + edits: NLSEdit[] +): string { + const generator = new SourceMapGenerator(); + generator.setSourceContent(filePath, originalSource); + + const lineCount = originalSource.split('\n').length; + + // Group edits by line + const editsByLine = new Map(); + for (const edit of edits) { + let arr = editsByLine.get(edit.line); + if (!arr) { + arr = []; + editsByLine.set(edit.line, arr); + } + arr.push(edit); + } + + for (let line = 0; line < lineCount; line++) { + const smLine = line + 1; // source maps use 1-based lines + + // Always map start of line + generator.addMapping({ + generated: { line: smLine, column: 0 }, + original: { line: smLine, column: 0 }, + source: filePath, + }); + + const lineEdits = editsByLine.get(line); + if (lineEdits) { + lineEdits.sort((a, b) => a.startCol - b.startCol); + + let cumulativeShift = 0; + + for (const edit of lineEdits) { + const origLen = edit.endCol - edit.startCol; + + // Map start of edit: the replacement begins at the same original position + generator.addMapping({ + generated: { line: smLine, column: edit.startCol + cumulativeShift }, + original: { line: smLine, column: edit.startCol }, + source: filePath, + }); + + cumulativeShift += edit.newLength - origLen; + + // Map content after edit: columns resume with the shift applied + generator.addMapping({ + generated: { line: smLine, column: edit.endCol + cumulativeShift }, + original: { line: smLine, column: edit.endCol }, + source: filePath, + }); + } + } + } - return { code: model.toString(), entries }; + return generator.toString(); } function replaceInOutput( @@ -300,7 +387,7 @@ export function nlsPlugin(options: NLSPluginOptions): esbuild.Plugin { .replace(/\.ts$/, ''); // Transform localize() calls to placeholders - const { code, entries: fileEntries } = transformToPlaceholders(source, moduleId); + const { code, entries: fileEntries, edits } = transformToPlaceholders(source, moduleId); // Collect entries for (const entry of fileEntries) { @@ -308,7 +395,15 @@ export function nlsPlugin(options: NLSPluginOptions): esbuild.Plugin { } if (fileEntries.length > 0) { - return { contents: code, loader: 'ts' }; + // Generate a source map that maps from the NLS-transformed source + // back to the original. Embed it inline so esbuild composes it + // with its own bundle source map, making the final map point to + // the original TS source. + const sourceName = relativePath.replace(/\\/g, '/'); + const sourcemap = generateNLSSourceMap(source, sourceName, edits); + const encodedMap = Buffer.from(sourcemap).toString('base64'); + const contentsWithMap = code + `\n//# sourceMappingURL=data:application/json;base64,${encodedMap}\n`; + return { contents: contentsWithMap, loader: 'ts' }; } // No NLS calls, return undefined to let esbuild handle normally diff --git a/build/next/private-to-property.ts b/build/next/private-to-property.ts index 64f1c4e74bf97..11f977774a5fd 100644 --- a/build/next/private-to-property.ts +++ b/build/next/private-to-property.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as ts from 'typescript'; +import { type RawSourceMap, type Mapping, SourceMapConsumer, SourceMapGenerator } from 'source-map'; /** * Converts native ES private fields (`#foo`) into regular JavaScript properties with short, @@ -52,12 +53,20 @@ interface Edit { // Private name → replacement name per class (identified by position in file) type ClassScope = Map; +export interface TextEdit { + readonly start: number; + readonly end: number; + readonly newText: string; +} + export interface ConvertPrivateFieldsResult { readonly code: string; readonly classCount: number; readonly fieldCount: number; readonly editCount: number; readonly elapsed: number; + /** Sorted edits applied to the original code, for source map adjustment. */ + readonly edits: readonly TextEdit[]; } /** @@ -72,7 +81,7 @@ export function convertPrivateFields(code: string, filename: string): ConvertPri const t1 = Date.now(); // Quick bail-out: if there are no `#` characters, nothing to do if (!code.includes('#')) { - return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1 }; + return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1, edits: [] }; } const sourceFile = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, false, ts.ScriptKind.JS); @@ -92,7 +101,7 @@ export function convertPrivateFields(code: string, filename: string): ConvertPri visit(sourceFile); if (edits.length === 0) { - return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1 }; + return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1, edits: [] }; } // Apply edits using substring concatenation (O(N+K), not O(N*K) like char-array splice) @@ -105,7 +114,7 @@ export function convertPrivateFields(code: string, filename: string): ConvertPri lastEnd = edit.end; } parts.push(code.substring(lastEnd)); - return { code: parts.join(''), classCount, fieldCount: nameCounter, editCount: edits.length, elapsed: Date.now() - t1 }; + return { code: parts.join(''), classCount, fieldCount: nameCounter, editCount: edits.length, elapsed: Date.now() - t1, edits }; // --- AST walking --- @@ -189,3 +198,110 @@ export function convertPrivateFields(code: string, filename: string): ConvertPri return undefined; } } + +/** + * Adjusts a source map to account for text edits applied to the generated JS. + * + * Each edit replaced a span `[start, end)` in the original generated JS with `newText`. + * This shifts all subsequent columns on the same line. The source map's generated + * columns are updated so they still point to the correct original positions. + * + * @param sourceMapJson The parsed source map JSON object. + * @param originalCode The original generated JS (before edits were applied). + * @param edits The sorted edits that were applied. + * @returns A new source map JSON object with adjusted generated columns. + */ +export function adjustSourceMap( + sourceMapJson: RawSourceMap, + originalCode: string, + edits: readonly TextEdit[] +): RawSourceMap { + if (edits.length === 0) { + return sourceMapJson; + } + + // Build a line-offset table for the original code to convert byte offsets to line/column + const lineStarts: number[] = [0]; + for (let i = 0; i < originalCode.length; i++) { + if (originalCode.charCodeAt(i) === 10 /* \n */) { + lineStarts.push(i + 1); + } + } + + function offsetToLineCol(offset: number): { line: number; col: number } { + let lo = 0, hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineStarts[mid] <= offset) { + lo = mid; + } else { + hi = mid - 1; + } + } + return { line: lo, col: offset - lineStarts[lo] }; + } + + // Convert edits from byte offsets to per-line column shifts + interface LineEdit { col: number; origLen: number; newLen: number } + const editsByLine = new Map(); + for (const edit of edits) { + const pos = offsetToLineCol(edit.start); + const origLen = edit.end - edit.start; + let arr = editsByLine.get(pos.line); + if (!arr) { + arr = []; + editsByLine.set(pos.line, arr); + } + arr.push({ col: pos.col, origLen, newLen: edit.newText.length }); + } + + // Use source-map library to read, adjust, and write + const consumer = new SourceMapConsumer(sourceMapJson); + const generator = new SourceMapGenerator({ file: sourceMapJson.file }); + + // Copy sourcesContent + for (let i = 0; i < sourceMapJson.sources.length; i++) { + const content = sourceMapJson.sourcesContent?.[i]; + if (content !== null && content !== undefined) { + generator.setSourceContent(sourceMapJson.sources[i], content); + } + } + + // Walk every mapping, adjust the generated column, and add to the new generator + consumer.eachMapping(mapping => { + const lineEdits = editsByLine.get(mapping.generatedLine - 1); // 0-based for our data + const adjustedCol = adjustColumn(mapping.generatedColumn, lineEdits); + + // Some mappings may be unmapped (no original position/source) - skip those. + if (mapping.source !== null && mapping.originalLine !== null && mapping.originalColumn !== null) { + const newMapping: Mapping = { + generated: { line: mapping.generatedLine, column: adjustedCol }, + original: { line: mapping.originalLine, column: mapping.originalColumn }, + source: mapping.source, + }; + if (mapping.name !== null) { + newMapping.name = mapping.name; + } + generator.addMapping(newMapping); + } + }); + + return JSON.parse(generator.toString()); +} + +function adjustColumn(col: number, lineEdits: { col: number; origLen: number; newLen: number }[] | undefined): number { + if (!lineEdits) { + return col; + } + let shift = 0; + for (const edit of lineEdits) { + if (edit.col + edit.origLen <= col) { + shift += edit.newLen - edit.origLen; + } else if (edit.col < col) { + return edit.col + shift; + } else { + break; + } + } + return col + shift; +} diff --git a/build/next/test/nls-sourcemap.test.ts b/build/next/test/nls-sourcemap.test.ts new file mode 100644 index 0000000000000..fd732b8680217 --- /dev/null +++ b/build/next/test/nls-sourcemap.test.ts @@ -0,0 +1,373 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as esbuild from 'esbuild'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { type RawSourceMap, SourceMapConsumer } from 'source-map'; +import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from '../nls-plugin.ts'; + +// analyzeLocalizeCalls requires the import path to end with `/nls` +const NLS_STUB = [ + 'export function localize(key: string, message: string, ...args: any[]): string {', + '\treturn message;', + '}', + 'export function localize2(key: string, message: string, ...args: any[]): { value: string; original: string } {', + '\treturn { value: message, original: message };', + '}', +].join('\n'); + +interface BundleResult { + js: string; + mapJson: RawSourceMap; + map: SourceMapConsumer; + cleanup: () => void; +} + +/** + * Helper: create a temp directory with source files, bundle with NLS, and return + * the generated JS + parsed source map. The NLS stub is automatically placed at + * `vs/nls.ts` so test files can import from `../vs/nls` (when placed in `test/`). + */ +async function bundleWithNLS( + files: Record, + entryPoint: string, + opts?: { postProcess?: boolean } +): Promise { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nls-sm-test-')); + const srcDir = path.join(tmpDir, 'src'); + const outDir = path.join(tmpDir, 'out'); + await fs.promises.mkdir(srcDir, { recursive: true }); + await fs.promises.mkdir(outDir, { recursive: true }); + + // Write source files (always include the NLS stub at vs/nls.ts) + const allFiles = { 'vs/nls.ts': NLS_STUB, ...files }; + for (const [name, content] of Object.entries(allFiles)) { + const filePath = path.join(srcDir, name); + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, content); + } + + const collector = createNLSCollector(); + + const result = await esbuild.build({ + entryPoints: [path.join(srcDir, entryPoint)], + outfile: path.join(outDir, entryPoint.replace(/\.ts$/, '.js')), + bundle: true, + format: 'esm', + platform: 'neutral', + target: ['es2024'], + packages: 'external', + sourcemap: 'linked', + sourcesContent: true, + write: false, + plugins: [ + nlsPlugin({ baseDir: srcDir, collector }), + ], + tsconfigRaw: JSON.stringify({ + compilerOptions: { + experimentalDecorators: true, + useDefineForClassFields: false + } + }), + logLevel: 'warning', + }); + + let jsContent = ''; + let mapContent = ''; + + for (const file of result.outputFiles!) { + if (file.path.endsWith('.js')) { + jsContent = file.text; + } else if (file.path.endsWith('.map')) { + mapContent = file.text; + } + } + + // Optionally apply NLS post-processing (replaces placeholders with indices) + if (opts?.postProcess) { + const nlsResult = await finalizeNLS(collector, outDir); + jsContent = postProcessNLS(jsContent, nlsResult.indexMap, false); + } + + assert.ok(jsContent, 'Expected JS output'); + assert.ok(mapContent, 'Expected source map output'); + + const mapJson = JSON.parse(mapContent); + const map = new SourceMapConsumer(mapJson); + const cleanup = () => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }; + + return { js: jsContent, mapJson, map, cleanup }; +} + +/** + * Find the 1-based line number in `text` that contains `needle`. + */ +function findLine(text: string, needle: string): number { + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(needle)) { + return i + 1; // 1-based + } + } + throw new Error(`Could not find "${needle}" in text`); +} + +/** + * Find the 0-based column of `needle` within the line that contains it. + */ +function findColumn(text: string, needle: string): number { + const lines = text.split('\n'); + for (const line of lines) { + const col = line.indexOf(needle); + if (col !== -1) { + return col; + } + } + throw new Error(`Could not find "${needle}" in text`); +} + +suite('NLS plugin source maps', () => { + + test('NLS plugin transforms localize calls into placeholders', async () => { + const source = [ + 'import { localize } from "../vs/nls";', + 'export const msg = localize("testKey", "Test Message");', + ].join('\n'); + + const { js, cleanup } = await bundleWithNLS( + { 'test/verify.ts': source }, + 'test/verify.ts', + ); + + try { + assert.ok(js.includes('%%NLS:'), + 'Bundle should contain %%NLS: placeholder.\nActual JS (first 500 chars):\n' + js.substring(0, 500)); + } finally { + cleanup(); + } + }); + + test('file without localize calls has correct source map', async () => { + const source = [ + 'export function add(a: number, b: number): number {', + '\treturn a + b;', + '}', + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'simple.ts': source }, + 'simple.ts', + ); + + try { + const bundleLine = findLine(js, 'return a + b'); + const bundleCol = findColumn(js, 'return a + b'); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + assert.ok(pos.source, 'Should have source'); + assert.strictEqual(pos.line, 2, 'Should map to line 2 of original'); + } finally { + cleanup(); + } + }); + + test('sourcesContent should contain original source, not NLS-transformed', async () => { + const source = [ + 'import { localize } from "../vs/nls";', + 'export const msg = localize("myKey", "Hello World");', + 'export function greet(): string {', + '\treturn msg;', + '}', + ].join('\n'); + + const { mapJson, cleanup } = await bundleWithNLS( + { 'test/greeting.ts': source }, + 'test/greeting.ts', + ); + + try { + const sourcesContent: string[] = mapJson.sourcesContent ?? []; + const sources: string[] = mapJson.sources ?? []; + const greetingIdx = sources.findIndex((s: string) => s.includes('greeting')); + assert.ok(greetingIdx >= 0, 'Should find greeting.ts in sources'); + + const greetingContent = sourcesContent[greetingIdx]; + assert.ok(greetingContent, 'Should have sourcesContent for greeting.ts'); + + assert.ok(!greetingContent.includes('%%NLS:'), + 'sourcesContent should NOT contain NLS placeholder.\nActual:\n' + greetingContent); + assert.ok(greetingContent.includes('localize("myKey", "Hello World")'), + 'sourcesContent should contain the exact original localize call.\nActual:\n' + greetingContent); + } finally { + cleanup(); + } + }); + + test('line mapping correct for code after localize calls', async () => { + const source = [ + 'import { localize } from "../vs/nls";', // 1 + 'const label = localize("key1", "A long message");', // 2 + 'const label2 = localize("key2", "Another message");', // 3 + 'export function computeResult(x: number): number {', // 4 + '\treturn x * 42;', // 5 + '}', // 6 + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/multi.ts': source }, + 'test/multi.ts', + ); + + try { + const bundleLine = findLine(js, 'return x * 42'); + const bundleCol = findColumn(js, 'return x * 42'); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + assert.ok(pos.source, 'Should have source'); + assert.strictEqual(pos.line, 5, 'Should map back to line 5'); + } finally { + cleanup(); + } + }); + + test('column mapping for code on same line after localize call', async () => { + // The NLS placeholder is longer than the original key, so column offsets + // for tokens AFTER the localize call on the same line will drift if + // source map mappings point to the NLS-transformed source positions. + const source = [ + 'import { localize } from "../vs/nls";', + 'const x = localize("k", "m"); const z = "FINDME"; export { x, z };', + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/coldrift.ts': source }, + 'test/coldrift.ts', + ); + + try { + assert.ok(js.includes('%%NLS:'), 'Bundle should contain NLS placeholders'); + + const bundleLine = findLine(js, 'FINDME'); + const bundleCol = findColumn(js, '"FINDME"'); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + + assert.ok(pos.source, 'Should have source'); + assert.strictEqual(pos.line, 2, 'Should map to line 2'); + + // The original column of "FINDME" in the source + const originalCol = findColumn(source, '"FINDME"'); + + // The mapped column should match the ORIGINAL source positions. + // Allow drift from TS->JS transform (const->var, export removal, etc.) + // but NOT the large NLS placeholder drift (~100+ chars) from before the fix. + const columnDrift = Math.abs(pos.column! - originalCol); + assert.ok(columnDrift <= 20, + `Column should be close to original. Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` + + `A drift > 20 indicates the NLS placeholder shift leaked into the source map.`); + } finally { + cleanup(); + } + }); + + test('class with localize - method positions map correctly', async () => { + const source = [ + 'import { localize } from "../vs/nls";', // 1 + '', // 2 + 'export class MyWidget {', // 3 + '\tprivate readonly label = localize("widgetLabel", "My Cool Widget");', // 4 + '', // 5 + '\tconstructor(private readonly name: string) {}', // 6 + '', // 7 + '\tgetDescription(): string {', // 8 + '\t\treturn this.name + ": " + this.label;', // 9 + '\t}', // 10 + '', // 11 + '\tdispose(): void {', // 12 + '\t\tconsole.log("disposed");', // 13 + '\t}', // 14 + '}', // 15 + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/widget.ts': source }, + 'test/widget.ts', + ); + + try { + const bundleLine = findLine(js, '"disposed"'); + const bundleCol = findColumn(js, 'console.log'); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + assert.ok(pos.source, 'Should have source'); + assert.strictEqual(pos.line, 13, 'Should map dispose method body to line 13'); + } finally { + cleanup(); + } + }); + + test('many localize calls - line mappings remain correct', async () => { + const source = [ + 'import { localize } from "../vs/nls";', // 1 + '', // 2 + 'const a = localize("a", "Alpha");', // 3 + 'const b = localize("b", "Bravo with a longer message");', // 4 + 'const c = localize("c", "Charlie");', // 5 + 'const d = localize("d", "Delta is the fourth");', // 6 + 'const e = localize("e", "Echo");', // 7 + '', // 8 + 'export function getAll(): string {', // 9 + '\treturn [a, b, c, d, e].join(", ");', // 10 + '}', // 11 + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/many.ts': source }, + 'test/many.ts', + ); + + try { + const bundleLine = findLine(js, '.join(", ")'); + const bundleCol = findColumn(js, '.join(", ")'); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + assert.ok(pos.source, 'Should have source'); + assert.strictEqual(pos.line, 10, 'Should map join() back to line 10'); + } finally { + cleanup(); + } + }); + + test('post-processed NLS - source map still has original content', async () => { + const source = [ + 'import { localize } from "../vs/nls";', + 'export const msg = localize("greeting", "Hello World");', + ].join('\n'); + + const { js, mapJson, cleanup } = await bundleWithNLS( + { 'test/post.ts': source }, + 'test/post.ts', + { postProcess: true } + ); + + try { + assert.ok(!js.includes('%%NLS:'), 'JS should not contain NLS placeholders after post-processing'); + + const sources: string[] = mapJson.sources ?? []; + const postIdx = sources.findIndex((s: string) => s.includes('post')); + assert.ok(postIdx >= 0, 'Should find post.ts in sources'); + + const postContent = (mapJson.sourcesContent ?? [])[postIdx]; + assert.ok(postContent, 'Should have sourcesContent for post.ts'); + + assert.ok(postContent.includes('localize("greeting"'), + 'sourcesContent should still contain original localize("greeting") call'); + assert.ok(!postContent.includes('%%NLS:'), + 'sourcesContent should not contain NLS placeholders'); + } finally { + cleanup(); + } + }); +}); diff --git a/build/next/test/private-to-property.test.ts b/build/next/test/private-to-property.test.ts index 3cde63a4bdaf4..aa9da72ce9a51 100644 --- a/build/next/test/private-to-property.test.ts +++ b/build/next/test/private-to-property.test.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { convertPrivateFields } from '../private-to-property.ts'; +import { convertPrivateFields, adjustSourceMap } from '../private-to-property.ts'; +import { SourceMapConsumer, SourceMapGenerator, type RawSourceMap } from 'source-map'; suite('convertPrivateFields', () => { @@ -272,4 +273,184 @@ suite('convertPrivateFields', () => { assert.ok(result.code.includes('$aa')); }); }); + + test('returns edits array', () => { + const code = 'class Foo { #x = 1; get() { return this.#x; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.strictEqual(result.edits.length, 2); + // Edits should be sorted by start position + assert.ok(result.edits[0].start < result.edits[1].start); + // First edit is the declaration #x, second is the usage this.#x + assert.strictEqual(result.edits[0].newText, '$a'); + assert.strictEqual(result.edits[1].newText, '$a'); + }); + + test('no edits when no private fields', () => { + const code = 'class Foo { x = 1; }'; + const result = convertPrivateFields(code, 'test.js'); + assert.deepStrictEqual(result.edits, []); + }); +}); + +suite('adjustSourceMap', () => { + + /** + * Helper: creates a source map with dense 1:1 mappings (every character) + * for a single-source file. Each column maps generated -> original identity. + */ + function createIdentitySourceMap(code: string, sourceName: string): RawSourceMap { + const gen = new SourceMapGenerator(); + gen.setSourceContent(sourceName, code); + const lines = code.split('\n'); + for (let line = 0; line < lines.length; line++) { + for (let col = 0; col < lines[line].length; col++) { + gen.addMapping({ + generated: { line: line + 1, column: col }, + original: { line: line + 1, column: col }, + source: sourceName, + }); + } + } + return JSON.parse(gen.toString()); + } + + test('no edits - returns mappings unchanged', () => { + const code = 'class Foo { x = 1; }'; + const map = createIdentitySourceMap(code, 'test.js'); + const originalMappings = map.mappings; + const result = adjustSourceMap(map, code, []); + assert.strictEqual(result.mappings, originalMappings); + }); + + test('single edit shrinks token - columns after edit shift left', () => { + // "var #longName = 1; var y = 2;" + // 0 4 14 22 + // After: "var $a = 1; var y = 2;" + // 0 4 7 15 + const code = 'var #longName = 1; var y = 2;'; + // Create a sparse map with mappings only at known token positions + const gen = new SourceMapGenerator(); + gen.setSourceContent('test.js', code); + // Map 'var' at col 0 + gen.addMapping({ generated: { line: 1, column: 0 }, original: { line: 1, column: 0 }, source: 'test.js' }); + // Map '#longName' at col 4 + gen.addMapping({ generated: { line: 1, column: 4 }, original: { line: 1, column: 4 }, source: 'test.js' }); + // Map '=' at col 14 + gen.addMapping({ generated: { line: 1, column: 14 }, original: { line: 1, column: 14 }, source: 'test.js' }); + // Map 'var' at col 19 + gen.addMapping({ generated: { line: 1, column: 19 }, original: { line: 1, column: 19 }, source: 'test.js' }); + // Map 'y' at col 23 + gen.addMapping({ generated: { line: 1, column: 23 }, original: { line: 1, column: 23 }, source: 'test.js' }); + const map = JSON.parse(gen.toString()); + + const result = adjustSourceMap(map, code, [{ start: 4, end: 13, newText: '$a' }]); + + const consumer = new SourceMapConsumer(result); + // 'y' was at gen col 23, edit shrunk 9->2 chars (delta -7), so now at gen col 16 + const pos = consumer.originalPositionFor({ line: 1, column: 16 }); + assert.strictEqual(pos.column, 23, 'y should map back to original column 23'); + + // '=' was at gen col 14, edit shrunk by 7, so now at gen col 7 + const pos2 = consumer.originalPositionFor({ line: 1, column: 7 }); + assert.strictEqual(pos2.column, 14, '= should map back to original column 14'); + }); + + test('edit on line does not affect other lines', () => { + const code = 'class Foo {\n #x = 1;\n get() { return 42; }\n}'; + const map = createIdentitySourceMap(code, 'test.js'); + + const hashPos = code.indexOf('#x'); + const result = adjustSourceMap(map, code, [{ start: hashPos, end: hashPos + 2, newText: '$a' }]); + + const consumer = new SourceMapConsumer(result); + // Line 3 (1-based) should be completely unaffected + const pos = consumer.originalPositionFor({ line: 3, column: 0 }); + assert.strictEqual(pos.line, 3); + assert.strictEqual(pos.column, 0); + }); + + test('multiple edits on same line accumulate shifts', () => { + // "this.#aaa + this.#bbb + this.#ccc;" + // 0 5 11 17 23 29 + const code = 'this.#aaa + this.#bbb + this.#ccc;'; + // Sparse map at token boundaries (not inside edit spans) + const gen = new SourceMapGenerator(); + gen.setSourceContent('test.js', code); + gen.addMapping({ generated: { line: 1, column: 0 }, original: { line: 1, column: 0 }, source: 'test.js' }); // 'this' + gen.addMapping({ generated: { line: 1, column: 5 }, original: { line: 1, column: 5 }, source: 'test.js' }); // '#aaa' + gen.addMapping({ generated: { line: 1, column: 10 }, original: { line: 1, column: 10 }, source: 'test.js' }); // '+' + gen.addMapping({ generated: { line: 1, column: 12 }, original: { line: 1, column: 12 }, source: 'test.js' }); // 'this' + gen.addMapping({ generated: { line: 1, column: 17 }, original: { line: 1, column: 17 }, source: 'test.js' }); // '#bbb' + gen.addMapping({ generated: { line: 1, column: 22 }, original: { line: 1, column: 22 }, source: 'test.js' }); // '+' + gen.addMapping({ generated: { line: 1, column: 24 }, original: { line: 1, column: 24 }, source: 'test.js' }); // 'this' + gen.addMapping({ generated: { line: 1, column: 29 }, original: { line: 1, column: 29 }, source: 'test.js' }); // '#ccc' + gen.addMapping({ generated: { line: 1, column: 33 }, original: { line: 1, column: 33 }, source: 'test.js' }); // ';' + const map = JSON.parse(gen.toString()); + + const edits = [ + { start: 5, end: 9, newText: '$a' }, // #aaa(4) -> $a(2), delta -2 + { start: 17, end: 21, newText: '$b' }, // #bbb(4) -> $b(2), delta -2 + { start: 29, end: 33, newText: '$c' }, // #ccc(4) -> $c(2), delta -2 + ]; + const result = adjustSourceMap(map, code, edits); + + const consumer = new SourceMapConsumer(result); + // After edits: "this.$a + this.$b + this.$c;" + // '#ccc' was at gen col 29, now at 29-2-2=25 + const pos = consumer.originalPositionFor({ line: 1, column: 25 }); + assert.strictEqual(pos.column, 29, 'third edit position should map to original column'); + + // '+' after #bbb was at gen col 22, both prior edits shift by -2 each: 22-4=18 + const pos2 = consumer.originalPositionFor({ line: 1, column: 18 }); + assert.strictEqual(pos2.column, 22, 'plus after second edit should map correctly'); + }); + + test('end-to-end: convertPrivateFields + adjustSourceMap', () => { + const code = [ + 'class MyWidget {', + ' #count = 0;', + ' increment() { this.#count++; }', + ' getValue() { return this.#count; }', + '}', + ].join('\n'); + + const map = createIdentitySourceMap(code, 'widget.js'); + const result = convertPrivateFields(code, 'widget.js'); + + assert.ok(result.edits.length > 0, 'should have edits'); + assert.ok(!result.code.includes('#count'), 'should not contain #count'); + + // Adjust the source map + const adjusted = adjustSourceMap(map, code, result.edits); + const consumer = new SourceMapConsumer(adjusted); + + // Find 'getValue' in the edited output and verify it maps back correctly + const editedLines = result.code.split('\n'); + const getValueLine = editedLines.findIndex(l => l.includes('getValue')); + assert.ok(getValueLine >= 0, 'should find getValue in edited code'); + + const getValueCol = editedLines[getValueLine].indexOf('getValue'); + const pos = consumer.originalPositionFor({ line: getValueLine + 1, column: getValueCol }); + + // getValue was on line 4 (1-based), same column in original + const origLines = code.split('\n'); + const origGetValueCol = origLines[3].indexOf('getValue'); + assert.strictEqual(pos.line, 4, 'getValue should map to original line 4'); + assert.strictEqual(pos.column, origGetValueCol, 'getValue column should match original'); + }); + + test('brand check: #field in obj -> string replacement adjusts map', () => { + const code = 'class C { #x; check(o) { return #x in o; } }'; + const map = createIdentitySourceMap(code, 'test.js'); + + const result = convertPrivateFields(code, 'test.js'); + const adjusted = adjustSourceMap(map, code, result.edits); + const consumer = new SourceMapConsumer(adjusted); + + // 'check' method should still map correctly + const editedCheckCol = result.code.indexOf('check'); + const pos = consumer.originalPositionFor({ line: 1, column: editedCheckCol }); + assert.strictEqual(pos.line, 1); + assert.strictEqual(pos.column, code.indexOf('check')); + }); }); diff --git a/build/next/working.md b/build/next/working.md index 71aac3fbdaf4c..b59b347611dbd 100644 --- a/build/next/working.md +++ b/build/next/working.md @@ -202,31 +202,44 @@ npm run gulp vscode-reh-web-darwin-arm64-min ## Source Maps +### Principle: Every Code Transform Must Preserve Source Maps + +Any step that modifies JS output - whether in an esbuild plugin or in post-processing - **must** update the source map accordingly. Failing to do so causes column drift that makes debuggers, crash reporters, and breakpoints point to wrong positions. The `source-map` library (v0.6.1, already a dependency) provides the `SourceMapConsumer`/`SourceMapGenerator` APIs for this. + +### Root Causes (before fixes) + +Source maps worked in transpile mode but failed in bundle mode. The observed pattern was "class-heavy files fail, simple utilities work" but the real cause was **"files with NLS calls vs files without"**. Class-heavy UI components use `localize()` extensively; utility files in `vs/base/` don't. + +Two categories of corruption: + +1. **NLS plugin `onLoad`** returned modified source without a source map. esbuild treated the NLS-transformed text as the "original" - `sourcesContent` embedded placeholders, and column mappings pointed to wrong positions. + +2. **Post-processing** (`postProcessNLS`, `convertPrivateFields`) modified bundled JS output without updating `.map` files. Column positions drifted by the cumulative length deltas of all replacements. + ### Fixes Applied -1. **`sourcesContent: true`** — Production bundles now embed original TypeScript source content in `.map` files, matching the old build's `includeContent: true` behavior. Without this, crash reports from CDN-hosted source maps can't show original source. +1. **`sourcesContent: true`** - Production bundles embed original TypeScript source content in `.map` files, matching the old build's `includeContent: true` behavior. + +2. **`--source-map-base-url` option** - Rewrites `sourceMappingURL` comments to point to CDN URLs. + +3. **NLS plugin inline source maps** (`nls-plugin.ts`) - The `onLoad` handler now generates an inline source map (`//# sourceMappingURL=data:...`) mapping from NLS-transformed source back to original. esbuild composes this with its own bundle source map. `SourceMapGenerator.setSourceContent` embeds the original source so `sourcesContent` in the final `.map` has the real TypeScript. Tests in `test/nls-sourcemap.test.ts`. -2. **`--source-map-base-url` option** — The `bundle` command accepts an optional `--source-map-base-url ` flag. When set, post-processing rewrites `sourceMappingURL` comments in `.js` and `.css` output files to point to the CDN (e.g., `https://main.vscode-cdn.net/sourcemaps//core/vs/...`). This matches the old build's `sourceMappingURL` function in `minifyTask()`. Wired up in `gulpfile.vscode.ts` for `core-ci-esbuild` and `vscode-esbuild-min` tasks. +4. **`convertPrivateFields` source map adjustment** (`private-to-property.ts`) - `convertPrivateFields` returns its sorted edits as `TextEdit[]`. `adjustSourceMap()` uses `SourceMapConsumer` to walk every mapping, adjusts generated columns based on cumulative edit shifts per line, and rebuilds with `SourceMapGenerator`. The post-processing loop in `index.ts` saves pre-mangle content + edits per JS file, then applies `adjustSourceMap` to the corresponding `.map`. Tests in `test/private-to-property.test.ts`. -### NLS Source Map Accuracy (Decision: Accept Imprecision) +### Not Yet Fixed -**Problem:** `postProcessNLS()` replaces `"%%NLS:moduleId#key%%"` placeholders (~40 chars) with short index values like `null` (4 chars) in the final JS output. This shifts column positions without updating the `.map` files. +**`postProcessNLS` column drift** - Replaces NLS placeholders with short indices in bundled output without updating `.map` files. Shifts columns but never lines, so line-level debugging and crash reporting work correctly. Fixing would require tracking replacement offsets through regex matches and adjusting the source map, similar to `adjustSourceMap`. -**Options considered:** +### Key Technical Details -| Option | Description | Effort | Accuracy | -|--------|-------------|--------|----------| -| A. Fixed-width placeholders | Pad placeholders to match replacement length | Hard — indices unknown until all modules are collected across parallel bundles | Perfect | -| B. Post-process source map | Parse `.map`, track replacement offsets per line, adjust VLQ mappings | Medium | Perfect | -| C. Two-pass build | Assign NLS indices during plugin phase | Not feasible with parallel bundling | N/A | -| **D. Accept imprecision** | NLS replacements only affect column positions; line-level debugging works | Zero | Line-level | +**esbuild `onLoad` source map composition:** esbuild's `onLoad` return type does NOT have a `sourcemap` field (as of v0.27.2). The only way to provide input source maps is to embed them inline in the returned `contents`. esbuild reads this and composes it with its own transform/bundle map. With `sourcesContent: true`, esbuild uses the source content from the inline map, not the `contents` string. -**Decision: Option D — accept imprecision.** Rationale: +**`adjustColumn` algorithm** handles three cases per edit on a line: +1. Edit entirely before the column: accumulate the delta (newLen - origLen) +2. Column falls inside the edit span: map to the start of the edit +3. Edit is after the column: stop (edits are sorted) -- NLS replacements only shift **columns**, never lines — line-level stack traces and breakpoints remain correct. -- Production crash reporting (the primary consumer of CDN source maps) uses line numbers; column-level accuracy is rarely needed. -- The old gulp build had the same fundamental issue in its `nls.nls()` step and used `SourceMapConsumer`/`SourceMapGenerator` to fix it — but that approach was fragile and slow. -- If column-level precision becomes important later (e.g., for minified+NLS bundles), Option B can be implemented: after NLS replacement, re-parse the source map, walk replacement sites, and adjust column offsets. This is a localized change in the post-processing loop. +**Plugin interaction:** Both the NLS plugin and `fileContentMapperPlugin` register `onLoad({ filter: /\.ts$/ })`. In esbuild, the first `onLoad` to return non-`undefined` wins. The NLS plugin is `unshift`ed (runs first), so files with NLS calls skip `fileContentMapperPlugin`. This is safe in practice since `product.ts` (which has `BUILD->INSERT_PRODUCT_CONFIGURATION`) has no localize calls. --- diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index f19b6fb6fe7f9..60c863d483b6a 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -8,7 +8,7 @@ "name": "@vscode/sample-source", "version": "0.0.0", "devDependencies": { - "@vscode/component-explorer": "^0.1.1-10", + "@vscode/component-explorer": "^0.1.1-11", "@vscode/component-explorer-vite-plugin": "^0.1.1-10", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", @@ -683,9 +683,9 @@ "license": "MIT" }, "node_modules/@vscode/component-explorer": { - "version": "0.1.1-10", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-10.tgz", - "integrity": "sha512-Nokjk2DB1hgKeUL1FW5dHfXySgj17BgxcsiyzcG6etdFIbMpzv85nMQxrW/88aklgmJPrRVefMRHFYSds/F3/g==", + "version": "0.1.1-11", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-11.tgz", + "integrity": "sha512-CP2KlhPApPh8zhFH2A2lD5/Zv/+UR02Id1hGfKgdlPQFyNLdfgTcXfVl55BHiwADGR+YRyNHocsglFuplQX8QQ==", "dev": true, "dependencies": { "react": "^18.2.0", @@ -693,9 +693,9 @@ } }, "node_modules/@vscode/component-explorer-vite-plugin": { - "version": "0.1.1-10", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-10.tgz", - "integrity": "sha512-1F2Ier7lpFPvYzWxyNCBy3qYzSwRyTw6k3pm+l6DBMMNT+OTnCZ3+awa7wtijZXMc4O1WooxswjrjBu++Oqftg==", + "version": "0.1.1-11", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-11.tgz", + "integrity": "sha512-+rVlilTUK5oMzkU8tagfmLfzZpSbXJANLKaLPBgN6pyBuDzkuRZmH6JzQbUCqeUGbVhHwG+U1thh5ws6ncFeEQ==", "dev": true, "dependencies": { "tinyglobby": "^0.2.0" diff --git a/build/vite/package.json b/build/vite/package.json index b4268fceca1c3..5998c829b2120 100644 --- a/build/vite/package.json +++ b/build/vite/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "devDependencies": { - "@vscode/component-explorer": "^0.1.1-10", + "@vscode/component-explorer": "^0.1.1-11", "@vscode/component-explorer-vite-plugin": "^0.1.1-10", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", diff --git a/build/vite/vite.config.ts b/build/vite/vite.config.ts index d6d8931fa315d..cdae205f030df 100644 --- a/build/vite/vite.config.ts +++ b/build/vite/vite.config.ts @@ -164,6 +164,7 @@ logger.warn = (msg, options) => { }; export default defineConfig({ + base: './', plugins: [ rollupEsmUrlPlugin({}), injectBuiltinExtensionsPlugin(), @@ -171,6 +172,7 @@ export default defineConfig({ componentExplorer({ logLevel: 'verbose', include: join(__dirname, '../../src/**/*.fixture.ts'), + build: 'all', }), ], customLogger: logger, @@ -188,6 +190,7 @@ export default defineConfig({ }, root: '../..', // To support /out/... paths build: { + outDir: join(__dirname, 'dist'), rollupOptions: { input: { //index: path.resolve(__dirname, 'index.html'), diff --git a/extensions/css-language-features/package-lock.json b/extensions/css-language-features/package-lock.json index af7a789d5a2db..3b3ff93fa4d3d 100644 --- a/extensions/css-language-features/package-lock.json +++ b/extensions/css-language-features/package-lock.json @@ -19,47 +19,47 @@ "vscode": "^1.77.0" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "node_modules/@types/node": { + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "dev": true, "license": "MIT", - "engines": { - "node": "20 || >=22" + "dependencies": { + "undici-types": "~6.20.0" } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", + "node_modules/balanced-match": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, "engines": { "node": "20 || >=22" } }, - "node_modules/@types/node": { - "version": "22.13.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", - "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", - "dev": true, + "node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "license": "ISC", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" diff --git a/extensions/mermaid-chat-features/package-lock.json b/extensions/mermaid-chat-features/package-lock.json index 3d502443d2a2d..0d0bb2a582beb 100644 --- a/extensions/mermaid-chat-features/package-lock.json +++ b/extensions/mermaid-chat-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "dompurify": "^3.2.7", - "mermaid": "^11.11.0" + "mermaid": "^11.12.3" }, "devDependencies": { "@types/node": "^22.18.10", @@ -49,42 +49,42 @@ "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", - "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.1.tgz", + "integrity": "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.0.3", - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/gast": "11.1.1", + "@chevrotain/types": "11.1.1", + "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/gast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", - "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.1.tgz", + "integrity": "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/types": "11.1.1", + "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.1.tgz", + "integrity": "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.1.tgz", + "integrity": "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.1.tgz", + "integrity": "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==", "license": "Apache-2.0" }, "node_modules/@iconify/types": { @@ -122,12 +122,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz", - "integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz", + "integrity": "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==", "license": "MIT", "dependencies": { - "langium": "3.3.1" + "langium": "^4.0.0" } }, "node_modules/@types/d3": { @@ -426,17 +426,17 @@ } }, "node_modules/chevrotain": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", - "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.1.tgz", + "integrity": "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.0.3", - "@chevrotain/gast": "11.0.3", - "@chevrotain/regexp-to-ast": "11.0.3", - "@chevrotain/types": "11.0.3", - "@chevrotain/utils": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/cst-dts-gen": "11.1.1", + "@chevrotain/gast": "11.1.1", + "@chevrotain/regexp-to-ast": "11.1.1", + "@chevrotain/types": "11.1.1", + "@chevrotain/utils": "11.1.1", + "lodash-es": "4.17.23" } }, "node_modules/chevrotain-allstar": { @@ -738,9 +738,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -974,9 +974,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz", - "integrity": "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==", + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", + "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -1085,19 +1085,20 @@ "license": "MIT" }, "node_modules/langium": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", - "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", "license": "MIT", "dependencies": { - "chevrotain": "~11.0.3", - "chevrotain-allstar": "~0.3.0", + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.0.8" + "vscode-uri": "~3.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.10.0", + "npm": ">=10.2.3" } }, "node_modules/layout-base": { @@ -1124,45 +1125,45 @@ } }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/mermaid": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.11.0.tgz", - "integrity": "sha512-9lb/VNkZqWTRjVgCV+l1N+t4kyi94y+l5xrmBmbbxZYkfRl5hEDaTPMOcaWKCl1McG8nBEaMlWwkcAEEgjhBgg==", + "version": "11.12.3", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz", + "integrity": "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==", "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.0.4", + "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", - "@mermaid-js/parser": "^0.6.2", + "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.11", - "dayjs": "^1.11.13", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", - "lodash-es": "^4.17.21", - "marked": "^15.0.7", + "lodash-es": "^4.17.23", + "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -1386,9 +1387,9 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" } } diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index fed905d2651ec..811bff8076d1f 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -131,6 +131,6 @@ }, "dependencies": { "dompurify": "^3.2.7", - "mermaid": "^11.11.0" + "mermaid": "^11.12.3" } } diff --git a/package-lock.json b/package-lock.json index 2b82a31c00847..8b1b67551abd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-8", + "@vscode/codicons": "^0.0.45-10", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -29,7 +29,7 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", - "@vscode/windows-registry": "^1.1.0", + "@vscode/windows-registry": "^1.2.0", "@xterm/addon-clipboard": "^0.3.0-beta.167", "@xterm/addon-image": "^0.10.0-beta.167", "@xterm/addon-ligatures": "^0.11.0-beta.167", @@ -84,8 +84,8 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/component-explorer": "^0.1.1-10", - "@vscode/component-explorer-cli": "^0.1.1-6", + "@vscode/component-explorer": "^0.1.1-11", + "@vscode/component-explorer-cli": "^0.1.1-7", "@vscode/gulp-electron": "https://github.com/microsoft/vscode-gulp-electron.git#405e3df0e4e9c37fcf549cbe6f5cef8d5ba5ddff", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", @@ -3059,15 +3059,15 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-8", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-8.tgz", - "integrity": "sha512-5MfjQ+LBXnzLB/+nfpB8EpvHPdUkoW57cFcrIAHz52L/sBjwOxZER3+K2+nwb+/ejAiPmogTBDoJP/NM85uBtQ==", + "version": "0.0.45-10", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-10.tgz", + "integrity": "sha512-05CYIpwSYKEQN0qBnfmLC/5VcasOwmeLsl3SGj944UyJ1/vJQpqL153A+0xh4geYEeqcOtIc42emmCizsCzf0Q==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { - "version": "0.1.1-10", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-10.tgz", - "integrity": "sha512-Nokjk2DB1hgKeUL1FW5dHfXySgj17BgxcsiyzcG6etdFIbMpzv85nMQxrW/88aklgmJPrRVefMRHFYSds/F3/g==", + "version": "0.1.1-11", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-11.tgz", + "integrity": "sha512-CP2KlhPApPh8zhFH2A2lD5/Zv/+UR02Id1hGfKgdlPQFyNLdfgTcXfVl55BHiwADGR+YRyNHocsglFuplQX8QQ==", "dev": true, "dependencies": { "react": "^18.2.0", @@ -3075,9 +3075,9 @@ } }, "node_modules/@vscode/component-explorer-cli": { - "version": "0.1.1-6", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.1.1-6.tgz", - "integrity": "sha512-OnypYKeBH8ZZh6++2NvVo9lPXFvHpIik6Y/KAa/UVMp4hI58KlQ0zEOWszvwR1i6mESn+BRWERFbQbNlKLec5g==", + "version": "0.1.1-7", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.1.1-7.tgz", + "integrity": "sha512-Q+R736ZJtn96k0tClXMFQaa3KUWoqC2LOR7iTaILiKjtgPa3St1d+ZztbT8OiLhk/nhTlymBavVue83068Qm/g==", "dev": true, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", @@ -3818,9 +3818,9 @@ } }, "node_modules/@vscode/windows-registry": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.3.tgz", - "integrity": "sha512-si8+b+2Wh0x2X6W2+kgDyLJD9hyGIrjUo1X/7RWlvsxyI5+Pg+bpdHJrVYtIW4cHOPVB0FYFaN1UZndbUbU5lQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.2.0.tgz", + "integrity": "sha512-IouvAGIIjPDKsLlBkdjVgfHrE+s4aESXzyQWwEPzbzHumKbCqIL5n54PNX0R4PHkYQlbinVrXn282KGSO4cr9A==", "hasInstallScript": true, "license": "MIT" }, diff --git a/package.json b/package.json index 14eb04131bde8..8561680d37c23 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "perf": "node scripts/code-perf.js", "update-build-ts-version": "npm install -D typescript@next && npm install -D @typescript/native-preview && (cd build && npm run typecheck)", "install-local-component-explorer": "npm install ../vscode-packages/js-component-explorer/dist/vscode-component-explorer-0.1.0.tgz ../vscode-packages/js-component-explorer/dist/vscode-component-explorer-cli-0.1.0.tgz --no-save && cd build/vite && npm install ../../../vscode-packages/js-component-explorer/dist/vscode-component-explorer-vite-plugin-0.1.0.tgz --no-save", - "install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/vite && npm install @vscode/component-explorer-vite-plugin@next" + "install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/vite && npm install @vscode/component-explorer-vite-plugin@next && npm install @vscode/component-explorer" }, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.23", @@ -83,7 +83,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-8", + "@vscode/codicons": "^0.0.45-10", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -97,7 +97,7 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", - "@vscode/windows-registry": "^1.1.0", + "@vscode/windows-registry": "^1.2.0", "@xterm/addon-clipboard": "^0.3.0-beta.167", "@xterm/addon-image": "^0.10.0-beta.167", "@xterm/addon-ligatures": "^0.11.0-beta.167", @@ -152,8 +152,8 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/component-explorer": "^0.1.1-10", - "@vscode/component-explorer-cli": "^0.1.1-6", + "@vscode/component-explorer": "^0.1.1-11", + "@vscode/component-explorer-cli": "^0.1.1-7", "@vscode/gulp-electron": "https://github.com/microsoft/vscode-gulp-electron.git#405e3df0e4e9c37fcf549cbe6f5cef8d5ba5ddff", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", diff --git a/remote/package-lock.json b/remote/package-lock.json index 625b7143211ee..49b97cb47d00d 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -21,7 +21,7 @@ "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", - "@vscode/windows-registry": "^1.1.0", + "@vscode/windows-registry": "^1.2.0", "@xterm/addon-clipboard": "^0.3.0-beta.167", "@xterm/addon-image": "^0.10.0-beta.167", "@xterm/addon-ligatures": "^0.11.0-beta.167", @@ -571,9 +571,9 @@ } }, "node_modules/@vscode/windows-registry": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.3.tgz", - "integrity": "sha512-si8+b+2Wh0x2X6W2+kgDyLJD9hyGIrjUo1X/7RWlvsxyI5+Pg+bpdHJrVYtIW4cHOPVB0FYFaN1UZndbUbU5lQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.2.0.tgz", + "integrity": "sha512-IouvAGIIjPDKsLlBkdjVgfHrE+s4aESXzyQWwEPzbzHumKbCqIL5n54PNX0R4PHkYQlbinVrXn282KGSO4cr9A==", "hasInstallScript": true, "license": "MIT" }, diff --git a/remote/package.json b/remote/package.json index ecc25ae5590e0..2de8217e16d43 100644 --- a/remote/package.json +++ b/remote/package.json @@ -16,7 +16,7 @@ "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", - "@vscode/windows-registry": "^1.1.0", + "@vscode/windows-registry": "^1.2.0", "@xterm/addon-clipboard": "^0.3.0-beta.167", "@xterm/addon-image": "^0.10.0-beta.167", "@xterm/addon-ligatures": "^0.11.0-beta.167", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 8ee7ddeb9bd32..1b80d4aedeac4 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-8", + "@vscode/codicons": "^0.0.45-10", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-8", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-8.tgz", - "integrity": "sha512-5MfjQ+LBXnzLB/+nfpB8EpvHPdUkoW57cFcrIAHz52L/sBjwOxZER3+K2+nwb+/ejAiPmogTBDoJP/NM85uBtQ==", + "version": "0.0.45-10", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-10.tgz", + "integrity": "sha512-05CYIpwSYKEQN0qBnfmLC/5VcasOwmeLsl3SGj944UyJ1/vJQpqL153A+0xh4geYEeqcOtIc42emmCizsCzf0Q==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 37b14b0114ffd..69493c7779ade 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-8", + "@vscode/codicons": "^0.0.45-10", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", diff --git a/src/vs/base/node/windowsVersion.ts b/src/vs/base/node/windowsVersion.ts new file mode 100644 index 0000000000000..95dd8e78c478e --- /dev/null +++ b/src/vs/base/node/windowsVersion.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as os from 'os'; +import { isWindows } from '../common/platform.js'; + +let versionInfo: { release: string; buildNumber: number } | undefined; + +/** + * Initializes the Windows version cache by reading from the registry. + * + * On Windows 8.1+, the `os.release()` function may return incorrect version numbers + * due to the deprecated GetVersionEx API returning compatibility-shimmed values + * when the application doesn't have a proper manifest. Reading from the registry + * gives us the real version. + * + * See: https://github.com/microsoft/vscode/issues/197444 + */ +export async function initWindowsVersionInfo() { + if (versionInfo) { + return; + } + + if (!isWindows) { + versionInfo = { release: os.release(), buildNumber: 0 }; + return; + } + + let buildNumber: number | undefined; + let release: string | undefined; + try { + const Registry = await import('@vscode/windows-registry'); + const versionKey = 'SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion'; + + const build = Registry.GetStringRegKey('HKEY_LOCAL_MACHINE', versionKey, 'CurrentBuild'); + if (build !== undefined) { + buildNumber = parseInt(build, 10); + if (isNaN(buildNumber)) { + buildNumber = undefined; + } + } + + const major = Registry.GetDWORDRegKey('HKEY_LOCAL_MACHINE', versionKey, 'CurrentMajorVersionNumber'); + const minor = Registry.GetDWORDRegKey('HKEY_LOCAL_MACHINE', versionKey, 'CurrentMinorVersionNumber'); + if (major !== undefined && minor !== undefined && build !== undefined) { + release = `${major}.${minor}.${build}`; + } + } catch { + // ignore + } finally { + versionInfo = { + release: release || os.release(), + buildNumber: buildNumber || getWindowsBuildNumberFromOsRelease() + }; + } +} + +/** + * Gets Windows version information from the registry. + * @returns The Windows version in Major.Minor.Build format (e.g., "10.0.19041") + */ +export async function getWindowsRelease(): Promise { + if (!versionInfo) { + await initWindowsVersionInfo(); + } + return versionInfo!.release; +} + +/** + * Gets the Windows build number from the registry. + * @returns The Windows build number (e.g., 19041 for Windows 10 2004) + */ +export async function getWindowsBuildNumberAsync(): Promise { + if (!versionInfo) { + await initWindowsVersionInfo(); + } + return versionInfo!.buildNumber; +} + +/** + * Synchronous version of getWindowsBuildNumberAsync(). + * @returns The Windows build number (e.g., 19041 for Windows 10 2004) + */ +export function getWindowsBuildNumberSync(): number { + if (versionInfo) { + return versionInfo.buildNumber; + } else { + return isWindows ? getWindowsBuildNumberFromOsRelease() : 0; + } +} + +/** + * Gets the cached Windows release string synchronously. + * Falls back to os.release() if the cache hasn't been initialized yet. + * @returns The Windows version in Major.Minor.Build format (e.g., "10.0.19041") + */ +export function getWindowsReleaseSync(): string { + return versionInfo?.release ?? os.release(); +} + +/** + * Parses the Windows build number from os.release(). + * This is used as a fallback when registry reading is not available. + */ +function getWindowsBuildNumberFromOsRelease(): number { + const osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release()); + if (osVersion && osVersion.length === 4) { + return parseInt(osVersion[3], 10); + } + return 0; +} diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index f80b86e9a8c51..05de6d1c46f1e 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -110,6 +110,7 @@ import { ExtensionsScannerService } from '../../platform/extensionManagement/nod import { UserDataProfilesHandler } from '../../platform/userDataProfile/electron-main/userDataProfilesHandler.js'; import { ProfileStorageChangesListenerChannel } from '../../platform/userDataProfile/electron-main/userDataProfileStorageIpc.js'; import { Promises, RunOnceScheduler, runWhenGlobalIdle } from '../../base/common/async.js'; +import { CancellationToken } from '../../base/common/cancellation.js'; import { resolveMachineId, resolveSqmId, resolveDevDeviceId, validateDevDeviceId } from '../../platform/telemetry/electron-main/telemetryUtils.js'; import { ExtensionsProfileScannerService } from '../../platform/extensionManagement/node/extensionsProfileScannerService.js'; import { LoggerChannel } from '../../platform/log/electron-main/logIpc.js'; @@ -927,6 +928,15 @@ export class CodeApplication extends Disposable { this.environmentMainService.continueOn = continueOn ?? undefined; } + // Extract session parameter to open a specific chat session in the target window + const session = params.get('session'); + if (session !== null) { + this.logService.trace(`app#handleProtocolUrl() found 'session' as parameter:`, uri.toString(true)); + + params.delete('session'); + uri = uri.with({ query: params.toString() }); + } + // Check if the protocol URL is a window openable to open... const windowOpenableFromProtocolUrl = this.getWindowOpenableFromProtocolUrl(uri); if (windowOpenableFromProtocolUrl) { @@ -948,6 +958,11 @@ export class CodeApplication extends Disposable { window?.focus(); // this should help ensuring that the right window gets focus when multiple are opened + // Open chat session in the target window if requested + if (window && session) { + window.sendWhenReady('vscode:openChatSession', CancellationToken.None, session); + } + return true; } } diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index eb7e0193373b8..82d80db5bdcb9 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -22,6 +22,7 @@ import { IProcessEnvironment, isLinux, isMacintosh, isWindows, OS } from '../../ import { cwd } from '../../base/common/process.js'; import { rtrim, trim } from '../../base/common/strings.js'; import { Promises as FSPromises } from '../../base/node/pfs.js'; +import { initWindowsVersionInfo } from '../../base/node/windowsVersion.js'; import { ProxyChannel } from '../../base/parts/ipc/common/ipc.js'; import { Client as NodeIPCClient } from '../../base/parts/ipc/common/ipc.net.js'; import { connect as nodeIPCConnect, serve as nodeIPCServe, Server as NodeIPCServer, XDG_RUNTIME_DIR } from '../../base/parts/ipc/node/ipc.net.js'; @@ -282,7 +283,10 @@ class CodeMain { stateService.init(), // Configuration service - configurationService.initialize() + configurationService.initialize(), + + // Accurate Windows version info. + isWindows ? initWindowsVersionInfo() : Promise.resolve() ]); // Initialize user data profiles after initializing the state diff --git a/src/vs/editor/common/editorContextKeys.ts b/src/vs/editor/common/editorContextKeys.ts index d1e7c0dd95c0a..632f9005347c8 100644 --- a/src/vs/editor/common/editorContextKeys.ts +++ b/src/vs/editor/common/editorContextKeys.ts @@ -93,4 +93,5 @@ export namespace EditorContextKeys { export const hasMultipleDocumentFormattingProvider = new RawContextKey('editorHasMultipleDocumentFormattingProvider', false, nls.localize('editorHasMultipleDocumentFormattingProvider', "Whether the editor has multiple document formatting providers")); export const hasMultipleDocumentSelectionFormattingProvider = new RawContextKey('editorHasMultipleDocumentSelectionFormattingProvider', false, nls.localize('editorHasMultipleDocumentSelectionFormattingProvider', "Whether the editor has multiple document selection formatting providers")); + export const selectionHasDiagnostics = new RawContextKey('editorSelectionHasDiagnostics', false, nls.localize('editorSelectionHasDiagnostics', "Whether any diagnostic is present in the current editor selection")); } diff --git a/src/vs/editor/contrib/gotoError/browser/markerSelectionStatus.ts b/src/vs/editor/contrib/gotoError/browser/markerSelectionStatus.ts new file mode 100644 index 0000000000000..e13f8bd60185f --- /dev/null +++ b/src/vs/editor/contrib/gotoError/browser/markerSelectionStatus.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { isEqual } from '../../../../base/common/resources.js'; +import { ICodeEditor } from '../../../browser/editorBrowser.js'; +import { EditorContributionInstantiation, registerEditorContribution } from '../../../browser/editorExtensions.js'; +import { Range } from '../../../common/core/range.js'; +import { IEditorContribution } from '../../../common/editorCommon.js'; +import { EditorContextKeys } from '../../../common/editorContextKeys.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; + +class MarkerSelectionStatus extends Disposable implements IEditorContribution { + + static readonly ID = 'editor.contrib.markerSelectionStatus'; + + private readonly _ctxHasDiagnostics: IContextKey; + + constructor( + private readonly _editor: ICodeEditor, + @IContextKeyService contextKeyService: IContextKeyService, + @IMarkerService private readonly _markerService: IMarkerService, + ) { + super(); + + this._ctxHasDiagnostics = EditorContextKeys.selectionHasDiagnostics.bindTo(contextKeyService); + + this._store.add(this._editor.onDidChangeCursorSelection(() => this._update())); + this._store.add(this._editor.onDidChangeModel(() => this._update())); + this._store.add(this._markerService.onMarkerChanged(e => { + const model = this._editor.getModel(); + if (model && e.some(uri => isEqual(uri, model.uri))) { + this._update(); + } + })); + + this._update(); + } + + override dispose(): void { + this._ctxHasDiagnostics.reset(); + super.dispose(); + } + + private _update(): void { + const model = this._editor.getModel(); + const selection = this._editor.getSelection(); + if (!model || !selection) { + this._ctxHasDiagnostics.reset(); + return; + } + + const markers = this._markerService.read({ + resource: model.uri, + severities: MarkerSeverity.Error | MarkerSeverity.Warning | MarkerSeverity.Info + }); + + const hasIntersecting = markers.some(marker => Range.areIntersecting( + { startLineNumber: marker.startLineNumber, startColumn: marker.startColumn, endLineNumber: marker.endLineNumber, endColumn: marker.endColumn }, + selection + )); + + this._ctxHasDiagnostics.set(hasIntersecting); + } +} + +registerEditorContribution(MarkerSelectionStatus.ID, MarkerSelectionStatus, EditorContributionInstantiation.AfterFirstRender); diff --git a/src/vs/editor/editor.all.ts b/src/vs/editor/editor.all.ts index 916cc40a98014..4802aeb185a03 100644 --- a/src/vs/editor/editor.all.ts +++ b/src/vs/editor/editor.all.ts @@ -30,6 +30,7 @@ import './contrib/inlineProgress/browser/inlineProgress.js'; import './contrib/gotoSymbol/browser/goToCommands.js'; import './contrib/gotoSymbol/browser/link/goToDefinitionAtPosition.js'; import './contrib/gotoError/browser/gotoError.js'; +import './contrib/gotoError/browser/markerSelectionStatus.js'; import './contrib/gpu/browser/gpuActions.js'; import './contrib/hover/browser/hoverContribution.js'; import './contrib/indentation/browser/indentation.js'; @@ -70,4 +71,3 @@ import './contrib/floatingMenu/browser/floatingMenu.contribution.js'; import './common/standaloneStrings.js'; import '../base/browser/ui/codicons/codiconStyles.js'; // The codicons are defined here and must be loaded - diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index d212e4cd14a0c..6d2cfda35684d 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -617,6 +617,8 @@ export class ActionList extends Disposable { // Keep focus on the filter input if the user is typing a filter. if (filterInputHasFocus) { this._filterInput?.focus(); + // Keep a highlighted item in the list so Enter works without pressing DownArrow first + this._focusCheckedOrFirst(); } else { this._list.domFocus(); // Restore focus to the previously focused item @@ -660,6 +662,8 @@ export class ActionList extends Disposable { focus(): void { if (this._filterInput && this._options?.focusFilterOnOpen) { this._filterInput.focus(); + // Highlight the first item so Enter works immediately + this._focusCheckedOrFirst(); return; } this._list.domFocus(); @@ -678,7 +682,12 @@ export class ActionList extends Disposable { return; } } - this.focusNext(); + // Set focus on the first focusable item without moving DOM focus + this._list.focusFirst(undefined, this.focusCondition); + const focused = this._list.getFocus(); + if (focused.length > 0) { + this._list.reveal(focused[0]); + } } finally { this._suppressHover = false; } @@ -853,7 +862,24 @@ export class ActionList extends Disposable { focusPrevious() { if (this._filterInput && dom.isActiveElement(this._filterInput)) { this._list.domFocus(); - this._list.focusLast(undefined, this.focusCondition); + // An item is already highlighted; advance from it instead of jumping to last + const current = this._list.getFocus(); + if (current.length > 0) { + this._list.focusPrevious(1, false, undefined, this.focusCondition); + const focused = this._list.getFocus(); + // If we couldn't move (already at first), go to filter + if (focused.length > 0 && focused[0] >= current[0]) { + this._filterInput.focus(); + } else if (focused.length > 0) { + this._list.reveal(focused[0]); + } + } else { + this._list.focusLast(undefined, this.focusCondition); + const focused = this._list.getFocus(); + if (focused.length > 0) { + this._list.reveal(focused[0]); + } + } return; } const previousFocus = this._list.getFocus(); @@ -873,7 +899,21 @@ export class ActionList extends Disposable { focusNext() { if (this._filterInput && dom.isActiveElement(this._filterInput)) { this._list.domFocus(); - this._list.focusFirst(undefined, this.focusCondition); + // An item is already highlighted; advance from it instead of jumping to first + const current = this._list.getFocus(); + if (current.length > 0) { + this._list.focusNext(1, false, undefined, this.focusCondition); + const focused = this._list.getFocus(); + if (focused.length > 0) { + this._list.reveal(focused[0]); + } + } else { + this._list.focusFirst(undefined, this.focusCondition); + const focused = this._list.getFocus(); + if (focused.length > 0) { + this._list.reveal(focused[0]); + } + } return; } const previousFocus = this._list.getFocus(); diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 41db8e8d87ed1..96fc6cbf50661 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -228,7 +228,7 @@ } .action-widget .monaco-list-row .action-list-item-toolbar .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover{ - background-color: var(--vscode-list-activeSelectionBackground); + background-color: var(--vscode-toolbar-hoverBackground); } .action-widget-delegate-label { diff --git a/src/vs/platform/browserView/common/browserViewTelemetry.ts b/src/vs/platform/browserView/common/browserViewTelemetry.ts new file mode 100644 index 0000000000000..f261d0261a20b --- /dev/null +++ b/src/vs/platform/browserView/common/browserViewTelemetry.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; + +/** Source of an Integrated Browser open event. */ +export type IntegratedBrowserOpenSource = + /** Created via CDP, such as by the agent using Playwright tools. */ + | 'cdpCreated' + /** Opened via the "Open Integrated Browser" command without a URL argument. + * This typically means the user ran the command manually from the Command Palette. */ + | 'commandWithoutUrl' + /** Opened via the "Open Integrated Browser" command with a URL argument. + * This typically means another extension or component invoked the command programmatically. */ + | 'commandWithUrl' + /** Opened via the "New Tab" command from an existing tab. */ + | 'newTabCommand' + /** Opened via the localhost link opener when the `workbench.browser.openLocalhostLinks` setting + * is enabled. This happens when clicking localhost links from the terminal, chat, or other sources. */ + | 'localhostLinkOpener' + /** Opened when clicking a link inside the Integrated Browser that opens in a new focused editor + * (e.g., links with target="_blank"). */ + | 'browserLinkForeground' + /** Opened when clicking a link inside the Integrated Browser that opens in a new background editor + * (e.g., Ctrl/Cmd+click). */ + | 'browserLinkBackground' + /** Opened when clicking a link inside the Integrated Browser that opens in a new window + * (e.g., Shift+click). */ + | 'browserLinkNewWindow' + /** Opened when the user copies a browser editor to a new window via "Copy into New Window". */ + | 'copyToNewWindow'; + +type IntegratedBrowserOpenEvent = { + source: IntegratedBrowserOpenSource; +}; + +type IntegratedBrowserOpenClassification = { + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the Integrated Browser was opened' }; + owner: 'jruales'; + comment: 'Tracks how users open the Integrated Browser'; +}; + +export function logBrowserOpen(telemetryService: ITelemetryService, source: IntegratedBrowserOpenSource): void { + telemetryService.publicLog2( + 'integratedBrowser.open', + { source } + ); +} diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 68e97b58b4f47..7db0af2ac3451 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -17,6 +17,8 @@ import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; import { IProductService } from '../../product/common/productService.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; +import { logBrowserOpen } from '../common/browserViewTelemetry.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -50,7 +52,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, - @IProductService private readonly productService: IProductService + @IProductService private readonly productService: IProductService, + @ITelemetryService private readonly telemetryService: ITelemetryService ) { super(); } @@ -162,6 +165,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa // Create the browser view (fires onTargetCreated) const view = this.createBrowserView(targetId, browserSession); + logBrowserOpen(this.telemetryService, 'cdpCreated'); + // Request the workbench to open the editor this.windowsMainService.sendToFocused('vscode:runAction', { id: 'vscode.open', diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index a2b807cf01b27..283fc610895d5 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -141,7 +141,12 @@ export class PlaywrightService extends Disposable implements IPlaywrightService result = err instanceof Error ? err.message : String(err); } - const summary = await this._pages.getSummary(pageId); + let summary; + try { + summary = await this._pages.getSummary(pageId); + } catch (err: unknown) { + summary = err instanceof Error ? err.message : String(err); + } return { result, summary }; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -292,7 +297,7 @@ class PlaywrightPageManager extends Disposable { this._trackedPages.add(viewId); this._fireTrackedPagesChanged(); - await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); return viewId; } @@ -434,12 +439,7 @@ class PlaywrightPageManager extends Disposable { return queued.page.p; } - if (this._trackedPages.has(viewId) && this._group) { - await this._addPageToGroup(viewId); - return this.getPage(viewId); - } - - throw new Error(`Page "${viewId}" has not been added to the Playwright service`); + throw new Error(`Page "${viewId}" not found`); } /** @@ -481,6 +481,8 @@ class PlaywrightPageManager extends Disposable { this._pageToViewId.delete(page); } this._viewIdToPage.delete(viewId); + this._trackedPages.delete(viewId); + this._fireTrackedPagesChanged(); } private onPageAdded(page: Page, timeoutMs = 10000): Promise { @@ -516,6 +518,8 @@ class PlaywrightPageManager extends Disposable { const viewId = this._pageToViewId.get(page); if (viewId) { this._viewIdToPage.delete(viewId); + this._trackedPages.delete(viewId); + this._fireTrackedPagesChanged(); } this._pageToViewId.delete(page); } diff --git a/src/vs/platform/environment/node/argvHelper.ts b/src/vs/platform/environment/node/argvHelper.ts index eb645fc902f1a..8927472c2bdf3 100644 --- a/src/vs/platform/environment/node/argvHelper.ts +++ b/src/vs/platform/environment/node/argvHelper.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { IProcessEnvironment } from '../../../base/common/platform.js'; +import { dirname, resolve } from '../../../base/common/path.js'; +import { IProcessEnvironment, isWindows } from '../../../base/common/platform.js'; import { localize } from '../../../nls.js'; import { NativeParsedArgs } from '../common/argv.js'; import { ErrorReporter, NATIVE_CLI_COMMANDS, OPTIONS, parseArgs } from './argv.js'; @@ -63,6 +64,17 @@ function stripAppPath(argv: string[]): string[] | undefined { export function parseMainProcessArgv(processArgv: string[]): NativeParsedArgs { let [, ...args] = processArgv; + // When code.exe is configured to 'Run as administrator' on Windows, the CLI launcher (code.cmd) sets ELECTRON_RUN_AS_NODE=1 and passes + // cli.js as the first argument. The elevated process does not inherit the environment variable so Electron starts as a GUI app with cli.js + // as a stray positional argument. Detect and strip it. The path may include a version subdirectory (e.g., 2ca3b2734b\resources\app\out\cli.js). + if (isWindows && args.length > 0) { + const resolvedArg = resolve(args[0]).toLowerCase(); + const installDir = dirname(process.execPath).toLowerCase() + '\\'; + if (resolvedArg.startsWith(installDir) && resolvedArg.endsWith('\\resources\\app\\out\\cli.js')) { + args.shift(); + } + } + // If dev, remove the first non-option argument: it's the app location if (process.env['VSCODE_DEV']) { args = stripAppPath(args) || []; diff --git a/src/vs/platform/remote/node/wsl.ts b/src/vs/platform/remote/node/wsl.ts index 12935846db9a8..60b96f3dd789a 100644 --- a/src/vs/platform/remote/node/wsl.ts +++ b/src/vs/platform/remote/node/wsl.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs'; -import * as os from 'os'; import * as cp from 'child_process'; import { join } from '../../../base/common/path.js'; +import { getWindowsBuildNumberAsync } from '../../../base/node/windowsVersion.js'; let hasWSLFeaturePromise: Promise | undefined; @@ -18,8 +18,8 @@ export async function hasWSLFeatureInstalled(refresh = false): Promise } async function testWSLFeatureInstalled(): Promise { - const windowsBuildNumber = getWindowsBuildNumber(); - if (windowsBuildNumber === undefined) { + const windowsBuildNumber = await getWindowsBuildNumberAsync(); + if (windowsBuildNumber === 0) { return false; } if (windowsBuildNumber >= 22000) { @@ -47,14 +47,6 @@ async function testWSLFeatureInstalled(): Promise { return false; } -function getWindowsBuildNumber(): number | undefined { - const osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release()); - if (osVersion) { - return parseInt(osVersion[3]); - } - return undefined; -} - function getSystem32Path(subPath: string): string | undefined { const systemRoot = process.env['SystemRoot']; if (systemRoot) { diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index e73086df28c9e..2f8d40d6ac0dd 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -18,7 +18,7 @@ import { escapeNonWindowsPath } from '../common/terminalEnvironment.js'; import type { ISerializeOptions, SerializeAddon as XtermSerializeAddon } from '@xterm/addon-serialize'; import type { Unicode11Addon as XtermUnicode11Addon } from '@xterm/addon-unicode11'; import { IGetTerminalLayoutInfoArgs, IProcessDetails, ISetTerminalLayoutInfoArgs, ITerminalTabLayoutInfoDto } from '../common/terminalProcess.js'; -import { getWindowsBuildNumber, sanitizeEnvForLogging } from './terminalEnvironment.js'; +import { sanitizeEnvForLogging } from './terminalEnvironment.js'; import { TerminalProcess } from './terminalProcess.js'; import { localize } from '../../../nls.js'; import { ignoreProcessNames } from './childProcessMonitor.js'; @@ -33,6 +33,7 @@ import * as performance from '../../../base/common/performance.js'; import pkg from '@xterm/headless'; import { AutoRepliesPtyServiceContribution } from './terminalContrib/autoReplies/autoRepliesContribController.js'; import { hasKey, isFunction, isNumber, isString } from '../../../base/common/types.js'; +import { getWindowsBuildNumberAsync } from '../../../base/node/windowsVersion.js'; type XtermTerminal = pkg.Terminal; const { Terminal: XtermTerminal } = pkg; @@ -510,10 +511,10 @@ export class PtyService extends Disposable implements IPtyService { if (!isWindows) { return original; } - if (getWindowsBuildNumber() < 17063) { + if (await getWindowsBuildNumberAsync() < 17063) { return original.replace(/\\/g, '/'); } - const wslExecutable = this._getWSLExecutablePath(); + const wslExecutable = await this._getWSLExecutablePath(); if (!wslExecutable) { return original; } @@ -528,10 +529,10 @@ export class PtyService extends Disposable implements IPtyService { // The backend is Windows, for example a local Windows workspace with a wsl session in // the terminal. if (isWindows) { - if (getWindowsBuildNumber() < 17063) { + if (await getWindowsBuildNumberAsync() < 17063) { return original; } - const wslExecutable = this._getWSLExecutablePath(); + const wslExecutable = await this._getWSLExecutablePath(); if (!wslExecutable) { return original; } @@ -547,8 +548,8 @@ export class PtyService extends Disposable implements IPtyService { return original; } - private _getWSLExecutablePath(): string | undefined { - const useWSLexe = getWindowsBuildNumber() >= 16299; + private async _getWSLExecutablePath(): Promise { + const useWSLexe = await getWindowsBuildNumberAsync() >= 16299; const is32ProcessOn64Windows = process.env.hasOwnProperty('PROCESSOR_ARCHITEW6432'); const systemRoot = process.env['SystemRoot']; if (systemRoot) { diff --git a/src/vs/platform/terminal/node/terminalEnvironment.ts b/src/vs/platform/terminal/node/terminalEnvironment.ts index 62a0c53bb8850..5aa0a0dd13a6e 100644 --- a/src/vs/platform/terminal/node/terminalEnvironment.ts +++ b/src/vs/platform/terminal/node/terminalEnvironment.ts @@ -18,15 +18,7 @@ import { MergedEnvironmentVariableCollection } from '../common/environmentVariab import { chmod, realpathSync, mkdirSync } from 'fs'; import { promisify } from 'util'; import { isString, SingleOrMany } from '../../../base/common/types.js'; - -export function getWindowsBuildNumber(): number { - const osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release()); - let buildNumber: number = 0; - if (osVersion && osVersion.length === 4) { - buildNumber = parseInt(osVersion[3]); - } - return buildNumber; -} +import { getWindowsBuildNumberAsync } from '../../../base/node/windowsVersion.js'; export interface IShellIntegrationConfigInjection { readonly type: 'injection'; @@ -83,7 +75,8 @@ export async function getShellIntegrationInjection( return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.IgnoreShellIntegrationFlag }; } // Shell integration requires Windows 10 build 18309+ (ConPTY support) - if (isWindows && getWindowsBuildNumber() < 18309) { + const windowsBuildNumber = isWindows ? await getWindowsBuildNumberAsync() : 0; + if (isWindows && windowsBuildNumber < 18309) { return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedWindowsBuild }; } @@ -103,7 +96,7 @@ export async function getShellIntegrationInjection( const scopedDownShellEnvs = ['PATH', 'VIRTUAL_ENV', 'HOME', 'SHELL', 'PWD']; if (shellLaunchConfig.shellIntegrationEnvironmentReporting) { if (isWindows) { - const enableWindowsEnvReporting = options.windowsUseConptyDll || getWindowsBuildNumber() >= 22631 && shell !== 'bash.exe'; + const enableWindowsEnvReporting = options.windowsUseConptyDll || windowsBuildNumber >= 22631 && shell !== 'bash.exe'; if (enableWindowsEnvReporting) { envMixin['VSCODE_SHELL_ENV_REPORTING'] = scopedDownShellEnvs.join(','); } diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index 1f49fa67daa7f..60563ff6487a8 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -17,10 +17,11 @@ import { ILogService, LogLevel } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { FlowControlConstants, IShellLaunchConfig, ITerminalChildProcess, ITerminalLaunchError, IProcessProperty, IProcessPropertyMap, ProcessPropertyType, TerminalShellType, IProcessReadyEvent, ITerminalProcessOptions, PosixShellType, IProcessReadyWindowsPty, GeneralShellType, ITerminalLaunchResult } from '../common/terminal.js'; import { ChildProcessMonitor } from './childProcessMonitor.js'; -import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, sanitizeEnvForLogging } from './terminalEnvironment.js'; +import { getShellIntegrationInjection, IShellIntegrationConfigInjection, sanitizeEnvForLogging } from './terminalEnvironment.js'; import { WindowsShellHelper } from './windowsShellHelper.js'; import { IPty, IPtyForkOptions, IWindowsPtyForkOptions, spawn } from 'node-pty'; import { isNumber } from '../../../base/common/types.js'; +import { getWindowsBuildNumberSync } from '../../../base/node/windowsVersion.js'; const enum ShutdownConstants { /** @@ -150,7 +151,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess this._initialCwd = cwd; this._properties[ProcessPropertyType.InitialCwd] = this._initialCwd; this._properties[ProcessPropertyType.Cwd] = this._initialCwd; - const useConpty = process.platform === 'win32' && getWindowsBuildNumber() >= 18309; + const useConpty = process.platform === 'win32' && getWindowsBuildNumberSync() >= 18309; const useConptyDll = useConpty && this._options.windowsUseConptyDll; this._ptyOptions = { name, @@ -625,7 +626,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess getWindowsPty(): IProcessReadyWindowsPty | undefined { return isWindows ? { backend: 'conpty', - buildNumber: getWindowsBuildNumber() + buildNumber: getWindowsBuildNumberSync() } : undefined; } } diff --git a/src/vs/platform/terminal/node/terminalProfiles.ts b/src/vs/platform/terminal/node/terminalProfiles.ts index ea98eb66fb018..e3198558e6841 100644 --- a/src/vs/platform/terminal/node/terminalProfiles.ts +++ b/src/vs/platform/terminal/node/terminalProfiles.ts @@ -16,8 +16,8 @@ import { enumeratePowerShellInstallations } from '../../../base/node/powershell. import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogService } from '../../log/common/log.js'; import { ITerminalEnvironment, ITerminalExecutable, ITerminalProfile, ITerminalProfileSource, ITerminalUnsafePath, ProfileSource, TerminalIcon, TerminalSettingId } from '../common/terminal.js'; -import { getWindowsBuildNumber } from './terminalEnvironment.js'; import { ThemeIcon } from '../../../base/common/themables.js'; +import { getWindowsBuildNumberAsync } from '../../../base/node/windowsVersion.js'; const enum Constants { UnixShellsPath = '/etc/shells' @@ -86,7 +86,7 @@ async function detectAvailableWindowsProfiles( // WSL 2 released in the May 2020 Update, this is where the `-d` flag was added that we depend // upon - const allowWslDiscovery = getWindowsBuildNumber() >= 19041; + const allowWslDiscovery = await getWindowsBuildNumberAsync() >= 19041; await initializeWindowsProfiles(testPwshSourcePaths); diff --git a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts index bb03a05958b4b..4b7ab9e50c74d 100644 --- a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts +++ b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts @@ -10,7 +10,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITerminalProcessOptions } from '../../common/terminal.js'; -import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, type IShellIntegrationInjectionFailure, sanitizeEnvForLogging } from '../../node/terminalEnvironment.js'; +import { getShellIntegrationInjection, IShellIntegrationConfigInjection, type IShellIntegrationInjectionFailure, sanitizeEnvForLogging } from '../../node/terminalEnvironment.js'; +import { getWindowsBuildNumberSync } from '../../../../base/node/windowsVersion.js'; const enabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: true, suggestEnabled: false, nonce: '' }, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined, isScreenReaderOptimized: false }; const disabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: false, suggestEnabled: false, nonce: '' }, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined, isScreenReaderOptimized: false }; @@ -32,14 +33,14 @@ suite('platform - terminalEnvironment', async () => { suite('getShellIntegrationInjection', async () => { suite('should not enable', async () => { // This test is only expected to work on Windows 10 build 18309 and above - (getWindowsBuildNumber() < 18309 ? test.skip : test)('when isFeatureTerminal or when no executable is provided', async () => { + (getWindowsBuildNumberSync() < 18309 ? test.skip : test)('when isFeatureTerminal or when no executable is provided', async () => { strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo'], isFeatureTerminal: true }, enabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo'], isFeatureTerminal: false }, enabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'injection'); }); }); // These tests are only expected to work on Windows 10 build 18309 and above - (getWindowsBuildNumber() < 18309 ? suite.skip : suite)('pwsh', async () => { + (getWindowsBuildNumberSync() < 18309 ? suite.skip : suite)('pwsh', async () => { const expectedPs1 = process.platform === 'win32' ? `try { . "${repoRoot}\\out\\vs\\workbench\\contrib\\terminal\\common\\scripts\\shellIntegration.ps1" } catch {}` : `. "${repoRoot}/out/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1"`; diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 05c4489758b68..698d277ca288b 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -7,7 +7,8 @@ import * as os from 'os'; import { IntervalTimer, timeout } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { isMacintosh } from '../../../base/common/platform.js'; +import { isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { getWindowsReleaseSync } from '../../../base/node/windowsVersion.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; @@ -32,10 +33,13 @@ export function createUpdateURL(baseUpdateUrl: string, platform: string, quality } /** - * Builds common headers for macOS update requests, including those issued + * Builds common headers for update requests, including those issued * via Electron's auto-updater (e.g. setFeedURL({ url, headers })) and - * manual HTTP requests that bypass the auto-updater. On macOS, this includes - * the Darwin kernel version which the update server uses for EOL detection. + * manual HTTP requests that bypass the auto-updater. The headers include + * OS version information which the update server uses for EOL detection. + * + * On macOS, the User-Agent includes the Darwin kernel version. + * On Windows, the User-Agent includes accurate Windows version from the registry. */ export function getUpdateRequestHeaders(productVersion: string): Record | undefined { if (isMacintosh) { @@ -45,6 +49,15 @@ export function getUpdateRequestHeaders(productVersion: string): Record(asJson) .then(update => { const updateType = getUpdateType(); @@ -362,7 +363,8 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun { detached: true, stdio: ['ignore', 'ignore', 'ignore'], - windowsVerbatimArguments: true + windowsVerbatimArguments: true, + env: { ...process.env, __COMPAT_LAYER: 'RunAsInvoker' } } ); @@ -494,7 +496,8 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } else { spawn(this.availableUpdate.packagePath, ['/silent', '/log', '/mergetasks=runcode,!desktopicon,!quicklaunchicon'], { detached: true, - stdio: ['ignore', 'ignore', 'ignore'] + stdio: ['ignore', 'ignore', 'ignore'], + env: { ...process.env, __COMPAT_LAYER: 'RunAsInvoker' } }); } } diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index e85e6824b5fc9..f0276cdacdc55 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -76,11 +76,15 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { ? 'vscode-exploration' : 'vscode-insiders'; + const params = new URLSearchParams(); + params.set('windowId', '_blank'); + params.set('session', activeSession.resource.toString()); + await openerService.open(URI.from({ scheme, authority: Schemas.file, path: folderUri.path, - query: 'windowId=_blank', + query: params.toString(), }), { openExternal: true }); } } @@ -107,7 +111,7 @@ class NewChatInSessionsWindowAction extends Action2 { override run(accessor: ServicesAccessor): void { const sessionsManagementService = accessor.get(ISessionsManagementService); - sessionsManagementService.openNewSession(); + sessionsManagementService.openNewSessionView(); } } diff --git a/src/vs/sessions/contrib/chat/browser/folderPicker.ts b/src/vs/sessions/contrib/chat/browser/folderPicker.ts index dddc95aed2bc1..1080d93df7dce 100644 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/folderPicker.ts @@ -18,6 +18,8 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { INewSession } from './newSession.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; const STORAGE_KEY_LAST_FOLDER = 'agentSessions.lastPickedFolder'; const STORAGE_KEY_RECENT_FOLDERS = 'agentSessions.recentlyPickedFolders'; @@ -83,10 +85,11 @@ export class FolderPicker extends Disposable { } } catch { /* ignore */ } - // Pre-fetch recently opened folders + // Pre-fetch recently opened folders, filtering out copilot worktrees this.workspacesService.getRecentlyOpened().then(recent => { this._cachedRecentFolders = recent.workspaces .filter(isRecentFolder) + .filter(r => !this._isCopilotWorktree(r.folderUri)) .slice(0, MAX_RECENT_FOLDERS) .map(r => ({ uri: r.folderUri, label: r.label })); }).catch(() => { /* ignore */ }); @@ -245,6 +248,12 @@ export class FolderPicker extends Disposable { label, group: { title: '', icon: Codicon.blank }, item: { uri: folder.uri, label }, + toolbarActions: [toAction({ + id: 'folderPicker.remove', + label: localize('folderPicker.remove', "Remove"), + class: ThemeIcon.asClassName(Codicon.close), + run: () => this._removeFolder(folder.uri), + })], }); } @@ -265,6 +274,27 @@ export class FolderPicker extends Disposable { return items; } + private _removeFolder(folderUri: URI): void { + // Remove from recently picked folders + this._recentlyPickedFolders = this._recentlyPickedFolders.filter(f => !isEqual(f, folderUri)); + this.storageService.store(STORAGE_KEY_RECENT_FOLDERS, JSON.stringify(this._recentlyPickedFolders.map(f => f.toString())), StorageScope.PROFILE, StorageTarget.MACHINE); + + // Remove from cached recent folders + this._cachedRecentFolders = this._cachedRecentFolders.filter(f => !isEqual(f.uri, folderUri)); + + // Remove from globally recently opened + this.workspacesService.removeRecentlyOpened([folderUri]); + + // Re-show the picker with updated items + this.actionWidgetService.hide(); + this.showPicker(); + } + + private _isCopilotWorktree(uri: URI): boolean { + const name = basename(uri); + return name.startsWith('copilot-worktree-'); + } + private _updateTriggerLabel(trigger: HTMLElement | undefined): void { if (!trigger) { return; diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 521b2e0b00cec..8c8617ccd9b93 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -94,6 +94,7 @@ class NewChatWidget extends Disposable { // Send button private _sendButton: Button | undefined; + private _sending = false; // Repository loading private readonly _openRepositoryCts = this._register(new MutableDisposable()); @@ -324,12 +325,12 @@ class NewChatWidget extends Disposable { } private _updateInputLoadingState(): void { - const loading = this._repositoryLoading || this._branchLoading; + const loading = this._repositoryLoading || this._branchLoading || this._sending; if (loading) { if (!this._loadingDelayDisposable.value) { const timer = setTimeout(() => { this._loadingDelayDisposable.clear(); - if (this._repositoryLoading || this._branchLoading) { + if (this._repositoryLoading || this._branchLoading || this._sending) { this._loadingSpinner?.classList.add('visible'); } }, 500); @@ -759,13 +760,13 @@ class NewChatWidget extends Disposable { return; } const hasText = !!this._editor?.getModel()?.getValue().trim(); - this._sendButton.enabled = hasText && !(this._newSession.value?.disabled ?? true); + this._sendButton.enabled = !this._sending && hasText && !(this._newSession.value?.disabled ?? true); } private _send(): void { const query = this._editor.getModel()?.getValue().trim(); const session = this._newSession.value; - if (!query || !session || session.disabled) { + if (!query || !session || session.disabled || this._sending) { return; } @@ -774,14 +775,26 @@ class NewChatWidget extends Disposable { this._contextAttachments.attachments.length > 0 ? [...this._contextAttachments.attachments] : undefined ); + this._sending = true; + this._editor.updateOptions({ readOnly: true }); + this._updateSendButtonState(); + this._updateInputLoadingState(); + this.sessionsManagementService.sendRequestForNewSession( session.resource - ).catch(e => this.logService.error('Failed to send request:', e)); - - // Clear sent session so a fresh one is created next time - this._newSession.clear(); - this._newSessionListener.clear(); - this._contextAttachments.clear(); + ).then(() => { + // Release ref without disposing - the service owns disposal + this._newSession.clearAndLeak(); + this._newSessionListener.clear(); + this._contextAttachments.clear(); + }, e => { + this.logService.error('Failed to send request:', e); + }).finally(() => { + this._sending = false; + this._editor.updateOptions({ readOnly: false }); + this._updateSendButtonState(); + this._updateInputLoadingState(); + }); } // --- Layout --- diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index d6bdaaa60b6d7..6aacdfccc6ce6 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -12,19 +12,16 @@ import { IContextKey, IContextKeyService, RawContextKey } from '../../../../plat import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ISessionOpenOptions, openSession as openSessionDefault } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; -import { ChatViewId, ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; -import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.js'; -import { IChatSessionItem, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { LocalChatSessionUri } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { INewSession, LocalNewSession, RemoteNewSession } from '../../chat/browser/newSession.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); @@ -38,7 +35,8 @@ const repositoryOptionId = 'repository'; * - For agent session items: repository is the workingDirectory from metadata * - For new sessions: repository comes from the session option with id 'repository' */ -export type IActiveSessionItem = (IChatSessionItem | IAgentSession) & { +export type IActiveSessionItem = (INewSession | IAgentSession) & { + readonly label?: string; /** * The repository URI for this session. */ @@ -73,7 +71,7 @@ export interface ISessionsManagementService { * Switch to the new-session view. * No-op if the current session is already a new session. */ - openNewSession(): void; + openNewSessionView(): void; /** * Create a pending session object for the given target type. @@ -103,12 +101,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _activeSession = observableValue(this, undefined); readonly activeSession: IObservable = this._activeSession; - private readonly _newSessions = new Map(); + private readonly _newSession = this._register(new MutableDisposable()); private lastSelectedSession: URI | undefined; private readonly isNewChatSessionContext: IContextKey; constructor( @IStorageService private readonly storageService: IStorageService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @@ -116,8 +115,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IViewsService private readonly viewsService: IViewsService, @ICommandService private readonly commandService: ICommandService, ) { super(); @@ -183,7 +180,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.setActiveSession(sessions[0]); this.instantiationService.invokeFunction(openSessionDefault, sessions[0]); } else { - this.openNewSession(); + this.openNewSessionView(); } } @@ -228,25 +225,31 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise { this.isNewChatSessionContext.set(false); - const existingSession = this.agentSessionsService.model.getSession(sessionResource); if (existingSession) { await this.openExistingSession(existingSession, openOptions); - } else if (LocalChatSessionUri.isLocalSession(sessionResource)) { - await this.openLocalSession(); - } else { - await this.openNewRemoteSession(sessionResource); + } else if (this._newSession.value && this.uriIdentityService.extUri.isEqual(sessionResource, this._newSession.value.resource)) { + await this.openNewSession(this._newSession.value); } } async createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise { + if (!this.isNewChatSessionContext.get()) { + this.isNewChatSessionContext.set(true); + } + let newSession: INewSession; if (target === AgentSessionProviders.Background || target === AgentSessionProviders.Local) { newSession = new LocalNewSession(sessionResource, defaultRepoUri, this.chatSessionsService, this.logService); } else { newSession = new RemoteNewSession(sessionResource, target, this.chatSessionsService, this.logService); } - this._newSessions.set(newSession.resource.toString(), newSession); + this._newSession.value = newSession; + this._activeSession.set({ + ...newSession, + repository: newSession.repoUri, + worktree: undefined, + }, undefined); return newSession; } @@ -258,57 +261,37 @@ export class SessionsManagementService extends Disposable implements ISessionsMa await this.instantiationService.invokeFunction(openSessionDefault, session, openOptions); } - /** - * Open a fresh local chat session - show the ChatViewPane and clear the widget. - */ - private async openLocalSession(): Promise { - const view = await this.viewsService.openView(ChatViewId) as ChatViewPane | undefined; - if (view) { - await view.widget.clear(); - if (view.widget.viewModel) { - const folder = this.workspaceContextService.getWorkspace().folders[0]; - const activeSessionItem: IActiveSessionItem = { - resource: view.widget.viewModel.sessionResource, - label: view.widget.viewModel.model.title || '', - timing: view.widget.viewModel.model.timing, - repository: folder?.uri, - worktree: undefined - }; - this._activeSession.set(activeSessionItem, undefined); - } - } - } - /** * Open a new remote session - load the model first, then show it in the ChatViewPane. */ - private async openNewRemoteSession(sessionResource: URI): Promise { - const modelRef = await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + private async openNewSession(newSession: INewSession): Promise { + this._activeSession.set({ + ...newSession, + repository: newSession.repoUri, + worktree: undefined, + }, undefined); + const sessionResource = newSession.resource; const chatWidget = await this.chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); if (!chatWidget?.viewModel) { this.logService.warn(`[ActiveSessionService] Failed to open session: ${sessionResource.toString()}`); - modelRef?.dispose(); return; } const repository = this.getRepositoryFromSessionOption(sessionResource); - const activeSessionItem: IActiveSessionItem = { - resource: sessionResource, - label: chatWidget.viewModel.model.title || '', - timing: chatWidget.viewModel.model.timing, - repository, - worktree: undefined - }; this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); - this._activeSession.set(activeSessionItem, undefined); } async sendRequestForNewSession(sessionResource: URI): Promise { - const session = this._newSessions.get(sessionResource.toString()); + const session = this._newSession.value; if (!session) { this.logService.error(`[SessionsManagementService] No new session found for resource: ${sessionResource.toString()}`); return; } + if (!this.uriIdentityService.extUri.isEqual(sessionResource, session.resource)) { + this.logService.error(`[SessionsManagementService] Session resource mismatch. Expected: ${session.resource.toString()}, received: ${sessionResource.toString()}`); + return; + } + const query = session.query; if (!query) { this.logService.error('[SessionsManagementService] No query set on session'); @@ -330,25 +313,20 @@ export class SessionsManagementService extends Disposable implements ISessionsMa attachedContext: session.attachedContext, }; - await this.sendCustomSession(sessionResource, query, sendOptions, session.selectedOptions); + await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); + await this.doSendRequestForNewSession(session, query, sendOptions, session.selectedOptions); - // Clean up the session after sending - this._newSessions.delete(sessionResource.toString()); - session.dispose(); + // Clean up the session after sending (setter disposes the previous value) + this._newSession.value = undefined; } - /** - * Custom sessions (worktree, cloud, etc.) go through the chat service. - * Options have already been applied via setOption during session configuration. - * Send the request, then wait for the extension to create an agent session. - */ - private async sendCustomSession(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise { + private async doSendRequestForNewSession(session: INewSession, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise { // 1. Open the session - loads the model and shows the ChatViewPane - await this.openSession(sessionResource); + await this.openSession(session.resource); // 2. Apply selected options (repository, branch, etc.) to the contributed session if (selectedOptions && selectedOptions.size > 0) { - const modelRef = this.chatService.acquireExistingSession(sessionResource); + const modelRef = this.chatService.acquireExistingSession(session.resource); if (modelRef) { const model = modelRef.object; const contributedSession = model.contributedChatSession; @@ -369,7 +347,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const existingResources = new Set( this.agentSessionsService.model.sessions.map(s => s.resource.toString()) ); - const result = await this.chatService.sendRequest(sessionResource, query, sendOptions); + const result = await this.chatService.sendRequest(session.resource, query, sendOptions); if (result.kind === 'rejected') { this.logService.error(`[ActiveSessionService] sendRequest rejected: ${result.reason}`); return; @@ -403,7 +381,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } - openNewSession(): void { + openNewSessionView(): void { // No-op if the current session is already a new session if (this.isNewChatSessionContext.get()) { return; diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index d9860c68197ee..367ee4382c68a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -104,7 +104,7 @@ export class AgenticSessionsViewPane extends ViewPane { const newSessionButtonContainer = DOM.append(sessionsContent, $('.agent-sessions-new-button-container')); const newSessionButton = this._register(new Button(newSessionButtonContainer, { ...defaultButtonStyles, secondary: true })); newSessionButton.label = localize('newSession', "New Session"); - this._register(newSessionButton.onDidClick(() => this.activeSessionService.openNewSession())); + this._register(newSessionButton.onDidClick(() => this.activeSessionService.openNewSessionView())); // Keybinding hint inside the button const keybinding = this.keybindingService.lookupKeybinding(ACTION_ID_NEW_CHAT); diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index d75fcd17e5a17..3e3270d3ee67d 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -27,6 +27,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext } from '../../../common/contextkeys.js'; import { EditorResourceAccessor, SideBySideEditor, Verbosity } from '../../../common/editor.js'; import { ResourceLabel } from '../../labels.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; import { mainWindow } from '../../../../base/browser/window.js'; @@ -65,6 +66,7 @@ export class ModalEditorPart { @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IHostService private readonly hostService: IHostService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { } @@ -95,8 +97,8 @@ export class ModalEditorPart { editorPart.close(); } - // Prevent unsupported commands - else { + // Prevent unsupported commands (not in sessions windows) + else if (!this.environmentService.isSessionsWindow) { const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer); if (resolved.kind === ResultKind.KbFound && resolved.commandId) { if ( @@ -244,8 +246,8 @@ export class ModalEditorPart { width = Math.max(containerDimension.width - horizontalPadding, 0); height = Math.max(availableHeight - verticalPadding, 0); } else { - const maxWidth = 1200; - const maxHeight = 800; + const maxWidth = 1400; + const maxHeight = 900; const targetWidth = containerDimension.width * 0.8; const targetHeight = availableHeight * 0.8; width = Math.min(targetWidth, maxWidth, containerDimension.width); diff --git a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts index eb26d779f06eb..36ccd6f2af856 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts @@ -22,7 +22,7 @@ const expandIcon = registerIcon('notifications-expand', Codicon.chevronUp, local const collapseIcon = registerIcon('notifications-collapse', Codicon.chevronDown, localize('collapseIcon', 'Icon for the collapse action in notifications.')); const configureIcon = registerIcon('notifications-configure', Codicon.gear, localize('configureIcon', 'Icon for the configure action in notifications.')); const doNotDisturbIcon = registerIcon('notifications-do-not-disturb', Codicon.bellSlash, localize('doNotDisturbIcon', 'Icon for the mute all action in notifications.')); -export const positionIcon = registerIcon('notifications-position', Codicon.move, localize('positionIcon', 'Icon for the position action in notifications.')); +export const positionIcon = registerIcon('notifications-position', Codicon.arrowSwap, localize('positionIcon', 'Icon for the position action in notifications.')); export class ClearNotificationAction extends Action { diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 8615e08cc6717..c231150b6030a 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -8,6 +8,9 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { localize } from '../../../../nls.js'; import { IBrowserViewBounds, IBrowserViewNavigationEvent, @@ -44,6 +47,19 @@ type IntegratedBrowserNavigationClassification = { comment: 'Tracks navigation patterns in integrated browser'; }; + +type IntegratedBrowserShareWithAgentEvent = { + shared: boolean; + dontAskAgain: boolean; +}; + +type IntegratedBrowserShareWithAgentClassification = { + shared: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the content was shared with the agent' }; + dontAskAgain: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the user chose to not be asked again' }; + owner: 'kycutler'; + comment: 'Tracks user choices around sharing browser content with agents'; +}; + export const IBrowserViewWorkbenchService = createDecorator('browserViewWorkbenchService'); /** @@ -154,7 +170,9 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IPlaywrightService private readonly playwrightService: IPlaywrightService + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IDialogService private readonly dialogService: IDialogService, + @IStorageService private readonly storageService: IStorageService, ) { super(); } @@ -360,8 +378,53 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.clearStorage(this.id); } + private static readonly SHARE_DONT_ASK_KEY = 'browserView.shareWithAgent.dontAskAgain'; + async setSharedWithAgent(shared: boolean): Promise { if (shared) { + const storedChoice = this.storageService.getBoolean(BrowserViewModel.SHARE_DONT_ASK_KEY, StorageScope.PROFILE); + + if (!storedChoice) { + // First time (or no stored preference) -- ask. + const result = await this.dialogService.confirm({ + type: 'question', + title: localize('browserView.shareWithAgent.title', 'Share with Agent?'), + message: localize('browserView.shareWithAgent.message', 'Share this browser page with the agent?'), + detail: localize( + 'browserView.shareWithAgent.detail', + 'The agent will be able to read and modify browser content and saved data, including cookies.' + ), + primaryButton: localize('browserView.shareWithAgent.allow', '&&Allow'), + cancelButton: localize('browserView.shareWithAgent.deny', 'Deny'), + checkbox: { label: localize('browserView.shareWithAgent.dontAskAgain', "Don't ask again"), checked: false }, + }); + + // Only persist "don't ask again" if user accepted sharing, so the button doesn't just do nothing. + if (result.confirmed && result.checkboxChecked) { + this.storageService.store(BrowserViewModel.SHARE_DONT_ASK_KEY, result.confirmed, StorageScope.PROFILE, StorageTarget.USER); + } + + this.telemetryService.publicLog2( + 'integratedBrowser.shareWithAgent', + { + shared: result.confirmed, + dontAskAgain: result.checkboxChecked ?? false + } + ); + + if (!result.confirmed) { + return; + } + } else { + this.telemetryService.publicLog2( + 'integratedBrowser.shareWithAgent', + { + shared: true, + dontAskAgain: true + } + ); + } + await this.playwrightService.startTrackingPage(this.id); } else { await this.playwrightService.stopTrackingPage(this.id); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 28c2dd24f25ee..4adcebc35a11f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -6,9 +6,10 @@ import './media/browser.css'; import { localize } from '../../../../nls.js'; import { $, addDisposableListener, Dimension, EventType, IDomPosition, registerExternalFocusChecker } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; @@ -44,8 +45,10 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; import { IElementAncestor, IElementData, IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js'; -import { logBrowserOpen } from './browserViewTelemetry.js'; +import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { URI } from '../../../../base/common/uri.js'; +import { ChatConfiguration } from '../../chat/common/constants.js'; +import { Event } from '../../../../base/common/event.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); @@ -59,6 +62,16 @@ export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey { + const agentSharingKeys = new Set(canShareBrowserWithAgentContext.keys()); + return Event.filter(contextKeyService.onDidChangeContext, e => e.affectsSome(agentSharingKeys)); +} + /** * Get the original implementation of HTMLElement focus (without window auto-focusing) * before it gets overridden by the workbench. @@ -67,12 +80,15 @@ const originalHtmlElementFocus = HTMLElement.prototype.focus; class BrowserNavigationBar extends Disposable { private readonly _urlInput: HTMLInputElement; + private readonly _shareButton: Button; + private readonly _shareButtonContainer: HTMLElement; constructor( editor: BrowserEditor, container: HTMLElement, instantiationService: IInstantiationService, - scopedContextKeyService: IContextKeyService + scopedContextKeyService: IContextKeyService, + configurationService: IConfigurationService ) { super(); @@ -104,11 +120,28 @@ class BrowserNavigationBar extends Disposable { } )); + // URL input container (wraps input + share toggle) + const urlContainer = $('.browser-url-container'); + // URL input this._urlInput = $('input.browser-url-input'); this._urlInput.type = 'text'; this._urlInput.placeholder = localize('browser.urlPlaceholder', "Enter a URL"); + // Share toggle button (inside URL bar, right side) + this._shareButtonContainer = $('.browser-share-toggle-container'); + this._shareButton = this._register(new Button(this._shareButtonContainer, { + supportIcons: true, + title: localize('browser.shareWithAgent', "Share with Agent"), + small: true, + hoverDelegate + })); + this._shareButton.element.classList.add('browser-share-toggle'); + this._shareButton.label = '$(agent)'; + + urlContainer.appendChild(this._urlInput); + urlContainer.appendChild(this._shareButtonContainer); + // Create actions toolbar (right side) with scoped context const actionsContainer = $('.browser-actions-toolbar'); const actionsToolbar = this._register(scopedInstantiationService.createInstance( @@ -126,9 +159,9 @@ class BrowserNavigationBar extends Disposable { navToolbar.context = editor; actionsToolbar.context = editor; - // Assemble layout: nav | url | actions + // Assemble layout: nav | url container | actions container.appendChild(navContainer); - container.appendChild(this._urlInput); + container.appendChild(urlContainer); container.appendChild(actionsContainer); // Setup URL input handler @@ -145,6 +178,33 @@ class BrowserNavigationBar extends Disposable { this._register(addDisposableListener(this._urlInput, EventType.FOCUS, () => { this._urlInput.select(); })); + + // Share toggle click handler + this._register(this._shareButton.onDidClick(() => { + editor.toggleShareWithAgent(); + })); + + // Show share button only when chat is enabled and browser tools are enabled + const updateShareButtonVisibility = () => { + this._shareButtonContainer.style.display = scopedContextKeyService.contextMatchesRules(canShareBrowserWithAgentContext) ? '' : 'none'; + }; + updateShareButtonVisibility(); + this._register(watchForAgentSharingContextChanges(scopedContextKeyService)(() => { + updateShareButtonVisibility(); + })); + } + + /** + * Update the share toggle visual state + */ + setShared(isShared: boolean): void { + this._shareButton.checked = isShared; + this._shareButton.label = isShared + ? localize('browser.sharingWithAgent', "Sharing with Agent") + ' $(agent)' + : '$(agent)'; + this._shareButton.setTitle(isShared + ? localize('browser.unshareWithAgent', "Stop Sharing with Agent") + : localize('browser.shareWithAgent', "Share with Agent")); } /** @@ -244,7 +304,7 @@ export class BrowserEditor extends EditorPane { const toolbar = $('.browser-toolbar'); // Create navigation bar widget with scoped context - this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService)); + this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService, this.configurationService)); root.appendChild(toolbar); @@ -332,6 +392,7 @@ export class BrowserEditor extends EditorPane { this._storageScopeContext.set(this._model.storageScope); this._devToolsOpenContext.set(this._model.isDevToolsOpen); + this._updateSharingState(); // Update find widget with new model this._findWidget.rawValue?.setModel(this._model); @@ -341,6 +402,14 @@ export class BrowserEditor extends EditorPane { this._model = undefined; })); + // Listen for sharing state changes on the model + this._inputDisposables.add(this._model.onDidChangeSharedWithAgent(() => { + this._updateSharingState(); + })); + this._inputDisposables.add(watchForAgentSharingContextChanges(this.contextKeyService)(() => { + this._updateSharingState(); + })); + // Initialize UI state and context keys from model this.updateNavigationState({ url: this._model.url, @@ -578,6 +647,21 @@ export class BrowserEditor extends EditorPane { return this._model?.url; } + private _updateSharingState(): void { + const sharingEnabled = this.contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext); + const isShared = sharingEnabled && !!this._model && this._model.sharedWithAgent; + + this._browserContainer.classList.toggle('shared', isShared); + this._navigationBar.setShared(isShared); + } + + toggleShareWithAgent(): void { + if (!this._model) { + return; + } + this._model.setSharedWithAgent(!this._model.sharedWithAgent); + } + async navigateToUrl(url: string): Promise { if (this._model) { this.group.pinEditor(this.input); // pin editor on navigation diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts index ed50b7d847999..8fdd335c19441 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts @@ -20,7 +20,7 @@ import { hasKey } from '../../../../base/common/types.js'; import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { BrowserEditor } from './browserEditor.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logBrowserOpen } from './browserViewTelemetry.js'; +import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; const LOADING_SPINNER_SVG = (color: string | undefined) => ` diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index c259cb4061fb1..359ef56102026 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -28,10 +28,11 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logBrowserOpen } from './browserViewTelemetry.js'; +import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; -// Register actions +// Register actions and browser tools import './browserViewActions.js'; +import './tools/browserTools.contribution.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( @@ -156,6 +157,16 @@ Registry.as(ConfigurationExtensions.Configuration).regis 'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' ) }, + 'workbench.browser.enableChatTools': { + type: 'boolean', + default: false, + experiment: { mode: 'startup' }, + tags: ['experimental'], + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.enableChatTools' }, + 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.' + ) + }, 'workbench.browser.dataStorage': { type: 'string', enum: [ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 39305f4c57bce..66f0f46827704 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -19,7 +19,7 @@ import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logBrowserOpen } from './browserViewTelemetry.js'; +import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; // Context key expression to check if browser editor is active const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditor.ID); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts deleted file mode 100644 index 3f6a4f848f3b5..0000000000000 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts +++ /dev/null @@ -1,45 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; - -/** - * Source of an Integrated Browser open event. - * - * - `'commandWithoutUrl'`: opened via the "Open Integrated Browser" command without a URL argument. - * This typically means the user ran the command manually from the Command Palette. - * - `'commandWithUrl'`: opened via the "Open Integrated Browser" command with a URL argument. - * This typically means another extension or component invoked the command programmatically. - * - `'newTabCommand'`: opened via the "New Tab" command from an existing tab. - * - `'localhostLinkOpener'`: opened via the localhost link opener when the - * `workbench.browser.openLocalhostLinks` setting is enabled. This happens when clicking - * localhost links from the terminal, chat, or other sources. - * - `'browserLinkForeground'`: opened when clicking a link inside the Integrated Browser that - * opens in a new focused editor (e.g., links with target="_blank"). - * - `'browserLinkBackground'`: opened when clicking a link inside the Integrated Browser that - * opens in a new background editor (e.g., Ctrl/Cmd+click). - * - `'browserLinkNewWindow'`: opened when clicking a link inside the Integrated Browser that - * opens in a new window (e.g., Shift+click). - * - `'copyToNewWindow'`: opened when the user copies a browser editor to a new window - * via "Copy into New Window". - */ -export type IntegratedBrowserOpenSource = 'commandWithoutUrl' | 'commandWithUrl' | 'newTabCommand' | 'localhostLinkOpener' | 'browserLinkForeground' | 'browserLinkBackground' | 'browserLinkNewWindow' | 'copyToNewWindow'; - -type IntegratedBrowserOpenEvent = { - source: IntegratedBrowserOpenSource; -}; - -type IntegratedBrowserOpenClassification = { - source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the Integrated Browser was opened' }; - owner: 'jruales'; - comment: 'Tracks how users open the Integrated Browser'; -}; - -export function logBrowserOpen(telemetryService: ITelemetryService, source: IntegratedBrowserOpenSource): void { - telemetryService.publicLog2( - 'integratedBrowser.open', - { source } - ); -} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index e040c1a17416e..958e00af4ab0b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -31,18 +31,69 @@ } } + .browser-url-container { + flex: 1; + display: flex; + align-items: center; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + border-radius: var(--vscode-cornerRadius-small); + + &:has(.browser-url-input:focus) { + border-color: var(--vscode-focusBorder); + } + } + .browser-url-input { flex: 1; padding: 4px 8px; - background-color: var(--vscode-input-background); + background-color: transparent; color: var(--vscode-input-foreground); - border: 1px solid var(--vscode-input-border); + border: none; border-radius: var(--vscode-cornerRadius-small); - outline: none; + outline: none !important; font-size: 13px; + } - &:focus { - border-color: var(--vscode-focusBorder); + .browser-share-toggle-container { + display: flex; + align-items: center; + margin: 2px 4px; + flex-shrink: 0; + + .browser-share-toggle { + padding: 2px 4px; + border-radius: var(--vscode-cornerRadius-small); + border-width: 0; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + gap: 4px; + + .codicon { + margin: 0; + } + + &:hover { + background-color: var(--vscode-toolbar-hoverBackground) !important; + color: var(--vscode-foreground); + } + + &.checked { + background: linear-gradient(135deg in lab, + color-mix(in srgb, #51a2ff 25%, transparent), + color-mix(in srgb, #4af0c0 25%, transparent), + color-mix(in srgb, #b44aff 25%, transparent) + ) !important; + color: var(--vscode-foreground); + + &:hover { + background: linear-gradient(135deg in lab, + color-mix(in srgb, #51a2ff 35%, transparent), + color-mix(in srgb, #4af0c0 35%, transparent), + color-mix(in srgb, #b44aff 35%, transparent) + ) !important; + } + } } } @@ -50,9 +101,41 @@ flex: 1; min-height: 0; margin: 0 2px 2px; - overflow: hidden; + overflow: visible; position: relative; + z-index: 0; /* Important: creates a new stacking context for the gradient border trick */ outline: none !important; + border-radius: 2px; + + &.shared { + &::before { + content: ''; + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + z-index: -2; + background: linear-gradient(135deg in lab, + color-mix(in srgb, #51a2ff 100%, transparent), + color-mix(in srgb, #4af0c0 100%, transparent), + color-mix(in srgb, #b44aff 100%, transparent) + ) !important; + pointer-events: none; + } + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -1; + background-color: var(--vscode-editor-background); + pointer-events: none; + } + } } .browser-placeholder-screenshot { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts new file mode 100644 index 0000000000000..e6981287fe2f1 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { IToolResult } from '../../../chat/common/tools/languageModelToolsService.js'; + +// eslint-disable-next-line local/code-import-patterns +import type { Page } from 'playwright-core'; + +/** + * Shared helper for running a Playwright function against a page and returning + * a tool result. Handles success/error formatting. + */ +export async function playwrightInvoke( + playwrightService: IPlaywrightService, + pageId: string, + fn: (page: Page, ...args: TArgs) => Promise, + ...args: TArgs +): Promise { + try { + const result = await playwrightService.invokeFunction(pageId, fn.toString(), ...args); + return { + content: [ + { kind: 'text', value: result.result ? JSON.stringify(result.result) : 'Script executed successfully' }, + { kind: 'text', value: result.summary } + ] + }; + } catch (e) { + return errorResult(e instanceof Error ? e.message : String(e)); + } +} + +export function errorResult(message: string): IToolResult { + return { + content: [{ kind: 'text', value: message }], + toolResultError: message, + }; +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts new file mode 100644 index 0000000000000..340a48a10c1e9 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribution } from '../../../../common/contributions.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; +import { ILanguageModelToolsService, ToolDataSource } from '../../../chat/common/tools/languageModelToolsService.js'; +import { BrowserEditorInput } from '../browserEditorInput.js'; +import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; +import { DragElementTool, DragElementToolData } from './dragElementTool.js'; +import { HandleDialogBrowserTool, HandleDialogBrowserToolData } from './handleDialogBrowserTool.js'; +import { HoverElementTool, HoverElementToolData } from './hoverElementTool.js'; +import { NavigateBrowserTool, NavigateBrowserToolData } from './navigateBrowserTool.js'; +import { OpenBrowserTool, OpenBrowserToolData } from './openBrowserTool.js'; +import { ReadBrowserTool, ReadBrowserToolData } from './readBrowserTool.js'; +import { RunPlaywrightCodeTool, RunPlaywrightCodeToolData } from './runPlaywrightCodeTool.js'; +import { ScreenshotBrowserTool, ScreenshotBrowserToolData } from './screenshotBrowserTool.js'; +import { TypeBrowserTool, TypeBrowserToolData } from './typeBrowserTool.js'; + +class BrowserChatAgentToolsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'browserView.chatAgentTools'; + private static readonly CONTEXT_ID = 'browserView.trackedPages'; + + private readonly _toolsStore = this._register(new DisposableStore()); + + private _trackedIds: ReadonlySet = new Set(); + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IChatContextService private readonly chatContextService: IChatContextService, + @IEditorService private readonly editorService: IEditorService, + ) { + super(); + + this._updateToolRegistrations(); + + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('workbench.browser.enableChatTools')) { + this._updateToolRegistrations(); + } + })); + } + + private _updateToolRegistrations(): void { + this._toolsStore.clear(); + + if (!this.configurationService.getValue('workbench.browser.enableChatTools')) { + this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, []); + return; + } + + const browserToolSet = this._toolsStore.add(this.toolsService.createToolSet( + ToolDataSource.Internal, + 'browser', + 'browser', + { + icon: Codicon.globe, + description: localize('browserToolSet.description', 'Open and interact with integrated browser pages'), + } + )); + + this._toolsStore.add(this.toolsService.registerTool(OpenBrowserToolData, this.instantiationService.createInstance(OpenBrowserTool))); + this._toolsStore.add(this.toolsService.registerTool(ReadBrowserToolData, this.instantiationService.createInstance(ReadBrowserTool))); + this._toolsStore.add(this.toolsService.registerTool(ScreenshotBrowserToolData, this.instantiationService.createInstance(ScreenshotBrowserTool))); + this._toolsStore.add(this.toolsService.registerTool(NavigateBrowserToolData, this.instantiationService.createInstance(NavigateBrowserTool))); + this._toolsStore.add(this.toolsService.registerTool(ClickBrowserToolData, this.instantiationService.createInstance(ClickBrowserTool))); + this._toolsStore.add(this.toolsService.registerTool(DragElementToolData, this.instantiationService.createInstance(DragElementTool))); + this._toolsStore.add(this.toolsService.registerTool(HoverElementToolData, this.instantiationService.createInstance(HoverElementTool))); + this._toolsStore.add(this.toolsService.registerTool(TypeBrowserToolData, this.instantiationService.createInstance(TypeBrowserTool))); + this._toolsStore.add(this.toolsService.registerTool(RunPlaywrightCodeToolData, this.instantiationService.createInstance(RunPlaywrightCodeTool))); + this._toolsStore.add(this.toolsService.registerTool(HandleDialogBrowserToolData, this.instantiationService.createInstance(HandleDialogBrowserTool))); + + this._toolsStore.add(browserToolSet.addTool(OpenBrowserToolData)); + this._toolsStore.add(browserToolSet.addTool(ReadBrowserToolData)); + this._toolsStore.add(browserToolSet.addTool(ScreenshotBrowserToolData)); + this._toolsStore.add(browserToolSet.addTool(NavigateBrowserToolData)); + this._toolsStore.add(browserToolSet.addTool(ClickBrowserToolData)); + this._toolsStore.add(browserToolSet.addTool(DragElementToolData)); + this._toolsStore.add(browserToolSet.addTool(HoverElementToolData)); + this._toolsStore.add(browserToolSet.addTool(TypeBrowserToolData)); + this._toolsStore.add(browserToolSet.addTool(RunPlaywrightCodeToolData)); + this._toolsStore.add(browserToolSet.addTool(HandleDialogBrowserToolData)); + + // Publish tracked browser pages as workspace context for chat requests + this.playwrightService.getTrackedPages().then(ids => { + this._trackedIds = new Set(ids); + this._updateBrowserContext(); + }); + this._toolsStore.add(this.playwrightService.onDidChangeTrackedPages(ids => { + this._trackedIds = new Set(ids); + this._updateBrowserContext(); + })); + this._toolsStore.add(this.editorService.onDidEditorsChange(() => this._updateBrowserContext())); + } + + private _updateBrowserContext(): void { + const lines: string[] = []; + const activeEditor = this.editorService.activeEditor; + const visibleEditors = new Set(this.editorService.visibleEditors); + for (const editor of this.editorService.editors) { + if (editor instanceof BrowserEditorInput && this._trackedIds.has(editor.id)) { + const title = editor.getTitle() || 'Untitled'; + const url = editor.getDescription() || 'about:blank'; + const hint = editor === activeEditor ? ' (active)' : visibleEditors.has(editor) ? ' (visible)' : ''; + lines.push(`- [${editor.id}] ${title} (${url})${hint}`); + } + } + + if (lines.length === 0) { + this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, []); + return; + } + + this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, [{ + handle: 0, + label: localize('browserContext.label', "Browser Pages"), + modelDescription: `The following browser pages are currently available and can be interacted with using the browser tools:`, + value: lines.join('\n'), + }]); + } +} +registerWorkbenchContribution2(BrowserChatAgentToolsContribution.ID, BrowserChatAgentToolsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts new file mode 100644 index 0000000000000..bd911b192fada --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult, playwrightInvoke } from './browserToolHelpers.js'; +import { OpenPageToolId } from './openBrowserTool.js'; + +export const ClickBrowserToolData: IToolData = { + id: 'click_element', + toolReferenceName: 'clickElement', + displayName: localize('clickBrowserTool.displayName', 'Click Element'), + userDescription: localize('clickBrowserTool.userDescription', 'Click an element in a browser page'), + modelDescription: 'Click on an element in a browser page.', + icon: Codicon.cursor, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + pageId: { + type: 'string', + description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + }, + selector: { + type: 'string', + description: 'Playwright selector of the element to click.' + }, + ref: { + type: 'string', + description: 'Element reference to click. One of "selector" or "ref" must be provided.' + }, + dblClick: { + type: 'boolean', + description: 'Set to true for double clicks. Default is false.' + }, + button: { + type: 'string', + enum: ['left', 'right', 'middle'], + description: 'Mouse button to click with. Default is "left".' + }, + }, + required: ['pageId'], + }, +}; + +interface IClickBrowserToolParams { + pageId: string; + selector?: string; + ref?: string; + dblClick?: boolean; + button?: 'left' | 'right' | 'middle'; +} + +export class ClickBrowserTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + invocationMessage: localize('browser.click.invocation', "Clicking element in browser"), + pastTenseMessage: localize('browser.click.past', "Clicked element in browser"), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as IClickBrowserToolParams; + + if (!params.pageId) { + return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); + } + + let selector = params.selector; + if (params.ref) { + selector = `aria-ref=${params.ref}`; + } + + if (!selector) { + return errorResult('Either a "selector" or "ref" parameter is required.'); + } + + const button = params.button ?? 'left'; + + if (params.dblClick) { + return playwrightInvoke(this.playwrightService, params.pageId, (page, sel, btn) => page.locator(sel).dblclick({ button: btn }), selector, button); + } + + return playwrightInvoke(this.playwrightService, params.pageId, (page, sel, btn) => page.locator(sel).click({ button: btn }), selector, button); + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts new file mode 100644 index 0000000000000..15feb72f5ae86 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult, playwrightInvoke } from './browserToolHelpers.js'; +import { OpenPageToolId } from './openBrowserTool.js'; + +export const DragElementToolData: IToolData = { + id: 'drag_element', + toolReferenceName: 'dragElement', + displayName: localize('dragElementTool.displayName', 'Drag Element'), + userDescription: localize('dragElementTool.userDescription', 'Drag an element over another element'), + modelDescription: 'Drag an element over another element in a browser page.', + icon: Codicon.move, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + pageId: { + type: 'string', + description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + }, + fromSelector: { + type: 'string', + description: 'Playwright selector of the element to drag.' + }, + fromRef: { + type: 'string', + description: 'Element reference of the element to drag. One of "fromSelector" or "fromRef" must be provided.' + }, + toSelector: { + type: 'string', + description: 'Playwright selector of the element to drop onto.' + }, + toRef: { + type: 'string', + description: 'Element reference of the element to drop onto. One of "toSelector" or "toRef" must be provided.' + }, + }, + required: ['pageId'], + }, +}; + +interface IDragElementToolParams { + pageId: string; + fromSelector?: string; + fromRef?: string; + toSelector?: string; + toRef?: string; +} + +export class DragElementTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + invocationMessage: localize('browser.drag.invocation', "Dragging element in browser"), + pastTenseMessage: localize('browser.drag.past', "Dragged element in browser"), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as IDragElementToolParams; + + if (!params.pageId) { + return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); + } + + let fromSelector = params.fromSelector; + if (params.fromRef) { + fromSelector = `aria-ref=${params.fromRef}`; + } + if (!fromSelector) { + return errorResult('Either a "fromSelector" or "fromRef" parameter is required for the source element.'); + } + + let toSelector = params.toSelector; + if (params.toRef) { + toSelector = `aria-ref=${params.toRef}`; + } + if (!toSelector) { + return errorResult('Either a "toSelector" or "toRef" parameter is required for the target element.'); + } + + return playwrightInvoke(this.playwrightService, params.pageId, (page, from, to) => page.dragAndDrop(from, to), fromSelector, toSelector); + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts new file mode 100644 index 0000000000000..34d8798bc72f9 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult } from './browserToolHelpers.js'; +import { OpenPageToolId } from './openBrowserTool.js'; + +export const HandleDialogBrowserToolData: IToolData = { + id: 'handle_dialog', + toolReferenceName: 'handleDialog', + displayName: localize('handleDialogBrowserTool.displayName', 'Handle Dialog'), + userDescription: localize('handleDialogBrowserTool.userDescription', 'Respond to a dialog in a browser page'), + modelDescription: 'Respond to a pending dialog (alert, confirm, prompt) or file chooser dialog on a browser page.', + icon: Codicon.comment, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + pageId: { + type: 'string', + description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + }, + accept: { + type: 'boolean', + description: 'Whether to accept (true) or dismiss (false) the dialog.' + }, + promptText: { + type: 'string', + description: 'Text to enter into a prompt dialog. Only applicable for prompt dialogs.' + }, + files: { + type: 'array', + items: { type: 'string' }, + description: 'Absolute paths of files to select. Required for file chooser dialogs.' + }, + }, + required: ['pageId', 'accept'], + }, +}; + +interface IHandleDialogBrowserToolParams { + pageId: string; + accept: boolean; + promptText?: string; + files?: string[]; +} + +export class HandleDialogBrowserTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + invocationMessage: localize('browser.handleDialog.invocation', "Handling browser dialog"), + pastTenseMessage: localize('browser.handleDialog.past', "Handled browser dialog"), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as IHandleDialogBrowserToolParams; + + if (!params.pageId) { + return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); + } + + try { + let result; + if (params.files !== undefined) { + result = await this.playwrightService.replyToFileChooser(params.pageId, params.accept ? params.files : []); + } else { + result = await this.playwrightService.replyToDialog(params.pageId, params.accept, params.promptText); + } + return { content: [{ kind: 'text', value: result.summary }] }; + } catch (e) { + return errorResult(e instanceof Error ? e.message : String(e)); + } + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts new file mode 100644 index 0000000000000..16c3ed50c9ee6 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult, playwrightInvoke } from './browserToolHelpers.js'; +import { OpenPageToolId } from './openBrowserTool.js'; + +export const HoverElementToolData: IToolData = { + id: 'hover_element', + toolReferenceName: 'hoverElement', + displayName: localize('hoverElementTool.displayName', 'Hover Element'), + userDescription: localize('hoverElementTool.userDescription', 'Hover over an element in a browser page'), + modelDescription: 'Hover over an element in a browser page. Provide either a Playwright selector or an element reference.', + icon: Codicon.cursor, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + pageId: { + type: 'string', + description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + }, + selector: { + type: 'string', + description: 'Playwright selector of the element to hover over.' + }, + ref: { + type: 'string', + description: 'Element reference to hover over. One of "selector" or "ref" must be provided.' + }, + }, + required: ['pageId'], + }, +}; + +interface IHoverElementToolParams { + pageId: string; + selector?: string; + ref?: string; +} + +export class HoverElementTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + invocationMessage: localize('browser.hover.invocation', "Hovering over element in browser"), + pastTenseMessage: localize('browser.hover.past', "Hovered over element in browser"), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as IHoverElementToolParams; + + if (!params.pageId) { + return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); + } + + let selector = params.selector; + if (params.ref) { + selector = `aria-ref=${params.ref}`; + } + + if (!selector) { + return errorResult('Either a "selector" or "ref" parameter is required.'); + } + + return playwrightInvoke(this.playwrightService, params.pageId, (page, sel) => page.locator(sel).hover(), selector); + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts new file mode 100644 index 0000000000000..dbad2b31af63f --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult, playwrightInvoke } from './browserToolHelpers.js'; +import { OpenPageToolId } from './openBrowserTool.js'; + +export const NavigateBrowserToolData: IToolData = { + id: 'navigate_page', + toolReferenceName: 'navigatePage', + displayName: localize('navigateBrowserTool.displayName', 'Navigate Page'), + userDescription: localize('navigateBrowserTool.userDescription', 'Navigate or reload a browser page'), + modelDescription: 'Navigate a browser page by URL, history, or reload.', + icon: Codicon.arrowRight, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + pageId: { + type: 'string', + description: `The browser page ID to navigate, acquired from context or ${OpenPageToolId}.` + }, + type: { + type: 'string', + enum: ['url', 'back', 'forward', 'reload'], + description: 'Navigation type: "url" to navigate to a URL (default, requires "url" param), "back" or "forward" for history, "reload" to refresh.' + }, + url: { + type: 'string', + description: 'The URL to navigate to. Required when type is "url".' + }, + }, + required: ['pageId'], + }, +}; + +interface INavigateBrowserToolParams { + pageId: string; + type?: 'url' | 'back' | 'forward' | 'reload'; + url?: string; +} + +export class NavigateBrowserTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const params = context.parameters as INavigateBrowserToolParams; + switch (params.type) { + case 'reload': + return { + invocationMessage: localize('browser.reload.invocation', "Reloading browser page"), + pastTenseMessage: localize('browser.reload.past', "Reloaded browser page"), + }; + case 'back': + return { + invocationMessage: localize('browser.goBack.invocation', "Going back in browser history"), + pastTenseMessage: localize('browser.goBack.past', "Went back in browser history"), + }; + case 'forward': + return { + invocationMessage: localize('browser.goForward.invocation', "Going forward in browser history"), + pastTenseMessage: localize('browser.goForward.past', "Went forward in browser history"), + }; + default: + return { + invocationMessage: localize('browser.navigate.invocation', "Navigating browser to {0}", params.url), + pastTenseMessage: localize('browser.navigate.past', "Navigated browser to {0}", params.url), + confirmationMessages: { + title: localize('browser.navigate.confirmTitle', 'Navigate Browser?'), + message: localize('browser.navigate.confirmMessage', 'This will navigate the browser to {0} and allow the agent to access its contents.', params.url), + allowAutoConfirm: true, + }, + }; + } + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as INavigateBrowserToolParams; + + if (!params.pageId) { + return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); + } + + switch (params.type) { + case 'reload': + return playwrightInvoke(this.playwrightService, params.pageId, (page) => page.reload({ waitUntil: 'domcontentloaded' })); + case 'back': + return playwrightInvoke(this.playwrightService, params.pageId, (page) => page.goBack({ waitUntil: 'domcontentloaded' })); + case 'forward': + return playwrightInvoke(this.playwrightService, params.pageId, (page) => page.goForward({ waitUntil: 'domcontentloaded' })); + default: { + if (!params.url) { + return errorResult('The "url" parameter is required when type is "url".'); + } + return playwrightInvoke(this.playwrightService, params.pageId, (page, url) => { + return page.goto(url, { waitUntil: 'domcontentloaded' }); + }, params.url); + } + } + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts new file mode 100644 index 0000000000000..09cc303f188ef --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult } from './browserToolHelpers.js'; + +export const OpenPageToolId = 'open_browser_page'; + +export const OpenBrowserToolData: IToolData = { + id: OpenPageToolId, + toolReferenceName: 'openBrowserPage', + displayName: localize('openBrowserTool.displayName', 'Open Browser Page'), + userDescription: localize('openBrowserTool.userDescription', 'Open a URL in the integrated browser'), + modelDescription: 'Open a new browser page in the integrated browser at the given URL. Returns a page ID that must be used with other browser tools to interact with the page.', + icon: Codicon.openInProduct, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The URL to open in the browser.' + }, + }, + required: ['url'], + }, +}; + +interface IOpenBrowserToolParams { + url: string; +} + +export class OpenBrowserTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const params = context.parameters as IOpenBrowserToolParams; + return { + invocationMessage: localize('browser.open.invocation', "Opening browser page at {0}", params.url ?? 'about:blank'), + pastTenseMessage: localize('browser.open.past', "Opened browser page at {0}", params.url ?? 'about:blank'), + confirmationMessages: { + title: localize('browser.open.confirmTitle', 'Open Browser Page?'), + message: localize('browser.open.confirmMessage', 'This will open {0} in the integrated browser. The agent will be able to read and interact with its contents.', params.url ?? 'about:blank'), + allowAutoConfirm: true, + }, + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as IOpenBrowserToolParams; + + if (!params.url) { + return errorResult('The "url" parameter is required.'); + } + + const { pageId, summary } = await this.playwrightService.openPage(params.url); + + return { + content: [{ + kind: 'text', + value: `Page ID: ${pageId}\n${summary}`, + }], + }; + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts new file mode 100644 index 0000000000000..f7b379e926fdd --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { localize } from '../../../../../nls.js'; +import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult } from './browserToolHelpers.js'; +import { OpenPageToolId } from './openBrowserTool.js'; + +export const ReadBrowserToolData: IToolData = { + id: 'read_page', + toolReferenceName: 'readPage', + displayName: localize('readBrowserTool.displayName', 'Read Page'), + userDescription: localize('readBrowserTool.userDescription', 'Read the content of a browser page'), + modelDescription: 'Get a snapshot of the current browser page state. This is better than screenshot.', + icon: Codicon.fileText, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + pageId: { + type: 'string', + description: `The browser page ID to read, acquired from context or ${OpenPageToolId}.` + }, + }, + required: ['pageId'], + }, +}; + +interface IReadBrowserToolParams { + pageId: string; +} + +export class ReadBrowserTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const link = `[browser page](${BrowserViewUri.forUrl('', _context.parameters.pageId).toString()})`; + return { + invocationMessage: new MarkdownString(localize('browser.read.invocation', "Reading {0}", link)), + pastTenseMessage: new MarkdownString(localize('browser.read.past', "Read {0}", link)), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as IReadBrowserToolParams; + + if (!params.pageId) { + return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); + } + + const summary = await this.playwrightService.getSummary(params.pageId); + if (!summary) { + return errorResult('No page summary available.'); + } + + return { + content: [{ + kind: 'text', + value: summary, + }], + }; + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts new file mode 100644 index 0000000000000..0e3d46a1b8adb --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult } from './browserToolHelpers.js'; +import { OpenPageToolId } from './openBrowserTool.js'; + +export const RunPlaywrightCodeToolData: IToolData = { + id: 'run_playwright_code', + toolReferenceName: 'runPlaywrightCode', + displayName: localize('runPlaywrightCodeTool.displayName', 'Run Playwright Code'), + userDescription: localize('runPlaywrightCodeTool.userDescription', 'Run a Playwright code snippet against a browser page'), + modelDescription: `Run a Playwright code snippet to control a browser page. Only use this if other browser tools are insufficient.`, + icon: Codicon.terminal, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + pageId: { + type: 'string', + description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + }, + code: { + type: 'string', + description: `The Playwright code to execute. The code must be concise, serve one clear purpose, and be self-contained. You **must not** directly access \`document\` or \`window\` using this tool. You must access it via the provided \`page\` object, e.g. "return page.evaluate(() => document.title)".` + }, + }, + required: ['pageId', 'code'], + }, +}; + +interface IRunPlaywrightCodeToolParams { + pageId: string; + code: string; +} + +export class RunPlaywrightCodeTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const params = context.parameters as IRunPlaywrightCodeToolParams; + const code = params.code ?? ''; + return { + invocationMessage: new MarkdownString(localize('browser.runCode.invocation', "Running Playwright code...")), + pastTenseMessage: new MarkdownString(localize('browser.runCode.past', "Ran Playwright code")), + confirmationMessages: { + title: localize('browser.runCode.confirmTitle', 'Run Playwright Code?'), + message: new MarkdownString(`\`\`\`javascript\n${code.trim()}\n\`\`\``), + disclaimer: localize('browser.runCode.confirmDisclaimer', 'Make sure you trust the code before continuing.'), + allowAutoConfirm: true, + } + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as IRunPlaywrightCodeToolParams; + + if (!params.pageId) { + return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); + } + + if (!params.code) { + return errorResult('The "code" parameter is required.'); + } + + let result; + try { + result = await this.playwrightService.invokeFunction(params.pageId, `async (page) => { ${params.code} }`); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return errorResult(`Code execution failed: ${message}`); + } + + const json = JSON.stringify(result.result || null); + + let outputMessage; + if (result.result) { + outputMessage = new MarkdownString(); + outputMessage.appendMarkdown(localize('browser.runCode.outputLabel', 'Output:')); + outputMessage.appendText('\n'); + outputMessage.appendCodeblock('json', json); + } + + return { + content: [ + { kind: 'text', value: result.result ? json : 'Code executed successfully' }, + { kind: 'text', value: result.summary } + ], + toolResultDetails: { + input: params.code.trim(), + output: result.result + ? [{ type: 'embed', isText: true, value: JSON.stringify(result.result, null, 2) }] + : [], + isError: false, + }, + }; + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts new file mode 100644 index 0000000000000..e0664e36a32f4 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult } from './browserToolHelpers.js'; +import { OpenPageToolId } from './openBrowserTool.js'; +import { ReadBrowserToolData } from './readBrowserTool.js'; + +export const ScreenshotBrowserToolData: IToolData = { + id: 'screenshot_page', + toolReferenceName: 'screenshotPage', + displayName: localize('screenshotBrowserTool.displayName', 'Screenshot Page'), + userDescription: localize('screenshotBrowserTool.userDescription', 'Capture a screenshot of a browser page'), + modelDescription: `Capture a screenshot of the current browser page. You can't perform actions based on the screenshot; use ${ReadBrowserToolData.id} for actions.`, + icon: Codicon.deviceCamera, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + pageId: { + type: 'string', + description: `The browser page ID to capture, acquired from context or ${OpenPageToolId}.` + }, + selector: { + type: 'string', + description: 'Playwright selector of an element to capture. If omitted, captures the whole page.' + }, + ref: { + type: 'string', + description: 'Element reference to capture. If omitted, captures the whole page.' + }, + fullPage: { + type: 'boolean', + description: 'Set to true to capture the full scrollable page instead of just the viewport. Incompatible with selector/ref.' + }, + }, + required: ['pageId'], + }, +}; + +interface IScreenshotBrowserToolParams { + pageId: string; + selector?: string; + ref?: string; + fullPage?: boolean; +} + +export class ScreenshotBrowserTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + invocationMessage: localize('browser.screenshot.invocation', "Capturing browser screenshot"), + pastTenseMessage: localize('browser.screenshot.past', "Captured browser screenshot"), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as IScreenshotBrowserToolParams; + + if (!params.pageId) { + return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); + } + + let selector = params.selector; + if (params.ref) { + selector = `aria-ref=${params.ref}`; + } + + const screenshot = await this.playwrightService.captureScreenshot(params.pageId, selector, params.fullPage); + + return { + content: [ + { + kind: 'data', + value: { + mimeType: 'image/jpeg', + data: screenshot, + }, + }, + ], + }; + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts new file mode 100644 index 0000000000000..a6e156e91a5ef --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult, playwrightInvoke } from './browserToolHelpers.js'; +import { OpenPageToolId } from './openBrowserTool.js'; + +export const TypeBrowserToolData: IToolData = { + id: 'type_in_page', + toolReferenceName: 'typeInPage', + displayName: localize('typeBrowserTool.displayName', 'Type in Page'), + userDescription: localize('typeBrowserTool.userDescription', 'Type text or press keys in a browser page'), + modelDescription: 'Type text or press keys in a browser page.', + icon: Codicon.symbolText, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + pageId: { + type: 'string', + description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + }, + text: { + type: 'string', + description: 'The text to type. One of "text" or "key" must be provided.' + }, + key: { + type: 'string', + description: 'A key or key combination to press (e.g., "Enter", "Tab", "Control+c"). One of "text" or "key" must be provided.' + }, + selector: { + type: 'string', + description: 'Playwright selector of element to target. If omitted, types into the focused element.' + }, + ref: { + type: 'string', + description: 'Element reference to target. If omitted, types into the focused element.' + }, + }, + required: ['pageId'], + }, +}; + +interface ITypeBrowserToolParams { + pageId: string; + text?: string; + key?: string; + selector?: string; + ref?: string; +} + +export class TypeBrowserTool implements IToolImpl { + constructor( + @IPlaywrightService private readonly playwrightService: IPlaywrightService, + ) { } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const params = context.parameters as ITypeBrowserToolParams; + if (params.key) { + return { + invocationMessage: localize('browser.pressKey.invocation', "Pressing key {0} in browser", params.key), + pastTenseMessage: localize('browser.pressKey.past', "Pressed key {0} in browser", params.key), + }; + } + return { + invocationMessage: localize('browser.type.invocation', "Typing text in browser"), + pastTenseMessage: localize('browser.type.past', "Typed text in browser"), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as ITypeBrowserToolParams; + + if (!params.pageId) { + return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); + } + + let selector = params.selector; + if (params.ref) { + selector = `aria-ref=${params.ref}`; + } + + if (!params.text && !params.key) { + return errorResult('Either a "text" or "key" parameter is required.'); + } + + // Press key + if (params.key) { + if (selector) { + return playwrightInvoke(this.playwrightService, params.pageId, (page, sel, key) => page.locator(sel).press(key), selector, params.key); + } + return playwrightInvoke(this.playwrightService, params.pageId, (page, key) => page.keyboard.press(key), params.key); + } + + // Type text + if (selector) { + return playwrightInvoke(this.playwrightService, params.pageId, (page, sel, text) => page.locator(sel).fill(text), selector, params.text!); + } + return playwrightInvoke(this.playwrightService, params.pageId, (page, text) => page.keyboard.type(text), params.text!); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts index bc789fbed6df9..99bad32ce49b2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts @@ -31,7 +31,7 @@ class AnnounceChatConfirmationAction extends Action2 { precondition: ChatContextKeys.enabled, f1: true, keybinding: { - weight: KeybindingWeight.ExternalExtension + 1, + weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyA | KeyMod.Shift, when: ContextKeyExpr.and(CONTEXT_ACCESSIBILITY_MODE_ENABLED, ChatContextKeys.Editing.hasQuestionCarousel.negate()) } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 1472e7f34c197..4903908dd4997 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -894,7 +894,7 @@ export function registerChatActions() { f1: true, precondition: ChatContextKeys.inChatSession, keybinding: [{ - weight: KeybindingWeight.ExternalExtension + 1, + weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, when: ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.Editing.hasQuestionCarousel), }] diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index a2a7c065c6cfc..d6c1702645bab 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -182,7 +182,10 @@ const TIP_CATALOG: ITipDefinition[] = [ 'tip.createSlashCommands', "Tip: Use [/create-instruction](command:workbench.action.chat.generateInstruction), [/create-prompt](command:workbench.action.chat.generatePrompt), [/create-agent](command:workbench.action.chat.generateAgent), or [/create-skill](command:workbench.action.chat.generateSkill) to generate reusable agent customization files." ), - when: ChatContextKeys.hasUsedCreateSlashCommands.negate(), + when: ContextKeyExpr.and( + ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + ChatContextKeys.hasUsedCreateSlashCommands.negate(), + ), enabledCommands: [ 'workbench.action.chat.generateInstruction', 'workbench.action.chat.generatePrompt', diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index fb688523bde10..43a9186f4978c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -67,7 +67,6 @@ background-color: var(--vscode-editorWidget-background); border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 0; border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); - border-bottom: none; font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 7095abd9ecdb4..5a31e7f187cb8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -185,7 +185,11 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { } protected _isSessionTypeEnabled(type: AgentSessionProviders): boolean { - return true; + if (type === AgentSessionProviders.Local) { + return true; // Local is always available + } + // Disable non-local session types when their provider is not registered yet + return !!this.chatSessionsService.getChatSessionContribution(type); } protected _getSessionCategory(sessionTypeItem: ISessionTypeItem) { diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index ae0c91cc93444..4ec33cc52a33a 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -24,7 +24,7 @@ import { IExtensionService } from '../../../services/extensions/common/extension import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { ACTION_ID_NEW_CHAT, CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../browser/actions/chatActions.js'; -import { IChatWidgetService } from '../browser/chat.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../browser/chat.js'; import { AgentSessionProviders } from '../browser/agentSessions/agentSessions.js'; import { isSessionInProgressStatus } from '../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../browser/agentSessions/agentSessionsService.js'; @@ -47,7 +47,8 @@ class ChatCommandLineHandler extends Disposable { @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @ILogService private readonly logService: ILogService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @IContextKeyService private readonly contextKeyService: IContextKeyService + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService ) { super(); @@ -61,6 +62,14 @@ class ChatCommandLineHandler extends Disposable { this.prompt(chatArgs); }); + + ipcRenderer.on('vscode:openChatSession', (_, ...args: unknown[]) => { + const sessionUriString = args[0] as string; + this.logService.trace('vscode:openChatSession', sessionUriString); + + const sessionResource = URI.parse(sessionUriString); + this.chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + }); } private async prompt(args: typeof this.environmentService.args.chat): Promise { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index b1c79a5e6e621..831a6292bae74 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -33,6 +33,7 @@ import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRang import { Range } from '../../../../../editor/common/core/range.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { override contextMatchesRules(rules: ContextKeyExpression): boolean { @@ -769,6 +770,7 @@ suite('ChatTipService', () => { test('shows tip.createSlashCommands when context key is false', () => { const service = createService(); contextKeyService.createKey(ChatContextKeys.hasUsedCreateSlashCommands.key, false); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); // Dismiss tips until we find createSlashCommands or run out let found = false; @@ -787,6 +789,21 @@ suite('ChatTipService', () => { assert.ok(found, 'Should eventually show tip.createSlashCommands when context key is false'); }); + test('does not show tip.createSlashCommands in non-local chat sessions', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.hasUsedCreateSlashCommands.key, false); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'cloud'); + + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.createSlashCommands', 'Should not show tip.createSlashCommands in non-local sessions'); + service.dismissTip(); + } + }); + test('does not show tip.createSlashCommands when context key is true', () => { storageService.store('chat.tips.usedCreateSlashCommands', true, StorageScope.APPLICATION, StorageTarget.MACHINE); const service = createService(); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index 2e6c7a6474b56..def506dfd1a66 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -94,6 +94,7 @@ registerAction2(InlineChatActions.FocusInlineChat); registerAction2(InlineChatActions.SubmitInlineChatInputAction); registerAction2(InlineChatActions.QueueInChatAction); registerAction2(InlineChatActions.HideInlineChatInputAction); +registerAction2(InlineChatActions.FixDiagnosticsAction); const workbenchContributionsRegistry = Registry.as(WorkbenchExtensions.Workbench); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 92789d87d0f16..882525d9900af 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -11,7 +11,7 @@ import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diff import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { InlineChatController, InlineChatRunOptions } from './inlineChatController.js'; -import { ACTION_ACCEPT_CHANGES, ACTION_ASK_IN_CHAT, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, InlineChatConfigKeys } from '../common/inlineChat.js'; +import { ACTION_ACCEPT_CHANGES, ACTION_ASK_IN_CHAT, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, InlineChatConfigKeys, CTX_FIX_DIAGNOSTICS_ENABLED } from '../common/inlineChat.js'; import { ctxHasEditorModification, ctxHasRequestInProgress } from '../../chat/browser/chatEditing/chatEditingEditorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; @@ -246,6 +246,33 @@ export abstract class AbstractInlineChatAction extends EditorAction2 { abstract runInlineChatCommand(accessor: ServicesAccessor, ctrl: InlineChatController, editor: ICodeEditor, ...args: unknown[]): void; } +export class FixDiagnosticsAction extends AbstractInlineChatAction { + + constructor() { + super({ + id: 'inlineChat.fixDiagnostics', + title: localize2('fix', 'Fix'), + icon: Codicon.editSparkle, + precondition: ContextKeyExpr.and(inlineChatContextKey, CTX_FIX_DIAGNOSTICS_ENABLED, EditorContextKeys.selectionHasDiagnostics, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + menu: [{ + id: MenuId.InlineChatEditorAffordance, + group: '1_quickfix', + order: 100, + when: ContextKeyExpr.and(CTX_FIX_DIAGNOSTICS_ENABLED, EditorContextKeys.selectionHasDiagnostics, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + }, { + id: MenuId.ChatEditorInlineMenu, + group: '2_chat', + order: 1, + when: ContextKeyExpr.and(CTX_FIX_DIAGNOSTICS_ENABLED, EditorContextKeys.selectionHasDiagnostics, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + }] + }); + } + + override runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, _editor: ICodeEditor, ..._args: unknown[]): void { + ctrl.run({ autoSend: true, attachDiagnostics: true }); + } +} + class KeepOrUndoSessionAction extends AbstractInlineChatAction { constructor(private readonly _keep: boolean, desc: IAction2Options) { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 7efaea3ee5d90..5b9416ce49d40 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -68,6 +68,7 @@ export abstract class InlineChatRunOptions { position?: IPosition; modelSelector?: ILanguageModelChatSelector; resolveOnResponse?: boolean; + attachDiagnostics?: boolean; static isInlineChatRunOptions(options: unknown): options is InlineChatRunOptions { @@ -75,7 +76,7 @@ export abstract class InlineChatRunOptions { return false; } - const { initialSelection, initialRange, message, autoSend, position, attachments, modelSelector, resolveOnResponse } = options; + const { initialSelection, initialRange, message, autoSend, position, attachments, modelSelector, resolveOnResponse, attachDiagnostics } = options; if ( typeof message !== 'undefined' && typeof message !== 'string' || typeof autoSend !== 'undefined' && typeof autoSend !== 'boolean' @@ -85,6 +86,7 @@ export abstract class InlineChatRunOptions { || typeof attachments !== 'undefined' && (!Array.isArray(attachments) || !attachments.every(item => item instanceof URI)) || typeof modelSelector !== 'undefined' && !isILanguageModelChatSelector(modelSelector) || typeof resolveOnResponse !== 'undefined' && typeof resolveOnResponse !== 'boolean' + || typeof attachDiagnostics !== 'undefined' && typeof attachDiagnostics !== 'boolean' ) { return false; } @@ -506,22 +508,24 @@ export class InlineChatController implements IEditorContribution { try { await this._applyModelDefaults(session, sessionStore); - // ADD diagnostics - const entries: IChatRequestVariableEntry[] = []; - for (const [range, marker] of this._markerDecorationsService.getLiveMarkers(uri)) { - if (range.intersectRanges(this._editor.getSelection())) { - const filter = IDiagnosticVariableEntryFilterData.fromMarker(marker); - entries.push(IDiagnosticVariableEntryFilterData.toEntry(filter)); + // ADD diagnostics (only when explicitly requested) + if (arg?.attachDiagnostics) { + const entries: IChatRequestVariableEntry[] = []; + for (const [range, marker] of this._markerDecorationsService.getLiveMarkers(uri)) { + if (range.intersectRanges(this._editor.getSelection())) { + const filter = IDiagnosticVariableEntryFilterData.fromMarker(marker); + entries.push(IDiagnosticVariableEntryFilterData.toEntry(filter)); + } + } + if (entries.length > 0) { + this._zone.value.widget.chatWidget.attachmentModel.addContext(...entries); + const msg = entries.length > 1 + ? localize('fixN', "Fix the attached problems") + : localize('fix1', "Fix the attached problem"); + this._zone.value.widget.chatWidget.input.setValue(msg, true); + arg.message = msg; + this._zone.value.widget.chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1)); } - } - if (entries.length > 0) { - this._zone.value.widget.chatWidget.attachmentModel.addContext(...entries); - this._zone.value.widget.chatWidget.input.setValue(entries.length > 1 - ? localize('fixN', "Fix the attached problems") - : localize('fix1', "Fix the attached problem"), - true - ); - this._zone.value.widget.chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1)); } // Check args diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts index 1123978c2e85b..e7773395fce86 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts @@ -173,7 +173,7 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi if (action instanceof MenuItemAction && action.id === quickFixCommandId) { return instantiationService.createInstance(QuickFixActionViewItem, action, this._editor); } - if (action instanceof MenuItemAction && (action.id === ACTION_START || action.id === ACTION_ASK_IN_CHAT)) { + if (action instanceof MenuItemAction && (action.id === ACTION_START || action.id === ACTION_ASK_IN_CHAT || action.id === 'inlineChat.fixDiagnostics')) { return instantiationService.createInstance(LabelWithKeybindingActionViewItem, action); } return undefined; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 1d27ddb7da5dd..2a997e1555fdb 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -225,9 +225,14 @@ export class InlineChatInputWidget extends Disposable { })); // ArrowUp on first action bar item moves focus back to input editor + // Escape on action bar hides the widget this._store.add(dom.addDisposableListener(actionBar.domNode, 'keydown', (e: KeyboardEvent) => { const event = new StandardKeyboardEvent(e); - if (event.keyCode === KeyCode.UpArrow) { + if (event.keyCode === KeyCode.Escape) { + event.preventDefault(); + event.stopPropagation(); + this.hide(); + } else if (event.keyCode === KeyCode.UpArrow) { const firstItem = actionBar.viewItems[0] as BaseActionViewItem | undefined; if (firstItem?.element && dom.isAncestorOfActiveElement(firstItem.element)) { event.preventDefault(); diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 28c824b62beaf..5eb1312bd5a33 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -22,6 +22,7 @@ export const enum InlineChatConfigKeys { DefaultModel = 'inlineChat.defaultModel', Affordance = 'inlineChat.affordance', RenderMode = 'inlineChat.renderMode', + FixDiagnostics = 'inlineChat.fixDiagnostics', } Registry.as(Extensions.Configuration).registerConfiguration({ @@ -83,6 +84,15 @@ Registry.as(Extensions.Configuration).registerConfigurat mode: 'auto' }, tags: ['experimental'] + }, + [InlineChatConfigKeys.FixDiagnostics]: { + description: localize('fixDiagnostics', "Controls whether the Fix action is shown for diagnostics in the editor."), + default: false, + type: 'boolean', + experiment: { + mode: 'auto' + }, + tags: ['experimental'] } } }); @@ -130,6 +140,7 @@ export const CTX_INLINE_CHAT_V2_ENABLED = ContextKeyExpr.or( ); export const CTX_HOVER_MODE = ContextKeyExpr.equals('config.inlineChat.renderMode', 'hover'); +export const CTX_FIX_DIAGNOSTICS_ENABLED = ContextKeyExpr.equals('config.inlineChat.fixDiagnostics', true); // --- (selected) action identifier diff --git a/src/vs/workbench/contrib/update/browser/update.contribution.ts b/src/vs/workbench/contrib/update/browser/update.contribution.ts index b013e577beb14..35a6855e8f9e4 100644 --- a/src/vs/workbench/contrib/update/browser/update.contribution.ts +++ b/src/vs/workbench/contrib/update/browser/update.contribution.ts @@ -9,7 +9,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../common/contributions.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { MenuId, registerAction2, Action2 } from '../../../../platform/actions/common/actions.js'; -import { ProductContribution, UpdateContribution, CONTEXT_UPDATE_STATE, SwitchProductQualityContribution, RELEASE_NOTES_URL, showReleaseNotesInEditor, DOWNLOAD_URL, DefaultAccountUpdateContribution } from './update.js'; +import { ProductContribution, UpdateContribution, CONTEXT_UPDATE_STATE, SwitchProductQualityContribution, showReleaseNotesInEditor, DefaultAccountUpdateContribution } from './update.js'; import { UpdateStatusBarEntryContribution } from './updateStatusBarEntry.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import product from '../../../../platform/product/common/product.js'; @@ -23,7 +23,6 @@ import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { URI } from '../../../../base/common/uri.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; const workbench = Registry.as(WorkbenchExtensions.Workbench); @@ -37,6 +36,8 @@ workbench.registerWorkbenchContribution(UpdateStatusBarEntryContribution, Lifecy export class ShowReleaseNotesAction extends Action2 { + static readonly AVAILABLE = !!product.releaseNotesUrl; + constructor() { super({ id: ShowCurrentReleaseNotesActionId, @@ -46,12 +47,10 @@ export class ShowReleaseNotesAction extends Action2 { }, category: { value: product.nameShort, original: product.nameShort }, f1: true, - precondition: RELEASE_NOTES_URL, menu: [{ id: MenuId.MenubarHelpMenu, group: '1_welcome', order: 5, - when: RELEASE_NOTES_URL, }] }); } @@ -100,7 +99,9 @@ export class ShowCurrentReleaseNotesFromCurrentFileAction extends Action2 { } } -registerAction2(ShowReleaseNotesAction); +if (ShowReleaseNotesAction.AVAILABLE) { + registerAction2(ShowReleaseNotesAction); +} registerAction2(ShowCurrentReleaseNotesFromCurrentFileAction); // Update @@ -174,16 +175,17 @@ class RestartToUpdateAction extends Action2 { class DownloadAction extends Action2 { static readonly ID = 'workbench.action.download'; + static readonly AVAILABLE = !!product.downloadUrl; constructor() { super({ id: DownloadAction.ID, title: localize2('openDownloadPage', "Download {0}", product.nameLong), - precondition: ContextKeyExpr.and(IsWebContext, DOWNLOAD_URL), // Only show when running in a web browser and a download url is available + precondition: IsWebContext, // Only show when running in a web browser f1: true, menu: [{ id: MenuId.StatusBarWindowIndicatorMenu, - when: ContextKeyExpr.and(IsWebContext, DOWNLOAD_URL) + when: IsWebContext }] }); } @@ -198,7 +200,9 @@ class DownloadAction extends Action2 { } } -registerAction2(DownloadAction); +if (DownloadAction.AVAILABLE) { + registerAction2(DownloadAction); +} registerAction2(CheckForUpdateAction); registerAction2(DownloadUpdateAction); registerAction2(InstallUpdateAction); diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index f21d2bb70abe2..a9a3ee1932ec6 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -34,8 +34,6 @@ import { IDefaultAccountService } from '../../../../platform/defaultAccount/comm export const CONTEXT_UPDATE_STATE = new RawContextKey('updateState', StateType.Uninitialized); export const MAJOR_MINOR_UPDATE_AVAILABLE = new RawContextKey('majorMinorUpdateAvailable', false); -export const RELEASE_NOTES_URL = new RawContextKey('releaseNotesUrl', ''); -export const DOWNLOAD_URL = new RawContextKey('downloadUrl', ''); let releaseNotesManager: ReleaseNotesManager | undefined = undefined; @@ -184,17 +182,7 @@ export class ProductContribution implements IWorkbenchContribution { @IConfigurationService configurationService: IConfigurationService, @IHostService hostService: IHostService, @IProductService productService: IProductService, - @IContextKeyService contextKeyService: IContextKeyService, ) { - if (productService.releaseNotesUrl) { - const releaseNotesUrlKey = RELEASE_NOTES_URL.bindTo(contextKeyService); - releaseNotesUrlKey.set(productService.releaseNotesUrl); - } - if (productService.downloadUrl) { - const downloadUrlKey = DOWNLOAD_URL.bindTo(contextKeyService); - downloadUrlKey.set(productService.downloadUrl); - } - if (isWeb) { return; } diff --git a/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts new file mode 100644 index 0000000000000..6c3b453b0a6ba --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { ResourceSet } from '../../../../base/common/map.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { QuickInputService } from '../../../../platform/quickinput/browser/quickInputService.js'; +import { PromptFilePickers } from '../../../contrib/chat/browser/promptSyntax/pickers/promptFilePickers.js'; +import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { AgentFileType, IExtensionPromptPath, IResolvedAgentFile, IPromptPath, IPromptsService, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; +import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; + +interface IFixturePromptsState { + localPromptFiles: IPromptPath[]; + userPromptFiles: IPromptPath[]; + extensionPromptFiles: IExtensionPromptPath[]; + agentInstructionFiles: IResolvedAgentFile[]; + disabled: ResourceSet; +} + +interface RenderPromptPickerOptions extends ComponentFixtureContext { + type: PromptsType; + placeholder: string; + seedData: (state: IFixturePromptsState) => void; +} + +class FixtureQuickInputService extends QuickInputService { + override createQuickPick(options: { useSeparators: true }): IQuickPick; + override createQuickPick(options?: { useSeparators: boolean }): IQuickPick; + override createQuickPick(options: { useSeparators: boolean } = { useSeparators: false }): IQuickPick { + const quickPick = super.createQuickPick(options) as IQuickPick; + quickPick.ignoreFocusOut = true; + return quickPick; + } +} + +export default defineThemedFixtureGroup({ + PromptFiles: defineComponentFixture({ + render: context => renderPromptFilePickerFixture({ + ...context, + type: PromptsType.prompt, + placeholder: 'Select the prompt file to run', + seedData: promptsService => { + promptsService.localPromptFiles = [ + { uri: URI.file('/workspace/.github/prompts/refactor.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Refactor Prompt', description: 'Refactor selected code' }, + { uri: URI.file('/workspace/.github/prompts/docs.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Docs Prompt', description: 'Generate docs for symbols' }, + ]; + promptsService.userPromptFiles = [ + { uri: URI.file('/home/dev/.copilot/prompts/review.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Review Prompt', description: 'Review this change' }, + ]; + }, + }), + }), + + InstructionFilesWithAgentInstructions: defineComponentFixture({ + render: context => renderPromptFilePickerFixture({ + ...context, + type: PromptsType.instructions, + placeholder: 'Select instruction files', + seedData: promptsService => { + promptsService.localPromptFiles = [ + { uri: URI.file('/workspace/.github/instructions/repo.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Repo Rules', description: 'Repository-wide coding rules' }, + ]; + promptsService.agentInstructionFiles = [ + { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd }, + { uri: URI.file('/workspace/.github/copilot-instructions.md'), realPath: undefined, type: AgentFileType.copilotInstructionsMd }, + ]; + }, + }), + }), +}); + +async function renderPromptFilePickerFixture({ container, disposableStore, theme, type, placeholder, seedData }: RenderPromptPickerOptions): Promise { + container.style.width = 'fit-content'; + container.style.minHeight = '0'; + container.style.padding = '8px'; + container.style.boxSizing = 'border-box'; + container.style.background = 'var(--vscode-editor-background)'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + container.style.position = 'relative'; + + const quickInputHost = document.createElement('div'); + quickInputHost.style.position = 'relative'; + const hostWidth = 800; + const hostHeight = 600; + quickInputHost.style.width = `${hostWidth}px`; + quickInputHost.style.height = `${hostHeight}px`; + quickInputHost.style.minHeight = `${hostHeight}px`; + quickInputHost.style.overflow = 'hidden'; + container.appendChild(quickInputHost); + + const promptsState: IFixturePromptsState = { + localPromptFiles: [], + userPromptFiles: [], + extensionPromptFiles: [], + agentInstructionFiles: [], + disabled: new ResourceSet(), + }; + seedData(promptsState); + + const promptsService = new class extends mock() { + override async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, _token: CancellationToken): Promise { + switch (storage) { + case PromptsStorage.local: + return promptsState.localPromptFiles.filter(file => file.type === type); + case PromptsStorage.user: + return promptsState.userPromptFiles.filter(file => file.type === type); + case PromptsStorage.extension: + return promptsState.extensionPromptFiles.filter(file => file.type === type); + } + } + + override async listAgentInstructions(_token: CancellationToken): Promise { + return promptsState.agentInstructionFiles; + } + + override async parseNew(_uri: URI, _token: CancellationToken): Promise { + throw new Error('Not implemented'); + } + + override getDisabledPromptFiles(_type: PromptsType): ResourceSet { + return promptsState.disabled; + } + + override setDisabledPromptFiles(_type: PromptsType, uris: ResourceSet): void { + promptsState.disabled = uris; + } + }; + + const layoutService = new class extends mock() { + override activeContainer = quickInputHost; + override get activeContainerDimension() { return { width: hostWidth, height: hostHeight }; } + override activeContainerOffset = { top: 0, quickPickTop: 20 }; + override mainContainer = quickInputHost; + override get mainContainerDimension() { return { width: hostWidth, height: hostHeight }; } + override mainContainerOffset = { top: 0, quickPickTop: 20 }; + override containers = [quickInputHost]; + override onDidLayoutMainContainer = Event.None; + override onDidLayoutContainer = Event.None; + override onDidLayoutActiveContainer = Event.None; + override onDidAddContainer = Event.None; + override onDidChangeActiveContainer = Event.None; + override getContainer(): HTMLElement { + return quickInputHost; + } + override whenContainerStylesLoaded(): Promise | undefined { + return undefined; + } + override focus(): void { } + }; + + const contextMenuService = new class extends mock() { + override onDidShowContextMenu = Event.None; + override onDidHideContextMenu = Event.None; + override showContextMenu(): void { } + }; + + const contextViewService = new class extends mock() { + override anchorAlignment = 0; + override showContextView() { return { close: () => { } }; } + override hideContextView(): void { } + override getContextViewElement(): HTMLElement { return quickInputHost; } + override layout(): void { } + }; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: registration => { + registration.defineInstance(ILayoutService, layoutService); + registration.defineInstance(IContextMenuService, contextMenuService); + registration.defineInstance(IContextViewService, contextViewService); + registration.define(IListService, ListService); + registration.define(IQuickInputService, FixtureQuickInputService); + registration.defineInstance(IPromptsService, promptsService); + registration.defineInstance(IOpenerService, new class extends mock() { }); + registration.defineInstance(IFileService, new class extends mock() { }); + registration.defineInstance(IDialogService, new class extends mock() { }); + registration.defineInstance(ICommandService, new class extends mock() { }); + registration.defineInstance(ILabelService, new class extends mock() { + override getUriLabel(uri: URI): string { + return uri.path; + } + }); + registration.defineInstance(IProductService, new class extends mock() { }); + } + }); + + const pickers = instantiationService.createInstance(PromptFilePickers); + + void pickers.selectPromptFile({ + placeholder, + type, + }); + + return container; +} diff --git a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Dark.png b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Dark.png index 2d42f424f6b88..bfe8d842cf7fe 100644 Binary files a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Dark.png and b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Light.png b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Light.png index 7fb858eae279f..dde67257b6050 100644 Binary files a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Light.png and b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Dark.png b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Dark.png index 9ebb23a380665..5657241a83eb7 100644 Binary files a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Dark.png and b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Light.png b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Light.png index e631cc0c3c30b..b09c6acc05a06 100644 Binary files a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Light.png and b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/ActionBar/Dark.png b/test/componentFixtures/.screenshots/baseline/baseUI/ActionBar/Dark.png index 0ced565eff406..a74b5296d1a7e 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/ActionBar/Dark.png and b/test/componentFixtures/.screenshots/baseline/baseUI/ActionBar/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/ActionBar/Light.png b/test/componentFixtures/.screenshots/baseline/baseUI/ActionBar/Light.png index 6523771ae702a..813f2881117ec 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/ActionBar/Light.png and b/test/componentFixtures/.screenshots/baseline/baseUI/ActionBar/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/ButtonBar/Dark.png b/test/componentFixtures/.screenshots/baseline/baseUI/ButtonBar/Dark.png index ab1c28ac44ff2..c1bcd6e1aa301 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/ButtonBar/Dark.png and b/test/componentFixtures/.screenshots/baseline/baseUI/ButtonBar/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/ButtonBar/Light.png b/test/componentFixtures/.screenshots/baseline/baseUI/ButtonBar/Light.png index 9afb35e184937..8ba87df3aa87c 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/ButtonBar/Light.png and b/test/componentFixtures/.screenshots/baseline/baseUI/ButtonBar/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/Buttons/Dark.png b/test/componentFixtures/.screenshots/baseline/baseUI/Buttons/Dark.png index cd2a52efff43b..0d199f49f958d 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/Buttons/Dark.png and b/test/componentFixtures/.screenshots/baseline/baseUI/Buttons/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/Buttons/Light.png b/test/componentFixtures/.screenshots/baseline/baseUI/Buttons/Light.png index 9651d1f2d57b0..fe6af01eca08e 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/Buttons/Light.png and b/test/componentFixtures/.screenshots/baseline/baseUI/Buttons/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/CountBadges/Dark.png b/test/componentFixtures/.screenshots/baseline/baseUI/CountBadges/Dark.png index e6cc74cdfc2c5..9dff836eb821b 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/CountBadges/Dark.png and b/test/componentFixtures/.screenshots/baseline/baseUI/CountBadges/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/CountBadges/Light.png b/test/componentFixtures/.screenshots/baseline/baseUI/CountBadges/Light.png index 8e9cd825d1fd1..d7035b0ed58ad 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/CountBadges/Light.png and b/test/componentFixtures/.screenshots/baseline/baseUI/CountBadges/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/HighlightedLabels/Dark.png b/test/componentFixtures/.screenshots/baseline/baseUI/HighlightedLabels/Dark.png index 36ed40b5266b7..ee0b9f27b9786 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/HighlightedLabels/Dark.png and b/test/componentFixtures/.screenshots/baseline/baseUI/HighlightedLabels/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/HighlightedLabels/Light.png b/test/componentFixtures/.screenshots/baseline/baseUI/HighlightedLabels/Light.png index cfca6a3329a9a..3798f28702dbb 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/HighlightedLabels/Light.png and b/test/componentFixtures/.screenshots/baseline/baseUI/HighlightedLabels/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/InputBoxes/Dark.png b/test/componentFixtures/.screenshots/baseline/baseUI/InputBoxes/Dark.png index 721a618d708cd..738aea249bc66 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/InputBoxes/Dark.png and b/test/componentFixtures/.screenshots/baseline/baseUI/InputBoxes/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/InputBoxes/Light.png b/test/componentFixtures/.screenshots/baseline/baseUI/InputBoxes/Light.png index d35879dc97583..d247eb3f93dda 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/InputBoxes/Light.png and b/test/componentFixtures/.screenshots/baseline/baseUI/InputBoxes/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/ProgressBars/Dark.png b/test/componentFixtures/.screenshots/baseline/baseUI/ProgressBars/Dark.png index bb25647846e3e..c1901de3f12a4 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/ProgressBars/Dark.png and b/test/componentFixtures/.screenshots/baseline/baseUI/ProgressBars/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/ProgressBars/Light.png b/test/componentFixtures/.screenshots/baseline/baseUI/ProgressBars/Light.png index a542ed0e11db3..94d47c7eb4a37 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/ProgressBars/Light.png and b/test/componentFixtures/.screenshots/baseline/baseUI/ProgressBars/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/Toggles/Dark.png b/test/componentFixtures/.screenshots/baseline/baseUI/Toggles/Dark.png index a59cc7894d20e..4c15594792160 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/Toggles/Dark.png and b/test/componentFixtures/.screenshots/baseline/baseUI/Toggles/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/baseUI/Toggles/Light.png b/test/componentFixtures/.screenshots/baseline/baseUI/Toggles/Light.png index 49c44de392fca..4345d2aa11d79 100644 Binary files a/test/componentFixtures/.screenshots/baseline/baseUI/Toggles/Light.png and b/test/componentFixtures/.screenshots/baseline/baseUI/Toggles/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Dark.png b/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Dark.png index b5d9569f906bd..339aeb80c75ba 100644 Binary files a/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Dark.png and b/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Light.png b/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Light.png index 0a8e19aeb3abf..25a9038c8be02 100644 Binary files a/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Light.png and b/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Dark.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Dark.png index b10535a2ffd01..ebee6a6096cdf 100644 Binary files a/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Dark.png and b/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Light.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Light.png index a6fd87499a814..63b05939a6dbd 100644 Binary files a/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Light.png and b/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Dark.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Dark.png index 52a2280560a1d..ad2595b04837a 100644 Binary files a/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Dark.png and b/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Light.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Light.png index 65169b87a1126..82b0147e7f2a8 100644 Binary files a/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Light.png and b/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Light.png differ diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Dark.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Dark.png index 297cb38eade94..63033ca991851 100644 Binary files a/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Dark.png and b/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Dark.png differ diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Light.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Light.png index 3d564e02878d5..4d303775e2646 100644 Binary files a/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Light.png and b/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Light.png differ diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index 9672580dfd896..f3a0ceb0bfffd 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -702,9 +702,9 @@ } }, "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", + "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", "license": "MIT", "engines": { "node": ">=16.9.0"