diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts index 165855ae6eb65..ba06d2632ad3c 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts @@ -312,10 +312,10 @@ export class DarwinTestRunner extends PosixTestRunner { /** @override */ protected override async binaryPath() { - const { nameLong } = await this.readProductJson(); + const { nameLong, nameShort } = await this.readProductJson(); return path.join( this.repoLocation.uri.fsPath, - `.build/electron/${nameLong}.app/Contents/MacOS/Electron` + `.build/electron/${nameLong}.app/Contents/MacOS/${nameShort}` ); } } diff --git a/.vscode/launch.json b/.vscode/launch.json index a7a15cc31a6c3..9dbed82ee94c2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -505,7 +505,7 @@ "request": "launch", "name": "Run Unit Tests", "program": "${workspaceFolder}/test/unit/electron/index.js", - "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Electron", + "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Code - OSS", "windows": { "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.exe" }, @@ -535,7 +535,7 @@ "request": "launch", "name": "Run Unit Tests For Current File", "program": "${workspaceFolder}/test/unit/electron/index.js", - "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Electron", + "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Code - OSS", "windows": { "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.exe" }, @@ -571,7 +571,7 @@ "timeout": 240000, "args": [ "-l", - "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Electron" + "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Code - OSS" ], "outFiles": [ "${cwd}/out/**/*.js" diff --git a/build/azure-pipelines/darwin/product-build-darwin-universal.yml b/build/azure-pipelines/darwin/product-build-darwin-universal.yml index ed94a1707911f..88722aecc7a53 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-universal.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-universal.yml @@ -98,6 +98,20 @@ jobs: DEBUG=* node build/darwin/create-universal-app.ts $(agent.builddirectory) displayName: Create Universal App + - script: | + set -e + APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" + APP_NAME="`ls $APP_ROOT | head -n 1`" + APP_PATH="$APP_ROOT/$APP_NAME" + EXEC_NAME=$(node -p "require(\"$APP_PATH/Contents/Resources/app/product.json\").nameShort") + # Create a symlink from 'Electron' to the actual executable for backward compatibility + # This ensures apps that relied on the hardcoded path 'Contents/MacOS/Electron' continue to work + # Remove this step once main branch is on 1.112 release. + if [ "$EXEC_NAME" != "Electron" ] && [ ! -L "$APP_PATH/Contents/MacOS/Electron" ]; then + ln -s "$EXEC_NAME" "$APP_PATH/Contents/MacOS/Electron" + fi + displayName: Create Electron symlink for backward compatibility + - script: | set -e APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 7604d54909f31..2854c1f941728 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -166,6 +166,21 @@ steps: chmod +x "$APP_PATH/Contents/Resources/app/bin/$CLI_APP_NAME" displayName: Make CLI executable + - script: | + set -e + APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" + APP_NAME="`ls $APP_ROOT | head -n 1`" + APP_PATH="$APP_ROOT/$APP_NAME" + EXEC_NAME=$(node -p "require(\"$APP_PATH/Contents/Resources/app/product.json\").nameShort") + # Create a symlink from 'Electron' to the actual executable for backward compatibility + # This ensures apps that relied on the hardcoded path 'Contents/MacOS/Electron' continue to work + # Remove this step once main branch is on 1.112 release. + if [ "$EXEC_NAME" != "Electron" ] && [ ! -L "$APP_PATH/Contents/MacOS/Electron" ]; then + ln -s "$EXEC_NAME" "$APP_PATH/Contents/MacOS/Electron" + fi + condition: eq(variables['BUILT_CLIENT'], 'true') + displayName: Create Electron symlink for backward compatibility + - script: | set -e APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml index 80be5496d973e..2028b862f8fbd 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml @@ -58,7 +58,9 @@ steps: set -e APP_ROOT="$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH)" APP_NAME="`ls $APP_ROOT | head -n 1`" - INTEGRATION_TEST_ELECTRON_PATH="$APP_ROOT/$APP_NAME/Contents/MacOS/Electron" \ + ProductJsonPath=$(find "$APP_ROOT" -name "product.json" -type f | head -n 1) + BINARY_NAME=$(jq -r '.nameShort' "$ProductJsonPath") + INTEGRATION_TEST_ELECTRON_PATH="$APP_ROOT/$APP_NAME/Contents/MacOS/$BINARY_NAME" \ ./scripts/test-integration.sh --build --tfs "Integration Tests" env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH) @@ -77,7 +79,9 @@ steps: set -e APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) APP_NAME="`ls $APP_ROOT | head -n 1`" - INTEGRATION_TEST_ELECTRON_PATH="$APP_ROOT/$APP_NAME/Contents/MacOS/Electron" \ + ProductJsonPath=$(find "$APP_ROOT" -name "product.json" -type f | head -n 1) + BINARY_NAME=$(jq -r '.nameShort' "$ProductJsonPath") + INTEGRATION_TEST_ELECTRON_PATH="$APP_ROOT/$APP_NAME/Contents/MacOS/$BINARY_NAME" \ ./scripts/test-remote-integration.sh env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH) diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index 6f5cf0d25d81b..d77b2931d9b2a 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -7,21 +7,22 @@ import { EventEmitter } from 'events'; EventEmitter.defaultMaxListeners = 100; +import es from 'event-stream'; +import glob from 'glob'; import gulp from 'gulp'; +import filter from 'gulp-filter'; +import plumber from 'gulp-plumber'; +import sourcemaps from 'gulp-sourcemaps'; import * as path from 'path'; import * as nodeUtil from 'util'; -import es from 'event-stream'; -import filter from 'gulp-filter'; -import * as util from './lib/util.ts'; +import * as ext from './lib/extensions.ts'; import { getVersion } from './lib/getVersion.ts'; -import * as task from './lib/task.ts'; -import watcher from './lib/watch/index.ts'; import { createReporter } from './lib/reporter.ts'; -import glob from 'glob'; -import plumber from 'gulp-plumber'; -import * as ext from './lib/extensions.ts'; +import * as task from './lib/task.ts'; import * as tsb from './lib/tsb/index.ts'; -import sourcemaps from 'gulp-sourcemaps'; +import { createTsgoStream, spawnTsgo } from './lib/tsgo.ts'; +import * as util from './lib/util.ts'; +import watcher from './lib/watch/index.ts'; const root = path.dirname(import.meta.dirname); const commit = getVersion(root); @@ -78,6 +79,18 @@ const compilations = [ const getBaseUrl = (out: string) => `https://main.vscode-cdn.net/sourcemaps/${commit}/${out}`; +function rewriteTsgoSourceMappingUrlsIfNeeded(build: boolean, out: string, baseUrl: string): Promise { + if (!build) { + return Promise.resolve(); + } + + return util.streamToPromise( + gulp.src(path.join(out, '**', '*.js'), { base: out }) + .pipe(util.rewriteSourceMappingURL(baseUrl)) + .pipe(gulp.dest(out)) + ); +} + const tasks = compilations.map(function (tsconfigFile) { const absolutePath = path.join(root, tsconfigFile); const relativeDirname = path.dirname(tsconfigFile.replace(/^(.*\/)?extensions\//i, '')); @@ -150,25 +163,22 @@ const tasks = compilations.map(function (tsconfigFile) { .pipe(gulp.dest(out)); })); - const compileTask = task.define(`compile-extension:${name}`, task.series(cleanTask, () => { - const pipeline = createPipeline(false, true); - const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'])); - const input = es.merge(nonts, pipeline.tsProjectSrc()); + const compileTask = task.define(`compile-extension:${name}`, task.series(cleanTask, async () => { + const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); + const copyNonTs = util.streamToPromise(nonts.pipe(gulp.dest(out))); + const tsgo = spawnTsgo(absolutePath, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); - return input - .pipe(pipeline()) - .pipe(gulp.dest(out)); + await Promise.all([copyNonTs, tsgo]); })); const watchTask = task.define(`watch-extension:${name}`, task.series(cleanTask, () => { - const pipeline = createPipeline(false); - const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'])); - const input = es.merge(nonts, pipeline.tsProjectSrc()); + const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const watchInput = watcher(src, { ...srcOpts, ...{ readDelay: 200 } }); + const watchNonTs = watchInput.pipe(filter(['**', '!**/*.ts'], { dot: true })).pipe(gulp.dest(out)); + const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); + const watchStream = es.merge(nonts.pipe(gulp.dest(out)), watchNonTs, tsgoStream); - return watchInput - .pipe(util.incremental(pipeline, input)) - .pipe(gulp.dest(out)); + return watchStream; })); // Tasks diff --git a/build/gulpfile.ts b/build/gulpfile.ts index a8e2917035ec7..a57218b844517 100644 --- a/build/gulpfile.ts +++ b/build/gulpfile.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { EventEmitter } from 'events'; +EventEmitter.defaultMaxListeners = 100; + import glob from 'glob'; import gulp from 'gulp'; import { createRequire } from 'node:module'; @@ -12,29 +14,25 @@ import * as compilation from './lib/compilation.ts'; import * as task from './lib/task.ts'; import * as util from './lib/util.ts'; -EventEmitter.defaultMaxListeners = 100; - const require = createRequire(import.meta.url); -const { transpileTask, compileTask, watchTask, compileApiProposalNamesTask, watchApiProposalNamesTask } = compilation; - // API proposal names -gulp.task(compileApiProposalNamesTask); -gulp.task(watchApiProposalNamesTask); +gulp.task(compilation.compileApiProposalNamesTask); +gulp.task(compilation.watchApiProposalNamesTask); // SWC Client Transpile -const transpileClientSWCTask = task.define('transpile-client-esbuild', task.series(util.rimraf('out'), transpileTask('src', 'out', true))); +const transpileClientSWCTask = task.define('transpile-client-esbuild', task.series(util.rimraf('out'), compilation.transpileTask('src', 'out', true))); gulp.task(transpileClientSWCTask); // Transpile only -const transpileClientTask = task.define('transpile-client', task.series(util.rimraf('out'), transpileTask('src', 'out'))); +const transpileClientTask = task.define('transpile-client', task.series(util.rimraf('out'), compilation.transpileTask('src', 'out'))); gulp.task(transpileClientTask); // Fast compile for development time -const compileClientTask = task.define('compile-client', task.series(util.rimraf('out'), compilation.copyCodiconsTask, compileApiProposalNamesTask, compileTask('src', 'out', false))); +const compileClientTask = task.define('compile-client', task.series(util.rimraf('out'), compilation.copyCodiconsTask, compilation.compileApiProposalNamesTask, compilation.compileTask('src', 'out', false))); gulp.task(compileClientTask); -const watchClientTask = task.define('watch-client', task.series(util.rimraf('out'), task.parallel(watchTask('out', false), watchApiProposalNamesTask, compilation.watchCodiconsTask))); +const watchClientTask = task.define('watch-client', task.series(util.rimraf('out'), task.parallel(compilation.watchTask('out', false), compilation.watchApiProposalNamesTask, compilation.watchCodiconsTask))); gulp.task(watchClientTask); // All diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 358bb3acad3cc..8bc20da0c12f7 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -364,6 +364,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d } else if (platform === 'darwin') { const shortcut = gulp.src('resources/darwin/bin/code.sh') .pipe(replace('@@APPNAME@@', product.applicationName)) + .pipe(replace('@@NAME@@', product.nameShort)) .pipe(rename('bin/code')); const policyDest = gulp.src('.build/policies/darwin/**', { base: '.build/policies/darwin' }) .pipe(rename(f => f.dirname = `policies/${f.dirname}`)); diff --git a/build/lib/electron.ts b/build/lib/electron.ts index aadc9b5fbe77b..64786cb2de7e2 100644 --- a/build/lib/electron.ts +++ b/build/lib/electron.ts @@ -109,6 +109,7 @@ export const config = { productAppName: product.nameLong, companyName: 'Microsoft Corporation', copyright: 'Copyright (C) 2026 Microsoft. All rights reserved', + darwinExecutable: product.nameShort, darwinIcon: 'resources/darwin/code.icns', darwinBundleIdentifier: product.darwinBundleIdentifier, darwinApplicationCategoryType: 'public.app-category.developer-tools', diff --git a/build/lib/tsgo.ts b/build/lib/tsgo.ts new file mode 100644 index 0000000000000..c6422deded4be --- /dev/null +++ b/build/lib/tsgo.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as cp from 'child_process'; +import es from 'event-stream'; +import * as path from 'path'; +import { createReporter } from './reporter.ts'; + +const root = path.dirname(path.dirname(import.meta.dirname)); +const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; +const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + +export function spawnTsgo(projectPath: string, onComplete?: () => Promise | void): Promise { + const reporter = createReporter('extensions'); + let report: NodeJS.ReadWriteStream | undefined; + + const beginReport = (emitError: boolean) => { + if (report) { + report.end(); + } + report = reporter.end(emitError); + }; + + const endReport = () => { + if (!report) { + return; + } + report.end(); + report = undefined; + }; + + const args = ['tsgo', '--project', projectPath, '--pretty', 'false', '--sourceMap', '--inlineSources']; + + beginReport(false); + + const child = cp.spawn(npx, args, { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + shell: true + }); + + let buffer = ''; + const handleLine = (line: string) => { + const trimmed = line.replace(ansiRegex, '').trim(); + if (!trimmed) { + return; + } + if (/Starting compilation|File change detected/i.test(trimmed)) { + beginReport(false); + return; + } + if (/Compilation complete/i.test(trimmed)) { + endReport(); + return; + } + + const match = /(.*\(\d+,\d+\): )(.*: )(.*)/.exec(trimmed); + + if (match) { + const fullpath = path.isAbsolute(match[1]) ? match[1] : path.join(root, match[1]); + const message = match[3]; + reporter(fullpath + message); + } else { + reporter(trimmed); + } + }; + + const handleData = (data: Buffer) => { + buffer += data.toString('utf8'); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + for (const line of lines) { + handleLine(line); + } + }; + + child.stdout?.on('data', handleData); + child.stderr?.on('data', handleData); + + const done = new Promise((resolve, reject) => { + child.on('exit', code => { + if (buffer.trim()) { + handleLine(buffer); + buffer = ''; + } + endReport(); + if (code === 0) { + Promise.resolve(onComplete?.()).then(() => resolve(), reject); + return; + } + reject(new Error(`tsgo exited with code ${code ?? 'unknown'}`)); + }); + child.on('error', err => { + endReport(); + reject(err); + }); + }); + + return done; +} + +export function createTsgoStream(projectPath: string, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { + const stream = es.through(); + + spawnTsgo(projectPath, onComplete).then(() => { + stream.emit('end'); + }).catch(() => { + // Errors are already reported by spawnTsgo via the reporter. + // Don't emit 'error' on the stream as that would exit the watch process. + stream.emit('end'); + }); + + return stream; +} diff --git a/extensions/css-language-features/package-lock.json b/extensions/css-language-features/package-lock.json index abb2975f7de83..42656ea4bae20 100644 --- a/extensions/css-language-features/package-lock.json +++ b/extensions/css-language-features/package-lock.json @@ -29,9 +29,9 @@ } }, "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==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/extensions/git-base/src/decorators.ts b/extensions/git-base/src/decorators.ts index 067d32cdb5ffa..23f8dfd357fc5 100644 --- a/extensions/git-base/src/decorators.ts +++ b/extensions/git-base/src/decorators.ts @@ -47,11 +47,11 @@ function _throttle(fn: Function, key: string): Function { return trigger; } -function decorate(decorator: (fn: Function, key: string) => Function): Function { - return function (original: any, context: ClassMethodDecoratorContext) { - if (context.kind !== 'method') { +function decorate(decorator: (fn: Function, key: string) => Function): MethodDecorator { + return (_target: any, key: string | symbol, descriptor: PropertyDescriptor): void => { + if (typeof descriptor.value !== 'function') { throw new Error('not supported'); } - return decorator(original, context.name.toString()); + descriptor.value = decorator(descriptor.value, String(key)); }; } diff --git a/extensions/git/src/api/extension.ts b/extensions/git/src/api/extension.ts index a716fa00dae28..7b0313b6c26e6 100644 --- a/extensions/git/src/api/extension.ts +++ b/extensions/git/src/api/extension.ts @@ -9,14 +9,14 @@ import { ApiRepository, ApiImpl } from './api1'; import { Event, EventEmitter } from 'vscode'; import { CloneManager } from '../cloneManager'; -function deprecated(original: unknown, context: ClassMemberDecoratorContext) { - if (typeof original !== 'function' || context.kind !== 'method') { +function deprecated(_target: unknown, key: string | symbol, descriptor: PropertyDescriptor): void { + if (typeof descriptor.value !== 'function') { throw new Error('not supported'); } - const key = context.name.toString(); - return function (this: unknown, ...args: unknown[]) { - console.warn(`Git extension API method '${key}' is deprecated.`); + const original = descriptor.value; + descriptor.value = function (this: unknown, ...args: unknown[]) { + console.warn(`Git extension API method '${String(key)}' is deprecated.`); return original.apply(this, args); }; } diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index b3e05e0016b57..4451a5e462002 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -372,13 +372,12 @@ interface ScmCommand { const Commands: ScmCommand[] = []; -function command(commandId: string, options: ScmCommandOptions = {}): Function { - return (value: unknown, context: ClassMethodDecoratorContext) => { - if (typeof value !== 'function' || context.kind !== 'method') { +function command(commandId: string, options: ScmCommandOptions = {}): MethodDecorator { + return (_target: any, key: string | symbol, descriptor: PropertyDescriptor): void => { + if (typeof descriptor.value !== 'function') { throw new Error('not supported'); } - const key = context.name.toString(); - Commands.push({ commandId, key, method: value, options }); + Commands.push({ commandId, key: String(key), method: descriptor.value, options }); }; } diff --git a/extensions/git/src/decorators.ts b/extensions/git/src/decorators.ts index 0e59a849ed2f9..3aa7d5dc55764 100644 --- a/extensions/git/src/decorators.ts +++ b/extensions/git/src/decorators.ts @@ -6,11 +6,14 @@ import { done } from './util'; function decorate(decorator: (fn: Function, key: string) => Function): Function { - return function (original: unknown, context: ClassMethodDecoratorContext) { - if (typeof original === 'function' && (context.kind === 'method' || context.kind === 'getter' || context.kind === 'setter')) { - return decorator(original, context.name.toString()); + return (_target: any, key: string, descriptor: PropertyDescriptor): void => { + if (typeof descriptor.value === 'function') { + descriptor.value = decorator(descriptor.value, key); + } else if (typeof descriptor.get === 'function') { + descriptor.get = decorator(descriptor.get, key) as () => any; + } else { + throw new Error('not supported'); } - throw new Error('not supported'); }; } @@ -85,5 +88,5 @@ export function debounce(delay: number): Function { clearTimeout(this[timerKey]); this[timerKey] = setTimeout(() => fn.apply(this, args), delay); }; - }); + }) as MethodDecorator; } diff --git a/extensions/github/src/util.ts b/extensions/github/src/util.ts index f7f54ec5f3f4b..2247292dd93ba 100644 --- a/extensions/github/src/util.ts +++ b/extensions/github/src/util.ts @@ -24,11 +24,14 @@ export class DisposableStore { } function decorate(decorator: (fn: Function, key: string) => Function): Function { - return function (original: any, context: ClassMethodDecoratorContext) { - if (context.kind === 'method' || context.kind === 'getter' || context.kind === 'setter') { - return decorator(original, context.name.toString()); + return (_target: any, key: string, descriptor: PropertyDescriptor): void => { + if (typeof descriptor.value === 'function') { + descriptor.value = decorator(descriptor.value, key); + } else if (typeof descriptor.get === 'function') { + descriptor.get = decorator(descriptor.get, key) as () => any; + } else { + throw new Error('not supported'); } - throw new Error('not supported'); }; } diff --git a/extensions/html-language-features/package-lock.json b/extensions/html-language-features/package-lock.json index ab0ef3f9510e2..442b79121ebb7 100644 --- a/extensions/html-language-features/package-lock.json +++ b/extensions/html-language-features/package-lock.json @@ -30,9 +30,9 @@ } }, "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==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/extensions/json-language-features/package-lock.json b/extensions/json-language-features/package-lock.json index 57df1eea33719..c7c66f40ccc77 100644 --- a/extensions/json-language-features/package-lock.json +++ b/extensions/json-language-features/package-lock.json @@ -30,9 +30,9 @@ } }, "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==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/extensions/notebook-renderers/tsconfig.json b/extensions/notebook-renderers/tsconfig.json index 0bc7baa21be4e..0c1d35312f799 100644 --- a/extensions/notebook-renderers/tsconfig.json +++ b/extensions/notebook-renderers/tsconfig.json @@ -12,7 +12,8 @@ ], "typeRoots": [ "./node_modules/@types" - ] + ], + "skipLibCheck": true }, "include": [ "src/**/*", diff --git a/extensions/tsconfig.base.json b/extensions/tsconfig.base.json index 9d939dd568aa7..db32799b3ba25 100644 --- a/extensions/tsconfig.base.json +++ b/extensions/tsconfig.base.json @@ -15,6 +15,7 @@ "noImplicitOverride": true, "noUnusedLocals": true, "noUnusedParameters": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true } } diff --git a/package.json b/package.json index b7034d2d07b97..8b2c972311791 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "1068d56a15875d7056ef59cfe1df4476138574fd", + "distro": "56aa42f9c6baf6359f28840af043e27a1311455d", "author": { "name": "Microsoft Corporation" }, diff --git a/resources/darwin/bin/code.sh b/resources/darwin/bin/code.sh index de5c3bfcab0f4..9410de8763ec1 100755 --- a/resources/darwin/bin/code.sh +++ b/resources/darwin/bin/code.sh @@ -29,7 +29,7 @@ if [ -z "$APP_PATH" ]; then exit 1 fi CONTENTS="$APP_PATH/Contents" -ELECTRON="$CONTENTS/MacOS/Electron" +ELECTRON="$CONTENTS/MacOS/@@NAME@@" CLI="$CONTENTS/Resources/app/out/cli.js" export VSCODE_NODE_OPTIONS=$NODE_OPTIONS export VSCODE_NODE_REPL_EXTERNAL_MODULE=$NODE_REPL_EXTERNAL_MODULE diff --git a/scripts/code-cli.sh b/scripts/code-cli.sh index 220c34d1a7e05..ef466e50d07d5 100755 --- a/scripts/code-cli.sh +++ b/scripts/code-cli.sh @@ -12,7 +12,8 @@ function code() { if [[ "$OSTYPE" == "darwin"* ]]; then NAME=`node -p "require('./product.json').nameLong"` - CODE="./.build/electron/$NAME.app/Contents/MacOS/Electron" + EXE_NAME=`node -p "require('./product.json').nameShort"` + CODE="./.build/electron/$NAME.app/Contents/MacOS/$EXE_NAME" else NAME=`node -p "require('./product.json').applicationName"` CODE=".build/electron/$NAME" diff --git a/scripts/code-perf.js b/scripts/code-perf.js index 4bc431479f3f7..f1fbcb4f0ef9b 100644 --- a/scripts/code-perf.js +++ b/scripts/code-perf.js @@ -6,6 +6,7 @@ // @ts-check const path = require('path'); +const fs = require('fs'); const perf = require('@vscode/vscode-perf'); const VSCODE_FOLDER = path.join(__dirname, '..'); @@ -62,9 +63,14 @@ function getExePath(buildPath) { } let relativeExePath; switch (process.platform) { - case 'darwin': - relativeExePath = path.join('Contents', 'MacOS', 'Electron'); + case 'darwin': { + const product = require(path.join(buildPath, 'Contents', 'Resources', 'app', 'product.json')); + relativeExePath = path.join('Contents', 'MacOS', product.nameShort); + if (!fs.existsSync(path.join(buildPath, relativeExePath))) { + relativeExePath = path.join('Contents', 'MacOS', 'Electron'); + } break; + } case 'linux': { const product = require(path.join(buildPath, 'resources', 'app', 'product.json')); relativeExePath = product.applicationName; diff --git a/scripts/code.sh b/scripts/code.sh index 1ddbfce7d1ac3..16fdefde55208 100755 --- a/scripts/code.sh +++ b/scripts/code.sh @@ -18,7 +18,8 @@ function code() { if [[ "$OSTYPE" == "darwin"* ]]; then NAME=`node -p "require('./product.json').nameLong"` - CODE="./.build/electron/$NAME.app/Contents/MacOS/Electron" + EXE_NAME=`node -p "require('./product.json').nameShort"` + CODE="./.build/electron/$NAME.app/Contents/MacOS/$EXE_NAME" else NAME=`node -p "require('./product.json').applicationName"` CODE=".build/electron/$NAME" diff --git a/scripts/node-electron.sh b/scripts/node-electron.sh index 102fe073e4fde..187bfe314bb11 100755 --- a/scripts/node-electron.sh +++ b/scripts/node-electron.sh @@ -11,7 +11,8 @@ pushd $ROOT if [[ "$OSTYPE" == "darwin"* ]]; then NAME=`node -p "require('./product.json').nameLong"` - CODE="$ROOT/.build/electron/$NAME.app/Contents/MacOS/Electron" + EXE_NAME=`node -p "require('./product.json').nameShort"` + CODE="$ROOT/.build/electron/$NAME.app/Contents/MacOS/$EXE_NAME" else NAME=`node -p "require('./product.json').applicationName"` CODE="$ROOT/.build/electron/$NAME" diff --git a/scripts/test.sh b/scripts/test.sh index 9ba8dedee0fe7..bc4661ecb7f37 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -12,7 +12,8 @@ cd $ROOT if [[ "$OSTYPE" == "darwin"* ]]; then NAME=`node -p "require('./product.json').nameLong"` - CODE="./.build/electron/$NAME.app/Contents/MacOS/Electron" + EXE_NAME=`node -p "require('./product.json').nameShort"` + CODE="./.build/electron/$NAME.app/Contents/MacOS/$EXE_NAME" else NAME=`node -p "require('./product.json').applicationName"` CODE=".build/electron/$NAME" diff --git a/src/vs/code/electron-utility/sharedProcess/contrib/extensions.ts b/src/vs/code/electron-utility/sharedProcess/contrib/extensions.ts index 4140b645fab6f..d982d6a4614da 100644 --- a/src/vs/code/electron-utility/sharedProcess/contrib/extensions.ts +++ b/src/vs/code/electron-utility/sharedProcess/contrib/extensions.ts @@ -10,21 +10,30 @@ import { migrateUnsupportedExtensions } from '../../../../platform/extensionMana import { INativeServerExtensionManagementService } from '../../../../platform/extensionManagement/node/extensionManagementService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; export class ExtensionsContributions extends Disposable { constructor( - @INativeServerExtensionManagementService extensionManagementService: INativeServerExtensionManagementService, - @IExtensionGalleryService extensionGalleryService: IExtensionGalleryService, - @IExtensionStorageService extensionStorageService: IExtensionStorageService, - @IGlobalExtensionEnablementService extensionEnablementService: IGlobalExtensionEnablementService, + @INativeServerExtensionManagementService private readonly extensionManagementService: INativeServerExtensionManagementService, + @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, + @IExtensionStorageService private readonly extensionStorageService: IExtensionStorageService, + @IGlobalExtensionEnablementService private readonly extensionEnablementService: IGlobalExtensionEnablementService, + @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, @IStorageService storageService: IStorageService, - @ILogService logService: ILogService, + @ILogService private readonly logService: ILogService, ) { super(); extensionManagementService.cleanUp(); - migrateUnsupportedExtensions(extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); + + this.migrateUnsupportedExtensions(); ExtensionStorageService.removeOutdatedExtensionVersions(extensionManagementService, storageService); } + private async migrateUnsupportedExtensions(): Promise { + for (const profile of this.userDataProfilesService.profiles) { + await migrateUnsupportedExtensions(profile, this.extensionManagementService, this.extensionGalleryService, this.extensionStorageService, this.extensionEnablementService, this.logService); + } + } + } diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index b3bdca721dd9e..8e29f4924766b 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -73,7 +73,7 @@ export async function main(argv: string[]): Promise { tunnelProcess = spawn('cargo', ['run', '--', subcommand, ...tunnelArgs], { cwd: join(getAppRoot(), 'cli'), stdio, env }); } else { const appPath = process.platform === 'darwin' - // ./Contents/MacOS/Electron => ./Contents/Resources/app/bin/code-tunnel-insiders + // ./Contents/MacOS/Code => ./Contents/Resources/app/bin/code-tunnel-insiders ? join(dirname(dirname(process.execPath)), 'Resources', 'app') : dirname(process.execPath); const tunnelCommand = join(appPath, 'bin', `${product.tunnelApplicationName}${isWindows ? '.exe' : ''}`); diff --git a/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts b/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts index 1efac036ddd23..79c8c7985fbb7 100644 --- a/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts +++ b/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts @@ -10,6 +10,7 @@ import { IExtensionStorageService } from './extensionStorage.js'; import { ExtensionType } from '../../extensions/common/extensions.js'; import { ILogService } from '../../log/common/log.js'; import * as semver from '../../../base/common/semver/semver.js'; +import { IUserDataProfile } from '../../userDataProfile/common/userDataProfile.js'; /** * Migrates the installed unsupported nightly extension to a supported pre-release extension. It includes following: @@ -18,13 +19,13 @@ import * as semver from '../../../base/common/semver/semver.js'; * - the extension is not installed * - or it is a release version and the unsupported extension is enabled. */ -export async function migrateUnsupportedExtensions(extensionManagementService: IExtensionManagementService, galleryService: IExtensionGalleryService, extensionStorageService: IExtensionStorageService, extensionEnablementService: IGlobalExtensionEnablementService, logService: ILogService): Promise { +export async function migrateUnsupportedExtensions(profile: IUserDataProfile | undefined, extensionManagementService: IExtensionManagementService, galleryService: IExtensionGalleryService, extensionStorageService: IExtensionStorageService, extensionEnablementService: IGlobalExtensionEnablementService, logService: ILogService): Promise { try { const extensionsControlManifest = await extensionManagementService.getExtensionsControlManifest(); if (!extensionsControlManifest.deprecated) { return; } - const installed = await extensionManagementService.getInstalled(ExtensionType.User); + const installed = await extensionManagementService.getInstalled(ExtensionType.User, profile?.extensionsResource); for (const [unsupportedExtensionId, deprecated] of Object.entries(extensionsControlManifest.deprecated)) { if (!deprecated?.extension) { continue; @@ -49,12 +50,12 @@ export async function migrateUnsupportedExtensions(extensionManagementService: I logService.info(`Migrating '${unsupportedExtension.identifier.id}' extension to '${preReleaseExtensionId}' extension...`); const isUnsupportedExtensionEnabled = !extensionEnablementService.getDisabledExtensions().some(e => areSameExtensions(e, unsupportedExtension.identifier)); - await extensionManagementService.uninstall(unsupportedExtension); + await extensionManagementService.uninstall(unsupportedExtension, { profileLocation: profile?.extensionsResource }); logService.info(`Uninstalled the unsupported extension '${unsupportedExtension.identifier.id}'`); let preReleaseExtension = installed.find(i => areSameExtensions(i.identifier, { id: preReleaseExtensionId })); if (!preReleaseExtension || (preReleaseExtension.isPreReleaseVersion !== !!preRelease && isUnsupportedExtensionEnabled)) { - preReleaseExtension = await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: preRelease, isMachineScoped: unsupportedExtension.isMachineScoped, operation: InstallOperation.Migrate, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); + preReleaseExtension = await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: preRelease, isMachineScoped: unsupportedExtension.isMachineScoped, operation: InstallOperation.Migrate, profileLocation: profile?.extensionsResource, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); logService.info(`Installed the pre-release extension '${preReleaseExtension.identifier.id}'`); if (!autoMigrate.donotDisable && !isUnsupportedExtensionEnabled) { await extensionEnablementService.disableExtension(preReleaseExtension.identifier); @@ -85,7 +86,7 @@ export async function migrateUnsupportedExtensions(extensionManagementService: I continue; } - await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: extensionToAutoUpdate.preRelease, isMachineScoped: extensionToAutoUpdate.isMachineScoped, operation: InstallOperation.Update, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); + await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: extensionToAutoUpdate.preRelease, isMachineScoped: extensionToAutoUpdate.isMachineScoped, operation: InstallOperation.Update, profileLocation: profile?.extensionsResource, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); logService.info(`Autoupdated '${extensionToAutoUpdate.identifier.id}' extension to '${gallery.version}' extension.`); } catch (error) { logService.error(error); diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 0d612da63817c..8b89e5b53a8ba 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -43,6 +43,10 @@ const _allApiProposals = { chatContextProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts', }, + chatHooks: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', + version: 1 + }, chatOutputRenderer: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', }, diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index e5fb1abc0b649..5d1a419f8f2b3 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -77,6 +77,18 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION, description: localize('showReleaseNotes', "Show Release Notes after an update. The Release Notes are fetched from a Microsoft online service."), tags: ['usesOnlineServices'] + }, + 'update.statusBar': { + type: 'string', + enum: ['hidden', 'actionable', 'detailed'], + default: 'detailed', + scope: ConfigurationScope.APPLICATION, + description: localize('statusBar', "Controls the visibility of the update status bar entry."), + enumDescriptions: [ + localize('hidden', "The status bar entry is never shown."), + localize('actionable', "The status bar entry is shown when an action is required (e.g., download, install, or restart)."), + localize('detailed', "The status bar entry is shown for all update states including progress.") + ] } } }); diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index b859dfd4c11c6..e1be2b01e3644 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -69,11 +69,11 @@ export type Disabled = { type: StateType.Disabled; reason: DisablementReason }; export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string }; export type CheckingForUpdates = { type: StateType.CheckingForUpdates; explicit: boolean }; export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate }; -export type Downloading = { type: StateType.Downloading; explicit: boolean; overwrite: boolean }; +export type Downloading = { type: StateType.Downloading; update?: IUpdate; explicit: boolean; overwrite: boolean; downloadedBytes?: number; totalBytes?: number; startTime?: number }; export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Updating = { type: StateType.Updating; update: IUpdate }; export type Ready = { type: StateType.Ready; update: IUpdate; explicit: boolean; overwrite: boolean }; -export type Overwriting = { type: StateType.Overwriting; explicit: boolean }; +export type Overwriting = { type: StateType.Overwriting; update: IUpdate; explicit: boolean }; export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | AvailableForDownload | Downloading | Downloaded | Updating | Ready | Overwriting; @@ -83,11 +83,11 @@ export const State = { Idle: (updateType: UpdateType, error?: string): Idle => ({ type: StateType.Idle, updateType, error }), CheckingForUpdates: (explicit: boolean): CheckingForUpdates => ({ type: StateType.CheckingForUpdates, explicit }), AvailableForDownload: (update: IUpdate): AvailableForDownload => ({ type: StateType.AvailableForDownload, update }), - Downloading: (explicit: boolean, overwrite: boolean): Downloading => ({ type: StateType.Downloading, explicit, overwrite }), + Downloading: (update: IUpdate | undefined, explicit: boolean, overwrite: boolean, downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading => ({ type: StateType.Downloading, update, explicit, overwrite, downloadedBytes, totalBytes, startTime }), Downloaded: (update: IUpdate, explicit: boolean, overwrite: boolean): Downloaded => ({ type: StateType.Downloaded, update, explicit, overwrite }), Updating: (update: IUpdate): Updating => ({ type: StateType.Updating, update }), Ready: (update: IUpdate, explicit: boolean, overwrite: boolean): Ready => ({ type: StateType.Ready, update, explicit, overwrite }), - Overwriting: (explicit: boolean): Overwriting => ({ type: StateType.Overwriting, explicit }), + Overwriting: (update: IUpdate, explicit: boolean): Overwriting => ({ type: StateType.Overwriting, update, explicit }), }; export interface IAutoUpdater extends Event.NodeEventEmitter { diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 0e63d40183955..e3bea75cf1fb2 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -248,7 +248,7 @@ export abstract class AbstractUpdateService implements IUpdateService { this.logService.info('update#readyStateCheck: newer update available, restarting update machinery'); await this.cancelPendingUpdate(); this._overwrite = true; - this.setState(State.Overwriting(explicit)); + this.setState(State.Overwriting(this._state.update, explicit)); this.doCheckForUpdates(explicit, pendingUpdateCommit); return true; } diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index b20673b8ccfac..d2d0579bac5be 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -118,7 +118,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return; } - this.setState(State.Downloading(this.state.explicit, this._overwrite)); + this.setState(State.Downloading(this.state.type === StateType.Overwriting ? this.state.update : undefined, this.state.explicit, this._overwrite)); } private onUpdateDownloaded(update: IUpdate): void { diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 3edbd9d9f9a07..72d6af1c48fec 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -8,11 +8,13 @@ import { existsSync, unlinkSync } from 'fs'; import { mkdir, readFile, unlink } from 'fs/promises'; import { tmpdir } from 'os'; import { app } from 'electron'; -import { timeout } from '../../../base/common/async.js'; +import { Delayer, timeout } from '../../../base/common/async.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { memoize } from '../../../base/common/decorators.js'; import { hash } from '../../../base/common/hash.js'; import * as path from '../../../base/common/path.js'; +import { transform } from '../../../base/common/stream.js'; import { URI } from '../../../base/common/uri.js'; import { checksum } from '../../../base/node/crypto.js'; import * as pfs from '../../../base/node/pfs.js'; @@ -188,7 +190,8 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return Promise.resolve(null); } - this.setState(State.Downloading(explicit, this._overwrite)); + const startTime = Date.now(); + this.setState(State.Downloading(update, explicit, this._overwrite, 0, undefined, startTime)); return this.cleanup(update.version).then(() => { return this.getUpdatePackagePath(update.version).then(updatePackagePath => { @@ -200,7 +203,32 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const downloadPath = `${updatePackagePath}.tmp`; return this.requestService.request({ url: update.url }, CancellationToken.None) - .then(context => this.fileService.writeFile(URI.file(downloadPath), context.stream)) + .then(context => { + // Get total size from Content-Length header + const contentLengthHeader = context.res.headers['content-length']; + const contentLength = typeof contentLengthHeader === 'string' ? contentLengthHeader : undefined; + const totalBytes = contentLength ? parseInt(contentLength, 10) : undefined; + + // Track downloaded bytes and update state periodically using Delayer + let downloadedBytes = 0; + const progressDelayer = new Delayer(500); + const progressStream = transform( + context.stream, + { + data: data => { + downloadedBytes += data.byteLength; + progressDelayer.trigger(() => { + this.setState(State.Downloading(update, explicit, this._overwrite, downloadedBytes, totalBytes, startTime)); + }); + return data; + } + }, + chunks => VSBuffer.concat(chunks) + ); + + return this.fileService.writeFile(URI.file(downloadPath), progressStream) + .finally(() => progressDelayer.dispose()); + }) .then(update.sha256hash ? () => checksum(downloadPath, update.sha256hash) : () => undefined) .then(() => pfs.Promises.rename(downloadPath, updatePackagePath, false /* no retry */)) .then(() => updatePackagePath); @@ -326,7 +354,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); const update: IUpdate = { version: 'unknown', productVersion: 'unknown' }; - this.setState(State.Downloading(true, false)); + this.setState(State.Downloading(update, true, false)); this.availableUpdate = { packagePath }; this.setState(State.Downloaded(update, true, false)); diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index bfb284d95117f..de3f735424e16 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -96,6 +96,7 @@ import './mainThreadChatStatus.js'; import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; +import './mainThreadHooks.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 4da025ba8adb8..986f8f27139b4 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -419,61 +419,66 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } const originalEditor = this._editorService.editors.find(editor => editor.resource?.toString() === originalResource.toString()); - const originalModel = this._chatService.getSession(originalResource); + const originalModel = this._chatService.getActiveSessionReference(originalResource); const contribution = this._chatSessionsService.getAllChatSessionContributions().find(c => c.type === chatSessionType); - // Migrate todos from old session to new session - this._chatTodoListService.migrateTodos(originalResource, modifiedResource); + try { - // Find the group containing the original editor - const originalGroup = - this.editorGroupService.groups.find(group => group.editors.some(editor => isEqual(editor.resource, originalResource))) - ?? this.editorGroupService.activeGroup; + // Migrate todos from old session to new session + this._chatTodoListService.migrateTodos(originalResource, modifiedResource); - const options: IChatEditorOptions = { - title: { - preferred: originalEditor?.getName() || undefined, - fallback: localize('chatEditorContributionName', "{0}", contribution?.displayName), - } - }; + // Find the group containing the original editor + const originalGroup = + this.editorGroupService.groups.find(group => group.editors.some(editor => isEqual(editor.resource, originalResource))) + ?? this.editorGroupService.activeGroup; - // Prefetch the chat session content to make the subsequent editor swap quick - const newSession = await this._chatSessionsService.getOrCreateChatSession( - URI.revive(modifiedResource), - CancellationToken.None, - ); - - if (originalEditor) { - newSession.transferredState = originalEditor instanceof ChatEditorInput - ? { editingSession: originalEditor.transferOutEditingSession(), inputState: originalModel?.inputModel.toJSON() } - : undefined; - - this._editorService.replaceEditors([{ - editor: originalEditor, - replacement: { - resource: modifiedResource, - options, - }, - }], originalGroup); - return; - } - - // If chat editor is in the side panel, then those are not listed as editors. - // In that case we need to transfer editing session using the original model. - if (originalModel) { - newSession.transferredState = { - editingSession: originalModel.editingSession, - inputState: originalModel.inputModel.toJSON() + const options: IChatEditorOptions = { + title: { + preferred: originalEditor?.getName() || undefined, + fallback: localize('chatEditorContributionName', "{0}", contribution?.displayName), + } }; - } - const chatViewWidget = this._chatWidgetService.getWidgetBySessionResource(originalResource); - if (chatViewWidget && isIChatViewViewContext(chatViewWidget.viewContext)) { - await this._chatWidgetService.openSession(modifiedResource, undefined, { preserveFocus: true }); - } else { - // Loading the session to ensure the session is created and editing session is transferred. - const ref = await this._chatService.loadSessionForResource(modifiedResource, ChatAgentLocation.Chat, CancellationToken.None); - ref?.dispose(); + // Prefetch the chat session content to make the subsequent editor swap quick + const newSession = await this._chatSessionsService.getOrCreateChatSession( + URI.revive(modifiedResource), + CancellationToken.None, + ); + + if (originalEditor) { + newSession.transferredState = originalEditor instanceof ChatEditorInput + ? { editingSession: originalEditor.transferOutEditingSession(), inputState: originalModel?.object?.inputModel.toJSON() } + : undefined; + + await this._editorService.replaceEditors([{ + editor: originalEditor, + replacement: { + resource: modifiedResource, + options, + }, + }], originalGroup); + return; + } + + // If chat editor is in the side panel, then those are not listed as editors. + // In that case we need to transfer editing session using the original model. + if (originalModel) { + newSession.transferredState = { + editingSession: originalModel.object.editingSession, + inputState: originalModel.object.inputModel.toJSON() + }; + } + + const chatViewWidget = this._chatWidgetService.getWidgetBySessionResource(originalResource); + if (chatViewWidget && isIChatViewViewContext(chatViewWidget.viewContext)) { + await this._chatWidgetService.openSession(modifiedResource, undefined, { preserveFocus: true }); + } else { + // Loading the session to ensure the session is created and editing session is transferred. + const ref = await this._chatService.loadSessionForResource(modifiedResource, ChatAgentLocation.Chat, CancellationToken.None); + ref?.dispose(); + } + } finally { + originalModel?.dispose(); } } diff --git a/src/vs/workbench/api/browser/mainThreadHooks.ts b/src/vs/workbench/api/browser/mainThreadHooks.ts new file mode 100644 index 0000000000000..3265f82878e50 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadHooks.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostContext, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; +import { HookResultKind, IHookResult, IHooksExecutionProxy, IHooksExecutionService } from '../../contrib/chat/common/hooksExecutionService.js'; +import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; + +@extHostNamedCustomer(MainContext.MainThreadHooks) +export class MainThreadHooks extends Disposable implements MainThreadHooksShape { + + constructor( + extHostContext: IExtHostContext, + @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, + ) { + super(); + const extHostProxy = extHostContext.getProxy(ExtHostContext.ExtHostHooks); + + // Adapter that implements IHooksExecutionProxy by forwarding to ExtHostHooksShape + const proxy: IHooksExecutionProxy = { + executeHook: async (hookType: HookTypeValue, sessionResource: URI, input: unknown): Promise => { + const results = await extHostProxy.$executeHook(hookType, sessionResource, input); + return results.map(r => ({ + kind: r.kind as HookResultKind, + result: r.result + })); + } + }; + + this._hooksExecutionService.setProxy(proxy); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 3c5f640337028..6cec4600e3449 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -65,6 +65,7 @@ import { IExtHostConsumerFileSystem } from './extHostFileSystemConsumer.js'; import { ExtHostFileSystemEventService, FileSystemWatcherCreateOptions } from './extHostFileSystemEventService.js'; import { IExtHostFileSystemInfo } from './extHostFileSystemInfo.js'; import { IExtHostInitDataService } from './extHostInitDataService.js'; +import { IExtHostHooks } from './extHostHooks.js'; import { ExtHostInteractive } from './extHostInteractive.js'; import { ExtHostLabelService } from './extHostLabelService.js'; import { ExtHostLanguageFeatures } from './extHostLanguageFeatures.js'; @@ -238,6 +239,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); + rpcProtocol.set(ExtHostContext.ExtHostHooks, accessor.get(IExtHostHooks)); // Check that no named customers are missing const expected = Object.values>(ExtHostContext); @@ -249,6 +251,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostMessageService = new ExtHostMessageService(rpcProtocol, extHostLogService); const extHostDialogs = new ExtHostDialogs(rpcProtocol); const extHostChatStatus = new ExtHostChatStatus(rpcProtocol); + const extHostHooks = accessor.get(IExtHostHooks); + extHostHooks.initialize(extHostChatAgents2); // Register API-ish commands ExtHostApiCommands.register(extHostCommands); @@ -1591,6 +1595,12 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider); }, + executeHook(hookType: vscode.ChatHookType, options: vscode.ChatHookExecutionOptions, token?: vscode.CancellationToken): Thenable { + checkProposedApiEnabled(extension, 'chatHooks'); + return extHostHooks.executeHook(hookType, options, token).then(results => + results.map(r => ({ kind: r.kind as unknown as vscode.ChatHookResultKind, result: r.result })) + ); + }, }; // namespace: lm @@ -2013,7 +2023,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I McpToolAvailability: extHostTypes.McpToolAvailability, McpToolInvocationContentData: extHostTypes.McpToolInvocationContentData, SettingsSearchResultKind: extHostTypes.SettingsSearchResultKind, - ChatTodoStatus: extHostTypes.ChatTodoStatus + ChatHookResultKind: extHostTypes.ChatHookResultKind, + ChatTodoStatus: extHostTypes.ChatTodoStatus, }; }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 34f6cb4649a5d..6489598d42ca0 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3197,6 +3197,15 @@ export interface IStartMcpOptions { errorOnUserInteraction?: boolean; } +export interface IHookResultDto { + readonly kind: number; + readonly result: string | object; +} + +export interface ExtHostHooksShape { + $executeHook(hookType: string, sessionResource: UriComponents, input: unknown): Promise; +} + export interface ExtHostMcpShape { @@ -3252,6 +3261,10 @@ export interface MainThreadMcpShape { export interface MainThreadDataChannelsShape extends IDisposable { } +export interface MainThreadHooksShape extends IDisposable { + // Empty - main thread only calls extension host, no callbacks needed +} + export interface ExtHostDataChannelsShape { $onDidReceiveData(channelId: string, data: unknown): void; } @@ -3485,6 +3498,7 @@ export const MainContext = { MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), + MainThreadHooks: createProxyIdentifier('MainThreadHooks'), MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), @@ -3562,6 +3576,7 @@ export const ExtHostContext = { ExtHostTelemetry: createProxyIdentifier('ExtHostTelemetry'), ExtHostLocalization: createProxyIdentifier('ExtHostLocalization'), ExtHostMcp: createProxyIdentifier('ExtHostMcp'), + ExtHostHooks: createProxyIdentifier('ExtHostHooks'), ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), }; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index d2458d0af6400..b15c6fc1fd31e 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -22,6 +22,7 @@ import { ILogService } from '../../../platform/log/common/log.js'; import { isChatViewTitleActionContext } from '../../contrib/chat/common/actions/chatActions.js'; import { IChatAgentRequest, IChatAgentResult, IChatAgentResultTimings, UserSelectedTools } from '../../contrib/chat/common/participants/chatAgents.js'; import { ChatAgentVoteDirection, IChatContentReference, IChatFollowup, IChatResponseErrorDetails, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatRequestHooks } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; @@ -447,6 +448,7 @@ interface InFlightChatRequest { requestId: string; extRequest: vscode.ChatRequest; extension: IRelaxedExtensionDescription; + hooks?: IChatRequestHooks; } export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsShape2 { @@ -523,6 +525,12 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return agent.apiAgent; } + getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined { + const sessionResourceString = sessionResource.toString(); + const request = [...this._inFlightRequests].find(r => r.extRequest.sessionResource.toString() === sessionResourceString); + return request?.hooks; + } + registerChatParticipantDetectionProvider(extension: IExtensionDescription, provider: vscode.ChatParticipantDetectionProvider): vscode.Disposable { const handle = ExtHostChatAgents2._participantDetectionProviderIdPool++; this._participantDetectionProviders.set(handle, new ExtHostParticipantDetector(extension, provider)); @@ -715,7 +723,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS agent.extension, this._logService ); - inFlightRequest = { requestId: requestDto.requestId, extRequest, extension: agent.extension }; + inFlightRequest = { requestId: requestDto.requestId, extRequest, extension: agent.extension, hooks: request.hooks }; this._inFlightRequests.add(inFlightRequest); diff --git a/src/vs/workbench/api/common/extHostHooks.ts b/src/vs/workbench/api/common/extHostHooks.ts new file mode 100644 index 0000000000000..01c69be62d119 --- /dev/null +++ b/src/vs/workbench/api/common/extHostHooks.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { UriComponents } from '../../../base/common/uri.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { IHookResult } from '../../contrib/chat/common/hooksExecutionService.js'; +import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { ExtHostHooksShape, IHookResultDto } from './extHost.protocol.js'; +import { ExtHostChatAgents2 } from './extHostChatAgents2.js'; + +export const IExtHostHooks = createDecorator('IExtHostHooks'); + +export interface IChatHookExecutionOptions { + readonly input?: unknown; + readonly toolInvocationToken: unknown; +} + +export interface IExtHostHooks extends ExtHostHooksShape { + initialize(extHostChatAgents: ExtHostChatAgents2): void; + executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise; + $executeHook(hookType: string, sessionResource: UriComponents, input: unknown): Promise; +} diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index a0a9ed3a0a5a1..d927b08e96e98 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3935,6 +3935,15 @@ export enum SettingsSearchResultKind { //#endregion +//#region Chat Hooks + +export enum ChatHookResultKind { + Success = 1, + Error = 2 +} + +//#endregion + //#region Speech export enum SpeechToTextStatus { diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index 55acd8bd9c11a..5f52766f40a0f 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -31,6 +31,8 @@ import { IExtHostMpcService } from '../common/extHostMcp.js'; import { NodeExtHostMpcService } from './extHostMcpNode.js'; import { IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { NodeExtHostAuthentication } from './extHostAuthentication.js'; +import { IExtHostHooks } from '../common/extHostHooks.js'; +import { NodeExtHostHooks } from './extHostHooksNode.js'; // ######################################################################### // ### ### @@ -53,3 +55,4 @@ registerSingleton(IExtHostTerminalService, ExtHostTerminalService, Instantiation registerSingleton(IExtHostTunnelService, NodeExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostVariableResolverProvider, NodeExtHostVariableResolverProviderService, InstantiationType.Eager); registerSingleton(IExtHostMpcService, NodeExtHostMpcService, InstantiationType.Eager); +registerSingleton(IExtHostHooks, NodeExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/node/extHostHooksNode.ts b/src/vs/workbench/api/node/extHostHooksNode.ts new file mode 100644 index 0000000000000..36c6ef91e7449 --- /dev/null +++ b/src/vs/workbench/api/node/extHostHooksNode.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { spawn } from 'child_process'; +import { homedir } from 'os'; +import { disposableTimeout } from '../../../base/common/async.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; +import { ILogService } from '../../../platform/log/common/log.js'; +import { HookTypeValue, IChatRequestHooks, IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; +import { IHookResultDto } from '../common/extHost.protocol.js'; +import { ExtHostChatAgents2 } from '../common/extHostChatAgents2.js'; +import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; +import { HookResultKind, IHookResult } from '../../contrib/chat/common/hooksExecutionService.js'; + +const SIGKILL_DELAY_MS = 5000; + +export class NodeExtHostHooks implements IExtHostHooks { + + private _extHostChatAgents: ExtHostChatAgents2 | undefined; + + constructor( + @ILogService private readonly _logService: ILogService + ) { } + + initialize(extHostChatAgents: ExtHostChatAgents2): void { + this._extHostChatAgents = extHostChatAgents; + } + + async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { + if (!this._extHostChatAgents) { + throw new Error('ExtHostHooks not initialized'); + } + + if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { + throw new Error('Invalid or missing tool invocation token'); + } + + const context = options.toolInvocationToken as IToolInvocationContext; + return this._executeHooks(hookType, context.sessionResource, options.input, token); + } + + async $executeHook(hookType: string, sessionResource: UriComponents, input: unknown): Promise { + if (!this._extHostChatAgents) { + return []; + } + + const uri = URI.revive(sessionResource); + const results = await this._executeHooks(hookType as HookTypeValue, uri, input, undefined); + return results.map(r => ({ kind: r.kind, result: r.result })); + } + + private async _executeHooks(hookType: HookTypeValue, sessionResource: URI, input: unknown, token?: CancellationToken): Promise { + const hooks = this._extHostChatAgents!.getHooksForSession(sessionResource); + if (!hooks) { + return []; + } + + const hookCommands = this._getHooksForType(hooks, hookType); + if (!hookCommands || hookCommands.length === 0) { + return []; + } + + this._logService.debug(`[ExtHostHooks] Executing ${hookCommands.length} hook(s) for type '${hookType}'`); + this._logService.trace(`[ExtHostHooks] Hook input:`, input); + + const results: IHookResult[] = []; + for (const hookCommand of hookCommands) { + try { + this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`); + const result = await this._executeCommand(hookCommand, input, token); + this._logService.debug(`[ExtHostHooks] Hook completed with result kind: ${result.kind === HookResultKind.Success ? 'Success' : 'Error'}`); + this._logService.trace(`[ExtHostHooks] Hook output:`, result.result); + results.push(result); + } catch (err) { + this._logService.debug(`[ExtHostHooks] Hook failed with error: ${err instanceof Error ? err.message : String(err)}`); + results.push({ + kind: HookResultKind.Error, + result: err instanceof Error ? err.message : String(err) + }); + } + } + return results; + } + + private _getHooksForType(hooks: IChatRequestHooks, hookType: HookTypeValue): readonly IHookCommand[] | undefined { + return hooks[hookType]; + } + + private _executeCommand(hook: IHookCommand, input: unknown, token?: CancellationToken): Promise { + const home = homedir(); + const cwd = hook.cwd ? hook.cwd.fsPath : home; + + // Determine command and args based on which property is specified + // For bash/powershell: spawn the shell directly with explicit args to avoid double shell wrapping + // For generic command: use shell=true to let the system shell handle it + let command: string; + let args: string[]; + let shell: boolean; + if (hook.bash) { + command = 'bash'; + args = ['-c', hook.bash]; + shell = false; + } else if (hook.powershell) { + command = 'powershell'; + args = ['-Command', hook.powershell]; + shell = false; + } else { + command = hook.command!; + args = []; + shell = true; + } + + const child = spawn(command, args, { + stdio: 'pipe', + cwd, + env: { ...process.env, ...hook.env }, + shell, + }); + + return new Promise((resolve, reject) => { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | null = null; + let exited = false; + + const disposables = new DisposableStore(); + const sigkillTimeout = disposables.add(new MutableDisposable()); + + const killWithEscalation = () => { + if (exited) { + return; + } + child.kill('SIGTERM'); + sigkillTimeout.value = disposableTimeout(() => { + if (!exited) { + child.kill('SIGKILL'); + } + }, SIGKILL_DELAY_MS); + }; + + const cleanup = () => { + exited = true; + disposables.dispose(); + }; + + // Collect output + child.stdout.on('data', data => stdout.push(data.toString())); + child.stderr.on('data', data => stderr.push(data.toString())); + + // Set up timeout (default 30 seconds) + disposables.add(disposableTimeout(killWithEscalation, (hook.timeoutSec ?? 30) * 1000)); + + // Set up cancellation + if (token) { + disposables.add(token.onCancellationRequested(killWithEscalation)); + } + + // Write input to stdin + if (input !== undefined && input !== null) { + try { + child.stdin.write(JSON.stringify(input)); + } catch { + // Ignore stdin write errors + } + } + child.stdin.end(); + + // Capture exit code + child.on('exit', code => { exitCode = code; }); + + // Resolve on close (after streams flush) + child.on('close', () => { + cleanup(); + const code = exitCode ?? 1; + const stdoutStr = stdout.join(''); + const stderrStr = stderr.join(''); + + if (code === 0) { + // Success - try to parse stdout as JSON, otherwise return as string + let result: string | object = stdoutStr; + try { + result = JSON.parse(stdoutStr); + } catch { + // Keep as string if not valid JSON + } + resolve({ kind: HookResultKind.Success, result }); + } else { + // Error + resolve({ kind: HookResultKind.Error, result: stderrStr }); + } + }); + + child.on('error', err => { + cleanup(); + reject(err); + }); + }); + } +} diff --git a/src/vs/workbench/api/test/node/extHostHooks.test.ts b/src/vs/workbench/api/test/node/extHostHooks.test.ts new file mode 100644 index 0000000000000..603fedbeceec4 --- /dev/null +++ b/src/vs/workbench/api/test/node/extHostHooks.test.ts @@ -0,0 +1,290 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../platform/log/common/log.js'; +import { NodeExtHostHooks } from '../../node/extHostHooksNode.js'; +import { HookType, IChatRequestHooks, IHookCommand } from '../../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { ExtHostChatAgents2 } from '../../common/extHostChatAgents2.js'; +import { IToolInvocationContext } from '../../../contrib/chat/common/tools/languageModelToolsService.js'; +import { ChatHookResultKind } from '../../common/extHostTypes.js'; + +function createHookCommand(command: string, options?: Partial>): IHookCommand { + return { + type: 'command', + command, + ...options, + }; +} + +function createMockToolInvocationContext(sessionResource: URI): IToolInvocationContext { + return { + sessionId: 'test-session-id', + sessionResource, + }; +} + +function createMockExtHostChatAgents(hooks: IChatRequestHooks | undefined): Pick { + return { + getHooksForSession(_sessionResource: URI): IChatRequestHooks | undefined { + return hooks; + } + }; +} + +suite.skip('ExtHostHooks', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let hooksService: NodeExtHostHooks; + let sessionResource: URI; + + setup(() => { + hooksService = new NodeExtHostHooks(new NullLogService()); + sessionResource = URI.parse('vscode-chat-session://test-session'); + }); + + test('executeHook throws when not initialized', async () => { + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + + await assert.rejects( + () => hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ), + /ExtHostHooks not initialized/ + ); + }); + + test('executeHook throws with invalid tool invocation token', async () => { + hooksService.initialize(createMockExtHostChatAgents(undefined) as ExtHostChatAgents2); + + await assert.rejects( + () => hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken: undefined }, + undefined + ), + /Invalid or missing tool invocation token/ + ); + + await assert.rejects( + () => hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken: { invalid: 'token' } }, + undefined + ), + /Invalid or missing tool invocation token/ + ); + }); + + test('executeHook returns empty array when no hooks found for session', async () => { + hooksService.initialize(createMockExtHostChatAgents(undefined) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const results = await hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ); + + assert.deepStrictEqual(results, []); + }); + + test('executeHook returns empty array when no hooks of specified type exist', async () => { + const hooks: IChatRequestHooks = { + // Only preToolUse hooks, no sessionStart + [HookType.PreToolUse]: [createHookCommand('echo "pre-tool"')] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const results = await hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ); + + assert.deepStrictEqual(results, []); + }); + + test('executeHook runs command and returns success result', async () => { + const hooks: IChatRequestHooks = { + [HookType.SessionStart]: [createHookCommand('echo "hello world"')] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const results = await hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, ChatHookResultKind.Success); + assert.strictEqual((results[0].result as string).trim(), 'hello world'); + }); + + test('executeHook parses JSON output', async () => { + const hooks: IChatRequestHooks = { + [HookType.SessionStart]: [createHookCommand('echo \'{"key": "value"}\'')] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const results = await hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, ChatHookResultKind.Success); + assert.deepStrictEqual(results[0].result, { key: 'value' }); + }); + + test('executeHook returns error result for non-zero exit code', async () => { + const hooks: IChatRequestHooks = { + [HookType.SessionStart]: [createHookCommand('exit 1')] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const results = await hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, ChatHookResultKind.Error); + }); + + test('executeHook captures stderr on failure', async () => { + const hooks: IChatRequestHooks = { + [HookType.SessionStart]: [createHookCommand('echo "error message" >&2 && exit 1')] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const results = await hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, ChatHookResultKind.Error); + assert.strictEqual((results[0].result as string).trim(), 'error message'); + }); + + test('executeHook handles multiple hooks', async () => { + const hooks: IChatRequestHooks = { + [HookType.SessionStart]: [ + createHookCommand('echo "first"'), + createHookCommand('echo "second"'), + createHookCommand('echo "third"') + ] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const results = await hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ); + + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].kind, ChatHookResultKind.Success); + assert.strictEqual((results[0].result as string).trim(), 'first'); + assert.strictEqual(results[1].kind, ChatHookResultKind.Success); + assert.strictEqual((results[1].result as string).trim(), 'second'); + assert.strictEqual(results[2].kind, ChatHookResultKind.Success); + assert.strictEqual((results[2].result as string).trim(), 'third'); + }); + + test('executeHook passes input to stdin as JSON', async () => { + const hooks: IChatRequestHooks = { + [HookType.PreToolUse]: [createHookCommand('cat')] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const input = { tool: 'bash', args: { command: 'ls' } }; + const results = await hooksService.executeHook( + HookType.PreToolUse, + { toolInvocationToken, input }, + undefined + ); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, ChatHookResultKind.Success); + assert.deepStrictEqual(results[0].result, input); + }); + + test('executeHook respects cancellation', async () => { + const hooks: IChatRequestHooks = { + [HookType.SessionStart]: [createHookCommand('sleep 10')] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const cts = disposables.add(new CancellationTokenSource()); + + const resultPromise = hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + cts.token + ); + + // Cancel after a short delay + setTimeout(() => cts.cancel(), 50); + + const results = await resultPromise; + assert.strictEqual(results.length, 1); + // Cancelled commands return error result + assert.strictEqual(results[0].kind, ChatHookResultKind.Error); + }); + + test('executeHook returns error for invalid command', async () => { + const hooks: IChatRequestHooks = { + [HookType.SessionStart]: [createHookCommand('/nonexistent/command/that/does/not/exist')] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const results = await hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, ChatHookResultKind.Error); + }); + + test('executeHook uses custom environment variables', async () => { + const hooks: IChatRequestHooks = { + [HookType.SessionStart]: [createHookCommand('echo $MY_VAR', { env: { MY_VAR: 'custom_value' } })] + }; + hooksService.initialize(createMockExtHostChatAgents(hooks) as ExtHostChatAgents2); + + const toolInvocationToken = createMockToolInvocationContext(sessionResource); + const results = await hooksService.executeHook( + HookType.SessionStart, + { toolInvocationToken }, + undefined + ); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, ChatHookResultKind.Success); + assert.strictEqual((results[0].result as string).trim(), 'custom_value'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 19d07a739aa01..b3d6bca7d5029 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -52,11 +52,14 @@ import { ILanguageModelsService, LanguageModelsService } from '../common/languag import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; +import { HooksExecutionService, IHooksExecutionService } from '../common/hooksExecutionService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME } from '../common/promptSyntax/config/promptFileLocations.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; import { PromptLanguageFeaturesProvider } from '../common/promptSyntax/promptFileContributions.js'; -import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; +import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; +import { hookFileSchema, HOOK_SCHEMA_URI, HOOK_FILE_GLOB } from '../common/promptSyntax/hookSchema.js'; +import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; @@ -142,6 +145,11 @@ import { ChatTipService, IChatTipService } from './chatTipService.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; +// Register JSON schema for hook files +const jsonContributionRegistry = Registry.as(JSONExtensions.JSONContribution); +jsonContributionRegistry.registerSchema(HOOK_SCHEMA_URI, hookFileSchema); +jsonContributionRegistry.registerSchemaAssociation(HOOK_SCHEMA_URI, HOOK_FILE_GLOB); + // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); configurationRegistry.registerConfiguration({ @@ -898,6 +906,43 @@ configurationRegistry.registerConfiguration({ }, ], }, + [PromptsConfig.HOOKS_LOCATION_KEY]: { + type: 'object', + title: nls.localize('chat.hookFilesLocations.title', "Hook File Locations",), + markdownDescription: nls.localize( + 'chat.hookFilesLocations.description', + "Specify paths to hook configuration files that define custom shell commands to execute at strategic points in an agent's workflow. [Learn More]({0}).\n\nRelative paths are resolved from the root folder(s) of your workspace. Supports Copilot hooks (`hooks.json`) and Claude Code hooks (`settings.json`, `settings.local.json`).", + HOOK_DOCUMENTATION_URL, + ), + default: { + ...DEFAULT_HOOK_FILE_PATHS.map((f) => ({ [f.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), + }, + additionalProperties: { type: 'boolean' }, + propertyNames: { + pattern: VALID_PROMPT_FOLDER_PATTERN, + patternErrorMessage: nls.localize('chat.hookFilesLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported."), + }, + restricted: true, + tags: ['prompts', 'hooks', 'agent'], + examples: [ + { + [DEFAULT_HOOK_FILE_PATHS[0].path]: true, + }, + { + [DEFAULT_HOOK_FILE_PATHS[0].path]: true, + 'custom-hooks/hooks.json': true, + }, + ], + }, + [PromptsConfig.USE_CHAT_HOOKS]: { + type: 'boolean', + title: nls.localize('chat.useChatHooks.title', "Use Chat Hooks",), + markdownDescription: nls.localize('chat.useChatHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), + default: true, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'hooks', 'agent'] + }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', scope: ConfigurationScope.RESOURCE, @@ -1441,6 +1486,7 @@ registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delay registerSingleton(IChatEditingService, ChatEditingService, InstantiationType.Delayed); registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, InstantiationType.Delayed); registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); +registerSingleton(IHooksExecutionService, HooksExecutionService, InstantiationType.Delayed); registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts new file mode 100644 index 0000000000000..214b76c56c0c5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -0,0 +1,239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChatViewId } from '../chat.js'; +import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { formatHookCommandLabel, HOOK_TYPES, HookType } from '../../common/promptSyntax/hookSchema.js'; +import { NEW_HOOK_COMMAND_ID } from './newPromptFileActions.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; +import { findHookCommandSelection } from './hookUtils.js'; +import { getHookSourceFormatLabel, HookSourceFormat, isReadOnlyHookSource, parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; + +/** + * Action ID for the `Configure Hooks` action. + */ +const CONFIGURE_HOOKS_ACTION_ID = 'workbench.action.chat.configure.hooks'; + +interface IHookEntry { + readonly hookType: HookType; + readonly hookTypeLabel: string; + /** The original hook type ID as it appears in the JSON file (for selection lookup) */ + readonly originalHookTypeId: string; + readonly fileUri: URI; + readonly filePath: string; + readonly displayLabel: string; + readonly commandFieldName: 'command' | 'bash' | 'powershell' | undefined; + readonly index: number; + /** The source format (Copilot, Claude) */ + readonly sourceFormat: HookSourceFormat; + /** Whether this hook is from a read-only source (Claude settings) */ + readonly isReadOnly: boolean; +} + +interface IHookQuickPickItem extends IQuickPickItem { + readonly hookEntry?: IHookEntry; + readonly commandId?: string; +} + +class ManageHooksAction extends Action2 { + constructor() { + super({ + id: CONFIGURE_HOOKS_ACTION_ID, + title: localize2('configure-hooks', "Configure Hooks..."), + shortTitle: localize2('configure-hooks.short', "Hooks"), + icon: Codicon.zap, + f1: true, + precondition: ChatContextKeys.enabled, + category: CHAT_CATEGORY, + menu: { + id: CHAT_CONFIG_MENU_ID, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), + order: 12, + group: '1_level' + } + }); + } + + public override async run( + accessor: ServicesAccessor, + ): Promise { + const promptsService = accessor.get(IPromptsService); + const quickInputService = accessor.get(IQuickInputService); + const fileService = accessor.get(IFileService); + const labelService = accessor.get(ILabelService); + const commandService = accessor.get(ICommandService); + const editorService = accessor.get(IEditorService); + const workspaceService = accessor.get(IWorkspaceContextService); + const pathService = accessor.get(IPathService); + + // Get workspace root and user home for path resolution + const workspaceFolder = workspaceService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + const userHomeUri = await pathService.userHome(); + const userHome = userHomeUri.fsPath ?? userHomeUri.path; + + // Get all hook files + const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); + + // Parse hook files to extract hook entries using format-aware parsing + const hookEntries: IHookEntry[] = []; + + for (const hookFile of hookFiles) { + try { + const content = await fileService.readFile(hookFile.uri); + const json = JSON.parse(content.value.toString()); + + // Use format-aware parsing + const { format, hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); + const isReadOnly = isReadOnlyHookSource(format); + + for (const [hookType, { hooks: commands, originalId }] of hooks) { + const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); + if (!hookTypeMeta) { + continue; + } + + for (let i = 0; i < commands.length; i++) { + const hookCommand = commands[i]; + const displayLabel = formatHookCommandLabel(hookCommand) || localize('commands.hook.emptyCommand', '(empty command)'); + hookEntries.push({ + hookType, + hookTypeLabel: hookTypeMeta.label, + originalHookTypeId: originalId, + fileUri: hookFile.uri, + filePath: labelService.getUriLabel(hookFile.uri, { relative: true }), + displayLabel, + commandFieldName: hookCommand.command !== undefined ? 'command' : hookCommand.bash !== undefined ? 'bash' : 'powershell', + index: i, + sourceFormat: format, + isReadOnly + }); + } + } + } catch { + // Skip files that can't be parsed + } + } + + // Build quick pick items grouped by hook type + const items: (IHookQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "New Hook..." option at the top + items.push({ + label: `$(plus) ${localize('commands.new-hook.label', 'Add new hook...')}`, + commandId: NEW_HOOK_COMMAND_ID, + alwaysShow: true + }); + + // Group entries by hook type + const groupedByType = new Map(); + for (const entry of hookEntries) { + const existing = groupedByType.get(entry.hookType) ?? []; + existing.push(entry); + groupedByType.set(entry.hookType, existing); + } + + // Sort hook types by their position in HOOK_TYPES + const sortedHookTypes = Array.from(groupedByType.keys()).sort((a, b) => { + const indexA = HOOK_TYPES.findIndex(h => h.id === a); + const indexB = HOOK_TYPES.findIndex(h => h.id === b); + return indexA - indexB; + }); + + // Add entries grouped by hook type + for (const hookTypeId of sortedHookTypes) { + const entries = groupedByType.get(hookTypeId)!; + const hookType = HOOK_TYPES.find(h => h.id === hookTypeId)!; + + items.push({ + type: 'separator', + label: hookType.label + }); + + for (const entry of entries) { + // Build description with source format indicator for read-only hooks + let description = entry.filePath; + if (entry.isReadOnly) { + description = `$(lock) ${getHookSourceFormatLabel(entry.sourceFormat)} · ${description}`; + } + + items.push({ + label: entry.displayLabel, + description, + hookEntry: entry + }); + } + } + + // Show empty state message if no hooks found + if (hookEntries.length === 0) { + items.push({ + type: 'separator', + label: localize('noHooks', "No hooks configured") + }); + } + + const selected = await quickInputService.pick(items, { + placeHolder: localize('commands.hooks.placeholder', 'Select a hook to open or add a new hook'), + title: localize('commands.hooks.title', 'Hooks') + }); + + if (selected) { + if (selected.commandId) { + await commandService.executeCommand(selected.commandId); + } else if (selected.hookEntry) { + const entry = selected.hookEntry; + let selection: ITextEditorSelection | undefined; + + // Try to find the command field to highlight + if (entry.commandFieldName) { + try { + const content = await fileService.readFile(entry.fileUri); + selection = findHookCommandSelection( + content.value.toString(), + entry.originalHookTypeId, + entry.index, + entry.commandFieldName + ); + } catch { + // Ignore errors and just open without selection + } + } + + await editorService.openEditor({ + resource: entry.fileUri, + options: { + selection, + pinned: false + } + }); + } + } + } +} + +/** + * Helper to register the `Manage Hooks` action. + */ +export function registerHookActions(): void { + registerAction2(ManageHooksAction); +} diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts new file mode 100644 index 0000000000000..6f54d82315646 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { findNodeAtLocation, Node, parseTree } from '../../../../../base/common/json.js'; +import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; + +/** + * Converts an offset in content to a 1-based line and column. + */ +function offsetToPosition(content: string, offset: number): { line: number; column: number } { + let line = 1; + let column = 1; + for (let i = 0; i < offset && i < content.length; i++) { + if (content[i] === '\n') { + line++; + column = 1; + } else { + column++; + } + } + return { line, column }; +} + +/** + * Finds the n-th command field node in a hook type array, handling both simple and nested formats. + * This iterates through the structure in the same order as the parser flattens hooks. + */ +function findNthCommandNode(tree: Node, hookType: string, targetIndex: number, fieldName: string): Node | undefined { + const hookTypeArray = findNodeAtLocation(tree, ['hooks', hookType]); + if (!hookTypeArray || hookTypeArray.type !== 'array' || !hookTypeArray.children) { + return undefined; + } + + let currentIndex = 0; + + for (let i = 0; i < hookTypeArray.children.length; i++) { + const item = hookTypeArray.children[i]; + if (item.type !== 'object') { + continue; + } + + // Check if this item has nested hooks (matcher format) + const nestedHooksNode = findNodeAtLocation(tree, ['hooks', hookType, i, 'hooks']); + if (nestedHooksNode && nestedHooksNode.type === 'array' && nestedHooksNode.children) { + // Iterate through nested hooks + for (let j = 0; j < nestedHooksNode.children.length; j++) { + if (currentIndex === targetIndex) { + return findNodeAtLocation(tree, ['hooks', hookType, i, 'hooks', j, fieldName]); + } + currentIndex++; + } + } else { + // Simple format - direct command + if (currentIndex === targetIndex) { + return findNodeAtLocation(tree, ['hooks', hookType, i, fieldName]); + } + currentIndex++; + } + } + + return undefined; +} + +/** + * Finds the selection range for a hook command field value in JSON content. + * Supports both simple format and nested matcher format: + * - Simple: { hooks: { hookType: [{ command: "..." }] } } + * - Nested: { hooks: { hookType: [{ matcher: "", hooks: [{ command: "..." }] }] } } + * + * The index is a flattened index across all commands in the hook type, regardless of nesting. + * + * @param content The JSON file content + * @param hookType The hook type (e.g., "sessionStart") + * @param index The flattened index of the hook command within the hook type + * @param fieldName The field name to find ('command', 'bash', or 'powershell') + * @returns The selection range for the field value, or undefined if not found + */ +export function findHookCommandSelection(content: string, hookType: string, index: number, fieldName: string): ITextEditorSelection | undefined { + const tree = parseTree(content); + if (!tree) { + return undefined; + } + + const node = findNthCommandNode(tree, hookType, index, fieldName); + if (!node || node.type !== 'string') { + return undefined; + } + + // Node offset/length includes quotes, so adjust to select only the value content + const valueStart = node.offset + 1; // After opening quote + const valueEnd = node.offset + node.length - 1; // Before closing quote + + const start = offsetToPosition(content, valueStart); + const end = offsetToPosition(content, valueEnd); + + return { + startLineNumber: start.line, + startColumn: start.column, + endLineNumber: end.line, + endColumn: end.column + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 3b1f279ef8892..1d19f566d1694 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -5,6 +5,7 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { SnippetController2 } from '../../../../../editor/contrib/snippet/browser/snippetController2.js'; import { localize, localize2 } from '../../../../../nls.js'; @@ -25,7 +26,11 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { getCleanPromptName, SKILL_FILENAME, HOOKS_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { HOOK_TYPES, HookType } from '../../common/promptSyntax/hookSchema.js'; +import { findHookCommandSelection } from './hookUtils.js'; +import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; +import { Range } from '../../../../../editor/common/core/range.js'; class AbstractNewPromptFileAction extends Action2 { @@ -175,6 +180,11 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `---`, `\${3:Define the functionality provided by this skill, including detailed instructions and examples}`, ].join('\n'); + case PromptsType.hook: + return JSON.stringify({ + version: 1, + hooks: {} + }, null, 4); default: throw new Error(`Unsupported prompt type: ${promptType}`); } @@ -186,6 +196,7 @@ export const NEW_PROMPT_COMMAND_ID = 'workbench.command.new.prompt'; export const NEW_INSTRUCTIONS_COMMAND_ID = 'workbench.command.new.instructions'; export const NEW_AGENT_COMMAND_ID = 'workbench.command.new.agent'; export const NEW_SKILL_COMMAND_ID = 'workbench.command.new.skill'; +export const NEW_HOOK_COMMAND_ID = 'workbench.command.new.hook'; class NewPromptFileAction extends AbstractNewPromptFileAction { constructor() { @@ -288,6 +299,168 @@ class NewSkillFileAction extends Action2 { } } +class NewHookFileAction extends Action2 { + constructor() { + super({ + id: NEW_HOOK_COMMAND_ID, + title: localize('commands.new.hook.local.title', "New Hook..."), + f1: false, + precondition: ChatContextKeys.enabled, + category: CHAT_CATEGORY, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + }, + menu: { + id: MenuId.CommandPalette, + when: ChatContextKeys.enabled + } + }); + } + + public override async run(accessor: ServicesAccessor) { + const editorService = accessor.get(IEditorService); + const fileService = accessor.get(IFileService); + const instaService = accessor.get(IInstantiationService); + const quickInputService = accessor.get(IQuickInputService); + const bulkEditService = accessor.get(IBulkEditService); + + const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, PromptsType.hook); + if (!selectedFolder) { + return; + } + + // Ask which hook type to add + const hookTypeItems = HOOK_TYPES.map(hookType => ({ + id: hookType.id, + label: hookType.label, + description: hookType.description + })); + + const selectedHookType = await quickInputService.pick(hookTypeItems, { + placeHolder: localize('commands.new.hook.type.placeholder', "Select a hook type to add"), + title: localize('commands.new.hook.type.title', "Add Hook") + }); + + if (!selectedHookType) { + return; + } + + // Create the hooks folder if it doesn't exist + await fileService.createFolder(selectedFolder.uri); + + // Use fixed hooks.json filename + const hookFileUri = URI.joinPath(selectedFolder.uri, HOOKS_FILENAME); + + // Check if hooks.json already exists + let hooksContent: { version: number; hooks: Record }; + const fileExists = await fileService.exists(hookFileUri); + + if (fileExists) { + // Parse existing file + const existingContent = await fileService.readFile(hookFileUri); + try { + hooksContent = JSON.parse(existingContent.value.toString()); + // Ensure hooks object exists + if (!hooksContent.hooks) { + hooksContent.hooks = {}; + } + } catch { + // If parsing fails, show error and open file for user to fix + const notificationService = accessor.get(INotificationService); + notificationService.error(localize('commands.new.hook.parseError', "Failed to parse existing hooks.json. Please fix the JSON syntax errors and try again.")); + await editorService.openEditor({ resource: hookFileUri }); + return; + } + } else { + // Create new structure + hooksContent = { version: 1, hooks: {} }; + } + + // Add the new hook entry (append if hook type already exists) + const hookTypeId = selectedHookType.id as HookType; + const newHookEntry = { + type: 'command', + command: '' + }; + let newHookIndex: number; + if (!hooksContent.hooks[hookTypeId]) { + hooksContent.hooks[hookTypeId] = [newHookEntry]; + newHookIndex = 0; + } else { + hooksContent.hooks[hookTypeId].push(newHookEntry); + newHookIndex = hooksContent.hooks[hookTypeId].length - 1; + } + + // Write the file + const jsonContent = JSON.stringify(hooksContent, null, '\t'); + + // Check if the file is already open in an editor + const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri)); + + if (existingEditor) { + // File is already open - first focus the editor, then update its model directly + await editorService.openEditor({ + resource: hookFileUri, + options: { + pinned: false + } + }); + + // Get the code editor and update its content directly + const editor = getCodeEditor(editorService.activeTextEditorControl); + if (editor && editor.hasModel() && isEqual(editor.getModel().uri, hookFileUri)) { + const model = editor.getModel(); + // Apply the full content replacement using executeEdits + model.pushEditOperations([], [{ + range: model.getFullModelRange(), + text: jsonContent + }], () => null); + + // Find and apply the selection + const selection = findHookCommandSelection(jsonContent, hookTypeId, newHookIndex, 'command'); + if (selection && selection.endLineNumber !== undefined && selection.endColumn !== undefined) { + editor.setSelection({ + startLineNumber: selection.startLineNumber, + startColumn: selection.startColumn, + endLineNumber: selection.endLineNumber, + endColumn: selection.endColumn + }); + editor.revealLineInCenter(selection.startLineNumber); + } + } + } else { + // File is not currently open in an editor + if (!fileExists) { + // File doesn't exist - write new file directly and open + await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + } else { + // File exists but isn't open - open it first, then use bulk edit for undo support + await editorService.openEditor({ + resource: hookFileUri, + options: { pinned: false } + }); + + // Apply the edit via bulk edit service for proper undo support + await bulkEditService.apply([ + new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent }) + ], { label: localize('addHook', "Add Hook") }); + } + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, hookTypeId, newHookIndex, 'command'); + + // Open editor with selection (or re-focus if already open) + await editorService.openEditor({ + resource: hookFileUri, + options: { + selection, + pinned: false + } + }); + } + } +} + class NewUntitledPromptFileAction extends Action2 { constructor() { super({ @@ -333,5 +506,6 @@ export function registerNewPromptFileActions(): void { registerAction2(NewInstructionsFileAction); registerAction2(NewAgentFileAction); registerAction2(NewSkillFileAction); + registerAction2(NewHookFileAction); registerAction2(NewUntitledPromptFileAction); } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts index 1db063db18129..42b0a3841b574 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts @@ -113,6 +113,8 @@ function getPlaceholderStringforNew(type: PromptsType): string { return localize('workbench.command.agent.create.location.placeholder', "Select a location to create the agent file"); case PromptsType.skill: return localize('workbench.command.skill.create.location.placeholder', "Select a location to create the skill"); + case PromptsType.hook: + return localize('workbench.command.hook.create.location.placeholder', "Select a location to create the hook file"); default: throw new Error('Unknown prompt type'); } @@ -129,6 +131,8 @@ function getPlaceholderStringforMove(type: PromptsType, isMove: boolean): string return localize('agent.move.location.placeholder', "Select a location to move the agent file to"); case PromptsType.skill: return localize('skill.move.location.placeholder', "Select a location to move the skill to"); + case PromptsType.hook: + throw new Error('Hooks cannot be moved'); default: throw new Error('Unknown prompt type'); } @@ -142,6 +146,8 @@ function getPlaceholderStringforMove(type: PromptsType, isMove: boolean): string return localize('agent.copy.location.placeholder', "Select a location to copy the agent file to"); case PromptsType.skill: return localize('skill.copy.location.placeholder', "Select a location to copy the skill to"); + case PromptsType.hook: + throw new Error('Hooks cannot be copied'); default: throw new Error('Unknown prompt type'); } @@ -187,6 +193,8 @@ function getLearnLabel(type: PromptsType): string { return localize('commands.agent.create.ask-folder.empty.docs-label', 'Learn how to configure custom agents'); case PromptsType.skill: return localize('commands.skill.create.ask-folder.empty.docs-label', 'Learn how to configure skills'); + case PromptsType.hook: + return localize('commands.hook.create.ask-folder.empty.docs-label', 'Learn how to configure hooks'); default: throw new Error('Unknown prompt type'); } @@ -202,6 +210,8 @@ function getMissingSourceFolderString(type: PromptsType): string { return localize('commands.agent.create.ask-folder.empty.placeholder', 'No agent source folders found.'); case PromptsType.skill: return localize('commands.skill.create.ask-folder.empty.placeholder', 'No skill source folders found.'); + case PromptsType.hook: + return localize('commands.hook.create.ask-folder.empty.placeholder', 'No hook source folders found.'); default: throw new Error('Unknown prompt type'); } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index e350c3b6289be..9bc9508463c8d 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -15,7 +15,7 @@ import { IOpenerService } from '../../../../../../platform/opener/common/opener. import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { getCleanPromptName } from '../../../common/promptSyntax/config/promptFileLocations.js'; -import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; +import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../newPromptFileActions.js'; import { IKeyMods, IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator } from '../../../../../../platform/quickinput/common/quickInput.js'; import { askForPromptFileName } from './askForPromptName.js'; @@ -98,6 +98,12 @@ function newHelpButton(type: PromptsType): IQuickInputButton & { helpURI: URI } helpURI: URI.parse(SKILL_DOCUMENTATION_URL), iconClass }; + case PromptsType.hook: + return { + tooltip: localize('help.hook', "Show help on hook files"), + helpURI: URI.parse(HOOK_DOCUMENTATION_URL), + iconClass + }; } } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileActions.ts index 4542390459355..670d4335f7913 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileActions.ts @@ -8,6 +8,7 @@ import { registerAgentActions } from './chatModeActions.js'; import { registerRunPromptActions } from './runPromptAction.js'; import { registerNewPromptFileActions } from './newPromptFileActions.js'; import { registerSkillActions } from './skillActions.js'; +import { registerHookActions } from './hookActions.js'; import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { SaveAsAgentFileAction, SaveAsInstructionsFileAction, SaveAsPromptFileAction } from './saveAsPromptFileActions.js'; @@ -19,6 +20,7 @@ export function registerPromptActions(): void { registerRunPromptActions(); registerAttachPromptActions(); registerSkillActions(); + registerHookActions(); registerAction2(SaveAsPromptFileAction); registerAction2(SaveAsInstructionsFileAction); registerAction2(SaveAsAgentFileAction); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index d98df9be8a864..4bf4872c009f9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -5,6 +5,8 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { getBaseLayerHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegate2.js'; +import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -161,6 +163,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent e.stopPropagation(); this.handleNext(); } + } else if ((event.ctrlKey || event.metaKey) && (event.keyCode === KeyCode.Backspace || event.keyCode === KeyCode.Delete)) { + e.stopPropagation(); } })); @@ -377,6 +381,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent ? questionText : questionText.value; + title.setAttribute('aria-label', messageContent); + // Check for subtitle in parentheses at the end const parenMatch = messageContent.match(/^(.+?)\s*(\([^)]+\))\s*$/); if (parenMatch) { @@ -547,6 +553,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const listItem = dom.$('.chat-question-list-item'); listItem.setAttribute('role', 'option'); listItem.setAttribute('aria-selected', String(isSelected)); + listItem.setAttribute('aria-label', localize('chat.questionCarousel.optionLabel', "Option {0}: {1}", index + 1, option.label)); listItem.id = `option-${question.id}-${index}`; listItem.tabIndex = -1; @@ -582,6 +589,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent listItem.classList.add('selected'); } + this._inputBoxes.add(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), listItem, option.label)); + // Click handler this._inputBoxes.add(dom.addDisposableListener(listItem, dom.EventType.CLICK, (e: MouseEvent) => { e.preventDefault(); @@ -727,6 +736,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const listItem = dom.$('.chat-question-list-item.multi-select'); listItem.setAttribute('role', 'option'); listItem.setAttribute('aria-selected', String(isChecked)); + listItem.setAttribute('aria-label', localize('chat.questionCarousel.optionLabel', "Option {0}: {1}", index + 1, option.label)); listItem.id = `option-${question.id}-${index}`; listItem.tabIndex = -1; @@ -781,6 +791,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); + this._inputBoxes.add(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), listItem, option.label)); + selectContainer.appendChild(listItem); checkboxes.push(checkbox); listItems.push(listItem); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 2c33c66fe4e75..8ba0e1da64613 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -239,19 +239,26 @@ } .interactive-session .interactive-response .value { - .chat-question-list-item:focus, + .chat-question-list-item:focus:not(.selected), .chat-question-list:focus { outline: none; } } -/* Single-select: highlight entire row when selected and also outline */ +/* Single-select: highlight entire row when selected */ .chat-question-list-item.selected { background-color: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); } +.chat-question-list:focus-within .chat-question-list-item.selected { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); +} + .chat-question-list-item.selected:hover { background-color: var(--vscode-list-activeSelectionBackground); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 07aa3be77dca1..8b989d713acf7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1664,7 +1664,7 @@ export class ChatWidget extends Disposable implements IChatWidget { widgetViewKindTag: this.getWidgetViewKindTag(), defaultMode: this.viewOptions.defaultMode, sessionTypePickerDelegate: this.viewOptions.sessionTypePickerDelegate, - workspacePickerDelegate: this.viewOptions.workspacePickerDelegate + workspacePickerDelegate: this.viewOptions.workspacePickerDelegate, }; if (this.viewModel?.editing) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 846fa671dc34c..91ca4894116f9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2140,6 +2140,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.height.set(newHeight, undefined); })); this._register(inputResizeObserver.observe(this.container)); + + if (this.options.renderStyle === 'compact') { + const toolbarsResizeObserver = this._register(new dom.DisposableResizeObserver(() => { + // Have to layout the editor when the toolbars change size, when they share width with the editor. + // This handles ensuring we layout when quick chat is shown/hidden. + // The toolbar may have changed since the last time it was visible. + if (this.cachedWidth) { + this.layout(this.cachedWidth); + } + })); + this._register(toolbarsResizeObserver.observe(toolbarsContainer)); + } } public toggleChatInputOverlay(editing: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts index 647486aa96357..306f8b863c92a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -51,7 +51,6 @@ export class ChatContextUsageDetails extends Disposable { super(); this.domNode = $('.chat-context-usage-details'); - this.domNode.setAttribute('tabindex', '0'); // Using same structure as ChatUsageWidget quota items this.quotaItem = this.domNode.appendChild($('.quota-item')); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 2fe2f182f6728..f879497b4df49 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -49,6 +49,8 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants import { ChatMessageRole, IChatMessage } from '../languageModels.js'; import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js'; import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; +import { IPromptsService } from '../promptSyntax/service/promptsService.js'; +import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; const serializedChatKey = 'interactive.sessions'; @@ -152,6 +154,7 @@ export class ChatService extends Disposable implements IChatService { @IChatTransferService private readonly chatTransferService: IChatTransferService, @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, + @IPromptsService private readonly promptsService: IPromptsService, ) { super(); @@ -889,6 +892,14 @@ export class ChatService extends Disposable implements IChatService { let detectedAgent: IChatAgentData | undefined; let detectedCommand: IChatAgentCommand | undefined; + // Collect hooks from hooks.json files + let collectedHooks: IChatRequestHooks | undefined; + try { + collectedHooks = await this.promptsService.getHooks(token); + } catch (error) { + this.logService.warn('[ChatService] Failed to collect hooks:', error); + } + const stopWatch = new StopWatch(false); store.add(token.onCancellationRequested(() => { this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`); @@ -949,6 +960,7 @@ export class ChatService extends Disposable implements IChatService { userSelectedTools: options?.userSelectedTools?.get(), modeInstructions: options?.modeInfo?.modeInstructions, editedFileEvents: request.editedFileEvents, + hooks: collectedHooks, }; let isInitialTools = true; diff --git a/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts new file mode 100644 index 0000000000000..37f5ef5779e3c --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { HookTypeValue } from './promptSyntax/hookSchema.js'; + +export const enum HookResultKind { + Success = 1, + Error = 2 +} + +export interface IHookResult { + readonly kind: HookResultKind; + readonly result: string | object; +} + +export interface IHooksExecutionOptions { + readonly input?: unknown; +} + +/** + * Callback interface for hook execution proxies. + * MainThreadHooks implements this to forward calls to the extension host. + */ +export interface IHooksExecutionProxy { + executeHook(hookType: HookTypeValue, sessionResource: URI, input: unknown): Promise; +} + +export const IHooksExecutionService = createDecorator('hooksExecutionService'); + +export interface IHooksExecutionService { + _serviceBrand: undefined; + + /** + * Called by mainThreadHooks when extension host is ready + */ + setProxy(proxy: IHooksExecutionProxy): void; + + /** + * Execute hooks of the given type for the given session + */ + executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; +} + +export class HooksExecutionService implements IHooksExecutionService { + declare readonly _serviceBrand: undefined; + + private _proxy: IHooksExecutionProxy | undefined; + + setProxy(proxy: IHooksExecutionProxy): void { + this._proxy = proxy; + } + + async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise { + if (!this._proxy) { + return []; + } + + return this._proxy.executeHook(hookType, sessionResource, options?.input); + } +} diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 6f59271436e96..e0362e6b8f837 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -26,6 +26,7 @@ import { asJson, IRequestService } from '../../../../../platform/request/common/ import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ChatContextKeys } from '../actions/chatContextKeys.js'; import { IChatAgentEditedFileEvent, IChatProgressHistoryResponseContent, IChatRequestModeInstructions, IChatRequestVariableData, ISerializableChatAgentData } from '../model/chatModel.js'; +import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; import { IRawChatCommandContribution } from './chatParticipantContribTypes.js'; import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js'; @@ -149,6 +150,11 @@ export interface IChatAgentRequest { userSelectedTools?: UserSelectedTools; modeInstructions?: IChatRequestModeInstructions; editedFileEvents?: IChatAgentEditedFileEvent[]; + /** + * Collected hooks configuration for this request. + * Contains all hooks defined in hooks.json files, organized by hook type. + */ + hooks?: IChatRequestHooks; /** * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts index 6468c60cdfac6..13bb2e6a70cab 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts @@ -29,7 +29,7 @@ enum ChatContributionPoint { chatInstructions = 'chatInstructions', chatAgents = 'chatAgents', chatPromptFiles = 'chatPromptFiles', - chatSkills = 'chatSkills' + chatSkills = 'chatSkills', } function registerChatFilesExtensionPoint(point: ChatContributionPoint) { @@ -79,6 +79,10 @@ function pointToType(contributionPoint: ChatContributionPoint): PromptsType { case ChatContributionPoint.chatInstructions: return PromptsType.instructions; case ChatContributionPoint.chatAgents: return PromptsType.agent; case ChatContributionPoint.chatSkills: return PromptsType.skill; + default: { + const exhaustiveCheck: never = contributionPoint; + throw new Error(`Unknown contribution point: ${exhaustiveCheck}`); + } } } @@ -148,16 +152,17 @@ CommandsRegistry.registerCommand('_listExtensionPromptFiles', async (accessor): const promptsService = accessor.get(IPromptsService); // Get extension prompt files for all prompt types in parallel - const [agents, instructions, prompts, skills] = await Promise.all([ + const [agents, instructions, prompts, skills, hooks] = await Promise.all([ promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None), promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), promptsService.listPromptFiles(PromptsType.prompt, CancellationToken.None), promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None), + promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None), ]); // Combine all files and collect extension-contributed ones const result: IExtensionPromptFileResult[] = []; - for (const file of [...agents, ...instructions, ...prompts, ...skills]) { + for (const file of [...agents, ...instructions, ...prompts, ...skills, ...hooks]) { if (file.storage === PromptsStorage.extension) { result.push({ uri: file.uri.toJSON(), type: file.type }); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index c543b912bd930..f32674092b0fa 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -70,6 +70,11 @@ export namespace PromptsConfig { */ export const SKILLS_LOCATION_KEY = 'chat.agentSkillsLocations'; + /** + * Configuration key for the locations of hook files. + */ + export const HOOKS_LOCATION_KEY = 'chat.hookFilesLocations'; + /** * Configuration key for prompt file suggestions. */ @@ -95,6 +100,11 @@ export namespace PromptsConfig { */ export const USE_AGENT_SKILLS = 'chat.useAgentSkills'; + /** + * Configuration key for chat hooks usage. + */ + export const USE_CHAT_HOOKS = 'chat.useChatHooks'; + /** * Configuration key for enabling stronger skill adherence prompt (experimental). */ @@ -245,6 +255,8 @@ export function getPromptFileLocationsConfigKey(type: PromptsType): string { return PromptsConfig.AGENTS_LOCATION_KEY; case PromptsType.skill: return PromptsConfig.SKILLS_LOCATION_KEY; + case PromptsType.hook: + return PromptsConfig.HOOKS_LOCATION_KEY; default: throw new Error('Unknown prompt type'); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 3e7dcd49adda7..48caf2eb37fa6 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -33,6 +33,11 @@ export const AGENT_FILE_EXTENSION = '.agent.md'; */ export const SKILL_FILENAME = 'SKILL.md'; +/** + * Default hook file name (case insensitive). + */ +export const HOOKS_FILENAME = 'hooks.json'; + /** * Copilot custom instructions file name. */ @@ -59,6 +64,11 @@ export const LEGACY_MODE_DEFAULT_SOURCE_FOLDER = '.github/chatmodes'; */ export const AGENTS_SOURCE_FOLDER = '.github/agents'; +/** + * Hooks folder. + */ +export const HOOKS_SOURCE_FOLDER = '.github/hooks'; + /** * Tracks where prompt files originate from. */ @@ -67,6 +77,7 @@ export enum PromptFileSource { CopilotPersonal = 'copilot-personal', ClaudePersonal = 'claude-personal', ClaudeWorkspace = 'claude-workspace', + ClaudeWorkspaceLocal = 'claude-workspace-local', AgentsWorkspace = 'agents-workspace', AgentsPersonal = 'agents-personal', ConfigWorkspace = 'config-workspace', @@ -144,6 +155,16 @@ export const DEFAULT_AGENT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ { path: AGENTS_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, ]; +/** + * Default hook file paths. + */ +export const DEFAULT_HOOK_FILE_PATHS: readonly IPromptSourceFolder[] = [ + { path: '.github/hooks/hooks.json', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: '.claude/settings.local.json', source: PromptFileSource.ClaudeWorkspaceLocal, storage: PromptsStorage.local }, + { path: '.claude/settings.json', source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, + { path: '~/.claude/settings.json', source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, +]; + /** * Helper function to check if a file is directly in the .github/agents/ folder (not in subfolders). */ @@ -180,6 +201,19 @@ export function getPromptFileType(fileUri: URI): PromptsType | undefined { return PromptsType.agent; } + // Check if it's a hooks.json file (case insensitive) + if (filename.toLowerCase() === HOOKS_FILENAME.toLowerCase()) { + return PromptsType.hook; + } + + // Check if it's a settings.local.json or settings.json file in a .claude folder + if (filename.toLowerCase() === 'settings.local.json' || filename.toLowerCase() === 'settings.json') { + const dir = dirname(fileUri.path); + if (dir.endsWith('/.claude') || dir === '.claude') { + return PromptsType.hook; + } + } + return undefined; } @@ -200,6 +234,8 @@ export function getPromptFileExtension(type: PromptsType): string { return AGENT_FILE_EXTENSION; case PromptsType.skill: return SKILL_FILENAME; + case PromptsType.hook: + return HOOKS_FILENAME; default: throw new Error('Unknown prompt type'); } @@ -215,6 +251,8 @@ export function getPromptFileDefaultLocations(type: PromptsType): readonly IProm return DEFAULT_AGENT_SOURCE_FOLDERS; case PromptsType.skill: return DEFAULT_SKILL_SOURCE_FOLDERS; + case PromptsType.hook: + return DEFAULT_HOOK_FILE_PATHS; default: throw new Error('Unknown prompt type'); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts new file mode 100644 index 0000000000000..8421a6bba75f1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { HookType, IHookCommand, normalizeHookTypeId, resolveHookCommand } from './hookSchema.js'; + +/** + * Maps Claude hook type names to our abstract HookType. + * Claude uses PascalCase and slightly different names. + * @see https://docs.anthropic.com/en/docs/claude-code/hooks + */ +export const CLAUDE_HOOK_TYPE_MAP: Record = { + 'SessionStart': HookType.SessionStart, + 'UserPromptSubmit': HookType.UserPromptSubmitted, + 'PreToolUse': HookType.PreToolUse, + 'PostToolUse': HookType.PostToolUse, + 'SubagentStart': HookType.SubagentStart, + 'SubagentStop': HookType.SubagentStop, + 'Stop': HookType.Stop, +}; + +/** + * Cached inverse mapping from HookType to Claude hook type name. + * Lazily computed on first access. + */ +let _hookTypeToClaudeName: Map | undefined; + +function getHookTypeToClaudeNameMap(): Map { + if (!_hookTypeToClaudeName) { + _hookTypeToClaudeName = new Map(); + for (const [claudeName, hookType] of Object.entries(CLAUDE_HOOK_TYPE_MAP)) { + _hookTypeToClaudeName.set(hookType, claudeName); + } + } + return _hookTypeToClaudeName; +} + +/** + * Resolves a Claude hook type name to our abstract HookType. + */ +export function resolveClaudeHookType(name: string): HookType | undefined { + return CLAUDE_HOOK_TYPE_MAP[name]; +} + +/** + * Gets the Claude hook type name for a given abstract HookType. + * Returns undefined if the hook type is not supported in Claude. + */ +export function getClaudeHookTypeName(hookType: HookType): string | undefined { + return getHookTypeToClaudeNameMap().get(hookType); +} + +/** + * Parses hooks from a Claude settings.json file. + * Claude format: + * { + * "hooks": { + * "PreToolUse": [ + * { "matcher": "Bash", "hooks": [{ "type": "command", "command": "..." }] } + * ] + * } + * } + * + * Or simpler format: + * { + * "hooks": { + * "PreToolUse": [{ "type": "command", "command": "..." }] + * } + * } + */ +export function parseClaudeHooks( + json: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): Map { + const result = new Map(); + + if (!json || typeof json !== 'object') { + return result; + } + + const root = json as Record; + const hooks = root.hooks; + + if (!hooks || typeof hooks !== 'object') { + return result; + } + + const hooksObj = hooks as Record; + + for (const originalId of Object.keys(hooksObj)) { + // Resolve Claude hook type name to our canonical HookType + const hookType = resolveClaudeHookType(originalId) ?? normalizeHookTypeId(originalId); + if (!hookType) { + continue; + } + + const hookArray = hooksObj[originalId]; + if (!Array.isArray(hookArray)) { + continue; + } + + const commands: IHookCommand[] = []; + + for (const item of hookArray) { + if (!item || typeof item !== 'object') { + continue; + } + + const itemObj = item as Record; + + // Claude can have nested hooks with matchers: { matcher: "Bash", hooks: [...] } + const nestedHooks = (itemObj as { hooks?: unknown }).hooks; + if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { + for (const nestedHook of nestedHooks) { + const resolved = resolveClaudeCommand(nestedHook as Record, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + } else { + // Direct hook command + const resolved = resolveClaudeCommand(itemObj, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + } + + if (commands.length > 0) { + const existing = result.get(hookType); + if (existing) { + existing.hooks.push(...commands); + } else { + result.set(hookType, { hooks: commands, originalId }); + } + } + } + + return result; +} + +/** + * Resolves a Claude hook command to our IHookCommand format. + * Claude commands can be: { type: "command", command: "..." } or { command: "..." } + */ +function resolveClaudeCommand( + raw: Record, + workspaceRootUri: URI | undefined, + userHome: string +): IHookCommand | undefined { + // Claude might not require 'type' field, so we're more lenient + const hasValidType = raw.type === undefined || raw.type === 'command'; + if (!hasValidType) { + return undefined; + } + + // Add type if missing for resolveHookCommand + const normalized = { ...raw, type: 'command' }; + return resolveHookCommand(normalized, workspaceRootUri, userHome); +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts new file mode 100644 index 0000000000000..4da87267c90b1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { basename, dirname } from '../../../../../base/common/path.js'; +import { HookType, IHookCommand, normalizeHookTypeId, resolveHookCommand } from './hookSchema.js'; +import { parseClaudeHooks } from './hookClaudeCompat.js'; + +/** + * Represents a hook source with its original and normalized properties. + * Used to display hooks from different formats in a unified view. + */ +export interface IResolvedHookEntry { + /** The normalized hook type (our canonical HookType enum) */ + readonly hookType: HookType; + /** The original hook type ID as it appears in the source file */ + readonly originalHookTypeId: string; + /** The source format this hook came from */ + readonly sourceFormat: HookSourceFormat; + /** The resolved hook command */ + readonly command: IHookCommand; + /** The index of this hook in its array (for editing) */ + readonly index: number; +} + +/** + * Supported hook file formats. + */ +export enum HookSourceFormat { + /** GitHub Copilot hooks.json format */ + Copilot = 'copilot', + /** Claude settings.json / settings.local.json format */ + Claude = 'claude', +} + +/** + * Determines the hook source format based on the file URI. + */ +export function getHookSourceFormat(fileUri: URI): HookSourceFormat { + const filename = basename(fileUri.path).toLowerCase(); + const dir = dirname(fileUri.path); + + // Claude format: .claude/settings.json or .claude/settings.local.json + if ((filename === 'settings.json' || filename === 'settings.local.json') && dir.endsWith('.claude')) { + return HookSourceFormat.Claude; + } + + // Copilot format: hooks.json (typically in .github/hooks/) + if (filename === 'hooks.json') { + return HookSourceFormat.Copilot; + } + + // Default to Copilot format + return HookSourceFormat.Copilot; +} + +/** + * Checks if a file is read-only based on its source format. + * Claude settings files should be read-only from our perspective since they have a different format. + */ +export function isReadOnlyHookSource(format: HookSourceFormat): boolean { + return format === HookSourceFormat.Claude; +} + +/** + * Parses hooks from a Copilot hooks.json file (our native format). + */ +export function parseCopilotHooks( + json: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): Map { + const result = new Map(); + + if (!json || typeof json !== 'object') { + return result; + } + + const root = json as Record; + + // Check version + if (root.version !== 1) { + return result; + } + + const hooks = root.hooks; + if (!hooks || typeof hooks !== 'object') { + return result; + } + + const hooksObj = hooks as Record; + + for (const originalId of Object.keys(hooksObj)) { + const hookType = normalizeHookTypeId(originalId); + if (!hookType) { + continue; + } + + const hookArray = hooksObj[originalId]; + if (!Array.isArray(hookArray)) { + continue; + } + + const commands: IHookCommand[] = []; + + for (const item of hookArray) { + const resolved = resolveHookCommand(item as Record, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + + if (commands.length > 0) { + result.set(hookType, { hooks: commands, originalId }); + } + } + + return result; +} + +/** + * Parses hooks from any supported format, auto-detecting the format from the file URI. + */ +export function parseHooksFromFile( + fileUri: URI, + json: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): { format: HookSourceFormat; hooks: Map } { + const format = getHookSourceFormat(fileUri); + + let hooks: Map; + + switch (format) { + case HookSourceFormat.Claude: + hooks = parseClaudeHooks(json, workspaceRootUri, userHome); + break; + case HookSourceFormat.Copilot: + default: + hooks = parseCopilotHooks(json, workspaceRootUri, userHome); + break; + } + + return { format, hooks }; +} + +/** + * Gets a human-readable label for a hook source format. + */ +export function getHookSourceFormatLabel(format: HookSourceFormat): string { + switch (format) { + case HookSourceFormat.Claude: + return 'Claude'; + case HookSourceFormat.Copilot: + return 'GitHub Copilot'; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts new file mode 100644 index 0000000000000..5d5623a113de0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -0,0 +1,370 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IJSONSchema } from '../../../../../base/common/jsonSchema.js'; +import * as nls from '../../../../../nls.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { isAbsolute } from '../../../../../base/common/path.js'; +import { untildify } from '../../../../../base/common/labels.js'; + +/** + * Enum of available hook types that can be configured in hooks.json + */ +export enum HookType { + SessionStart = 'sessionStart', + UserPromptSubmitted = 'userPromptSubmitted', + PreToolUse = 'preToolUse', + PostToolUse = 'postToolUse', + PostToolUseFailure = 'postToolUseFailure', + SubagentStart = 'subagentStart', + SubagentStop = 'subagentStop', + Stop = 'stop', +} + +/** + * String literal type derived from HookType enum values. + */ +export type HookTypeValue = `${HookType}`; + +/** + * Metadata for hook types including localized labels and descriptions + */ +export const HOOK_TYPES = [ + { + id: HookType.SessionStart, + label: nls.localize('hookType.sessionStart.label', "Session Start"), + description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins or when resuming an existing session.") + }, + { + id: HookType.UserPromptSubmitted, + label: nls.localize('hookType.userPromptSubmitted.label', "User Prompt Submitted"), + description: nls.localize('hookType.userPromptSubmitted.description', "Executed when the user submits a prompt to the agent.") + }, + { + id: HookType.PreToolUse, + label: nls.localize('hookType.preToolUse.label', "Pre-Tool Use"), + description: nls.localize('hookType.preToolUse.description', "Executed before the agent uses any tool (such as bash, edit, view).") + }, + { + id: HookType.PostToolUse, + label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"), + description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.") + }, + { + id: HookType.PostToolUseFailure, + label: nls.localize('hookType.postToolUseFailure.label', "Post-Tool Use Failure"), + description: nls.localize('hookType.postToolUseFailure.description', "Executed after a tool completes execution with a failure.") + }, + { + id: HookType.SubagentStart, + label: nls.localize('hookType.subagentStart.label', "Subagent Start"), + description: nls.localize('hookType.subagentStart.description', "Executed when a subagent is started.") + }, + { + id: HookType.SubagentStop, + label: nls.localize('hookType.subagentStop.label', "Subagent Stop"), + description: nls.localize('hookType.subagentStop.description', "Executed when a subagent stops.") + }, + { + id: HookType.Stop, + label: nls.localize('hookType.stop.label', "Stop"), + description: nls.localize('hookType.stop.description', "Executed when the agent stops.") + } +] as const; + +/** + * A single hook command configuration. + */ +export interface IHookCommand { + readonly type: 'command'; + /** Cross-platform command to execute. */ + readonly command?: string; + /** Bash-specific command. */ + readonly bash?: string; + /** PowerShell-specific command. */ + readonly powershell?: string; + /** Resolved working directory URI. */ + readonly cwd?: URI; + readonly env?: Record; + readonly timeoutSec?: number; +} + +/** + * Collected hooks for a chat request, organized by hook type. + * This is passed to the extension host so it knows what hooks are available. + */ +export interface IChatRequestHooks { + readonly [HookType.SessionStart]?: readonly IHookCommand[]; + readonly [HookType.UserPromptSubmitted]?: readonly IHookCommand[]; + readonly [HookType.PreToolUse]?: readonly IHookCommand[]; + readonly [HookType.PostToolUse]?: readonly IHookCommand[]; + readonly [HookType.PostToolUseFailure]?: readonly IHookCommand[]; + readonly [HookType.SubagentStart]?: readonly IHookCommand[]; + readonly [HookType.SubagentStop]?: readonly IHookCommand[]; + readonly [HookType.Stop]?: readonly IHookCommand[]; +} + +/** + * JSON Schema for GitHub Copilot hook configuration files. + * Hooks enable executing custom shell commands at strategic points in an agent's workflow. + */ +const hookCommandSchema: IJSONSchema = { + type: 'object', + additionalProperties: false, + required: ['type'], + anyOf: [ + { required: ['command'] }, + { required: ['bash'] }, + { required: ['powershell'] } + ], + errorMessage: nls.localize('hook.commandRequired', 'At least one of "command", "bash", or "powershell" must be specified.'), + properties: { + type: { + type: 'string', + enum: ['command'], + description: nls.localize('hook.type', 'Must be "command".') + }, + command: { + type: 'string', + description: nls.localize('hook.command', 'The command to execute. This is the recommended way to specify commands and works cross-platform.') + }, + bash: { + type: 'string', + description: nls.localize('hook.bash', 'Path to a bash script or an inline bash command. Use for Unix-specific commands when cross-platform "command" is not sufficient.') + }, + powershell: { + type: 'string', + description: nls.localize('hook.powershell', 'Path to a PowerShell script or an inline PowerShell command. Use for Windows-specific commands when cross-platform "command" is not sufficient.') + }, + cwd: { + type: 'string', + description: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).') + }, + env: { + type: 'object', + additionalProperties: { type: 'string' }, + description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.') + }, + timeoutSec: { + type: 'number', + default: 30, + description: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 30).') + } + } +}; + +const hookArraySchema: IJSONSchema = { + type: 'array', + items: hookCommandSchema +}; + +export const hookFileSchema: IJSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + description: nls.localize('hookFile.description', 'GitHub Copilot hook configuration file. Hooks enable executing custom shell commands at strategic points in an agent\'s workflow.'), + additionalProperties: false, + required: ['version', 'hooks'], + properties: { + version: { + type: 'number', + enum: [1], + description: nls.localize('hookFile.version', 'Schema version. Must be 1.') + }, + hooks: { + type: 'object', + description: nls.localize('hookFile.hooks', 'Hook definitions organized by type.'), + additionalProperties: false, + properties: { + sessionStart: { + ...hookArraySchema, + description: nls.localize('hookFile.sessionStart', 'Executed when a new agent session begins or when resuming an existing session. Use to initialize environments, log session starts, validate project state, or set up temporary resources.') + }, + userPromptSubmitted: { + ...hookArraySchema, + description: nls.localize('hookFile.userPromptSubmitted', 'Executed when the user submits a prompt to the agent. Use to log user requests for auditing and usage analysis.') + }, + preToolUse: { + ...hookArraySchema, + description: nls.localize('hookFile.preToolUse', 'Executed before the agent uses any tool (such as bash, edit, view). This is the most powerful hook as it can approve or deny tool executions. Use to block dangerous commands, enforce security policies, require approval for sensitive operations, or log tool usage.') + }, + postToolUse: { + ...hookArraySchema, + description: nls.localize('hookFile.postToolUse', 'Executed after a tool completes execution successfully. Use to log execution results, track usage statistics, generate audit trails, or monitor performance.') + }, + postToolUseFailure: { + ...hookArraySchema, + description: nls.localize('hookFile.postToolUseFailure', 'Executed after a tool completes execution with a failure. Use to log errors, send failure alerts, or trigger recovery actions.') + }, + subagentStart: { + ...hookArraySchema, + description: nls.localize('hookFile.subagentStart', 'Executed when a subagent is started. Use to log subagent spawning, track nested agent usage, or initialize subagent-specific resources.') + }, + subagentStop: { + ...hookArraySchema, + description: nls.localize('hookFile.subagentStop', 'Executed when a subagent stops. Use to log subagent completion, cleanup subagent resources, or aggregate subagent results.') + }, + stop: { + ...hookArraySchema, + description: nls.localize('hookFile.stop', 'Executed when the agent session stops. Use to cleanup resources, generate final reports, or send completion notifications.') + } + } + } + }, + defaultSnippets: [ + { + label: nls.localize('hookFile.snippet.basic', 'Basic hook configuration'), + description: nls.localize('hookFile.snippet.basic.description', 'A basic hook configuration with common hooks'), + body: { + version: 1, + hooks: { + sessionStart: [ + { + type: 'command', + command: '${1:echo "Session started"}' + } + ], + preToolUse: [ + { + type: 'command', + command: '${2:./scripts/validate.sh}', + timeoutSec: 15 + } + ] + } + } + } + ] +}; + +/** + * URI for the hook schema registration. + */ +export const HOOK_SCHEMA_URI = 'vscode://schemas/hooks'; + +/** + * Glob pattern for hook files. + */ +export const HOOK_FILE_GLOB = 'hooks/hooks.json'; + +/** + * Normalizes a raw hook type identifier to the canonical HookType enum value. + * Supports alternative casing and naming conventions from different tools: + * - Claude Code: PreToolUse, PostToolUse, SessionStart, Stop, SubagentStart, SubagentStop, UserPromptSubmit + * - GitHub Copilot: sessionStart, userPromptSubmitted, preToolUse, postToolUse, etc. + * + * @see https://docs.anthropic.com/en/docs/claude-code/hooks + * @see https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks#types-of-hooks + */ +export function normalizeHookTypeId(rawHookTypeId: string): HookType | undefined { + // Check if it's already a canonical HookType value + if (Object.values(HookType).includes(rawHookTypeId as HookType)) { + return rawHookTypeId as HookType; + } + + // Handle alternative names from Claude Code + switch (rawHookTypeId) { + case 'SessionStart': + return HookType.SessionStart; + case 'UserPromptSubmit': + return HookType.UserPromptSubmitted; + case 'PreToolUse': + return HookType.PreToolUse; + case 'PostToolUse': + return HookType.PostToolUse; + case 'PostToolUseFailure': + return HookType.PostToolUseFailure; + case 'SubagentStart': + return HookType.SubagentStart; + case 'SubagentStop': + return HookType.SubagentStop; + case 'Stop': + return HookType.Stop; + default: + return undefined; + } +} + +/** + * Normalizes a raw hook command object, validating structure. + * This is an internal helper - use resolveHookCommand for the full resolution. + */ +function normalizeHookCommand(raw: Record): { command?: string; bash?: string; powershell?: string; cwd?: string; env?: Record; timeoutSec?: number } | undefined { + if (raw.type !== 'command') { + return undefined; + } + + const hasCommand = typeof raw.command === 'string' && raw.command.length > 0; + const hasBash = typeof raw.bash === 'string' && raw.bash.length > 0; + const hasPowerShell = typeof raw.powershell === 'string' && raw.powershell.length > 0; + + return { + ...(hasCommand && { command: raw.command as string }), + ...(hasBash && { bash: raw.bash as string }), + ...(hasPowerShell && { powershell: raw.powershell as string }), + ...(typeof raw.cwd === 'string' && { cwd: raw.cwd }), + ...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record }), + ...(typeof raw.timeoutSec === 'number' && { timeoutSec: raw.timeoutSec }), + }; +} + +/** + * Formats a hook command for display. + * If `command` is present, returns just that value. + * Otherwise, joins "bash: " and "powershell: " with " | ". + */ +export function formatHookCommandLabel(hook: IHookCommand): string { + if (hook.command) { + return hook.command; + } + + const parts: string[] = []; + if (hook.bash) { + parts.push(`bash: ${hook.bash}`); + } + if (hook.powershell) { + parts.push(`powershell: ${hook.powershell}`); + } + return parts.join(' | '); +} + +/** + * Resolves a raw hook command object to the canonical IHookCommand format. + * Normalizes the command and resolves the cwd path relative to the workspace root. + * @param raw The raw hook command object from JSON + * @param workspaceRootUri The workspace root URI to resolve relative cwd paths against + * @param userHome The user's home directory path for tilde expansion + */ +export function resolveHookCommand(raw: Record, workspaceRootUri: URI | undefined, userHome: string): IHookCommand | undefined { + const normalized = normalizeHookCommand(raw); + if (!normalized) { + return undefined; + } + + let cwdUri: URI | undefined; + if (normalized.cwd) { + // Expand tilde to user home directory + const expandedCwd = untildify(normalized.cwd, userHome); + if (isAbsolute(expandedCwd)) { + // Use absolute path directly + cwdUri = URI.file(expandedCwd); + } else if (workspaceRootUri) { + // Resolve relative to workspace root + cwdUri = joinPath(workspaceRootUri, expandedCwd); + } + } else { + cwdUri = workspaceRootUri; + } + + return { + type: 'command', + ...(normalized.command && { command: normalized.command }), + ...(normalized.bash && { bash: normalized.bash }), + ...(normalized.powershell && { powershell: normalized.powershell }), + ...(cwdUri && { cwd: cwdUri }), + ...(normalized.env && { env: normalized.env }), + ...(normalized.timeoutSec !== undefined && { timeoutSec: normalized.timeoutSec }), + }; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 698bf5d2e2624..19550c51dafdf 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -621,18 +621,20 @@ export class PromptValidator { } } -const allAttributeNames = { +const allAttributeNames: Record = { [PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint], [PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent], [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata], + [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; const githubCopilotAgentAttributeNames = [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.tools, PromptHeaderAttributes.target, GithubPromptHeaderAttributes.mcpServers, PromptHeaderAttributes.infer]; -const recommendedAttributeNames = { +const recommendedAttributeNames: Record = { [PromptsType.prompt]: allAttributeNames[PromptsType.prompt].filter(name => !isNonRecommendedAttribute(name)), [PromptsType.instructions]: allAttributeNames[PromptsType.instructions].filter(name => !isNonRecommendedAttribute(name)), [PromptsType.agent]: allAttributeNames[PromptsType.agent].filter(name => !isNonRecommendedAttribute(name)), [PromptsType.skill]: allAttributeNames[PromptsType.skill].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, isGitHubTarget: boolean): string[] { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index 7da38b26d2252..4d588cc1cfd49 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -12,6 +12,8 @@ export const PROMPT_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-prompt-snipp export const INSTRUCTIONS_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-custom-instructions'; export const AGENT_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-custom-chat-modes'; // todo export const SKILL_DOCUMENTATION_URL = 'https://aka.ms/vscode-agent-skills'; +// TODO: update link when available +export const HOOK_DOCUMENTATION_URL = 'https://aka.ms/vscode-chat-hooks'; /** * Language ID for the reusable prompt syntax. @@ -51,6 +53,9 @@ export function getLanguageIdForPromptsType(type: PromptsType): string { return AGENT_LANGUAGE_ID; case PromptsType.skill: return SKILL_LANGUAGE_ID; + case PromptsType.hook: + // Hooks use JSON syntax with schema validation + return 'json'; default: throw new Error(`Unknown prompt type: ${type}`); } @@ -66,6 +71,7 @@ export function getPromptsTypeForLanguageId(languageId: string): PromptsType | u return PromptsType.agent; case SKILL_LANGUAGE_ID: return PromptsType.skill; + // Note: hook uses 'json' language ID which is shared, so we don't map it here default: return undefined; } @@ -79,7 +85,8 @@ export enum PromptsType { instructions = 'instructions', prompt = 'prompt', agent = 'agent', - skill = 'skill' + skill = 'skill', + hook = 'hook' } export function isValidPromptType(type: string): type is PromptsType { return Object.values(PromptsType).includes(type as PromptsType); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 6462387473593..fdb4790ac6ff5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -14,6 +14,7 @@ import { IChatModeInstructions, IVariableReference } from '../../chatModes.js'; import { PromptsType } from '../promptTypes.js'; import { IHandOff, ParsedPromptFile } from '../promptFileParser.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; +import { IChatRequestHooks } from '../hookSchema.js'; import { IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; /** @@ -396,4 +397,10 @@ export interface IPromptsService extends IDisposable { * Used for diagnostics and config-info displays. */ getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken): Promise; + + /** + * Gets all hooks collected from hooks.json files. + * The result is cached and invalidated when hook files change. + */ + getHooks(token: CancellationToken): Promise; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 35e3fee0fe44e..d37fa5f5fd36b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -34,6 +34,10 @@ import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../p import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; +import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; +import { parseHooksFromFile } from '../hookCompatibility.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../../services/path/common/pathService.js'; /** * Error thrown when a skill file is missing the required name attribute. @@ -87,6 +91,11 @@ export class PromptsService extends Disposable implements IPromptsService { */ private readonly cachedSlashCommands: CachedPromise; + /** + * Cached hooks. Invalidated when hook files change. + */ + private readonly cachedHooks: CachedPromise; + /** * Cache for parsed prompt files keyed by URI. * The number in the returned tuple is textModel.getVersionId(), which is an internal VS Code counter that increments every time the text model's content changes. @@ -114,6 +123,7 @@ export class PromptsService extends Disposable implements IPromptsService { [PromptsType.instructions]: new ResourceMap>(), [PromptsType.agent]: new ResourceMap>(), [PromptsType.skill]: new ResourceMap>(), + [PromptsType.hook]: new ResourceMap>(), }; constructor( @@ -128,6 +138,8 @@ export class PromptsService extends Disposable implements IPromptsService { @IStorageService private readonly storageService: IStorageService, @IExtensionService private readonly extensionService: IExtensionService, @ITelemetryService private readonly telemetryService: ITelemetryService, + @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, + @IPathService private readonly pathService: IPathService, ) { super(); @@ -146,6 +158,14 @@ export class PromptsService extends Disposable implements IPromptsService { (token) => this.computePromptSlashCommands(token), () => Event.any(this.getFileLocatorEvent(PromptsType.prompt), Event.filter(modelChangeEvent, e => e.promptType === PromptsType.prompt)) )); + + this.cachedHooks = this._register(new CachedPromise( + (token) => this.computeHooks(token), + () => this.getFileLocatorEvent(PromptsType.hook) + )); + + // Hack: Subscribe to activate caching (CachedPromise only caches when onDidChange has listeners) + this._register(this.cachedHooks.onDidChange(() => { })); } private getFileLocatorEvent(type: PromptsType): Event { @@ -341,11 +361,15 @@ export class PromptsService extends Disposable implements IPromptsService { .map(result => result.value); const activationEvent = this.getProviderActivationEvent(type); + if (!activationEvent) { + // No provider activation event for this type (e.g., hooks) + return contributedFiles; + } const providerFiles = await this.listFromProviders(type, activationEvent, token); return [...contributedFiles, ...providerFiles]; } - private getProviderActivationEvent(type: PromptsType): string { + private getProviderActivationEvent(type: PromptsType): string | undefined { switch (type) { case PromptsType.agent: return CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT; @@ -355,6 +379,8 @@ export class PromptsService extends Disposable implements IPromptsService { return PROMPT_FILE_PROVIDER_ACTIVATION_EVENT; case PromptsType.skill: return SKILL_PROVIDER_ACTIVATION_EVENT; + case PromptsType.hook: + return undefined; // hooks don't have extension providers } } @@ -366,6 +392,13 @@ export class PromptsService extends Disposable implements IPromptsService { for (const uri of folders) { result.push({ uri, storage: PromptsStorage.local, type }); } + } else if (type === PromptsType.hook) { + // For hooks, return the Copilot hooks folder for creating new hooks + // (Claude paths are read-only and not included here) + const hooksFolders = await this.fileLocator.getHookSourceFolders(); + for (const uri of hooksFolders) { + result.push({ uri, storage: PromptsStorage.local, type }); + } } else { for (const uri of await this.fileLocator.getConfigBasedSourceFolders(type)) { result.push({ uri, storage: PromptsStorage.local, type }); @@ -848,6 +881,81 @@ export class PromptsService extends Disposable implements IPromptsService { return result; } + public getHooks(token: CancellationToken): Promise { + return this.cachedHooks.get(token); + } + + private async computeHooks(token: CancellationToken): Promise { + const hookFiles = await this.listPromptFiles(PromptsType.hook, token); + + if (hookFiles.length === 0) { + this.logger.trace('[PromptsService] No hook files found.'); + return undefined; + } + + this.logger.trace(`[PromptsService] Found ${hookFiles.length} hook file(s).`); + + // Get user home for tilde expansion + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + // Get workspace root for resolving relative cwd paths + const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + + const collectedHooks: Record = { + [HookType.SessionStart]: [], + [HookType.UserPromptSubmitted]: [], + [HookType.PreToolUse]: [], + [HookType.PostToolUse]: [], + [HookType.PostToolUseFailure]: [], + [HookType.SubagentStart]: [], + [HookType.SubagentStop]: [], + [HookType.Stop]: [], + }; + + for (const hookFile of hookFiles) { + try { + const content = await this.fileService.readFile(hookFile.uri); + const json = JSON.parse(content.value.toString()); + + // Use format-aware parsing that handles Copilot, Claude, and Cursor formats + const { format, hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); + + for (const [hookType, { hooks: commands }] of hooks) { + for (const command of commands) { + collectedHooks[hookType].push(command); + this.logger.trace(`[PromptsService] Collected ${hookType} hook from ${hookFile.uri} (format: ${format})`); + } + } + } catch (error) { + this.logger.warn(`[PromptsService] Failed to parse hook file: ${hookFile.uri}`, error); + } + } + + // Check if any hooks were collected + const hasHooks = Object.values(collectedHooks).some(arr => arr.length > 0); + if (!hasHooks) { + this.logger.trace('[PromptsService] No valid hooks collected.'); + return undefined; + } + + // Build the result, only including hook types that have entries + const result: IChatRequestHooks = { + ...(collectedHooks[HookType.SessionStart].length > 0 && { sessionStart: collectedHooks[HookType.SessionStart] }), + ...(collectedHooks[HookType.UserPromptSubmitted].length > 0 && { userPromptSubmitted: collectedHooks[HookType.UserPromptSubmitted] }), + ...(collectedHooks[HookType.PreToolUse].length > 0 && { preToolUse: collectedHooks[HookType.PreToolUse] }), + ...(collectedHooks[HookType.PostToolUse].length > 0 && { postToolUse: collectedHooks[HookType.PostToolUse] }), + ...(collectedHooks[HookType.PostToolUseFailure].length > 0 && { postToolUseFailure: collectedHooks[HookType.PostToolUseFailure] }), + ...(collectedHooks[HookType.SubagentStart].length > 0 && { subagentStart: collectedHooks[HookType.SubagentStart] }), + ...(collectedHooks[HookType.SubagentStop].length > 0 && { subagentStop: collectedHooks[HookType.SubagentStop] }), + ...(collectedHooks[HookType.Stop].length > 0 && { stop: collectedHooks[HookType.Stop] }), + }; + + this.logger.trace(`[PromptsService] Collected hooks: ${JSON.stringify(Object.keys(result))}`); + return result; + } + public async getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken): Promise { const files: IPromptFileDiscoveryResult[] = []; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 2d81297751b07..731c55576a6f8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -12,7 +12,7 @@ import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../ import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; +import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, DEFAULT_HOOK_FILE_PATHS, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource, HOOKS_SOURCE_FOLDER } from '../config/promptFileLocations.js'; import { PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -116,12 +116,19 @@ export class PromptFilesLocator { const defaultFolders = getPromptFileDefaultLocations(type); for (const sourceFolder of defaultFolders) { + let folderPath: URI; if (sourceFolder.storage === PromptsStorage.local) { for (const workspaceFolder of folders) { - result.push(joinPath(workspaceFolder.uri, sourceFolder.path)); + folderPath = joinPath(workspaceFolder.uri, sourceFolder.path); + // For hooks, the paths are file paths, so get the parent directory + result.push(type === PromptsType.hook ? dirname(folderPath) : folderPath); } } else if (sourceFolder.storage === PromptsStorage.user) { - result.push(joinPath(userHome, sourceFolder.path)); + // For tilde paths, strip the ~/ prefix before joining with userHome + const relativePath = isTildePath(sourceFolder.path) ? sourceFolder.path.substring(2) : sourceFolder.path; + folderPath = joinPath(userHome, relativePath); + // For hooks, the paths are file paths, so get the parent directory + result.push(type === PromptsType.hook ? dirname(folderPath) : folderPath); } } @@ -193,6 +200,15 @@ export class PromptFilesLocator { return this.toAbsoluteLocations(PromptsType.agent, DEFAULT_AGENT_SOURCE_FOLDERS, userHome).map(l => l.uri); } + /** + * Gets the hook source folders for creating new hooks. + * Returns only the Copilot hooks folder (.github/hooks) since Claude paths are read-only. + */ + public async getHookSourceFolders(): Promise { + const { folders } = this.workspaceService.getWorkspace(); + return folders.map(folder => joinPath(folder.uri, HOOKS_SOURCE_FOLDER)); + } + /** * Get all possible unambiguous prompt file source folders based on * the current workspace folder structure. @@ -328,9 +344,18 @@ export class PromptFilesLocator { const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); if (type === PromptsType.agent) { configuredLocations.push(...DEFAULT_AGENT_SOURCE_FOLDERS); + } else if (type === PromptsType.hook) { + configuredLocations.push(...DEFAULT_HOOK_FILE_PATHS); } const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, undefined); + + // For hooks, the paths are file paths (e.g., '.github/hooks/hooks.json'), not folder paths. + // We need to watch the parent directories of these files. + if (type === PromptsType.hook) { + return absoluteLocations.map((location) => ({ parent: dirname(location.uri) })); + } + return absoluteLocations.map((location) => firstNonGlobParentAndPattern(location.uri)); } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index 50d5b38401e50..e9d2d91f3acef 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -44,8 +44,10 @@ import { ChatTransferService, IChatTransferService } from '../../../common/model import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { NullLanguageModelsService } from '../../common/languageModels.js'; import { MockChatVariablesService } from '../../common/mockChatVariables.js'; +import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; function getAgentData(id: string): IChatAgentData { return { @@ -83,6 +85,7 @@ suite('ChatEditingService', function () { collection.set(IEditorWorkerService, new SyncDescriptor(TestWorkerService)); collection.set(IChatService, new SyncDescriptor(ChatService)); collection.set(IMcpService, new TestMcpService()); + collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts new file mode 100644 index 0000000000000..03257775f1eef --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -0,0 +1,344 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; +import { ITextEditorSelection } from '../../../../../../platform/editor/common/editor.js'; + +/** + * Helper to extract the selected text from content using a selection range. + */ +function getSelectedText(content: string, selection: ITextEditorSelection): string { + const lines = content.split('\n'); + if (selection.startLineNumber === selection.endLineNumber) { + return lines[selection.startLineNumber - 1].substring(selection.startColumn - 1, selection.endColumn! - 1); + } + // Multi-line selection + const result: string[] = []; + result.push(lines[selection.startLineNumber - 1].substring(selection.startColumn - 1)); + for (let i = selection.startLineNumber; i < selection.endLineNumber! - 1; i++) { + result.push(lines[i]); + } + result.push(lines[selection.endLineNumber! - 1].substring(0, selection.endColumn! - 1)); + return result.join('\n'); +} + +suite('hookUtils', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('findHookCommandSelection', () => { + + suite('simple format', () => { + const simpleFormat = `{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "echo first" + }, + { + "type": "command", + "command": "echo second" + } + ], + "userPromptSubmitted": [ + { + "type": "command", + "command": "echo foo > test.derp" + } + ] + } +}`; + + test('finds first command in sessionStart', () => { + const result = findHookCommandSelection(simpleFormat, 'sessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(simpleFormat, result), 'echo first'); + assert.deepStrictEqual(result, { + startLineNumber: 7, + startColumn: 17, + endLineNumber: 7, + endColumn: 27 + }); + }); + + test('finds second command in sessionStart', () => { + const result = findHookCommandSelection(simpleFormat, 'sessionStart', 1, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(simpleFormat, result), 'echo second'); + assert.deepStrictEqual(result, { + startLineNumber: 11, + startColumn: 17, + endLineNumber: 11, + endColumn: 28 + }); + }); + + test('finds command in userPromptSubmitted', () => { + const result = findHookCommandSelection(simpleFormat, 'userPromptSubmitted', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(simpleFormat, result), 'echo foo > test.derp'); + assert.deepStrictEqual(result, { + startLineNumber: 17, + startColumn: 17, + endLineNumber: 17, + endColumn: 37 + }); + }); + + test('returns undefined for out of bounds index', () => { + const result = findHookCommandSelection(simpleFormat, 'sessionStart', 5, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for non-existent hook type', () => { + const result = findHookCommandSelection(simpleFormat, 'nonExistent', 0, 'command'); + assert.strictEqual(result, undefined); + }); + }); + + suite('nested matcher format', () => { + const nestedFormat = `{ + "forceLoginMethod": "console", + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "echo 'foobarbaz5' > ~/foobarbaz.txt" + } + ] + } + ] + } +}`; + + test('finds command inside nested hooks', () => { + const result = findHookCommandSelection(nestedFormat, 'UserPromptSubmit', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(nestedFormat, result), 'echo \'foobarbaz5\' > ~/foobarbaz.txt'); + assert.deepStrictEqual(result, { + startLineNumber: 10, + startColumn: 19, + endLineNumber: 10, + endColumn: 54 + }); + }); + + test('returns undefined for non-existent field name', () => { + const result = findHookCommandSelection(nestedFormat, 'UserPromptSubmit', 0, 'bash'); + assert.strictEqual(result, undefined); + }); + }); + + suite('mixed format with multiple nested hooks', () => { + const mixedFormat = `{ + "hooks": { + "preToolUse": [ + { + "matcher": "edit_file", + "hooks": [ + { + "type": "command", + "command": "first nested" + }, + { + "type": "command", + "command": "second nested" + } + ] + }, + { + "type": "command", + "command": "simple after nested" + } + ] + } +}`; + + test('finds first command in first nested hooks array', () => { + const result = findHookCommandSelection(mixedFormat, 'preToolUse', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(mixedFormat, result), 'first nested'); + assert.deepStrictEqual(result, { + startLineNumber: 9, + startColumn: 19, + endLineNumber: 9, + endColumn: 31 + }); + }); + + test('finds second command in first nested hooks array', () => { + const result = findHookCommandSelection(mixedFormat, 'preToolUse', 1, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(mixedFormat, result), 'second nested'); + assert.deepStrictEqual(result, { + startLineNumber: 13, + startColumn: 19, + endLineNumber: 13, + endColumn: 32 + }); + }); + + test('finds simple command after nested structure', () => { + const result = findHookCommandSelection(mixedFormat, 'preToolUse', 2, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(mixedFormat, result), 'simple after nested'); + assert.deepStrictEqual(result, { + startLineNumber: 19, + startColumn: 17, + endLineNumber: 19, + endColumn: 36 + }); + }); + }); + + suite('bash and powershell fields', () => { + const platformSpecificFormat = `{ + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "echo hello from bash", + "powershell": "Write-Host hello" + } + ] + } +}`; + + test('finds bash field', () => { + const result = findHookCommandSelection(platformSpecificFormat, 'sessionStart', 0, 'bash'); + assert.ok(result); + assert.strictEqual(getSelectedText(platformSpecificFormat, result), 'echo hello from bash'); + assert.deepStrictEqual(result, { + startLineNumber: 6, + startColumn: 14, + endLineNumber: 6, + endColumn: 34 + }); + }); + + test('finds powershell field', () => { + const result = findHookCommandSelection(platformSpecificFormat, 'sessionStart', 0, 'powershell'); + assert.ok(result); + assert.strictEqual(getSelectedText(platformSpecificFormat, result), 'Write-Host hello'); + assert.deepStrictEqual(result, { + startLineNumber: 7, + startColumn: 20, + endLineNumber: 7, + endColumn: 36 + }); + }); + }); + + suite('edge cases', () => { + test('returns undefined for empty content', () => { + const result = findHookCommandSelection('', 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for invalid JSON', () => { + const result = findHookCommandSelection('{ invalid json }', 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when hooks key is missing', () => { + const content = '{ "version": 1 }'; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when hook type array is empty', () => { + const content = '{ "hooks": { "sessionStart": [] } }'; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when hook item is not an object', () => { + const content = '{ "hooks": { "sessionStart": ["not an object"] } }'; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('handles empty command string', () => { + const content = `{ + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "" + } + ] + } +}`; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), ''); + assert.deepStrictEqual(result, { + startLineNumber: 6, + startColumn: 17, + endLineNumber: 6, + endColumn: 17 + }); + }); + + test('handles multiline command value', () => { + // JSON strings can contain escaped newlines + const content = `{ + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "line1\\nline2" + } + ] + } +}`; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'line1\\nline2'); + assert.deepStrictEqual(result, { + startLineNumber: 6, + startColumn: 17, + endLineNumber: 6, + endColumn: 29 + }); + }); + }); + + suite('nested matcher with empty hooks array', () => { + const emptyNestedHooks = `{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "some-pattern", + "hooks": [] + }, + { + "type": "command", + "command": "after empty nested" + } + ] + } +}`; + + test('skips empty nested hooks and finds subsequent command', () => { + const result = findHookCommandSelection(emptyNestedHooks, 'UserPromptSubmit', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(emptyNestedHooks, result), 'after empty nested'); + assert.deepStrictEqual(result, { + startLineNumber: 10, + startColumn: 17, + endLineNumber: 10, + endColumn: 35 + }); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index f48737b798956..23f59fb2ba82c 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -44,6 +44,8 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { MockChatService } from './mockChatService.js'; import { MockChatVariablesService } from '../mockChatVariables.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; +import { MockPromptsService } from '../promptSyntax/service/mockPromptsService.js'; const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; const chatAgentWithUsedContext: IChatAgent = { @@ -156,6 +158,7 @@ suite('ChatService', () => { [IChatVariablesService, new MockChatVariablesService()], [IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()], [IMcpService, new TestMcpService()], + [IPromptsService, new MockPromptsService()], ))); instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); instantiationService.stub(ILogService, new NullLogService()); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts index 0a3dc060d7ee0..a81e59d0ec652 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts @@ -82,6 +82,51 @@ suite('promptFileLocations', function () { const uri = URI.file('/workspace/.github/skills/test/Skill.md'); assert.strictEqual(getPromptFileType(uri), PromptsType.skill); }); + + test('hooks.json should be recognized as hook', () => { + const uri = URI.file('/workspace/.github/hooks/hooks.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('HOOKS.JSON (uppercase) should be recognized as hook', () => { + const uri = URI.file('/workspace/.github/hooks/HOOKS.JSON'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('hooks.json in any folder should be recognized as hook', () => { + const uri = URI.file('/workspace/some/other/path/hooks.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('settings.json in .claude folder should be recognized as hook', () => { + const uri = URI.file('/workspace/.claude/settings.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('settings.local.json in .claude folder should be recognized as hook', () => { + const uri = URI.file('/workspace/.claude/settings.local.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('SETTINGS.JSON (uppercase) in .claude folder should be recognized as hook', () => { + const uri = URI.file('/workspace/.claude/SETTINGS.JSON'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('settings.json outside .claude folder should NOT be recognized as hook', () => { + const uri = URI.file('/workspace/.github/settings.json'); + assert.strictEqual(getPromptFileType(uri), undefined); + }); + + test('settings.local.json outside .claude folder should NOT be recognized as hook', () => { + const uri = URI.file('/workspace/some/path/settings.local.json'); + assert.strictEqual(getPromptFileType(uri), undefined); + }); + + test('settings.json in ~/.claude folder should be recognized as hook', () => { + const uri = URI.file('/Users/user/.claude/settings.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); }); suite('getCleanPromptName', () => { @@ -162,5 +207,21 @@ suite('promptFileLocations', function () { test('regular .md files should return false', () => { assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/SKILL2.md')), false); }); + + test('hooks.json should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.github/hooks/hooks.json')), true); + }); + + test('settings.json in .claude folder should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/settings.json')), true); + }); + + test('settings.local.json in .claude folder should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/settings.local.json')), true); + }); + + test('settings.json outside .claude folder should return false', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/settings.json')), false); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts new file mode 100644 index 0000000000000..778b57732ffea --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -0,0 +1,362 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { HookType } from '../../../common/promptSyntax/hookSchema.js'; +import { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName } from '../../../common/promptSyntax/hookClaudeCompat.js'; +import { URI } from '../../../../../../base/common/uri.js'; + +suite('HookClaudeCompat', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('resolveClaudeHookType', () => { + test('resolves PreToolUse', () => { + assert.strictEqual(resolveClaudeHookType('PreToolUse'), HookType.PreToolUse); + }); + + test('resolves UserPromptSubmit', () => { + assert.strictEqual(resolveClaudeHookType('UserPromptSubmit'), HookType.UserPromptSubmitted); + }); + + test('returns undefined for unknown type', () => { + assert.strictEqual(resolveClaudeHookType('UnknownHook'), undefined); + }); + + test('returns undefined for camelCase (not Claude format)', () => { + assert.strictEqual(resolveClaudeHookType('preToolUse'), undefined); + }); + }); + + suite('getClaudeHookTypeName', () => { + test('gets PreToolUse for HookType.PreToolUse', () => { + assert.strictEqual(getClaudeHookTypeName(HookType.PreToolUse), 'PreToolUse'); + }); + + test('gets UserPromptSubmit for HookType.UserPromptSubmitted', () => { + assert.strictEqual(getClaudeHookTypeName(HookType.UserPromptSubmitted), 'UserPromptSubmit'); + }); + + test('returns undefined for HookType.PostToolUseFailure (not supported)', () => { + assert.strictEqual(getClaudeHookTypeName(HookType.PostToolUseFailure), undefined); + }); + }); + + suite('parseClaudeHooks', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + suite('basic parsing', () => { + test('parses simple hook with command', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "pre-tool"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 1); + assert.ok(result.has(HookType.PreToolUse)); + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.originalId, 'PreToolUse'); + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "pre-tool"'); + }); + + test('parses multiple hook types', () => { + const json = { + hooks: { + SessionStart: [{ type: 'command', command: 'echo "start"' }], + Stop: [{ type: 'command', command: 'echo "stop"' }] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 2); + assert.ok(result.has(HookType.SessionStart)); + assert.ok(result.has(HookType.Stop)); + }); + + test('parses multiple commands for same hook type', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "first"' }, + { type: 'command', command: 'echo "second"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + assert.strictEqual(entry.hooks[0].command, 'echo "first"'); + assert.strictEqual(entry.hooks[1].command, 'echo "second"'); + }); + }); + + suite('nested hooks with matchers', () => { + test('parses nested hooks with matcher', () => { + const json = { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "bash hook"' } + ] + } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "bash hook"'); + }); + + test('parses multiple nested hooks within one matcher', () => { + const json = { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "first"' }, + { type: 'command', command: 'echo "second"' } + ] + } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + }); + + test('parses multiple matchers for same hook type', () => { + const json = { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [{ type: 'command', command: 'echo "bash"' }] + }, + { + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo "write"' }] + } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + assert.strictEqual(entry.hooks[0].command, 'echo "bash"'); + assert.strictEqual(entry.hooks[1].command, 'echo "write"'); + }); + + test('parses mix of direct and nested hooks', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "direct"' }, + { + matcher: 'Bash', + hooks: [{ type: 'command', command: 'echo "nested"' }] + } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + assert.strictEqual(entry.hooks[0].command, 'echo "direct"'); + assert.strictEqual(entry.hooks[1].command, 'echo "nested"'); + }); + }); + + suite('command without type field', () => { + test('parses command without explicit type field', () => { + const json = { + hooks: { + PreToolUse: [ + { command: 'echo "no type"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "no type"'); + }); + }); + + suite('hook type name mapping', () => { + test('maps UserPromptSubmit to UserPromptSubmitted', () => { + const json = { + hooks: { + UserPromptSubmit: [{ type: 'command', command: 'echo "submitted"' }] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.ok(result.has(HookType.UserPromptSubmitted)); + assert.strictEqual(result.get(HookType.UserPromptSubmitted)!.originalId, 'UserPromptSubmit'); + }); + }); + + suite('invalid inputs', () => { + test('returns empty map for null json', () => { + const result = parseClaudeHooks(null, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty map for undefined json', () => { + const result = parseClaudeHooks(undefined, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty map for non-object json', () => { + const result = parseClaudeHooks('string', workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty map for missing hooks property', () => { + const result = parseClaudeHooks({}, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty map for non-object hooks property', () => { + const result = parseClaudeHooks({ hooks: 'invalid' }, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('skips unknown hook types', () => { + const json = { + hooks: { + UnknownType: [{ type: 'command', command: 'echo "test"' }], + PreToolUse: [{ type: 'command', command: 'echo "known"' }] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 1); + assert.ok(result.has(HookType.PreToolUse)); + }); + + test('skips non-array hook entries', () => { + const json = { + hooks: { + PreToolUse: { type: 'command', command: 'echo "not array"' } + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 0); + }); + + test('skips invalid command entries', () => { + const json = { + hooks: { + PreToolUse: [ + 'invalid string', + null, + { type: 'command', command: 'valid' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'valid'); + }); + + test('skips commands with wrong type', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'script', command: 'invalid type' }, + { type: 'command', command: 'valid' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'valid'); + }); + }); + + suite('cwd and env resolution', () => { + test('resolves cwd relative to workspace', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"', cwd: 'src' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.deepStrictEqual(entry.hooks[0].cwd, URI.file('/workspace/src')); + }); + + test('preserves env variables', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"', env: { NODE_ENV: 'production' } } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.deepStrictEqual(entry.hooks[0].env, { NODE_ENV: 'production' }); + }); + + test('preserves timeoutSec', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"', timeoutSec: 60 } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks[0].timeoutSec, 60); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts new file mode 100644 index 0000000000000..f9557563e11e5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -0,0 +1,331 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { HookType, normalizeHookTypeId, resolveHookCommand } from '../../../common/promptSyntax/hookSchema.js'; +import { URI } from '../../../../../../base/common/uri.js'; + +suite('HookSchema', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('normalizeHookTypeId', () => { + + suite('Claude Code hook types (PascalCase)', () => { + // @see https://code.claude.com/docs/en/hooks#hook-lifecycle + + test('SessionStart -> sessionStart', () => { + assert.strictEqual(normalizeHookTypeId('SessionStart'), HookType.SessionStart); + }); + + test('UserPromptSubmit -> userPromptSubmitted', () => { + assert.strictEqual(normalizeHookTypeId('UserPromptSubmit'), HookType.UserPromptSubmitted); + }); + + test('PreToolUse -> preToolUse', () => { + assert.strictEqual(normalizeHookTypeId('PreToolUse'), HookType.PreToolUse); + }); + + test('PostToolUse -> postToolUse', () => { + assert.strictEqual(normalizeHookTypeId('PostToolUse'), HookType.PostToolUse); + }); + + test('PostToolUseFailure -> postToolUseFailure', () => { + assert.strictEqual(normalizeHookTypeId('PostToolUseFailure'), HookType.PostToolUseFailure); + }); + + test('SubagentStart -> subagentStart', () => { + assert.strictEqual(normalizeHookTypeId('SubagentStart'), HookType.SubagentStart); + }); + + test('SubagentStop -> subagentStop', () => { + assert.strictEqual(normalizeHookTypeId('SubagentStop'), HookType.SubagentStop); + }); + + test('Stop -> stop', () => { + assert.strictEqual(normalizeHookTypeId('Stop'), HookType.Stop); + }); + }); + + suite('unknown hook types', () => { + test('unknown type returns undefined', () => { + assert.strictEqual(normalizeHookTypeId('unknownHook'), undefined); + }); + + test('empty string returns undefined', () => { + assert.strictEqual(normalizeHookTypeId(''), undefined); + }); + + test('typo returns undefined', () => { + assert.strictEqual(normalizeHookTypeId('sessionstart'), undefined); + assert.strictEqual(normalizeHookTypeId('SESSIONSTART'), undefined); + }); + }); + }); + + suite('resolveHookCommand', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + suite('command property', () => { + test('resolves basic command', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: workspaceRoot + }); + }); + + test('resolves command with all optional properties', () => { + const result = resolveHookCommand({ + type: 'command', + command: './scripts/validate.sh', + cwd: 'src', + env: { NODE_ENV: 'test' }, + timeoutSec: 60 + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: './scripts/validate.sh', + cwd: URI.file('/workspace/src'), + env: { NODE_ENV: 'test' }, + timeoutSec: 60 + }); + }); + + test('empty command returns object without command', () => { + const result = resolveHookCommand({ + type: 'command', + command: '' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + cwd: workspaceRoot + }); + }); + }); + + suite('bash shorthand', () => { + test('preserves bash property', () => { + const result = resolveHookCommand({ + type: 'command', + bash: 'echo "hello world"' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + bash: 'echo "hello world"', + cwd: workspaceRoot + }); + }); + + test('bash with cwd and env', () => { + const result = resolveHookCommand({ + type: 'command', + bash: './test.sh', + cwd: 'scripts', + env: { DEBUG: '1' } + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + bash: './test.sh', + cwd: URI.file('/workspace/scripts'), + env: { DEBUG: '1' } + }); + }); + + test('empty bash returns object without bash', () => { + const result = resolveHookCommand({ + type: 'command', + bash: '' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + cwd: workspaceRoot + }); + }); + }); + + suite('powershell shorthand', () => { + test('preserves powershell property', () => { + const result = resolveHookCommand({ + type: 'command', + powershell: 'Write-Host "hello"' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + powershell: 'Write-Host "hello"', + cwd: workspaceRoot + }); + }); + + test('powershell with timeoutSec', () => { + const result = resolveHookCommand({ + type: 'command', + powershell: 'Get-Process', + timeoutSec: 30 + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + powershell: 'Get-Process', + cwd: workspaceRoot, + timeoutSec: 30 + }); + }); + + test('empty powershell returns object without powershell', () => { + const result = resolveHookCommand({ + type: 'command', + powershell: '' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + cwd: workspaceRoot + }); + }); + }); + + suite('multiple properties specified', () => { + test('preserves both command and bash', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'direct-command', + bash: 'bash-script.sh' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'direct-command', + bash: 'bash-script.sh', + cwd: workspaceRoot + }); + }); + + test('preserves both command and powershell', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'direct-command', + powershell: 'ps-script.ps1' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'direct-command', + powershell: 'ps-script.ps1', + cwd: workspaceRoot + }); + }); + + test('preserves both bash and powershell when no command', () => { + const result = resolveHookCommand({ + type: 'command', + bash: 'bash-script.sh', + powershell: 'ps-script.ps1' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + bash: 'bash-script.sh', + powershell: 'ps-script.ps1', + cwd: workspaceRoot + }); + }); + }); + + suite('cwd resolution', () => { + test('cwd is not resolved when no workspace root', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + cwd: 'src' + }, undefined, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello' + }); + }); + + test('cwd is resolved relative to workspace root', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + cwd: 'nested/path' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: URI.file('/workspace/nested/path') + }); + }); + }); + + suite('invalid inputs', () => { + test('wrong type returns undefined', () => { + const result = resolveHookCommand({ + type: 'script', + command: 'echo hello' + }, workspaceRoot, userHome); + assert.strictEqual(result, undefined); + }); + + test('missing type returns undefined', () => { + const result = resolveHookCommand({ + command: 'echo hello' + }, workspaceRoot, userHome); + assert.strictEqual(result, undefined); + }); + + test('no command/bash/powershell returns object with just type and cwd', () => { + const result = resolveHookCommand({ + type: 'command', + cwd: '/workspace' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + cwd: URI.file('/workspace') + }); + }); + + test('ignores non-string cwd', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + cwd: 123 + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: workspaceRoot + }); + }); + + test('ignores non-object env', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + env: 'invalid' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: workspaceRoot + }); + }); + + test('ignores non-number timeoutSec', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + timeoutSec: '30' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: workspaceRoot + }); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index 1fb0c86c42384..d307385e6a808 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -66,5 +66,7 @@ export class MockPromptsService implements IPromptsService { findAgentSkills(token: CancellationToken): Promise { throw new Error('Method not implemented.'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any getPromptDiscoveryInfo(_type: any, _token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getHooks(_token: CancellationToken): Promise { throw new Error('Method not implemented.'); } dispose(): void { } } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 8574e03852068..23ea0f7354a25 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -2684,9 +2684,11 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } const extensionsToUninstall: UninstallExtensionInfo[] = [{ extension: extension.local }]; - for (const packExtension of this.getAllPackedExtensions(extension, this.local)) { - if (packExtension.local && !extensionsToUninstall.some(e => areSameExtensions(e.extension.identifier, packExtension.identifier))) { - extensionsToUninstall.push({ extension: packExtension.local }); + if (!areSameExtensions(extension.identifier, { id: this.productService.defaultChatAgent.extensionId })) { + for (const packExtension of this.getAllPackedExtensions(extension, this.local)) { + if (packExtension.local && !extensionsToUninstall.some(e => areSameExtensions(e.extension.identifier, packExtension.identifier))) { + extensionsToUninstall.push({ extension: packExtension.local }); + } } } diff --git a/src/vs/workbench/contrib/extensions/browser/unsupportedExtensionsMigrationContribution.ts b/src/vs/workbench/contrib/extensions/browser/unsupportedExtensionsMigrationContribution.ts index 1ef6e0ab1f1b5..4c84c8a6cf80f 100644 --- a/src/vs/workbench/contrib/extensions/browser/unsupportedExtensionsMigrationContribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/unsupportedExtensionsMigrationContribution.ts @@ -21,10 +21,10 @@ export class UnsupportedExtensionsMigrationContrib implements IWorkbenchContribu ) { // Unsupported extensions are not migrated for local extension management server, because it is done in shared process if (extensionManagementServerService.remoteExtensionManagementServer) { - migrateUnsupportedExtensions(extensionManagementServerService.remoteExtensionManagementServer.extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); + migrateUnsupportedExtensions(undefined, extensionManagementServerService.remoteExtensionManagementServer.extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); } if (extensionManagementServerService.webExtensionManagementServer) { - migrateUnsupportedExtensions(extensionManagementServerService.webExtensionManagementServer.extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); + migrateUnsupportedExtensions(undefined, extensionManagementServerService.webExtensionManagementServer.extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); } } diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts index ade5b69a2d51b..a144b6b1c9514 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts @@ -38,7 +38,7 @@ import { ExtensionKind } from '../../../../../platform/environment/common/enviro import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; import { RemoteAgentService } from '../../../../services/remote/electron-browser/remoteAgentService.js'; import { ISharedProcessService } from '../../../../../platform/ipc/electron-browser/services.js'; -import { TestContextService } from '../../../../test/common/workbenchTestServices.js'; +import { TestContextService, TestProductService } from '../../../../test/common/workbenchTestServices.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { TestLifecycleService } from '../../../../test/browser/workbenchTestServices.js'; @@ -82,7 +82,7 @@ suite('ExtensionsWorkbenchServiceTest', () => { instantiationService.stub(ILogService, NullLogService); instantiationService.stub(IFileService, disposableStore.add(new FileService(new NullLogService()))); instantiationService.stub(IProgressService, ProgressService); - instantiationService.stub(IProductService, {}); + instantiationService.stub(IProductService, TestProductService); instantiationService.stub(IExtensionGalleryService, ExtensionGalleryService); instantiationService.stub(IURLService, NativeURLService); diff --git a/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css new file mode 100644 index 0000000000000..243c3aea22166 --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.update-status-tooltip { + display: flex; + flex-direction: column; + padding: 4px 0; + min-width: 300px; + max-width: 400px; +} + +/* Header with title and gear icon */ +.update-status-tooltip .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.update-status-tooltip .header .title { + font-weight: 600; + font-size: var(--vscode-bodyFontSize); + color: var(--vscode-foreground); + margin-bottom: 0; +} + +.update-status-tooltip .header .monaco-action-bar { + margin-left: auto; +} + +/* Product info section with logo */ +.update-status-tooltip .product-info { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.update-status-tooltip .product-logo { + width: 48px; + height: 48px; + border-radius: var(--vscode-cornerRadius-large); + padding: 5px; + flex-shrink: 0; + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +.update-status-tooltip .product-details { + display: flex; + flex-direction: column; + justify-content: center; +} + +.update-status-tooltip .product-name { + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 4px; +} + +.update-status-tooltip .product-version, +.update-status-tooltip .product-release-date { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-bodyFontSize-small); +} + +.update-status-tooltip .release-notes-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; + font-size: var(--vscode-bodyFontSize-small); + cursor: pointer; +} + +.update-status-tooltip .release-notes-link:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +/* What's Included section */ +.update-status-tooltip .whats-included .section-title { + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 8px; +} + +.update-status-tooltip .whats-included ul { + margin: 0; + padding-left: 16px; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-bodyFontSize-small); +} + +.update-status-tooltip .whats-included li { + margin-bottom: 2px; +} + +/* Progress bar */ +.update-status-tooltip .progress-container { + margin-bottom: 8px; +} + +.update-status-tooltip .progress-bar { + width: 100%; + height: 4px; + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 30%, transparent); + border-radius: var(--vscode-cornerRadius-small); + overflow: hidden; +} + +.update-status-tooltip .progress-bar .progress-fill { + height: 100%; + background-color: var(--vscode-progressBar-background); + border-radius: var(--vscode-cornerRadius-small); + transition: width 0.3s ease; +} + +.update-status-tooltip .progress-text { + display: flex; + justify-content: space-between; + margin-top: 4px; + font-size: var(--vscode-bodyFontSize-small); + color: var(--vscode-descriptionForeground); +} + +.update-status-tooltip .progress-details { + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; +} + +.update-status-tooltip .speed-info, +.update-status-tooltip .time-remaining { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-bodyFontSize-small); +} + +/* Action button */ +.update-status-tooltip .action-button-container { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} + +.update-status-tooltip .action-button-container .monaco-button { + padding: 4px 14px; +} diff --git a/src/vs/workbench/contrib/update/browser/update.contribution.ts b/src/vs/workbench/contrib/update/browser/update.contribution.ts index dbc489922b27d..d030ea3557620 100644 --- a/src/vs/workbench/contrib/update/browser/update.contribution.ts +++ b/src/vs/workbench/contrib/update/browser/update.contribution.ts @@ -10,6 +10,7 @@ import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } fr 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 { UpdateStatusBarEntryContribution } from './updateStatusBarEntry.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import product from '../../../../platform/product/common/product.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; @@ -30,10 +31,11 @@ workbench.registerWorkbenchContribution(ProductContribution, LifecyclePhase.Rest workbench.registerWorkbenchContribution(UpdateContribution, LifecyclePhase.Restored); workbench.registerWorkbenchContribution(SwitchProductQualityContribution, LifecyclePhase.Restored); workbench.registerWorkbenchContribution(DefaultAccountUpdateContribution, LifecyclePhase.Eventually); +workbench.registerWorkbenchContribution(UpdateStatusBarEntryContribution, LifecyclePhase.Restored); // Release notes -export class ShowCurrentReleaseNotesAction extends Action2 { +export class ShowReleaseNotesAction extends Action2 { constructor() { super({ @@ -54,13 +56,14 @@ export class ShowCurrentReleaseNotesAction extends Action2 { }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, version?: string): Promise { const instantiationService = accessor.get(IInstantiationService); const productService = accessor.get(IProductService); const openerService = accessor.get(IOpenerService); + const targetVersion = version ?? productService.version; try { - await showReleaseNotesInEditor(instantiationService, productService.version, false); + await showReleaseNotesInEditor(instantiationService, targetVersion, false); } catch (err) { if (productService.releaseNotesUrl) { await openerService.open(URI.parse(productService.releaseNotesUrl)); @@ -97,7 +100,7 @@ export class ShowCurrentReleaseNotesFromCurrentFileAction extends Action2 { } } -registerAction2(ShowCurrentReleaseNotesAction); +registerAction2(ShowReleaseNotesAction); registerAction2(ShowCurrentReleaseNotesFromCurrentFileAction); // Update diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts new file mode 100644 index 0000000000000..270dc32d25b56 --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -0,0 +1,552 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { asCSSUrl } from '../../../../base/browser/cssValue.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import { isWeb } from '../../../../base/common/platform.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import * as nls from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { Downloading, IUpdate, IUpdateService, Overwriting, StateType, State as UpdateState } from '../../../../platform/update/common/update.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, TooltipContent } from '../../../services/statusbar/browser/statusbar.js'; +import './media/updateStatusBarEntry.css'; + +/** + * Displays update status and actions in the status bar. + */ +export class UpdateStatusBarEntryContribution extends Disposable implements IWorkbenchContribution { + private static readonly NAME = nls.localize('updateStatus', "Update Status"); + private readonly statusBarEntryAccessor = this._register(new MutableDisposable()); + private lastStateType: StateType | undefined; + + constructor( + @IUpdateService private readonly updateService: IUpdateService, + @IStatusbarService private readonly statusbarService: IStatusbarService, + @IProductService private readonly productService: IProductService, + @ICommandService private readonly commandService: ICommandService, + @IHoverService private readonly hoverService: IHoverService, + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + super(); + + if (isWeb) { + return; // Electron only + } + + this._register(this.updateService.onStateChange(state => this.onUpdateStateChange(state))); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('update.statusBar')) { + this.onUpdateStateChange(this.updateService.state); + } + })); + this.onUpdateStateChange(this.updateService.state); + } + + private onUpdateStateChange(state: UpdateState) { + if (this.lastStateType !== state.type) { + this.statusBarEntryAccessor.clear(); + this.lastStateType = state.type; + } + + const statusBarMode = this.configurationService.getValue('update.statusBar'); + + if (statusBarMode === 'hidden') { + this.statusBarEntryAccessor.clear(); + return; + } + + const actionRequiredStates = [ + StateType.AvailableForDownload, + StateType.Downloaded, + StateType.Ready + ]; + + // In 'actionable' mode, only show for states that require user action + if (statusBarMode === 'actionable' && !actionRequiredStates.includes(state.type)) { + this.statusBarEntryAccessor.clear(); + return; + } + + switch (state.type) { + case StateType.Uninitialized: + case StateType.Idle: + case StateType.Disabled: + this.statusBarEntryAccessor.clear(); + break; + + case StateType.CheckingForUpdates: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.checkingForUpdates', "$(sync~spin) Checking for updates..."), + ariaLabel: nls.localize('updateStatus.checkingForUpdatesAria', "Checking for updates"), + tooltip: this.getCheckingTooltip(), + command: ShowTooltipCommand + }); + break; + + case StateType.AvailableForDownload: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.updateAvailableStatus', "$(cloud-download) Update is available. Click here to download."), + ariaLabel: nls.localize('updateStatus.updateAvailableAria', "Update available. Click here to download."), + tooltip: this.getAvailableTooltip(state.update), + command: 'update.downloadNow' + }); + break; + + case StateType.Downloading: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: this.getDownloadingText(state), + ariaLabel: nls.localize('updateStatus.downloadingUpdateAria', "Downloading update"), + tooltip: this.getDownloadingTooltip(state), + command: ShowTooltipCommand + }); + break; + + case StateType.Downloaded: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.updateReadyStatus', "$(package) Downloaded update. Click here to install."), + ariaLabel: nls.localize('updateStatus.updateReadyAria', "Downloaded update. Click here to install."), + tooltip: this.getReadyToInstallTooltip(state.update), + command: 'update.install' + }); + break; + + case StateType.Updating: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.installingUpdateStatus', "$(sync~spin) Installing update..."), + ariaLabel: nls.localize('updateStatus.installingUpdateAria', "Installing update"), + tooltip: this.getUpdatingTooltip(state.update), + command: ShowTooltipCommand + }); + break; + + case StateType.Ready: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.restartToUpdateStatus', "$(debug-restart) Update is ready. Click here to restart."), + ariaLabel: nls.localize('updateStatus.restartToUpdateAria', "Update is ready. Click here to restart."), + tooltip: this.getRestartToUpdateTooltip(state.update), + command: 'update.restart' + }); + break; + + case StateType.Overwriting: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.downloadingNewerUpdateStatus', "$(sync~spin) Downloading update..."), + ariaLabel: nls.localize('updateStatus.downloadingNewerUpdateAria', "Downloading a newer update"), + tooltip: this.getOverwritingTooltip(state), + command: ShowTooltipCommand + }); + break; + } + } + + private updateStatusBarEntry(entry: IStatusbarEntry) { + if (this.statusBarEntryAccessor.value) { + this.statusBarEntryAccessor.value.update(entry); + } else { + this.statusBarEntryAccessor.value = this.statusbarService.addEntry( + entry, + 'status.update', + StatusbarAlignment.LEFT, + -Number.MAX_VALUE + ); + } + } + + private getCheckingTooltip(): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.checkingForUpdatesTitle', "Checking for Updates"), store); + this.appendProductInfo(container); + + const waitMessage = dom.append(container, dom.$('.progress-details')); + waitMessage.textContent = nls.localize('updateStatus.checkingPleaseWait', "Checking for updates, please wait..."); + + return container; + } + }; + } + + private getAvailableTooltip(update: IUpdate): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.updateAvailableTitle', "Update Available"), store); + this.appendProductInfo(container, update); + this.appendWhatsIncluded(container); + + this.appendActionButton(container, nls.localize('updateStatus.downloadButton', "Download"), store, () => { + this.runCommandAndClose('update.downloadNow'); + }); + + return container; + } + }; + } + + private getDownloadingText({ downloadedBytes, totalBytes }: Downloading): string { + if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { + return nls.localize('updateStatus.downloadUpdateProgressStatus', "$(sync~spin) Downloading update: {0} / {1} • {2}%", + formatBytes(downloadedBytes), + formatBytes(totalBytes), + Math.round((downloadedBytes / totalBytes) * 100)); + } else { + return nls.localize('updateStatus.downloadUpdateStatus', "$(sync~spin) Downloading update..."); + } + } + + private getDownloadingTooltip(state: Downloading): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.downloadingUpdateTitle', "Downloading Update"), store); + this.appendProductInfo(container, state.update); + + const { downloadedBytes, totalBytes } = state; + if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { + const percentage = Math.round((downloadedBytes / totalBytes) * 100); + + const progressContainer = dom.append(container, dom.$('.progress-container')); + const progressBar = dom.append(progressContainer, dom.$('.progress-bar')); + const progressFill = dom.append(progressBar, dom.$('.progress-fill')); + progressFill.style.width = `${percentage}%`; + + const progressText = dom.append(progressContainer, dom.$('.progress-text')); + const percentageSpan = dom.append(progressText, dom.$('span')); + percentageSpan.textContent = `${percentage}%`; + + const sizeSpan = dom.append(progressText, dom.$('span')); + sizeSpan.textContent = `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`; + + const speed = computeDownloadSpeed(state); + if (speed !== undefined && speed > 0) { + const speedInfo = dom.append(container, dom.$('.speed-info')); + speedInfo.textContent = nls.localize('updateStatus.downloadSpeed', '{0}/s', formatBytes(speed)); + } + + const timeRemaining = computeDownloadTimeRemaining(state); + if (timeRemaining !== undefined && timeRemaining > 0) { + const timeRemainingNode = dom.append(container, dom.$('.time-remaining')); + timeRemainingNode.textContent = `~${formatTimeRemaining(timeRemaining)} ${nls.localize('updateStatus.timeRemaining', "remaining")}`; + } + } else { + const waitMessage = dom.append(container, dom.$('.progress-details')); + waitMessage.textContent = nls.localize('updateStatus.downloadingPleaseWait', "Downloading, please wait..."); + } + + return container; + } + }; + } + + private getReadyToInstallTooltip(update: IUpdate): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.updateReadyTitle', "Update is Ready to Install"), store); + this.appendProductInfo(container, update); + this.appendWhatsIncluded(container); + + this.appendActionButton(container, nls.localize('updateStatus.installButton', "Install"), store, () => { + this.runCommandAndClose('update.install'); + }); + + return container; + } + }; + } + + private getRestartToUpdateTooltip(update: IUpdate): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.updateInstalledTitle', "Update Installed"), store); + this.appendProductInfo(container, update); + this.appendWhatsIncluded(container); + + this.appendActionButton(container, nls.localize('updateStatus.restartButton', "Restart"), store, () => { + this.runCommandAndClose('update.restart'); + }); + + return container; + } + }; + } + + private getUpdatingTooltip(update: IUpdate): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.installingUpdateTitle', "Installing Update"), store); + this.appendProductInfo(container, update); + + const message = dom.append(container, dom.$('.progress-details')); + message.textContent = nls.localize('updateStatus.installingPleaseWait', "Installing update, please wait..."); + + return container; + } + }; + } + + private getOverwritingTooltip(state: Overwriting): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.downloadingNewerUpdateTitle', "Downloading Newer Update"), store); + this.appendProductInfo(container, state.update); + + const message = dom.append(container, dom.$('.progress-details')); + message.textContent = nls.localize('updateStatus.downloadingNewerPleaseWait', "A newer update was released. Downloading, please wait..."); + + return container; + } + }; + } + + private createTooltipDisposableStore(token: CancellationToken): DisposableStore { + const store = new DisposableStore(); + store.add(token.onCancellationRequested(() => store.dispose())); + return store; + } + + private runCommandAndClose(command: string, ...args: unknown[]): void { + this.commandService.executeCommand(command, ...args); + this.hoverService.hideHover(true); + } + + private appendHeader(container: HTMLElement, title: string, store: DisposableStore) { + const header = dom.append(container, dom.$('.header')); + const text = dom.append(header, dom.$('.title')); + text.textContent = title; + + const actionBar = store.add(new ActionBar(header, { hoverDelegate: nativeHoverDelegate })); + actionBar.push([toAction({ + id: 'update.openSettings', + label: nls.localize('updateStatus.settingsTooltip', "Update Settings"), + class: ThemeIcon.asClassName(Codicon.gear), + run: () => this.runCommandAndClose('workbench.action.openSettings', '@id:update*'), + })], { icon: true, label: false }); + } + + private appendProductInfo(container: HTMLElement, update?: IUpdate) { + const productInfo = dom.append(container, dom.$('.product-info')); + + const logoContainer = dom.append(productInfo, dom.$('.product-logo')); + logoContainer.style.backgroundImage = asCSSUrl(FileAccess.asBrowserUri('vs/workbench/browser/media/code-icon.svg')); + logoContainer.setAttribute('role', 'img'); + logoContainer.setAttribute('aria-label', this.productService.nameLong); + + const details = dom.append(productInfo, dom.$('.product-details')); + + const productName = dom.append(details, dom.$('.product-name')); + productName.textContent = this.productService.nameLong; + + const productVersion = this.productService.version; + if (productVersion) { + const currentVersion = dom.append(details, dom.$('.product-version')); + currentVersion.textContent = nls.localize('updateStatus.currentVersionLabel', "Current Version: {0}", productVersion); + } + + const version = update?.productVersion; + if (version) { + const latestVersion = dom.append(details, dom.$('.product-version')); + latestVersion.textContent = nls.localize('updateStatus.latestVersionLabel', "Latest Version: {0}", version); + } + + const releaseDate = update?.timestamp ?? tryParseDate(this.productService.date); + if (releaseDate) { + const releaseDateNode = dom.append(details, dom.$('.product-release-date')); + releaseDateNode.textContent = nls.localize('updateStatus.releasedLabel', "Released {0}", formatDate(releaseDate)); + } + + const releaseNotesVersion = version ?? productVersion; + if (releaseNotesVersion) { + const link = dom.append(details, dom.$('a.release-notes-link')) as HTMLAnchorElement; + link.textContent = nls.localize('updateStatus.releaseNotesLink', "Release Notes"); + link.href = '#'; + link.addEventListener('click', (e) => { + e.preventDefault(); + this.runCommandAndClose('update.showCurrentReleaseNotes', releaseNotesVersion); + }); + } + } + + private appendWhatsIncluded(container: HTMLElement): void { + const whatsIncluded = dom.append(container, dom.$('.whats-included')); + + const sectionTitle = dom.append(whatsIncluded, dom.$('.section-title')); + sectionTitle.textContent = nls.localize('updateStatus.whatsIncludedTitle', "What's Included"); + + const list = dom.append(whatsIncluded, dom.$('ul')); + + const items = [ + nls.localize('updateStatus.featureItem', "New features and functionality"), + nls.localize('updateStatus.bugFixesItem', "Bug fixes and improvements"), + nls.localize('updateStatus.securityItem', "Security fixes and enhancements") + ]; + + for (const item of items) { + const li = dom.append(list, dom.$('li')); + li.textContent = item; + } + } + + private appendActionButton(container: HTMLElement, label: string, store: DisposableStore, onClick: () => void): void { + const buttonContainer = dom.append(container, dom.$('.action-button-container')); + const button = store.add(new Button(buttonContainer, { ...defaultButtonStyles, secondary: true, hoverDelegate: nativeHoverDelegate })); + button.label = label; + store.add(button.onDidClick(onClick)); + } +} + +/** + * Tries to parse a date string and returns the timestamp or undefined if parsing fails. + */ +export function tryParseDate(date: string | undefined): number | undefined { + try { + return date !== undefined ? Date.parse(date) : undefined; + } catch { + return undefined; + } +} + +/** + * Formats a timestamp as a localized date string. + */ +export function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +/** + * Computes an estimate of remaining download time in seconds. + */ +export function computeDownloadTimeRemaining(state: Downloading): number | undefined { + const { downloadedBytes, totalBytes, startTime } = state; + if (downloadedBytes === undefined || totalBytes === undefined || startTime === undefined) { + return undefined; + } + + const elapsedMs = Date.now() - startTime; + if (downloadedBytes <= 0 || totalBytes <= 0 || elapsedMs <= 0) { + return undefined; + } + + const remainingBytes = totalBytes - downloadedBytes; + if (remainingBytes <= 0) { + return 0; + } + + const bytesPerMs = downloadedBytes / elapsedMs; + if (bytesPerMs <= 0) { + return undefined; + } + + const remainingMs = remainingBytes / bytesPerMs; + return Math.ceil(remainingMs / 1000); +} + +/** + * Formats the time remaining as a human-readable string. + */ +export function formatTimeRemaining(seconds: number): string { + const hours = seconds / 3600; + if (hours >= 1) { + const formattedHours = formatDecimal(hours); + return formattedHours === '1' + ? nls.localize('timeRemainingHour', "{0} hour", formattedHours) + : nls.localize('timeRemainingHours', "{0} hours", formattedHours); + } + + const minutes = Math.floor(seconds / 60); + if (minutes >= 1) { + return nls.localize('timeRemainingMinutes', "{0} min", minutes); + } + + return nls.localize('timeRemainingSeconds', "{0}s", seconds); +} + +/** + * Formats a byte count as a human-readable string. + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return nls.localize('bytes', "{0} B", bytes); + } + + const kb = bytes / 1024; + if (kb < 1024) { + return nls.localize('kilobytes', "{0} KB", formatDecimal(kb)); + } + + const mb = kb / 1024; + if (mb < 1024) { + return nls.localize('megabytes', "{0} MB", formatDecimal(mb)); + } + + const gb = mb / 1024; + return nls.localize('gigabytes', "{0} GB", formatDecimal(gb)); +} + +/** + * Formats a number to 1 decimal place, omitting ".0" for whole numbers. + */ +function formatDecimal(value: number): string { + const rounded = Math.round(value * 10) / 10; + return rounded % 1 === 0 ? rounded.toString() : rounded.toFixed(1); +} + +/** + * Computes the current download speed in bytes per second. + */ +export function computeDownloadSpeed(state: Downloading): number | undefined { + const { downloadedBytes, startTime } = state; + if (downloadedBytes === undefined || startTime === undefined) { + return undefined; + } + + const elapsedMs = Date.now() - startTime; + if (elapsedMs <= 0 || downloadedBytes <= 0) { + return undefined; + } + + return (downloadedBytes / elapsedMs) * 1000; +} diff --git a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts new file mode 100644 index 0000000000000..16f10c93674c5 --- /dev/null +++ b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Downloading, StateType } from '../../../../../platform/update/common/update.js'; +import { computeDownloadSpeed, computeDownloadTimeRemaining, formatBytes, formatTimeRemaining } from '../../browser/updateStatusBarEntry.js'; + +suite('UpdateStatusBarEntry', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function createDownloadingState(downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading { + return { type: StateType.Downloading, explicit: true, overwrite: false, downloadedBytes, totalBytes, startTime }; + } + + suite('computeDownloadTimeRemaining', () => { + test('returns undefined for invalid or incomplete input', () => { + const now = Date.now(); + + // Missing parameters + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState()), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, undefined, now)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(undefined, 1000, now)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 1000, undefined)), undefined); + + // Zero or negative values + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(0, 1000, now - 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 0, now - 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 1000, now + 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(-100, 1000, now - 1000)), undefined); + }); + + test('returns 0 when download is complete or over-downloaded', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(1000, 1000, now - 1000)), 0); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(1500, 1000, now - 1000)), 0); + }); + + test('computes correct time remaining', () => { + const now = Date.now(); + + // Simple case: Downloaded 500 bytes of 1000 in 1000ms => 1s remaining + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 1000, now - 1000)), 1); + + // 10 seconds remaining: Downloaded 100MB of 200MB in 10s + const downloadedBytes = 100 * 1024 * 1024; + const totalBytes = 200 * 1024 * 1024; + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(downloadedBytes, totalBytes, now - 10000)), 10); + + // Rounds up: 900 of 1000 bytes in 900ms => 100ms remaining => rounds to 1s + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(900, 1000, now - 900)), 1); + + // Realistic scenario: 50MB of 100MB in 50s => 50s remaining + const downloaded50MB = 50 * 1024 * 1024; + const total100MB = 100 * 1024 * 1024; + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(downloaded50MB, total100MB, now - 50000)), 50); + }); + }); + + suite('formatTimeRemaining', () => { + test('formats seconds for values less than 1 minute', () => { + assert.strictEqual(formatTimeRemaining(1), '1s'); + assert.strictEqual(formatTimeRemaining(30), '30s'); + assert.strictEqual(formatTimeRemaining(59), '59s'); + }); + + test('formats minutes for values between 1 minute and 1 hour', () => { + assert.strictEqual(formatTimeRemaining(60), '1 min'); + assert.strictEqual(formatTimeRemaining(120), '2 min'); + assert.strictEqual(formatTimeRemaining(90), '1 min'); // Floors to 1 min + assert.strictEqual(formatTimeRemaining(3599), '59 min'); + }); + + test('formats fractional hours for values >= 1 hour', () => { + assert.strictEqual(formatTimeRemaining(3600), '1 hour'); + assert.strictEqual(formatTimeRemaining(5400), '1.5 hours'); // 1.5 hours + assert.strictEqual(formatTimeRemaining(7200), '2 hours'); + assert.strictEqual(formatTimeRemaining(9000), '2.5 hours'); // 2.5 hours + assert.strictEqual(formatTimeRemaining(3960), '1.1 hours'); // 1 hour 6 min = 1.1 hours + }); + }); + + suite('formatBytes', () => { + test('formats bytes for values less than 1 KB', () => { + assert.strictEqual(formatBytes(0), '0 B'); + assert.strictEqual(formatBytes(1), '1 B'); + assert.strictEqual(formatBytes(512), '512 B'); + assert.strictEqual(formatBytes(1023), '1023 B'); + }); + + test('formats kilobytes for values between 1 KB and 1 MB', () => { + assert.strictEqual(formatBytes(1024), '1 KB'); + assert.strictEqual(formatBytes(1536), '1.5 KB'); // 1.5 KB + assert.strictEqual(formatBytes(2048), '2 KB'); + assert.strictEqual(formatBytes(1024 * 100), '100 KB'); + assert.strictEqual(formatBytes(1024 * 1023), '1023 KB'); + }); + + test('formats megabytes for values between 1 MB and 1 GB', () => { + assert.strictEqual(formatBytes(1024 * 1024), '1 MB'); + assert.strictEqual(formatBytes(1024 * 1024 * 1.5), '1.5 MB'); + assert.strictEqual(formatBytes(1024 * 1024 * 100), '100 MB'); + assert.strictEqual(formatBytes(1024 * 1024 * 512), '512 MB'); + }); + + test('formats gigabytes for values >= 1 GB', () => { + assert.strictEqual(formatBytes(1024 * 1024 * 1024), '1 GB'); + assert.strictEqual(formatBytes(1024 * 1024 * 1024 * 1.5), '1.5 GB'); + assert.strictEqual(formatBytes(1024 * 1024 * 1024 * 10), '10 GB'); + }); + + test('rounds to one decimal place correctly', () => { + assert.strictEqual(formatBytes(1126), '1.1 KB'); + assert.strictEqual(formatBytes(1075), '1 KB'); + assert.strictEqual(formatBytes(1024 * 1024 * 25.35), '25.4 MB'); + }); + }); + + suite('computeDownloadSpeed', () => { + test('returns undefined for invalid or incomplete input', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(undefined, 1000, now - 1000)), undefined); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(500, 1000, undefined)), undefined); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(undefined, undefined, undefined)), undefined); + }); + + test('returns undefined for zero or negative elapsed time', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(500, 1000, now + 1000)), undefined); + }); + + test('returns undefined for zero downloaded bytes', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(0, 1000, now - 1000)), undefined); + }); + + test('computes correct download speed in bytes per second', () => { + const now = Date.now(); + + // 1000 bytes in 1 second = 1000 B/s + const speed1 = computeDownloadSpeed(createDownloadingState(1000, 2000, now - 1000)); + assert.ok(speed1 !== undefined); + assert.ok(Math.abs(speed1 - 1000) < 50); // Allow small timing variance + + // 10 MB in 10 seconds = 1 MB/s = 1048576 B/s + const tenMB = 10 * 1024 * 1024; + const speed2 = computeDownloadSpeed(createDownloadingState(tenMB, tenMB * 2, now - 10000)); + assert.ok(speed2 !== undefined); + const expectedSpeed = 1024 * 1024; // 1 MB/s + assert.ok(Math.abs(speed2 - expectedSpeed) < expectedSpeed * 0.01); // Within 1% + }); + }); +}); diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts index 6a70526e4c7da..03e466cec0421 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts @@ -286,14 +286,14 @@ export class GettingStartedPage extends EditorPane { badgeelement.parentElement?.setAttribute('aria-checked', 'true'); badgeelement.classList.remove(...ThemeIcon.asClassNameArray(gettingStartedUncheckedCodicon)); badgeelement.classList.add('complete', ...ThemeIcon.asClassNameArray(gettingStartedCheckedCodicon)); - badgeelement.setAttribute('aria-label', localize('stepDone', "Checkbox for Step {0}: Completed", step.title)); + badgeelement.setAttribute('aria-label', localize('stepDone', "{0}: Completed", step.title)); } else { badgeelement.setAttribute('aria-checked', 'false'); badgeelement.parentElement?.setAttribute('aria-checked', 'false'); badgeelement.classList.remove('complete', ...ThemeIcon.asClassNameArray(gettingStartedCheckedCodicon)); badgeelement.classList.add(...ThemeIcon.asClassNameArray(gettingStartedUncheckedCodicon)); - badgeelement.setAttribute('aria-label', localize('stepNotDone', "Checkbox for Step {0}: Not completed", step.title)); + badgeelement.setAttribute('aria-label', localize('stepNotDone', "{0}: Not completed", step.title)); } }); if (step.done) { @@ -1559,8 +1559,8 @@ export class GettingStartedPage extends EditorPane { 'role': 'checkbox', 'aria-checked': step.done ? 'true' : 'false', 'aria-label': step.done - ? localize('stepDone', "Checkbox for Step {0}: Completed", step.title) - : localize('stepNotDone', "Checkbox for Step {0}: Not completed", step.title), + ? localize('stepDone', "{0}: Completed", step.title) + : localize('stepNotDone', "{0}: Not completed", step.title), }); const container = $('.step-description-container', { 'x-step-description-for': step.id }); diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts new file mode 100644 index 0000000000000..04b2fde38c6e3 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 1 + +declare module 'vscode' { + + /** + * The type of hook to execute. + */ + export type ChatHookType = 'sessionStart' | 'userPromptSubmitted' | 'preToolUse' | 'postToolUse' | 'postToolUseFailure' | 'subagentStart' | 'subagentStop' | 'stop'; + + /** + * Options for executing a hook command. + */ + export interface ChatHookExecutionOptions { + /** + * Input data to pass to the hook via stdin (will be JSON-serialized). + */ + readonly input?: unknown; + /** + * The tool invocation token from the chat request context, + * used to associate the hook execution with the current chat session. + */ + readonly toolInvocationToken: ChatParticipantToolToken; + } + + /** + * The kind of result from a hook execution. + */ + export enum ChatHookResultKind { + /** + * Hook executed successfully (exit code 0). + */ + Success = 1, + /** + * Hook returned an error (any non-zero exit code). + */ + Error = 2 + } + + /** + * Result of executing a hook command. + */ + export interface ChatHookResult { + /** + * The kind of result. + */ + readonly kind: ChatHookResultKind; + /** + * The result from the hook. For success, this is stdout parsed as JSON. + * For errors, this is stderr. + */ + readonly result: string | object; + } + + export namespace chat { + /** + * Execute all hooks of the specified type for the current chat session. + * Hooks are configured in hooks.json files in the workspace. + * + * @param hookType The type of hook to execute. + * @param options Hook execution options including the input data. + * @param token Optional cancellation token. + * @returns A promise that resolves to an array of hook execution results. + */ + export function executeHook(hookType: ChatHookType, options: ChatHookExecutionOptions, token?: CancellationToken): Thenable; + } +} diff --git a/test/automation/src/electron.ts b/test/automation/src/electron.ts index 7475a8a8494fe..f6f1d78551e51 100644 --- a/test/automation/src/electron.ts +++ b/test/automation/src/electron.ts @@ -121,13 +121,22 @@ function findFilePath(root: string, path: string): string { throw new Error(`Could not find ${path} in any subdirectory`); } +function parseVersion(version: string) { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version); + if (!match) { + throw new Error(`Invalid version string: ${version}`); + } + const [, major, minor, patch] = match; + return { major: parseInt(major), minor: parseInt(minor), patch: parseInt(patch) }; +} + export function getDevElectronPath(): string { const buildPath = join(root, '.build'); const product = require(join(root, 'product.json')); switch (process.platform) { case 'darwin': - return join(buildPath, 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', 'Electron'); + return join(buildPath, 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', `${product.nameShort}`); case 'linux': return join(buildPath, 'electron', `${product.applicationName}`); case 'win32': @@ -139,8 +148,20 @@ export function getDevElectronPath(): string { export function getBuildElectronPath(root: string): string { switch (process.platform) { - case 'darwin': - return join(root, 'Contents', 'MacOS', 'Electron'); + case 'darwin': { + const packageJson = require(join(root, 'Contents', 'Resources', 'app', 'package.json')); + const product = require(join(root, 'Contents', 'Resources', 'app', 'product.json')); + const { major, minor } = parseVersion(packageJson.version); + // For macOS builds using the legacy Electron binary name, versions up to and including + // 1.109.x ship the executable as "Electron". From later versions onward, the executable + // is renamed to match product.nameShort. This check preserves compatibility with older + // builds; update the cutoff here only if the binary naming scheme changes again. + if (major === 1 && minor <= 109) { + return join(root, 'Contents', 'MacOS', 'Electron'); + } else { + return join(root, 'Contents', 'MacOS', product.nameShort); + } + } case 'linux': { const product = require(join(root, 'resources', 'app', 'product.json')); return join(root, product.applicationName); diff --git a/test/mcp/src/application.ts b/test/mcp/src/application.ts index 1eff6a914ad02..e2749493ad442 100644 --- a/test/mcp/src/application.ts +++ b/test/mcp/src/application.ts @@ -196,7 +196,7 @@ async function ensureStableCode(): Promise { })); if (process.platform === 'darwin') { - // Visual Studio Code.app/Contents/MacOS/Electron + // Visual Studio Code.app/Contents/MacOS/Code stableCodePath = path.dirname(path.dirname(path.dirname(stableCodeExecutable))); } else { // VSCode/Code.exe (Windows) | VSCode/code (Linux) diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 1ee1b036fb8f7..95273f9f7fc18 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -779,18 +779,22 @@ export class TestContext { switch (os.platform()) { case 'darwin': { let appName: string; + let binaryName: string; switch (this.options.quality) { case 'stable': appName = 'Visual Studio Code.app'; + binaryName = 'Code'; break; case 'insider': appName = 'Visual Studio Code - Insiders.app'; + binaryName = 'Code - Insiders'; break; case 'exploration': appName = 'Visual Studio Code - Exploration.app'; + binaryName = 'Code - Exploration'; break; } - filePath = path.join(dir, appName, 'Contents/MacOS/Electron'); + filePath = path.join(dir, appName, 'Contents/MacOS', binaryName); break; } case 'linux': { diff --git a/test/smoke/src/main.ts b/test/smoke/src/main.ts index 15279bbd5a192..fc8b4f8800f96 100644 --- a/test/smoke/src/main.ts +++ b/test/smoke/src/main.ts @@ -322,7 +322,7 @@ async function ensureStableCode(): Promise { }); if (process.platform === 'darwin') { - // Visual Studio Code.app/Contents/MacOS/Electron + // Visual Studio Code.app/Contents/MacOS/Code stableCodePath = path.dirname(path.dirname(path.dirname(stableCodeExecutable))); } else { // VSCode/Code.exe (Windows) | VSCode/code (Linux)