diff --git a/.configurations/configuration.dsc.yaml b/.config/configuration.winget similarity index 78% rename from .configurations/configuration.dsc.yaml rename to .config/configuration.winget index c41f115aff9..a9e45597205 100644 --- a/.configurations/configuration.dsc.yaml +++ b/.config/configuration.winget @@ -5,7 +5,8 @@ properties: - resource: Microsoft.WinGet.DSC/WinGetPackage directives: description: Install Git - allowPrerelease: true + # Requires elevation for the set operation (i.e., for installing the package) + securityContext: elevated settings: id: Git.Git source: winget @@ -13,7 +14,8 @@ properties: id: npm directives: description: Install NodeJS version 20 - allowPrerelease: true + # Requires elevation for the set operation (i.e., for installing the package) + securityContext: elevated settings: id: OpenJS.NodeJS.LTS version: "20.14.0" @@ -21,7 +23,6 @@ properties: - resource: Microsoft.WinGet.DSC/WinGetPackage directives: description: Install Python 3.10 - allowPrerelease: true settings: id: Python.Python.3.10 source: winget @@ -29,7 +30,8 @@ properties: id: vsPackage directives: description: Install Visual Studio 2022 (any edition is OK) - allowPrerelease: true + # Requires elevation for the set operation (i.e., for installing the package) + securityContext: elevated settings: id: Microsoft.VisualStudio.2022.BuildTools source: winget @@ -38,6 +40,8 @@ properties: - vsPackage directives: description: Install required VS workloads + # Requires elevation for the get and set operations + securityContext: elevated allowPrerelease: true settings: productId: Microsoft.VisualStudio.Product.BuildTools diff --git a/.eslint-ignore b/.eslint-ignore index da66a76c396..e493198185e 100644 --- a/.eslint-ignore +++ b/.eslint-ignore @@ -37,4 +37,5 @@ **/test/unit/assert.js **/test/automation/out/** **/typings/** +**/.build/** !.vscode diff --git a/.eslint-plugin-local/code-amd-node-module.ts b/.eslint-plugin-local/code-amd-node-module.ts index ff7ef6ab33b..b622c98a89a 100644 --- a/.eslint-plugin-local/code-amd-node-module.ts +++ b/.eslint-plugin-local/code-amd-node-module.ts @@ -51,7 +51,7 @@ export = new class ApiProviderNaming implements eslint.Rule.RuleModule { node, messageId: 'amdX' }); - } + }; return { ['ImportExpression Literal']: checkImport, diff --git a/.eslint-plugin-local/code-declare-service-brand.ts b/.eslint-plugin-local/code-declare-service-brand.ts index f2d28bbfc01..85cf0671545 100644 --- a/.eslint-plugin-local/code-declare-service-brand.ts +++ b/.eslint-plugin-local/code-declare-service-brand.ts @@ -19,7 +19,7 @@ export = new class DeclareServiceBrand implements eslint.Rule.RuleModule { node, message: `The '_serviceBrand'-property should not have a value`, fix: (fixer) => { - return fixer.replaceText(node, 'declare _serviceBrand: undefined;') + return fixer.replaceText(node, 'declare _serviceBrand: undefined;'); } }); } diff --git a/.eslint-plugin-local/code-ensure-no-disposables-leak-in-test.ts b/.eslint-plugin-local/code-ensure-no-disposables-leak-in-test.ts index 56a1d4a70ad..c657df9bd30 100644 --- a/.eslint-plugin-local/code-ensure-no-disposables-leak-in-test.ts +++ b/.eslint-plugin-local/code-ensure-no-disposables-leak-in-test.ts @@ -27,7 +27,7 @@ export = new class EnsureNoDisposablesAreLeakedInTestSuite implements eslint.Rul return { [`Program > ExpressionStatement > CallExpression[callee.name='suite']`]: (node: Node) => { - const src = context.getSourceCode().getText(node) + const src = context.getSourceCode().getText(node); if (!src.includes('ensureNoDisposablesAreLeakedInTestSuite(')) { context.report({ node, diff --git a/.eslint-plugin-local/code-import-patterns.ts b/.eslint-plugin-local/code-import-patterns.ts index e4fe52412e6..ed14fa35d5a 100644 --- a/.eslint-plugin-local/code-import-patterns.ts +++ b/.eslint-plugin-local/code-import-patterns.ts @@ -44,7 +44,7 @@ export = new class implements eslint.Rule.RuleModule { readonly meta: eslint.Rule.RuleMetaData = { messages: { badImport: 'Imports violates \'{{restrictions}}\' restrictions. See https://github.com/microsoft/vscode/wiki/Source-Code-Organization', - badFilename: 'Missing definition in `code-import-patterns` for this file. Define rules at https://github.com/microsoft/vscode/blob/main/.eslintrc.json', + badFilename: 'Missing definition in `code-import-patterns` for this file. Define rules at https://github.com/microsoft/vscode/blob/main/eslint.config.js', badAbsolute: 'Imports have to be relative to support ESM', badExtension: 'Imports have to end with `.js` or `.css` to support ESM', }, diff --git a/.eslint-plugin-local/code-limited-top-functions.ts b/.eslint-plugin-local/code-limited-top-functions.ts index 97eef9a6e9d..7b48d02a0fe 100644 --- a/.eslint-plugin-local/code-limited-top-functions.ts +++ b/.eslint-plugin-local/code-limited-top-functions.ts @@ -14,13 +14,13 @@ export = new class implements eslint.Rule.RuleModule { layerbreaker: 'You are only allowed to define limited top level functions.' }, schema: { - type: "array", + type: 'array', items: { - type: "object", + type: 'object', additionalProperties: { - type: "array", + type: 'array', items: { - type: "string" + type: 'string' } } } @@ -65,6 +65,6 @@ export = new class implements eslint.Rule.RuleModule { } } } - } + }; } }; diff --git a/.eslint-plugin-local/code-must-use-result.ts b/.eslint-plugin-local/code-must-use-result.ts index e59b1920f2e..e249f36dccf 100644 --- a/.eslint-plugin-local/code-must-use-result.ts +++ b/.eslint-plugin-local/code-must-use-result.ts @@ -14,22 +14,22 @@ const VALID_USES = new Set([ export = new class MustUseResults implements eslint.Rule.RuleModule { readonly meta: eslint.Rule.RuleMetaData = { schema: false - } + }; create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - const config = <{ message: string, functions: string[] }[]>context.options[0]; + const config = <{ message: string; functions: string[] }[]>context.options[0]; const listener: eslint.Rule.RuleListener = {}; for (const { message, functions } of config) { for (const fn of functions) { - const query = `CallExpression[callee.property.name='${fn}'], CallExpression[callee.name='${fn}']` + const query = `CallExpression[callee.property.name='${fn}'], CallExpression[callee.name='${fn}']`; listener[query] = (node: any) => { const cast: TSESTree.CallExpression = node; if (!VALID_USES.has(cast.parent?.type)) { context.report({ node, message }); } - } + }; } } diff --git a/.eslint-plugin-local/code-must-use-super-dispose.ts b/.eslint-plugin-local/code-must-use-super-dispose.ts index 4f7f964699f..ca776d8a2ad 100644 --- a/.eslint-plugin-local/code-must-use-super-dispose.ts +++ b/.eslint-plugin-local/code-must-use-super-dispose.ts @@ -14,7 +14,7 @@ export = new class NoAsyncSuite implements eslint.Rule.RuleModule { return; } - const body = context.getSourceCode().getText(node) + const body = context.getSourceCode().getText(node); if (body.includes('super.dispose')) { return; diff --git a/.eslint-plugin-local/code-no-dangerous-type-assertions.ts b/.eslint-plugin-local/code-no-dangerous-type-assertions.ts index 233fae02c82..f900d778a94 100644 --- a/.eslint-plugin-local/code-no-dangerous-type-assertions.ts +++ b/.eslint-plugin-local/code-no-dangerous-type-assertions.ts @@ -32,7 +32,7 @@ export = new class NoDangerousTypeAssertions implements eslint.Rule.RuleModule { context.report({ node, - message: "Don't use type assertions for creating objects as this can hide type errors." + message: `Don't use type assertions for creating objects as this can hide type errors.` }); }, }; diff --git a/.eslint-plugin-local/code-no-deep-import-of-internal.ts b/.eslint-plugin-local/code-no-deep-import-of-internal.ts new file mode 100644 index 00000000000..3f54665b49a --- /dev/null +++ b/.eslint-plugin-local/code-no-deep-import-of-internal.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; +import { join, dirname } from 'path'; +import { createImportRuleListener } from './utils'; + +export = new class implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noDeepImportOfInternal: 'No deep import of internal modules allowed! Use a re-export from a non-internal module instead. Internal modules can only be imported by direct parents (any module in {{parentDir}}).' + }, + docs: { + url: 'https://github.com/microsoft/vscode/wiki/Source-Code-Organization' + }, + schema: [ + { + type: 'object', + additionalProperties: { + type: 'boolean' + } + } + ] + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + const patterns = context.options[0] as Record; + const internalModulePattern = Object.entries(patterns).map(([key, v]) => v ? key : undefined).filter(v => !!v); + const allowedPatterns = Object.entries(patterns).map(([key, v]) => !v ? key : undefined).filter(v => !!v); + + return createImportRuleListener((node, path) => { + const importerModuleDir = dirname(context.filename); + if (path[0] === '.') { + path = join(importerModuleDir, path); + } + const importedModulePath = path; + + const importerDirParts = splitParts(importerModuleDir); + const importedModuleParts = splitParts(importedModulePath); + + for (let i = 0; i < importedModuleParts.length; i++) { + if (internalModulePattern.some(p => importedModuleParts[i].match(p)) && allowedPatterns.every(p => !importedModuleParts[i].match(p))) { + const importerDirJoined = importerDirParts.join('/'); + const expectedParentDir = importedModuleParts.slice(0, i).join('/'); + if (!importerDirJoined.startsWith(expectedParentDir)) { + context.report({ + node, + messageId: 'noDeepImportOfInternal', + data: { + parentDir: expectedParentDir + } + }); + return; + } + } + } + }); + } +}; + +function splitParts(path: string): string[] { + return path.split(/\\|\//); +} diff --git a/.eslint-plugin-local/code-no-global-document-listener.ts b/.eslint-plugin-local/code-no-global-document-listener.ts index 6b3e83fe1f5..049426a5a03 100644 --- a/.eslint-plugin-local/code-no-global-document-listener.ts +++ b/.eslint-plugin-local/code-no-global-document-listener.ts @@ -25,6 +25,6 @@ export = new class NoGlobalDocumentListener implements eslint.Rule.RuleModule { }); } }, - } + }; } }; diff --git a/.eslint-plugin-local/code-no-nls-in-standalone-editor.ts b/.eslint-plugin-local/code-no-nls-in-standalone-editor.ts index 19ad65ee871..c0d60985604 100644 --- a/.eslint-plugin-local/code-no-nls-in-standalone-editor.ts +++ b/.eslint-plugin-local/code-no-nls-in-standalone-editor.ts @@ -24,7 +24,7 @@ export = new class NoNlsInStandaloneEditorRule implements eslint.Rule.RuleModule || /vs(\/|\\)editor(\/|\\)common(\/|\\)standalone(\/|\\)/.test(fileName) || /vs(\/|\\)editor(\/|\\)editor.api/.test(fileName) || /vs(\/|\\)editor(\/|\\)editor.main/.test(fileName) - || /vs(\/|\\)editor(\/|\\)editor.worker/.test(fileName) + || /vs(\/|\\)editor(\/|\\)editor.worker.start/.test(fileName) ) { return createImportRuleListener((node, path) => { // resolve relative paths diff --git a/.eslint-plugin-local/code-no-runtime-import.ts b/.eslint-plugin-local/code-no-runtime-import.ts index 61597236e0c..afebe0b0d68 100644 --- a/.eslint-plugin-local/code-no-runtime-import.ts +++ b/.eslint-plugin-local/code-no-runtime-import.ts @@ -16,13 +16,13 @@ export = new class implements eslint.Rule.RuleModule { layerbreaker: 'You are only allowed to import {{import}} from here using `import type ...`.' }, schema: { - type: "array", + type: 'array', items: { - type: "object", + type: 'object', additionalProperties: { - type: "array", + type: 'array', items: { - type: "string" + type: 'string' } } } diff --git a/.eslint-plugin-local/code-no-standalone-editor.ts b/.eslint-plugin-local/code-no-standalone-editor.ts index 3fad6719581..36bf48b1417 100644 --- a/.eslint-plugin-local/code-no-standalone-editor.ts +++ b/.eslint-plugin-local/code-no-standalone-editor.ts @@ -38,7 +38,7 @@ export = new class NoNlsInStandaloneEditorRule implements eslint.Rule.RuleModule || /vs(\/|\\)editor(\/|\\)common(\/|\\)standalone(\/|\\)/.test(path) || /vs(\/|\\)editor(\/|\\)editor.api/.test(path) || /vs(\/|\\)editor(\/|\\)editor.main/.test(path) - || /vs(\/|\\)editor(\/|\\)editor.worker/.test(path) + || /vs(\/|\\)editor(\/|\\)editor.worker.start/.test(path) ) { context.report({ loc: node.loc, diff --git a/.eslint-plugin-local/code-no-static-self-ref.ts b/.eslint-plugin-local/code-no-static-self-ref.ts index 52edfb254f6..f620645565a 100644 --- a/.eslint-plugin-local/code-no-static-self-ref.ts +++ b/.eslint-plugin-local/code-no-static-self-ref.ts @@ -26,19 +26,19 @@ export = new class implements eslint.Rule.RuleModule { return; } - const classCtor = classDeclaration.body.body.find(node => node.type === 'MethodDefinition' && node.kind === 'constructor') + const classCtor = classDeclaration.body.body.find(node => node.type === 'MethodDefinition' && node.kind === 'constructor'); if (!classCtor) { return; } const name = classDeclaration.id.name; - const valueText = context.sourceCode.getText(propertyDefinition.value) + const valueText = context.sourceCode.getText(propertyDefinition.value); if (valueText.includes(name + '.')) { if (classCtor.value?.type === 'FunctionExpression' && !classCtor.value.params.find((param: any) => param.type === 'TSParameterProperty' && param.decorators?.length > 0)) { - return + return; } context.report({ diff --git a/.eslint-plugin-local/code-no-unused-expressions.ts b/.eslint-plugin-local/code-no-unused-expressions.ts index 14f2f53d47f..bd632884dbd 100644 --- a/.eslint-plugin-local/code-no-unused-expressions.ts +++ b/.eslint-plugin-local/code-no-unused-expressions.ts @@ -58,7 +58,7 @@ module.exports = { allowTernary = config.allowTernary || false, allowTaggedTemplates = config.allowTaggedTemplates || false; - // eslint-disable-next-line jsdoc/require-description + /** * @param node any node * @returns whether the given node structurally represents a directive @@ -68,7 +68,7 @@ module.exports = { node.expression.type === 'Literal' && typeof node.expression.value === 'string'; } - // eslint-disable-next-line jsdoc/require-description + /** * @param predicate ([a] -> Boolean) the function used to make the determination * @param list the input list @@ -83,7 +83,7 @@ module.exports = { return list.slice(); } - // eslint-disable-next-line jsdoc/require-description + /** * @param node a Program or BlockStatement node * @returns the leading sequence of directive nodes in the given node's body @@ -92,7 +92,7 @@ module.exports = { return takeWhile(looksLikeDirective, node.body); } - // eslint-disable-next-line jsdoc/require-description + /** * @param node any node * @param ancestors the given node's ancestors diff --git a/.eslint-plugin-local/index.js b/.eslint-plugin-local/index.js index 9f45316837a..198cb8362dc 100644 --- a/.eslint-plugin-local/index.js +++ b/.eslint-plugin-local/index.js @@ -1,3 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ const glob = require('glob'); const path = require('path'); diff --git a/.eslint-plugin-local/vscode-dts-string-type-literals.ts b/.eslint-plugin-local/vscode-dts-string-type-literals.ts index bca084c4af6..0f6d711a3db 100644 --- a/.eslint-plugin-local/vscode-dts-string-type-literals.ts +++ b/.eslint-plugin-local/vscode-dts-string-type-literals.ts @@ -19,7 +19,7 @@ export = new class ApiTypeDiscrimination implements eslint.Rule.RuleModule { create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { return { ['TSPropertySignature[optional=false] TSTypeAnnotation TSLiteralType Literal']: (node: any) => { - const raw = String((node).raw) + const raw = String((node).raw); if (/^('|").*\1$/.test(raw)) { @@ -29,6 +29,6 @@ export = new class ApiTypeDiscrimination implements eslint.Rule.RuleModule { }); } } - } + }; } }; diff --git a/.github/classifier.json b/.github/classifier.json index 93ca6f3293c..c941f85491e 100644 --- a/.github/classifier.json +++ b/.github/classifier.json @@ -285,7 +285,7 @@ "workbench-fonts": {"assign": []}, "workbench-history": {"assign": ["bpasero"]}, "workbench-hot-exit": {"assign": ["bpasero"]}, - "workbench-hover": {"assign": ["Tyriar"]}, + "workbench-hover": {"assign": ["Tyriar", "benibenj"]}, "workbench-launch": {"assign": []}, "workbench-link": {"assign": []}, "workbench-multiroot": {"assign": ["bpasero"]}, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 813b8dac765..40dca0aaefb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,7 @@ jobs: - name: Setup Build Environment run: | sudo apt-get update - sudo apt-get install -y libxkbfile-dev pkg-config libkrb5-dev libxss1 dbus xvfb libgtk-3-0 libgbm1 + sudo apt-get install -y libxkbfile-dev pkg-config libkrb5-dev libxss1 xvfb libgtk-3-0 libgbm1 sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults diff --git a/.github/workflows/monaco-editor.yml b/.github/workflows/monaco-editor.yml index 1f5694faec2..2f32abb59b0 100644 --- a/.github/workflows/monaco-editor.yml +++ b/.github/workflows/monaco-editor.yml @@ -65,7 +65,7 @@ jobs: run: npm run monaco-compile-check - name: Editor Distro & ESM - run: npm run gulp editor-esm + run: npm run gulp editor-distro - name: Editor ESM sources check working-directory: ./test/monaco diff --git a/.npmrc b/.npmrc index 71d98e6c583..05f84f8a2ad 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="34.3.2" -ms_build_id="11161073" +target="34.5.1" +ms_build_id="11369351" runtime="electron" build_from_source="true" legacy-peer-deps="true" diff --git a/.nvmrc b/.nvmrc index 0254b1e633c..5bd6811705e 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.18.2 +20.19.0 diff --git a/.vscode/extensions/vscode-selfhost-test-provider/package-lock.json b/.vscode/extensions/vscode-selfhost-test-provider/package-lock.json index a71a68e4e36..342512be8a9 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/package-lock.json +++ b/.vscode/extensions/vscode-selfhost-test-provider/package-lock.json @@ -56,12 +56,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.12.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz", - "integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/ansi-styles": { @@ -92,10 +93,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" } } } diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts index 17e65cbce50..b211cff4419 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts @@ -86,10 +86,10 @@ const tsPrinter = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); const formatJsonValue = (value: unknown) => { if (typeof value !== 'object') { - return JSON.stringify(value); + return JSON.stringify(value, undefined, '\t'); } - const src = ts.createSourceFile('', `(${JSON.stringify(value)})`, ts.ScriptTarget.ES5, true); + const src = ts.createSourceFile('', `(${JSON.stringify(value, undefined, '\t')})`, ts.ScriptTarget.ES5, true); const outerExpression = src.statements[0] as ts.ExpressionStatement; const parenExpression = outerExpression.expression as ts.ParenthesizedExpression; diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts index b5ffd440b33..165855ae6eb 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts @@ -306,9 +306,7 @@ export class DarwinTestRunner extends PosixTestRunner { protected override getDefaultArgs() { return [ TEST_ELECTRON_SCRIPT_PATH, - '--no-sandbox', - '--disable-dev-shm-usage', - '--use-gl=swiftshader', + '--no-sandbox' ]; } diff --git a/.vscode/notebooks/api.github-issues b/.vscode/notebooks/api.github-issues index 3a0eaec922f..07b5f0c1ca0 100644 --- a/.vscode/notebooks/api.github-issues +++ b/.vscode/notebooks/api.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"March 2025\"" + "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"April 2025\"" }, { "kind": 1, diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index ca93d503338..4911987e921 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\r\n\r\n$MILESTONE=milestone:\"February 2025\"" + "value": "$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n$MILESTONE=milestone:\"April 2025\"" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index 0fd05ece485..f6e6cc35b4f 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n$MILESTONE=milestone:\"February 2025\"\n\n$MINE=assignee:@me" + "value": "$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n$MILESTONE=milestone:\"April 2025\"\n\n$MINE=assignee:@me" }, { "kind": 1, diff --git a/.vscode/notebooks/my-work.github-issues b/.vscode/notebooks/my-work.github-issues index c7674cef414..401fde7e981 100644 --- a/.vscode/notebooks/my-work.github-issues +++ b/.vscode/notebooks/my-work.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n// current milestone name\n$MILESTONE=milestone:\"February 2025\"\n" + "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n// current milestone name\n$MILESTONE=milestone:\"March 2025\"\n" }, { "kind": 1, diff --git a/.vscode/settings.json b/.vscode/settings.json index 23cb6bae8c2..ba659d5ac8b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -168,16 +168,18 @@ "[github-issues]": { "editor.wordWrap": "on" }, + "inlineChat.enableV2": true, "css.format.spaceAroundSelectorSeparator": true, - "typescript.enablePromptUseWorkspaceTsdk": true, "eslint.useFlatConfig": true, "editor.occurrencesHighlightDelay": 0, + // "editor.experimental.preferTreeSitter.typescript": true, + // "editor.experimental.preferTreeSitter.regex": true, + // "editor.experimental.preferTreeSitter.css": true, "typescript.experimental.expandableHover": true, - "git.diagnosticsCommitHook.Enabled": true, - "git.diagnosticsCommitHook.Sources": { + "git.diagnosticsCommitHook.enabled": true, + "git.diagnosticsCommitHook.sources": { "*": "error", "ts": "warning", "eslint": "warning" - }, - "git.showReferenceDetails": true + } } diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 0f17c5ed11b..ce3bf61f52c 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -225,7 +225,7 @@ OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -atom/language-sass 0.62.1 - MIT +atom/language-sass 0.61.4 - MIT https://github.com/atom/language-sass The MIT License (MIT) @@ -1203,7 +1203,34 @@ to the base-name name of the original file, and an extension of txt, html, or si --------------------------------------------------------- -go-syntax 0.7.9 - MIT +fish-shell 3.7.1 +https://github.com/fish-shell/fish-shell + +Fish is a smart and user-friendly command line shell. + +Copyright (C) 2005-2009 Axel Liljencrantz +Copyright (C) 2009- fish-shell contributors + +fish is free software. + +Most of fish is licensed under the GNU General Public License version 2, and +you can redistribute it and/or modify it under the terms of the GNU GPL as +published by the Free Software Foundation. + +fish also includes software licensed under the Python Software Foundation License version 2, the MIT +license, and the GNU Library General Public License version 2. + +Full licensing information is contained in doc_src/license.rst. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +more details. +--------------------------------------------------------- + +--------------------------------------------------------- + +go-syntax 0.8.0 - MIT https://github.com/worlpaker/go-syntax MIT License @@ -1519,7 +1546,7 @@ SOFTWARE. --------------------------------------------------------- -jlelong/vscode-latex-basics 1.10.0 - MIT +jlelong/vscode-latex-basics 1.13.0 - MIT https://github.com/jlelong/vscode-latex-basics Copyright (c) vscode-latex-basics authors @@ -2101,7 +2128,7 @@ SOFTWARE. --------------------------------------------------------- -microsoft/vscode-mssql 1.23.0 - MIT +microsoft/vscode-mssql 1.29.0 - MIT https://github.com/microsoft/vscode-mssql ------------------------------------------ START OF LICENSE ----------------------------------------- @@ -3485,4 +3512,24 @@ Apache License WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +--------------------------------------------------------- + +--------------------------------------------------------- + +zsh 5.9 +https://github.com/zsh-users/zsh + +Unless otherwise noted in the header of specific files, files in this distribution have the licence shown below. + +However, note that certain shell functions are licensed under versions of the GNU General Public Licence. Anyone distributing the shell as a binary including those files needs to take account of this. Search shell functions for "Copyright" for specific copyright information. None of the core functions are affected by this, so those files may simply be omitted. + +-- + +The Z Shell is copyright (c) 1992-2017 Paul Falstad, Richard Coleman, Zoltán Hidvégi, Andrew Main, Peter Stephenson, Sven Wischnowsky, and others. All rights reserved. Individual authors, whether or not specifically named, retain copyright in all changes; in what follows, they are referred to as `the Zsh Development Group'. This is for convenience only and this body has no legal status. The Z shell is distributed under the following licence; any provisions made in individual files take precedence. + +Permission is hereby granted, without written agreement and without licence or royalty fees, to use, copy, modify, and distribute this software and to distribute modified versions of this software for any purpose, provided that the above copyright notice and the following two paragraphs appear in all copies of this software. + +In no event shall the Zsh Development Group be liable to any party for direct, indirect, special, incidental, or consequential damages arising out of the use of this software and its documentation, even if the Zsh Development Group have been advised of the possibility of such damage. + +The Zsh Development Group specifically disclaim any warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose. The software provided hereunder is on an "as is" basis, and the Zsh Development Group have no obligation to provide maintenance, support, updates, enhancements, or modifications. --------------------------------------------------------- \ No newline at end of file diff --git a/build/.cachesalt b/build/.cachesalt index 5a294f35396..2b7a6b46c22 100644 --- a/build/.cachesalt +++ b/build/.cachesalt @@ -1 +1 @@ -2024-12-11T00:28:56.838Z +2025-04-08T11:12:10.188Z diff --git a/build/.moduleignore b/build/.moduleignore index 6b7f365730f..3e654cfe5c3 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -59,6 +59,7 @@ fsevents/test/** !@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm !@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm !@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +!@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm native-keymap/binding.gyp native-keymap/build/** diff --git a/build/azure-pipelines/alpine/cli-build-alpine.yml b/build/azure-pipelines/alpine/cli-build-alpine.yml index 145133481f2..6c0543d2e7c 100644 --- a/build/azure-pipelines/alpine/cli-build-alpine.yml +++ b/build/azure-pipelines/alpine/cli-build-alpine.yml @@ -67,11 +67,11 @@ steps: VSCODE_CLI_ARTIFACT: vscode_cli_alpine_arm64_cli VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_CLI_ENV: - CXX_aarch64-unknown-linux-musl: musl-g++ - CC_aarch64-unknown-linux-musl: musl-gcc OPENSSL_LIB_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm64-linux-musl/lib OPENSSL_INCLUDE_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm64-linux-musl/include OPENSSL_STATIC: "1" + SYSROOT_ARCH: arm64 + IS_MUSL: "1" - ${{ if eq(parameters.VSCODE_BUILD_ALPINE, true) }}: - template: ../cli/cli-compile.yml@self diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml index d6fe74a9d61..95aa6fa2449 100644 --- a/build/azure-pipelines/alpine/product-build-alpine.yml +++ b/build/azure-pipelines/alpine/product-build-alpine.yml @@ -27,7 +27,7 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry - - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js alpine $VSCODE_ARCH > .build/packagelockhash + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js alpine $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key - task: Cache@2 diff --git a/build/azure-pipelines/cli/cli-compile.yml b/build/azure-pipelines/cli/cli-compile.yml index a5d8bdc1a2c..8c9eec62d53 100644 --- a/build/azure-pipelines/cli/cli-compile.yml +++ b/build/azure-pipelines/cli/cli-compile.yml @@ -43,14 +43,20 @@ steps: set -e if [ -n "$SYSROOT_ARCH" ]; then export VSCODE_SYSROOT_DIR=$(Build.SourcesDirectory)/.build/sysroots - node -e '(async () => { const { getVSCodeSysroot } = require("../build/linux/debian/install-sysroot.js"); await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' + node -e '(async () => { const { getVSCodeSysroot } = require("../build/linux/debian/install-sysroot.js"); await getVSCodeSysroot(process.env["SYSROOT_ARCH"], process.env["IS_MUSL"] === "1"); })()' if [ "$SYSROOT_ARCH" == "arm64" ]; then - export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc" - export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS="-C link-arg=--sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" - export CC_aarch64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc --sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" - export PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/usr/lib/aarch64-linux-gnu/pkgconfig:$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/usr/share/pkgconfig" - export PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" - export OBJDUMP="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/bin/objdump" + if [ -n "$IS_MUSL" ]; then + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="$VSCODE_SYSROOT_DIR/output/bin/aarch64-linux-musl-gcc" + export CC_aarch64_unknown_linux_musl="$VSCODE_SYSROOT_DIR/output/bin/aarch64-linux-musl-gcc" + export CXX_aarch64_unknown_linux_musl="$VSCODE_SYSROOT_DIR/output/bin/aarch64-linux-musl-g++" + else + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc" + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS="-C link-arg=--sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + export CC_aarch64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc --sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + export PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/usr/lib/aarch64-linux-gnu/pkgconfig:$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/usr/share/pkgconfig" + export PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + export OBJDUMP="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/bin/objdump" + fi elif [ "$SYSROOT_ARCH" == "amd64" ]; then export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/bin/x86_64-linux-gnu-gcc" export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="-C link-arg=--sysroot=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot -C link-arg=-L$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/usr/lib/x86_64-linux-gnu" @@ -71,7 +77,7 @@ steps: cargo build --release --target ${{ parameters.VSCODE_CLI_TARGET }} --bin=code # verify glibc requirement - if [ -n "$SYSROOT_ARCH" ]; then + if [ -n "$SYSROOT_ARCH" ] && [ -n "$OBJDUMP" ]; then glibc_version="2.28" while IFS= read -r line; do if [[ $line == *"GLIBC_"* ]]; then @@ -85,6 +91,8 @@ steps: if [[ "$glibc_version" != "2.28" ]]; then echo "Error: binary has dependency on GLIBC > 2.28, found $glibc_version" exit 1 + else + echo "Maximum GLIBC version is $glibc_version as expected." fi fi displayName: Compile ${{ parameters.VSCODE_CLI_TARGET }} diff --git a/build/azure-pipelines/cli/cli-darwin-sign.yml b/build/azure-pipelines/cli/cli-darwin-sign.yml index ba8150651a7..d702b82fc57 100644 --- a/build/azure-pipelines/cli/cli-darwin-sign.yml +++ b/build/azure-pipelines/cli/cli-darwin-sign.yml @@ -36,12 +36,12 @@ steps: - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-darwin $(Build.ArtifactStagingDirectory)/pkg "*.zip" env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Codesign + displayName: ✍️ Codesign - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll notarize-darwin $(Build.ArtifactStagingDirectory)/pkg "*.zip" env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Notarize + displayName: ✍️ Notarize - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: - script: | diff --git a/build/azure-pipelines/cli/cli-win32-sign.yml b/build/azure-pipelines/cli/cli-win32-sign.yml index bc711bec4a7..eb85e9e2e06 100644 --- a/build/azure-pipelines/cli/cli-win32-sign.yml +++ b/build/azure-pipelines/cli/cli-win32-sign.yml @@ -44,7 +44,7 @@ steps: - powershell: node build\azure-pipelines\common\sign $env:EsrpCliDllPath sign-windows $(Build.ArtifactStagingDirectory)/sign "*.exe" env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Codesign + displayName: ✍️ Codesign - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: - powershell: | diff --git a/build/azure-pipelines/cli/test.yml b/build/azure-pipelines/cli/test.yml index 8b525845548..6e2a1c68a16 100644 --- a/build/azure-pipelines/cli/test.yml +++ b/build/azure-pipelines/cli/test.yml @@ -7,4 +7,4 @@ steps: - script: cargo test workingDirectory: cli - displayName: Run unit tests + displayName: 🧪 Run unit tests diff --git a/build/azure-pipelines/common/checkForArtifact.js b/build/azure-pipelines/common/checkForArtifact.js new file mode 100644 index 00000000000..899448f78bd --- /dev/null +++ b/build/azure-pipelines/common/checkForArtifact.js @@ -0,0 +1,34 @@ +"use strict"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +Object.defineProperty(exports, "__esModule", { value: true }); +const publish_1 = require("./publish"); +const retry_1 = require("./retry"); +async function getPipelineArtifacts() { + const result = await (0, publish_1.requestAZDOAPI)('artifacts'); + return result.value.filter(a => !/sbom$/.test(a.name)); +} +async function main([variableName, artifactName]) { + if (!variableName || !artifactName) { + throw new Error(`Usage: node checkForArtifact.js `); + } + try { + const artifacts = await (0, retry_1.retry)(() => getPipelineArtifacts()); + const artifact = artifacts.find(a => a.name === artifactName); + console.log(`##vso[task.setvariable variable=${variableName}]${artifact ? 'true' : 'false'}`); + } + catch (err) { + console.error(`ERROR: Failed to get pipeline artifacts: ${err}`); + console.log(`##vso[task.setvariable variable=${variableName}]false`); + } +} +main(process.argv.slice(2)) + .then(() => { + process.exit(0); +}, err => { + console.error(err); + process.exit(1); +}); +//# sourceMappingURL=checkForArtifact.js.map \ No newline at end of file diff --git a/build/azure-pipelines/common/checkForArtifact.ts b/build/azure-pipelines/common/checkForArtifact.ts new file mode 100644 index 00000000000..e0a1a2ce1d3 --- /dev/null +++ b/build/azure-pipelines/common/checkForArtifact.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Artifact, requestAZDOAPI } from './publish'; +import { retry } from './retry'; + +async function getPipelineArtifacts(): Promise { + const result = await requestAZDOAPI<{ readonly value: Artifact[] }>('artifacts'); + return result.value.filter(a => !/sbom$/.test(a.name)); +} + +async function main([variableName, artifactName]: string[]): Promise { + if (!variableName || !artifactName) { + throw new Error(`Usage: node checkForArtifact.js `); + } + + try { + const artifacts = await retry(() => getPipelineArtifacts()); + const artifact = artifacts.find(a => a.name === artifactName); + console.log(`##vso[task.setvariable variable=${variableName}]${artifact ? 'true' : 'false'}`); + } catch (err) { + console.error(`ERROR: Failed to get pipeline artifacts: ${err}`); + console.log(`##vso[task.setvariable variable=${variableName}]false`); + } +} + +main(process.argv.slice(2)) + .then(() => { + process.exit(0); + }, err => { + console.error(err); + process.exit(1); + }); diff --git a/build/azure-pipelines/common/codesign.js b/build/azure-pipelines/common/codesign.js new file mode 100644 index 00000000000..e3a8f330dcd --- /dev/null +++ b/build/azure-pipelines/common/codesign.js @@ -0,0 +1,30 @@ +"use strict"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.printBanner = printBanner; +exports.streamProcessOutputAndCheckResult = streamProcessOutputAndCheckResult; +exports.spawnCodesignProcess = spawnCodesignProcess; +const zx_1 = require("zx"); +function printBanner(title) { + title = `${title} (${new Date().toISOString()})`; + console.log('\n'); + console.log('#'.repeat(75)); + console.log(`# ${title.padEnd(71)} #`); + console.log('#'.repeat(75)); + console.log('\n'); +} +async function streamProcessOutputAndCheckResult(name, promise) { + const result = await promise.pipe(process.stdout); + if (result.ok) { + console.log(`\n${name} completed successfully. Duration: ${result.duration} ms`); + return; + } + throw new Error(`${name} failed: ${result.stderr}`); +} +function spawnCodesignProcess(esrpCliDLLPath, type, folder, glob) { + return (0, zx_1.$) `node build/azure-pipelines/common/sign ${esrpCliDLLPath} ${type} ${folder} ${glob}`; +} +//# sourceMappingURL=codesign.js.map \ No newline at end of file diff --git a/build/azure-pipelines/common/codesign.ts b/build/azure-pipelines/common/codesign.ts new file mode 100644 index 00000000000..9f26b3924b5 --- /dev/null +++ b/build/azure-pipelines/common/codesign.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, ProcessPromise } from 'zx'; + +export function printBanner(title: string) { + title = `${title} (${new Date().toISOString()})`; + + console.log('\n'); + console.log('#'.repeat(75)); + console.log(`# ${title.padEnd(71)} #`); + console.log('#'.repeat(75)); + console.log('\n'); +} + +export async function streamProcessOutputAndCheckResult(name: string, promise: ProcessPromise): Promise { + const result = await promise.pipe(process.stdout); + if (result.ok) { + console.log(`\n${name} completed successfully. Duration: ${result.duration} ms`); + return; + } + + throw new Error(`${name} failed: ${result.stderr}`); +} + +export function spawnCodesignProcess(esrpCliDLLPath: string, type: 'sign-windows' | 'sign-windows-appx' | 'sign-pgp' | 'sign-darwin' | 'notarize-darwin', folder: string, glob: string): ProcessPromise { + return $`node build/azure-pipelines/common/sign ${esrpCliDLLPath} ${type} ${folder} ${glob}`; +} diff --git a/build/azure-pipelines/common/publish.js b/build/azure-pipelines/common/publish.js index 8e052881b2c..d65a4348f9b 100644 --- a/build/azure-pipelines/common/publish.js +++ b/build/azure-pipelines/common/publish.js @@ -7,6 +7,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.e = e; +exports.requestAZDOAPI = requestAZDOAPI; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const stream_1 = require("stream"); @@ -228,11 +230,11 @@ class ESRPReleaseService { } async getReleaseStatus(releaseId) { const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grs/${releaseId}`; - const res = await fetch(url, { + const res = await (0, retry_1.retry)(() => fetch(url, { headers: { 'Authorization': `Bearer ${this.accessToken}` } - }); + })); if (!res.ok) { const text = await res.text(); throw new Error(`Failed to get release status: ${res.statusText}\n${text}`); @@ -241,11 +243,11 @@ class ESRPReleaseService { } async getReleaseDetails(releaseId) { const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grd/${releaseId}`; - const res = await fetch(url, { + const res = await (0, retry_1.retry)(() => fetch(url, { headers: { 'Authorization': `Bearer ${this.accessToken}` } - }); + })); if (!res.ok) { const text = await res.text(); throw new Error(`Failed to get release status: ${res.statusText}\n${text}`); @@ -317,7 +319,7 @@ async function requestAZDOAPI(path) { const abortController = new AbortController(); const timeout = setTimeout(() => abortController.abort(), 2 * 60 * 1000); try { - const res = await fetch(`${e('BUILDS_API_URL')}${path}?api-version=6.0`, { ...azdoFetchOptions, signal: abortController.signal }); + const res = await (0, retry_1.retry)(() => fetch(`${e('BUILDS_API_URL')}${path}?api-version=6.0`, { ...azdoFetchOptions, signal: abortController.signal })); if (!res.ok) { throw new Error(`Unexpected status code: ${res.status}`); } @@ -618,10 +620,12 @@ async function main() { if (e('VSCODE_BUILD_STAGE_WEB') === 'True') { stages.add('Web'); } + let timeline; + let artifacts; let resultPromise = Promise.resolve([]); const operations = []; while (true) { - const [timeline, artifacts] = await Promise.all([(0, retry_1.retry)(() => getPipelineTimeline()), (0, retry_1.retry)(() => getPipelineArtifacts())]); + [timeline, artifacts] = await Promise.all([(0, retry_1.retry)(() => getPipelineTimeline()), (0, retry_1.retry)(() => getPipelineArtifacts())]); const stagesCompleted = new Set(timeline.records.filter(r => r.type === 'Stage' && r.state === 'completed' && stages.has(r.name)).map(r => r.name)); const stagesInProgress = [...stages].filter(s => !stagesCompleted.has(s)); const artifactsInProgress = artifacts.filter(a => processing.has(a.name)); @@ -689,9 +693,22 @@ async function main() { console.error(`[${operations[i].name}]`, result.reason); } } + // Fail the job if any of the artifacts failed to publish if (results.some(r => r.status === 'rejected')) { throw new Error('Some artifacts failed to publish'); } + // Also fail the job if any of the stages did not succeed + let shouldFail = false; + for (const stage of stages) { + const record = timeline.records.find(r => r.name === stage && r.type === 'Stage'); + if (record.result !== 'succeeded' && record.result !== 'succeededWithIssues') { + shouldFail = true; + console.error(`Stage ${stage} did not succeed: ${record.result}`); + } + } + if (shouldFail) { + throw new Error('Some stages did not succeed'); + } console.log(`All ${done.size} artifacts published!`); } if (require.main === module) { diff --git a/build/azure-pipelines/common/publish.ts b/build/azure-pipelines/common/publish.ts index 37bf5ccc6cd..2b1c15007b3 100644 --- a/build/azure-pipelines/common/publish.ts +++ b/build/azure-pipelines/common/publish.ts @@ -20,7 +20,7 @@ import { BlobClient, BlobServiceClient, BlockBlobClient, ContainerClient, Contai import jws from 'jws'; import { clearInterval, setInterval } from 'node:timers'; -function e(name: string): string { +export function e(name: string): string { const result = process.env[name]; if (typeof result !== 'string') { @@ -480,11 +480,11 @@ class ESRPReleaseService { private async getReleaseStatus(releaseId: string): Promise { const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grs/${releaseId}`; - const res = await fetch(url, { + const res = await retry(() => fetch(url, { headers: { 'Authorization': `Bearer ${this.accessToken}` } - }); + })); if (!res.ok) { const text = await res.text(); @@ -497,11 +497,11 @@ class ESRPReleaseService { private async getReleaseDetails(releaseId: string): Promise { const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grd/${releaseId}`; - const res = await fetch(url, { + const res = await retry(() => fetch(url, { headers: { 'Authorization': `Bearer ${this.accessToken}` } - }); + })); if (!res.ok) { const text = await res.text(); @@ -583,12 +583,12 @@ const azdoFetchOptions = { } }; -async function requestAZDOAPI(path: string): Promise { +export async function requestAZDOAPI(path: string): Promise { const abortController = new AbortController(); const timeout = setTimeout(() => abortController.abort(), 2 * 60 * 1000); try { - const res = await fetch(`${e('BUILDS_API_URL')}${path}?api-version=6.0`, { ...azdoFetchOptions, signal: abortController.signal }); + const res = await retry(() => fetch(`${e('BUILDS_API_URL')}${path}?api-version=6.0`, { ...azdoFetchOptions, signal: abortController.signal })); if (!res.ok) { throw new Error(`Unexpected status code: ${res.status}`); @@ -600,7 +600,7 @@ async function requestAZDOAPI(path: string): Promise { } } -interface Artifact { +export interface Artifact { readonly name: string; readonly resource: { readonly downloadUrl: string; @@ -620,6 +620,7 @@ interface Timeline { readonly name: string; readonly type: string; readonly state: string; + readonly result: string; }[]; } @@ -959,11 +960,13 @@ async function main() { if (e('VSCODE_BUILD_STAGE_MACOS') === 'True') { stages.add('macOS'); } if (e('VSCODE_BUILD_STAGE_WEB') === 'True') { stages.add('Web'); } + let timeline: Timeline; + let artifacts: Artifact[]; let resultPromise = Promise.resolve[]>([]); const operations: { name: string; operation: Promise }[] = []; while (true) { - const [timeline, artifacts] = await Promise.all([retry(() => getPipelineTimeline()), retry(() => getPipelineArtifacts())]); + [timeline, artifacts] = await Promise.all([retry(() => getPipelineTimeline()), retry(() => getPipelineArtifacts())]); const stagesCompleted = new Set(timeline.records.filter(r => r.type === 'Stage' && r.state === 'completed' && stages.has(r.name)).map(r => r.name)); const stagesInProgress = [...stages].filter(s => !stagesCompleted.has(s)); const artifactsInProgress = artifacts.filter(a => processing.has(a.name)); @@ -1044,10 +1047,27 @@ async function main() { } } + // Fail the job if any of the artifacts failed to publish if (results.some(r => r.status === 'rejected')) { throw new Error('Some artifacts failed to publish'); } + // Also fail the job if any of the stages did not succeed + let shouldFail = false; + + for (const stage of stages) { + const record = timeline.records.find(r => r.name === stage && r.type === 'Stage')!; + + if (record.result !== 'succeeded' && record.result !== 'succeededWithIssues') { + shouldFail = true; + console.error(`Stage ${stage} did not succeed: ${record.result}`); + } + } + + if (shouldFail) { + throw new Error('Some stages did not succeed'); + } + console.log(`All ${done.size} artifacts published!`); } diff --git a/build/azure-pipelines/common/waitForArtifacts.js b/build/azure-pipelines/common/waitForArtifacts.js new file mode 100644 index 00000000000..b9ffb73962d --- /dev/null +++ b/build/azure-pipelines/common/waitForArtifacts.js @@ -0,0 +1,46 @@ +"use strict"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +Object.defineProperty(exports, "__esModule", { value: true }); +const publish_1 = require("../common/publish"); +const retry_1 = require("../common/retry"); +async function getPipelineArtifacts() { + const result = await (0, publish_1.requestAZDOAPI)('artifacts'); + return result.value.filter(a => !/sbom$/.test(a.name)); +} +async function main(artifacts) { + if (artifacts.length === 0) { + throw new Error(`Usage: node waitForArtifacts.js ...`); + } + // This loop will run for 30 minutes and waits to the x64 and arm64 artifacts + // to be uploaded to the pipeline by the `macOS` and `macOSARM64` jobs. As soon + // as these artifacts are found, the loop completes and the `macOSUnivesrsal` + // job resumes. + for (let index = 0; index < 60; index++) { + try { + console.log(`Waiting for artifacts (${artifacts.join(', ')}) to be uploaded (${index + 1}/60)...`); + const allArtifacts = await (0, retry_1.retry)(() => getPipelineArtifacts()); + console.log(` * Artifacts attached to the pipelines: ${allArtifacts.length > 0 ? allArtifacts.map(a => a.name).join(', ') : 'none'}`); + const foundArtifacts = allArtifacts.filter(a => artifacts.includes(a.name)); + console.log(` * Found artifacts: ${foundArtifacts.length > 0 ? foundArtifacts.map(a => a.name).join(', ') : 'none'}`); + if (foundArtifacts.length === artifacts.length) { + console.log(` * All artifacts were found`); + return; + } + } + catch (err) { + console.error(`ERROR: Failed to get pipeline artifacts: ${err}`); + } + await new Promise(c => setTimeout(c, 30_000)); + } + throw new Error(`ERROR: Artifacts (${artifacts.join(', ')}) were not uploaded within 30 minutes.`); +} +main(process.argv.splice(2)).then(() => { + process.exit(0); +}, err => { + console.error(err); + process.exit(1); +}); +//# sourceMappingURL=waitForArtifacts.js.map \ No newline at end of file diff --git a/build/azure-pipelines/common/waitForArtifacts.ts b/build/azure-pipelines/common/waitForArtifacts.ts new file mode 100644 index 00000000000..3fed6cd38d2 --- /dev/null +++ b/build/azure-pipelines/common/waitForArtifacts.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Artifact, requestAZDOAPI } from '../common/publish'; +import { retry } from '../common/retry'; + +async function getPipelineArtifacts(): Promise { + const result = await requestAZDOAPI<{ readonly value: Artifact[] }>('artifacts'); + return result.value.filter(a => !/sbom$/.test(a.name)); +} + +async function main(artifacts: string[]): Promise { + if (artifacts.length === 0) { + throw new Error(`Usage: node waitForArtifacts.js ...`); + } + + // This loop will run for 30 minutes and waits to the x64 and arm64 artifacts + // to be uploaded to the pipeline by the `macOS` and `macOSARM64` jobs. As soon + // as these artifacts are found, the loop completes and the `macOSUnivesrsal` + // job resumes. + for (let index = 0; index < 60; index++) { + try { + console.log(`Waiting for artifacts (${artifacts.join(', ')}) to be uploaded (${index + 1}/60)...`); + const allArtifacts = await retry(() => getPipelineArtifacts()); + console.log(` * Artifacts attached to the pipelines: ${allArtifacts.length > 0 ? allArtifacts.map(a => a.name).join(', ') : 'none'}`); + + const foundArtifacts = allArtifacts.filter(a => artifacts.includes(a.name)); + console.log(` * Found artifacts: ${foundArtifacts.length > 0 ? foundArtifacts.map(a => a.name).join(', ') : 'none'}`); + + if (foundArtifacts.length === artifacts.length) { + console.log(` * All artifacts were found`); + return; + } + } catch (err) { + console.error(`ERROR: Failed to get pipeline artifacts: ${err}`); + } + + await new Promise(c => setTimeout(c, 30_000)); + } + + throw new Error(`ERROR: Artifacts (${artifacts.join(', ')}) were not uploaded within 30 minutes.`); +} + +main(process.argv.splice(2)).then(() => { + process.exit(0); +}, err => { + console.error(err); + process.exit(1); +}); diff --git a/build/azure-pipelines/darwin/codesign.js b/build/azure-pipelines/darwin/codesign.js new file mode 100644 index 00000000000..edc3a5f6f80 --- /dev/null +++ b/build/azure-pipelines/darwin/codesign.js @@ -0,0 +1,30 @@ +"use strict"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +Object.defineProperty(exports, "__esModule", { value: true }); +const codesign_1 = require("../common/codesign"); +const publish_1 = require("../common/publish"); +async function main() { + const arch = (0, publish_1.e)('VSCODE_ARCH'); + const esrpCliDLLPath = (0, publish_1.e)('EsrpCliDllPath'); + const pipelineWorkspace = (0, publish_1.e)('PIPELINE_WORKSPACE'); + const folder = `${pipelineWorkspace}/unsigned_vscode_client_darwin_${arch}_archive`; + const glob = `VSCode-darwin-${arch}.zip`; + // Codesign + (0, codesign_1.printBanner)('Codesign'); + const codeSignTask = (0, codesign_1.spawnCodesignProcess)(esrpCliDLLPath, 'sign-darwin', folder, glob); + await (0, codesign_1.streamProcessOutputAndCheckResult)('Codesign', codeSignTask); + // Notarize + (0, codesign_1.printBanner)('Notarize'); + const notarizeTask = (0, codesign_1.spawnCodesignProcess)(esrpCliDLLPath, 'notarize-darwin', folder, glob); + await (0, codesign_1.streamProcessOutputAndCheckResult)('Notarize', notarizeTask); +} +main().then(() => { + process.exit(0); +}, err => { + console.error(`ERROR: ${err}`); + process.exit(1); +}); +//# sourceMappingURL=codesign.js.map \ No newline at end of file diff --git a/build/azure-pipelines/darwin/codesign.ts b/build/azure-pipelines/darwin/codesign.ts new file mode 100644 index 00000000000..a9de0206d6e --- /dev/null +++ b/build/azure-pipelines/darwin/codesign.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { printBanner, spawnCodesignProcess, streamProcessOutputAndCheckResult } from '../common/codesign'; +import { e } from '../common/publish'; + +async function main() { + const arch = e('VSCODE_ARCH'); + const esrpCliDLLPath = e('EsrpCliDllPath'); + const pipelineWorkspace = e('PIPELINE_WORKSPACE'); + + const folder = `${pipelineWorkspace}/unsigned_vscode_client_darwin_${arch}_archive`; + const glob = `VSCode-darwin-${arch}.zip`; + + // Codesign + printBanner('Codesign'); + const codeSignTask = spawnCodesignProcess(esrpCliDLLPath, 'sign-darwin', folder, glob); + await streamProcessOutputAndCheckResult('Codesign', codeSignTask); + + // Notarize + printBanner('Notarize'); + const notarizeTask = spawnCodesignProcess(esrpCliDLLPath, 'notarize-darwin', folder, glob); + await streamProcessOutputAndCheckResult('Notarize', notarizeTask); +} + +main().then(() => { + process.exit(0); +}, err => { + console.error(`ERROR: ${err}`); + process.exit(1); +}); diff --git a/build/azure-pipelines/darwin/product-build-darwin-sign.yml b/build/azure-pipelines/darwin/product-build-darwin-sign.yml deleted file mode 100644 index dffb6665d99..00000000000 --- a/build/azure-pipelines/darwin/product-build-darwin-sign.yml +++ /dev/null @@ -1,81 +0,0 @@ -steps: - - task: NodeTool@0 - inputs: - versionSource: fromFile - versionFilePath: .nvmrc - nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - - - task: UseDotNet@2 - inputs: - version: 6.x - - - task: EsrpCodeSigning@5 - inputs: - UseMSIAuthentication: true - ConnectedServiceName: vscode-esrp - AppRegistrationClientId: $(ESRP_CLIENT_ID) - AppRegistrationTenantId: $(ESRP_TENANT_ID) - AuthAKVName: vscode-esrp - AuthSignCertName: esrp-sign - FolderPath: . - Pattern: noop - displayName: 'Install ESRP Tooling' - - - script: | - # For legacy purposes, arch for x64 is just 'darwin' - case $VSCODE_ARCH in - x64) ASSET_ID="darwin" ;; - arm64) ASSET_ID="darwin-arm64" ;; - universal) ASSET_ID="darwin-universal" ;; - esac - echo "##vso[task.setvariable variable=ASSET_ID]$ASSET_ID" - displayName: Set asset id variable - - - script: | - if [ -z "$(ASSET_ID)" ]; then - echo "ASSET_ID is empty" - exit 1 - else - echo "ASSET_ID is set to $(ASSET_ID)" - fi - displayName: Check ASSET_ID variable - - - download: current - artifact: unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive - displayName: Download $(VSCODE_ARCH) artifact - - - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-darwin $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive VSCode-darwin-$(VSCODE_ARCH).zip - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Codesign - - - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll notarize-darwin $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive VSCode-darwin-$(VSCODE_ARCH).zip - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Notarize - - - script: unzip $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-$(VSCODE_ARCH).zip -d $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH) - displayName: Extract signed 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" - codesign -dv --deep --verbose=4 "$APP_PATH" - "$APP_PATH/Contents/Resources/app/bin/code" --export-default-configuration=.build - displayName: Verify signature - condition: and(succeeded(), ne(variables['VSCODE_ARCH'], 'arm64')) - - - script: mv $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-x64.zip $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin.zip - displayName: Rename x64 build to its legacy name - condition: and(succeeded(), eq(variables['VSCODE_ARCH'], 'x64')) - - - task: 1ES.PublishPipelineArtifact@1 - inputs: - targetPath: $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-$(ASSET_ID).zip - artifactName: vscode_client_darwin_$(VSCODE_ARCH)_archive - sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH) - sbomPackageName: "VS Code macOS $(VSCODE_ARCH)" - sbomPackageVersion: $(Build.SourceVersion) - displayName: Publish client archive diff --git a/build/azure-pipelines/darwin/product-build-darwin-test.yml b/build/azure-pipelines/darwin/product-build-darwin-test.yml index 9e054574c81..c542cacaf19 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-test.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-test.yml @@ -1,12 +1,17 @@ parameters: - name: VSCODE_QUALITY type: string - - name: VSCODE_RUN_UNIT_TESTS + - name: VSCODE_RUN_ELECTRON_TESTS type: boolean - - name: VSCODE_RUN_INTEGRATION_TESTS + - name: VSCODE_RUN_BROWSER_TESTS type: boolean - - name: VSCODE_RUN_SMOKE_TESTS + - name: VSCODE_RUN_REMOTE_TESTS type: boolean + - name: VSCODE_TEST_ARTIFACT_NAME + type: string + - name: PUBLISH_TASK_NAME + type: string + default: PublishPipelineArtifact@0 steps: - script: npm exec -- npm-run-all -lp "electron $(VSCODE_ARCH)" "playwright-install" @@ -15,62 +20,78 @@ steps: displayName: Download Electron and Playwright retryCountOnTaskFailure: 3 - - ${{ if eq(parameters.VSCODE_RUN_UNIT_TESTS, true) }}: - - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: ./scripts/test.sh --tfs "Unit Tests" - displayName: Run unit tests (Electron) + displayName: 🧪 Run unit tests (Electron) timeoutInMinutes: 15 - script: npm run test-node - displayName: Run unit tests (node.js) + displayName: 🧪 Run unit tests (node.js) timeoutInMinutes: 15 - - script: npm run test-browser-no-install -- --sequential --browser chromium --browser webkit --tfs "Browser Unit Tests" + + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: + - script: npm run test-browser-no-install -- --browser webkit --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - displayName: Run unit tests (Browser, Chromium & Webkit) + displayName: 🧪 Run unit tests (Browser, Webkit) timeoutInMinutes: 30 - - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: ./scripts/test.sh --build --tfs "Unit Tests" - displayName: Run unit tests (Electron) + displayName: 🧪 Run unit tests (Electron) timeoutInMinutes: 15 - script: npm run test-node -- --build - displayName: Run unit tests (node.js) + displayName: 🧪 Run unit tests (node.js) timeoutInMinutes: 15 - - script: npm run test-browser-no-install -- --sequential --build --browser chromium --browser webkit --tfs "Browser Unit Tests" + + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: + - script: npm run test-browser-no-install -- --build --browser webkit --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - displayName: Run unit tests (Browser, Chromium & Webkit) + displayName: 🧪 Run unit tests (Browser, Webkit) timeoutInMinutes: 30 - - ${{ if eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true) }}: - - script: | - set -e - npm run gulp \ - compile-extension:configuration-editing \ - compile-extension:css-language-features-server \ - compile-extension:emmet \ - compile-extension:git \ - compile-extension:github-authentication \ - compile-extension:html-language-features-server \ - compile-extension:ipynb \ - compile-extension:notebook-renderers \ - compile-extension:json-language-features-server \ - compile-extension:markdown-language-features \ - compile-extension-media \ - compile-extension:microsoft-authentication \ - compile-extension:typescript-language-features \ - compile-extension:vscode-api-tests \ - compile-extension:vscode-colorize-tests \ - compile-extension:vscode-colorize-perf-tests \ - compile-extension:vscode-test-resolver - displayName: Build integration tests + - script: | + set -e + npm run gulp \ + compile-extension:configuration-editing \ + compile-extension:css-language-features-server \ + compile-extension:emmet \ + compile-extension:git \ + compile-extension:github-authentication \ + compile-extension:html-language-features-server \ + compile-extension:ipynb \ + compile-extension:notebook-renderers \ + compile-extension:json-language-features-server \ + compile-extension:markdown-language-features \ + compile-extension-media \ + compile-extension:microsoft-authentication \ + compile-extension:typescript-language-features \ + compile-extension:vscode-api-tests \ + compile-extension:vscode-colorize-tests \ + compile-extension:vscode-colorize-perf-tests \ + compile-extension:vscode-test-resolver + displayName: Build integration tests - - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: - - script: ./scripts/test-integration --tfs "Integration Tests" - displayName: Run integration tests (Electron) + - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: + - script: ./scripts/test-integration.sh --tfs "Integration Tests" + displayName: 🧪 Run integration tests (Electron) timeoutInMinutes: 20 - - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: + - script: ./scripts/test-web-integration.sh --browser webkit + displayName: 🧪 Run integration tests (Browser, Webkit) + timeoutInMinutes: 20 + + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: + - script: ./scripts/test-remote-integration.sh + displayName: 🧪 Run integration tests (Remote) + timeoutInMinutes: 20 + + - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: | # Figure out the full absolute path of the product we just built # including the remote server and configure the integration tests @@ -82,15 +103,17 @@ steps: ./scripts/test-integration.sh --build --tfs "Integration Tests" env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH) - displayName: Run integration tests (Electron) + displayName: 🧪 Run integration tests (Electron) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - script: ./scripts/test-web-integration.sh --browser webkit env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH)-web - displayName: Run integration tests (Browser, Webkit) + displayName: 🧪 Run integration tests (Browser, Webkit) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: - script: | set -e APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) @@ -99,42 +122,45 @@ steps: ./scripts/test-remote-integration.sh env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH) - displayName: Run integration tests (Remote) + displayName: 🧪 Run integration tests (Remote) timeoutInMinutes: 20 - - ${{ if eq(parameters.VSCODE_RUN_SMOKE_TESTS, true) }}: - - script: ps -ef - displayName: Diagnostics before smoke test run - continueOnError: true - condition: succeededOrFailed() + - script: ps -ef + displayName: Diagnostics before smoke test run + continueOnError: true + condition: succeededOrFailed() - - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: - - script: npm run compile - workingDirectory: test/smoke - displayName: Compile smoke tests + # - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + # - script: npm run compile + # workingDirectory: test/smoke + # displayName: Compile smoke tests - - script: npm run gulp compile-extension-media - displayName: Compile extensions for smoke tests + # - script: npm run gulp compile-extension-media + # displayName: Compile extensions for smoke tests - - script: npm run smoketest-no-compile -- --tracing - timeoutInMinutes: 20 - displayName: Run smoke tests (Electron) + # - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: + # - script: npm run smoketest-no-compile -- --tracing + # timeoutInMinutes: 20 + # displayName: 🧪 Run smoke tests (Electron) - - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: | set -e APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) APP_NAME="`ls $APP_ROOT | head -n 1`" npm run smoketest-no-compile -- --tracing --build "$APP_ROOT/$APP_NAME" timeoutInMinutes: 20 - displayName: Run smoke tests (Electron) + displayName: 🧪 Run smoke tests (Electron) + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - script: npm run smoketest-no-compile -- --web --tracing --headless env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH)-web timeoutInMinutes: 20 - displayName: Run smoke tests (Browser, Chromium) + displayName: 🧪 Run smoke tests (Browser, Chromium) + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: - script: | set -e npm run gulp compile-extension:vscode-test-resolver @@ -144,57 +170,50 @@ steps: env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH) timeoutInMinutes: 20 - displayName: Run smoke tests (Remote) + displayName: 🧪 Run smoke tests (Remote) - - script: ps -ef - displayName: Diagnostics after smoke test run - continueOnError: true - condition: succeededOrFailed() + - script: ps -ef + displayName: Diagnostics after smoke test run + continueOnError: true + condition: succeededOrFailed() - - ${{ if or(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - task: 1ES.PublishPipelineArtifact@1 - inputs: - targetPath: .build/crashes - ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: - artifactName: crash-dump-macos-$(VSCODE_ARCH)-integration-$(System.JobAttempt) - ${{ elseif and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, false), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - artifactName: crash-dump-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) - ${{ else }}: - artifactName: crash-dump-macos-$(VSCODE_ARCH)-$(System.JobAttempt) - sbomEnabled: false - displayName: "Publish Crash Reports" - continueOnError: true - condition: failed() + - task: ${{ parameters.PUBLISH_TASK_NAME }} + inputs: + targetPath: .build/crashes + ${{ if eq(parameters.VSCODE_TEST_ARTIFACT_NAME, '') }}: + artifactName: crash-dump-macos-$(VSCODE_ARCH)-$(System.JobAttempt) + ${{ else }}: + artifactName: crash-dump-macos-$(VSCODE_ARCH)-${{ parameters.VSCODE_TEST_ARTIFACT_NAME }}-$(System.JobAttempt) + sbomEnabled: false + displayName: "Publish Crash Reports" + continueOnError: true + condition: failed() - # In order to properly symbolify above crash reports - # (if any), we need the compiled native modules too - - task: 1ES.PublishPipelineArtifact@1 - inputs: - targetPath: node_modules - ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: - artifactName: node-modules-macos-$(VSCODE_ARCH)-integration-$(System.JobAttempt) - ${{ elseif and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, false), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - artifactName: node-modules-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) - ${{ else }}: - artifactName: node-modules-macos-$(VSCODE_ARCH)-$(System.JobAttempt) - sbomEnabled: false - displayName: "Publish Node Modules" - continueOnError: true - condition: failed() + # In order to properly symbolify above crash reports + # (if any), we need the compiled native modules too + - task: ${{ parameters.PUBLISH_TASK_NAME }} + inputs: + targetPath: node_modules + ${{ if eq(parameters.VSCODE_TEST_ARTIFACT_NAME, '') }}: + artifactName: node-modules-macos-$(VSCODE_ARCH)-$(System.JobAttempt) + ${{ else }}: + artifactName: node-modules-macos-$(VSCODE_ARCH)-${{ parameters.VSCODE_TEST_ARTIFACT_NAME }}-$(System.JobAttempt) + sbomEnabled: false + displayName: "Publish Node Modules" + continueOnError: true + condition: failed() - - task: 1ES.PublishPipelineArtifact@1 - inputs: - targetPath: .build/logs - ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: - artifactName: logs-macos-$(VSCODE_ARCH)-integration-$(System.JobAttempt) - ${{ elseif and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, false), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - artifactName: logs-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) - ${{ else }}: - artifactName: logs-macos-$(VSCODE_ARCH)-$(System.JobAttempt) - sbomEnabled: false - displayName: "Publish Log Files" - continueOnError: true - condition: succeededOrFailed() + - task: ${{ parameters.PUBLISH_TASK_NAME }} + inputs: + targetPath: .build/logs + ${{ if eq(parameters.VSCODE_TEST_ARTIFACT_NAME, '') }}: + artifactName: logs-macos-$(VSCODE_ARCH)-$(System.JobAttempt) + ${{ else }}: + artifactName: logs-macos-$(VSCODE_ARCH)-${{ parameters.VSCODE_TEST_ARTIFACT_NAME }}-$(System.JobAttempt) + sbomEnabled: false + displayName: "Publish Log Files" + continueOnError: true + condition: succeededOrFailed() - task: PublishTestResults@2 displayName: Publish Tests Results diff --git a/build/azure-pipelines/darwin/product-build-darwin-universal.yml b/build/azure-pipelines/darwin/product-build-darwin-universal.yml index 3bb62e15403..ff88bf759ef 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-universal.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-universal.yml @@ -50,6 +50,11 @@ steps: GITHUB_TOKEN: "$(github-distro-mixin-password)" displayName: Install build dependencies + - pwsh: node build/azure-pipelines/common/waitForArtifacts.js unsigned_vscode_client_darwin_x64_archive unsigned_vscode_client_darwin_arm64_archive + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Wait for x64 and arm64 artifacts + - download: current artifact: unsigned_vscode_client_darwin_x64_archive displayName: Download x64 artifact @@ -87,14 +92,60 @@ steps: DEBUG=electron-osx-sign* node build/darwin/sign.js $(agent.builddirectory) displayName: Set Hardened Entitlements - - script: pushd $(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) && zip -r -X -y $(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH).zip * && popd + - script: | + set -e + mkdir -p $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive + pushd $(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) && zip -r -X -y $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-$(VSCODE_ARCH).zip * && popd displayName: Archive build + - task: UseDotNet@2 + inputs: + version: 6.x + + - task: EsrpCodeSigning@5 + inputs: + UseMSIAuthentication: true + ConnectedServiceName: vscode-esrp + AppRegistrationClientId: $(ESRP_CLIENT_ID) + AppRegistrationTenantId: $(ESRP_TENANT_ID) + AuthAKVName: vscode-esrp + AuthSignCertName: esrp-sign + FolderPath: . + Pattern: noop + displayName: 'Install ESRP Tooling' + + - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-darwin $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive VSCode-darwin-$(VSCODE_ARCH).zip + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: ✍️ Codesign + + - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll notarize-darwin $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive VSCode-darwin-$(VSCODE_ARCH).zip + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: ✍️ Notarize + + - script: unzip $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-$(VSCODE_ARCH).zip -d $(Build.ArtifactStagingDirectory)/VSCode-darwin-$(VSCODE_ARCH) + displayName: Extract signed app + + - script: | + set -e + APP_ROOT="$(Build.ArtifactStagingDirectory)/VSCode-darwin-$(VSCODE_ARCH)" + APP_NAME="`ls $APP_ROOT | head -n 1`" + APP_PATH="$APP_ROOT/$APP_NAME" + codesign -dv --deep --verbose=4 "$APP_PATH" + "$APP_PATH/Contents/Resources/app/bin/code" --export-default-configuration=.build + displayName: Verify signature + condition: and(succeeded(), ne(variables['VSCODE_ARCH'], 'arm64')) + + - script: mv $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-x64.zip $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin.zip + displayName: Rename x64 build to its legacy name + condition: and(succeeded(), eq(variables['VSCODE_ARCH'], 'x64')) + - task: 1ES.PublishPipelineArtifact@1 inputs: - targetPath: $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH).zip - artifactName: unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive - sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH) - sbomPackageName: "VS Code macOS $(VSCODE_ARCH) (unsigned)" + targetPath: $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-universal.zip + artifactName: vscode_client_darwin_$(VSCODE_ARCH)_archive + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/VSCode-darwin-$(VSCODE_ARCH) + sbomPackageName: "VS Code macOS $(VSCODE_ARCH)" sbomPackageVersion: $(Build.SourceVersion) displayName: Publish client archive diff --git a/build/azure-pipelines/darwin/product-build-darwin.yml b/build/azure-pipelines/darwin/product-build-darwin.yml index bd37d675aa2..a6072c8f4fa 100644 --- a/build/azure-pipelines/darwin/product-build-darwin.yml +++ b/build/azure-pipelines/darwin/product-build-darwin.yml @@ -1,14 +1,22 @@ parameters: + - name: VSCODE_ARCH + type: string - name: VSCODE_QUALITY type: string - name: VSCODE_CIBUILD type: boolean - - name: VSCODE_RUN_UNIT_TESTS + - name: VSCODE_RUN_ELECTRON_TESTS type: boolean - - name: VSCODE_RUN_INTEGRATION_TESTS + default: false + - name: VSCODE_RUN_BROWSER_TESTS type: boolean - - name: VSCODE_RUN_SMOKE_TESTS + default: false + - name: VSCODE_RUN_REMOTE_TESTS type: boolean + default: false + - name: VSCODE_TEST_ARTIFACT_NAME + type: string + default: "" steps: - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: @@ -45,7 +53,7 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry - - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js darwin $VSCODE_ARCH > .build/packagelockhash + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key - task: Cache@2 @@ -165,15 +173,7 @@ steps: GITHUB_TOKEN: "$(github-distro-mixin-password)" displayName: Transpile - - ${{ if or(eq(parameters.VSCODE_RUN_UNIT_TESTS, true), eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - template: product-build-darwin-test.yml@self - parameters: - VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} - VSCODE_RUN_UNIT_TESTS: ${{ parameters.VSCODE_RUN_UNIT_TESTS }} - VSCODE_RUN_INTEGRATION_TESTS: ${{ parameters.VSCODE_RUN_INTEGRATION_TESTS }} - VSCODE_RUN_SMOKE_TESTS: ${{ parameters.VSCODE_RUN_SMOKE_TESTS }} - - - ${{ elseif and(ne(parameters.VSCODE_CIBUILD, true), ne(parameters.VSCODE_QUALITY, 'oss')) }}: + - ${{ if and(ne(parameters.VSCODE_CIBUILD, true), ne(parameters.VSCODE_QUALITY, 'oss')) }}: - task: DownloadPipelineArtifact@2 inputs: artifact: unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli @@ -218,26 +218,106 @@ steps: - script: | set -e - ARCHIVE_PATH=".build/darwin/client/VSCode-darwin-$(VSCODE_ARCH).zip" + ARCHIVE_PATH="$(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-$(VSCODE_ARCH).zip" mkdir -p $(dirname $ARCHIVE_PATH) - (cd ../VSCode-darwin-$(VSCODE_ARCH) && zip -Xry $(Build.SourcesDirectory)/$ARCHIVE_PATH *) + (cd ../VSCode-darwin-$(VSCODE_ARCH) && zip -Xry $ARCHIVE_PATH *) echo "##vso[task.setvariable variable=CLIENT_PATH]$ARCHIVE_PATH" condition: and(succeededOrFailed(), eq(variables['BUILT_CLIENT'], 'true')) displayName: Package client - - script: echo "##vso[task.setvariable variable=ARTIFACT_PREFIX]attempt$(System.JobAttempt)_" - condition: and(succeededOrFailed(), notIn(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues')) - displayName: Generate artifact prefix + - pwsh: node build/azure-pipelines/common/checkForArtifact.js CLIENT_ARCHIVE_UPLOADED unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Check for client artifact - task: 1ES.PublishPipelineArtifact@1 inputs: targetPath: $(CLIENT_PATH) - artifactName: $(ARTIFACT_PREFIX)unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive + artifactName: unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH) sbomPackageName: "VS Code macOS $(VSCODE_ARCH) (unsigned)" sbomPackageVersion: $(Build.SourceVersion) + condition: and(succeeded(), ne(variables['CLIENT_PATH'], ''), eq(variables['CLIENT_ARCHIVE_UPLOADED'], 'false')) + displayName: Publish client archive (unsigned) + + - task: UseDotNet@2 + inputs: + version: 6.x + + - task: EsrpCodeSigning@5 + inputs: + UseMSIAuthentication: true + ConnectedServiceName: vscode-esrp + AppRegistrationClientId: $(ESRP_CLIENT_ID) + AppRegistrationTenantId: $(ESRP_TENANT_ID) + AuthAKVName: vscode-esrp + AuthSignCertName: esrp-sign + FolderPath: . + Pattern: noop + displayName: 'Install ESRP Tooling' + + - pwsh: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + $EsrpCodeSigningTool = (gci -directory -filter EsrpCodeSigning_* $(Agent.RootDirectory)/_tasks | Select-Object -last 1).FullName + $Version = (gci -directory $EsrpCodeSigningTool | Select-Object -last 1).FullName + echo "##vso[task.setvariable variable=EsrpCliDllPath]$Version/net6.0/esrpcli.dll" + displayName: Find ESRP CLI + + - script: npx deemon --detach --wait node build/azure-pipelines/darwin/codesign.js + env: + EsrpCliDllPath: $(EsrpCliDllPath) + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: ✍️ Codesign & Notarize + + - ${{ if or(eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true), eq(parameters.VSCODE_RUN_BROWSER_TESTS, true), eq(parameters.VSCODE_RUN_REMOTE_TESTS, true)) }}: + - template: product-build-darwin-test.yml@self + parameters: + VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} + VSCODE_TEST_ARTIFACT_NAME: ${{ parameters.VSCODE_TEST_ARTIFACT_NAME }} + VSCODE_RUN_ELECTRON_TESTS: ${{ parameters.VSCODE_RUN_ELECTRON_TESTS }} + VSCODE_RUN_BROWSER_TESTS: ${{ parameters.VSCODE_RUN_BROWSER_TESTS }} + VSCODE_RUN_REMOTE_TESTS: ${{ parameters.VSCODE_RUN_REMOTE_TESTS }} + ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + PUBLISH_TASK_NAME: 1ES.PublishPipelineArtifact@1 + + - ${{ if and(ne(parameters.VSCODE_CIBUILD, true), ne(parameters.VSCODE_QUALITY, 'oss')) }}: + - script: npx deemon --attach node build/azure-pipelines/darwin/codesign.js + condition: succeededOrFailed() + displayName: "Post-job: ✍️ Codesign & Notarize" + + - script: unzip $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-$(VSCODE_ARCH).zip -d $(Build.ArtifactStagingDirectory)/VSCode-darwin-$(VSCODE_ARCH) + displayName: Extract signed app + + - script: | + set -e + APP_ROOT="$(Build.ArtifactStagingDirectory)/VSCode-darwin-$(VSCODE_ARCH)" + APP_NAME="`ls $APP_ROOT | head -n 1`" + APP_PATH="$APP_ROOT/$APP_NAME" + codesign -dv --deep --verbose=4 "$APP_PATH" + "$APP_PATH/Contents/Resources/app/bin/code" --export-default-configuration=.build + displayName: Verify signature + + - script: mv $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-x64.zip $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin.zip + displayName: Rename x64 build to its legacy name + condition: and(succeeded(), eq(variables['VSCODE_ARCH'], 'x64')) + + - task: 1ES.PublishPipelineArtifact@1 + inputs: + ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: + targetPath: $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-arm64.zip + ${{ else }}: + targetPath: $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin.zip + artifactName: vscode_client_darwin_$(VSCODE_ARCH)_archive + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/VSCode-darwin-$(VSCODE_ARCH) + sbomPackageName: "VS Code macOS $(VSCODE_ARCH)" + sbomPackageVersion: $(Build.SourceVersion) displayName: Publish client archive + - script: echo "##vso[task.setvariable variable=ARTIFACT_PREFIX]attempt$(System.JobAttempt)_" + condition: and(succeededOrFailed(), notIn(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues')) + displayName: Generate artifact prefix + - task: 1ES.PublishPipelineArtifact@1 inputs: targetPath: $(SERVER_PATH) diff --git a/build/azure-pipelines/linux/build-snap.sh b/build/azure-pipelines/linux/build-snap.sh new file mode 100755 index 00000000000..144f41cae86 --- /dev/null +++ b/build/azure-pipelines/linux/build-snap.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -e + +# Get snapcraft version +snapcraft --version + +# Make sure we get latest packages +sudo apt-get update +sudo apt-get upgrade -y +sudo apt-get install -y curl apt-transport-https ca-certificates + +# Define variables +SNAP_ROOT="$(pwd)/.build/linux/snap/$VSCODE_ARCH" + +# Create snap package +BUILD_VERSION="$(date +%s)" +SNAP_FILENAME="code-$VSCODE_QUALITY-$VSCODE_ARCH-$BUILD_VERSION.snap" +SNAP_PATH="$SNAP_ROOT/$SNAP_FILENAME" +case $VSCODE_ARCH in + x64) SNAPCRAFT_TARGET_ARGS="" ;; + *) SNAPCRAFT_TARGET_ARGS="--target-arch $VSCODE_ARCH" ;; +esac +(cd $SNAP_ROOT/code-* && sudo --preserve-env snapcraft snap $SNAPCRAFT_TARGET_ARGS --output "$SNAP_PATH") diff --git a/build/azure-pipelines/linux/codesign.js b/build/azure-pipelines/linux/codesign.js new file mode 100644 index 00000000000..98b97db5666 --- /dev/null +++ b/build/azure-pipelines/linux/codesign.js @@ -0,0 +1,29 @@ +"use strict"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +Object.defineProperty(exports, "__esModule", { value: true }); +const codesign_1 = require("../common/codesign"); +const publish_1 = require("../common/publish"); +async function main() { + const esrpCliDLLPath = (0, publish_1.e)('EsrpCliDllPath'); + // Start the code sign processes in parallel + // 1. Codesign deb package + // 2. Codesign rpm package + const codesignTask1 = (0, codesign_1.spawnCodesignProcess)(esrpCliDLLPath, 'sign-pgp', '.build/linux/deb', '*.deb'); + const codesignTask2 = (0, codesign_1.spawnCodesignProcess)(esrpCliDLLPath, 'sign-pgp', '.build/linux/rpm', '*.rpm'); + // Codesign deb package + (0, codesign_1.printBanner)('Codesign deb package'); + await (0, codesign_1.streamProcessOutputAndCheckResult)('Codesign deb package', codesignTask1); + // Codesign rpm package + (0, codesign_1.printBanner)('Codesign rpm package'); + await (0, codesign_1.streamProcessOutputAndCheckResult)('Codesign rpm package', codesignTask2); +} +main().then(() => { + process.exit(0); +}, err => { + console.error(`ERROR: ${err}`); + process.exit(1); +}); +//# sourceMappingURL=codesign.js.map \ No newline at end of file diff --git a/build/azure-pipelines/linux/codesign.ts b/build/azure-pipelines/linux/codesign.ts new file mode 100644 index 00000000000..1f74cc21ee9 --- /dev/null +++ b/build/azure-pipelines/linux/codesign.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { printBanner, spawnCodesignProcess, streamProcessOutputAndCheckResult } from '../common/codesign'; +import { e } from '../common/publish'; + +async function main() { + const esrpCliDLLPath = e('EsrpCliDllPath'); + + // Start the code sign processes in parallel + // 1. Codesign deb package + // 2. Codesign rpm package + const codesignTask1 = spawnCodesignProcess(esrpCliDLLPath, 'sign-pgp', '.build/linux/deb', '*.deb'); + const codesignTask2 = spawnCodesignProcess(esrpCliDLLPath, 'sign-pgp', '.build/linux/rpm', '*.rpm'); + + // Codesign deb package + printBanner('Codesign deb package'); + await streamProcessOutputAndCheckResult('Codesign deb package', codesignTask1); + + // Codesign rpm package + printBanner('Codesign rpm package'); + await streamProcessOutputAndCheckResult('Codesign rpm package', codesignTask2); +} + +main().then(() => { + process.exit(0); +}, err => { + console.error(`ERROR: ${err}`); + process.exit(1); +}); diff --git a/build/azure-pipelines/linux/product-build-linux-test.yml b/build/azure-pipelines/linux/product-build-linux-test.yml index 6796339c738..7e9325354a3 100644 --- a/build/azure-pipelines/linux/product-build-linux-test.yml +++ b/build/azure-pipelines/linux/product-build-linux-test.yml @@ -1,12 +1,14 @@ parameters: - name: VSCODE_QUALITY type: string - - name: VSCODE_RUN_UNIT_TESTS + - name: VSCODE_RUN_ELECTRON_TESTS type: boolean - - name: VSCODE_RUN_INTEGRATION_TESTS + - name: VSCODE_RUN_BROWSER_TESTS type: boolean - - name: VSCODE_RUN_SMOKE_TESTS + - name: VSCODE_RUN_REMOTE_TESTS type: boolean + - name: VSCODE_TEST_ARTIFACT_NAME + type: string - name: PUBLISH_TASK_NAME type: string default: PublishPipelineArtifact@0 @@ -31,76 +33,82 @@ steps: stat $ELECTRON_ROOT/chrome-sandbox displayName: Change setuid helper binary permission - - ${{ if eq(parameters.VSCODE_RUN_UNIT_TESTS, true) }}: - - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: ./scripts/test.sh --tfs "Unit Tests" env: DISPLAY: ":10" - displayName: Run unit tests (Electron) + displayName: 🧪 Run unit tests (Electron) timeoutInMinutes: 15 - script: npm run test-node - displayName: Run unit tests (node.js) + displayName: 🧪 Run unit tests (node.js) timeoutInMinutes: 15 + + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - script: npm run test-browser-no-install -- --browser chromium --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - displayName: Run unit tests (Browser, Chromium) + displayName: 🧪 Run unit tests (Browser, Chromium) timeoutInMinutes: 15 - - - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: ./scripts/test.sh --build --tfs "Unit Tests" - displayName: Run unit tests (Electron) + displayName: 🧪 Run unit tests (Electron) timeoutInMinutes: 15 - script: npm run test-node -- --build - displayName: Run unit tests (node.js) + displayName: 🧪 Run unit tests (node.js) timeoutInMinutes: 15 + + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - script: npm run test-browser-no-install -- --build --browser chromium --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - displayName: Run unit tests (Browser, Chromium) + displayName: 🧪 Run unit tests (Browser, Chromium) timeoutInMinutes: 15 - - ${{ if eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true) }}: - - script: | - set -e - npm run gulp \ - compile-extension:configuration-editing \ - compile-extension:css-language-features-server \ - compile-extension:emmet \ - compile-extension:git \ - compile-extension:github-authentication \ - compile-extension:html-language-features-server \ - compile-extension:ipynb \ - compile-extension:notebook-renderers \ - compile-extension:json-language-features-server \ - compile-extension:markdown-language-features \ - compile-extension-media \ - compile-extension:microsoft-authentication \ - compile-extension:typescript-language-features \ - compile-extension:vscode-api-tests \ - compile-extension:vscode-colorize-tests \ - compile-extension:vscode-colorize-perf-tests \ - compile-extension:vscode-test-resolver - displayName: Build integration tests + - script: | + set -e + npm run gulp \ + compile-extension:configuration-editing \ + compile-extension:css-language-features-server \ + compile-extension:emmet \ + compile-extension:git \ + compile-extension:github-authentication \ + compile-extension:html-language-features-server \ + compile-extension:ipynb \ + compile-extension:notebook-renderers \ + compile-extension:json-language-features-server \ + compile-extension:markdown-language-features \ + compile-extension-media \ + compile-extension:microsoft-authentication \ + compile-extension:typescript-language-features \ + compile-extension:vscode-api-tests \ + compile-extension:vscode-colorize-tests \ + compile-extension:vscode-colorize-perf-tests \ + compile-extension:vscode-test-resolver + displayName: Build integration tests - - ${{ if eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true) }}: - - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: ./scripts/test-integration.sh --tfs "Integration Tests" env: DISPLAY: ":10" - displayName: Run integration tests (Electron) + displayName: 🧪 Run integration tests (Electron) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - script: ./scripts/test-web-integration.sh --browser chromium - displayName: Run integration tests (Browser, Chromium) + displayName: 🧪 Run integration tests (Browser, Chromium) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: - script: ./scripts/test-remote-integration.sh - displayName: Run integration tests (Remote) + displayName: 🧪 Run integration tests (Remote) timeoutInMinutes: 20 - - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: | # Figure out the full absolute path of the product we just built # including the remote server and configure the integration tests @@ -113,15 +121,17 @@ steps: ./scripts/test-integration.sh --build --tfs "Integration Tests" env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH) - displayName: Run integration tests (Electron) + displayName: 🧪 Run integration tests (Electron) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - script: ./scripts/test-web-integration.sh --browser chromium env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH)-web - displayName: Run integration tests (Browser, Chromium) + displayName: 🧪 Run integration tests (Browser, Chromium) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: - script: | set -e APP_ROOT=$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH) @@ -131,116 +141,110 @@ steps: ./scripts/test-remote-integration.sh env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH) - displayName: Run integration tests (Remote) + displayName: 🧪 Run integration tests (Remote) timeoutInMinutes: 20 - - ${{ if eq(parameters.VSCODE_RUN_SMOKE_TESTS, true) }}: - - script: | - set -e - ps -ef - cat /proc/sys/fs/inotify/max_user_watches - lsof | wc -l - displayName: Diagnostics before smoke test run (processes, max_user_watches, number of opened file handles) - continueOnError: true - condition: succeededOrFailed() + - script: | + set -e + ps -ef + cat /proc/sys/fs/inotify/max_user_watches + lsof | wc -l + displayName: Diagnostics before smoke test run (processes, max_user_watches, number of opened file handles) + continueOnError: true + condition: succeededOrFailed() - - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: - - script: npm run compile - workingDirectory: test/smoke - displayName: Compile smoke tests + - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - script: npm run compile + workingDirectory: test/smoke + displayName: Compile smoke tests - - script: npm run gulp compile-extension:markdown-language-features compile-extension:ipynb compile-extension-media compile-extension:vscode-test-resolver - displayName: Build extensions for smoke tests - - - script: npm run gulp node - displayName: Download node.js for remote smoke tests - retryCountOnTaskFailure: 3 + - script: npm run gulp node + displayName: Download node.js for remote smoke tests + retryCountOnTaskFailure: 3 + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: npm run smoketest-no-compile -- --tracing timeoutInMinutes: 20 - displayName: Run smoke tests (Electron) + displayName: 🧪 Run smoke tests (Electron) - - script: npm run smoketest-no-compile -- --web --tracing --headless --electronArgs="--disable-dev-shm-usage" + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: + - script: npm run smoketest-no-compile -- --web --tracing --headless timeoutInMinutes: 20 - displayName: Run smoke tests (Browser, Chromium) + displayName: 🧪 Run smoke tests (Browser, Chromium) + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: - script: npm run smoketest-no-compile -- --remote --tracing timeoutInMinutes: 20 - displayName: Run smoke tests (Remote) + displayName: 🧪 Run smoke tests (Remote) - - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)" timeoutInMinutes: 20 - displayName: Run smoke tests (Electron) + displayName: 🧪 Run smoke tests (Electron) - - script: npm run smoketest-no-compile -- --web --tracing --headless --electronArgs="--disable-dev-shm-usage" + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: + - script: npm run smoketest-no-compile -- --web --tracing --headless env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH)-web timeoutInMinutes: 20 - displayName: Run smoke tests (Browser, Chromium) + displayName: 🧪 Run smoke tests (Browser, Chromium) + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: - script: | set -e - npm run gulp compile-extension:vscode-test-resolver APP_PATH=$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH) VSCODE_REMOTE_SERVER_PATH="$(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH)" \ npm run smoketest-no-compile -- --tracing --remote --build "$APP_PATH" timeoutInMinutes: 20 - displayName: Run smoke tests (Remote) + displayName: 🧪 Run smoke tests (Remote) - - script: | - set -e - ps -ef - cat /proc/sys/fs/inotify/max_user_watches - lsof | wc -l - displayName: Diagnostics after smoke test run (processes, max_user_watches, number of opened file handles) - continueOnError: true - condition: succeededOrFailed() + - script: | + set -e + ps -ef + cat /proc/sys/fs/inotify/max_user_watches + lsof | wc -l + displayName: Diagnostics after smoke test run (processes, max_user_watches, number of opened file handles) + continueOnError: true + condition: succeededOrFailed() - - ${{ if or(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - task: ${{ parameters.PUBLISH_TASK_NAME }} - inputs: - targetPath: .build/crashes - ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: - artifactName: crash-dump-linux-$(VSCODE_ARCH)-integration-$(System.JobAttempt) - ${{ elseif and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, false), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - artifactName: crash-dump-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) - ${{ else }}: - artifactName: crash-dump-linux-$(VSCODE_ARCH)-$(System.JobAttempt) - sbomEnabled: false - displayName: "Publish Crash Reports" - continueOnError: true - condition: failed() + - task: ${{ parameters.PUBLISH_TASK_NAME }} + inputs: + targetPath: .build/crashes + ${{ if eq(parameters.VSCODE_TEST_ARTIFACT_NAME, '') }}: + artifactName: crash-dump-linux-$(VSCODE_ARCH)-$(System.JobAttempt) + ${{ else }}: + artifactName: crash-dump-linux-$(VSCODE_ARCH)-${{ parameters.VSCODE_TEST_ARTIFACT_NAME }}-$(System.JobAttempt) + sbomEnabled: false + displayName: "Publish Crash Reports" + continueOnError: true + condition: failed() - # In order to properly symbolify above crash reports - # (if any), we need the compiled native modules too - - task: ${{ parameters.PUBLISH_TASK_NAME }} - inputs: - targetPath: node_modules - ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: - artifactName: node-modules-linux-$(VSCODE_ARCH)-integration-$(System.JobAttempt) - ${{ elseif and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, false), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - artifactName: node-modules-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) - ${{ else }}: - artifactName: node-modules-linux-$(VSCODE_ARCH)-$(System.JobAttempt) - sbomEnabled: false - displayName: "Publish Node Modules" - continueOnError: true - condition: failed() + # In order to properly symbolify above crash reports + # (if any), we need the compiled native modules too + - task: ${{ parameters.PUBLISH_TASK_NAME }} + inputs: + targetPath: node_modules + ${{ if eq(parameters.VSCODE_TEST_ARTIFACT_NAME, '') }}: + artifactName: node-modules-linux-$(VSCODE_ARCH)-$(System.JobAttempt) + ${{ else }}: + artifactName: node-modules-linux-$(VSCODE_ARCH)-${{ parameters.VSCODE_TEST_ARTIFACT_NAME }}-$(System.JobAttempt) + sbomEnabled: false + displayName: "Publish Node Modules" + continueOnError: true + condition: failed() - - task: ${{ parameters.PUBLISH_TASK_NAME }} - inputs: - targetPath: .build/logs - ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: - artifactName: logs-linux-$(VSCODE_ARCH)-integration-$(System.JobAttempt) - ${{ elseif and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, false), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - artifactName: logs-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) - ${{ else }}: - artifactName: logs-linux-$(VSCODE_ARCH)-$(System.JobAttempt) - sbomEnabled: false - displayName: "Publish Log Files" - continueOnError: true - condition: succeededOrFailed() + - task: ${{ parameters.PUBLISH_TASK_NAME }} + inputs: + targetPath: .build/logs + ${{ if eq(parameters.VSCODE_TEST_ARTIFACT_NAME, '') }}: + artifactName: logs-linux-$(VSCODE_ARCH)-$(System.JobAttempt) + ${{ else }}: + artifactName: logs-linux-$(VSCODE_ARCH)-${{ parameters.VSCODE_TEST_ARTIFACT_NAME }}-$(System.JobAttempt) + sbomEnabled: false + displayName: "Publish Log Files" + continueOnError: true + condition: succeededOrFailed() - task: PublishTestResults@2 displayName: Publish Tests Results diff --git a/build/azure-pipelines/linux/product-build-linux.yml b/build/azure-pipelines/linux/product-build-linux.yml index b9300b1ccba..96f76f6846a 100644 --- a/build/azure-pipelines/linux/product-build-linux.yml +++ b/build/azure-pipelines/linux/product-build-linux.yml @@ -1,16 +1,22 @@ parameters: - name: VSCODE_QUALITY type: string - - name: VSCODE_CIBUILD - type: boolean - - name: VSCODE_RUN_UNIT_TESTS - type: boolean - - name: VSCODE_RUN_INTEGRATION_TESTS - type: boolean - - name: VSCODE_RUN_SMOKE_TESTS - type: boolean - name: VSCODE_ARCH type: string + - name: VSCODE_CIBUILD + type: boolean + - name: VSCODE_RUN_ELECTRON_TESTS + type: boolean + default: false + - name: VSCODE_RUN_BROWSER_TESTS + type: boolean + default: false + - name: VSCODE_RUN_REMOTE_TESTS + type: boolean + default: false + - name: VSCODE_TEST_ARTIFACT_NAME + type: string + default: "" steps: - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: @@ -48,7 +54,6 @@ steps: # Start X server ./build/azure-pipelines/linux/apt-retry.sh sudo apt-get update ./build/azure-pipelines/linux/apt-retry.sh sudo apt-get install -y pkg-config \ - dbus \ xvfb \ libgtk-3-0 \ libxkbfile-dev \ @@ -59,17 +64,13 @@ steps: sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults sudo service xvfb start - # Start dbus session - sudo mkdir -p /var/run/dbus - DBUS_LAUNCH_RESULT=$(sudo dbus-daemon --config-file=/usr/share/dbus-1/system.conf --print-address) - echo "##vso[task.setvariable variable=DBUS_SESSION_BUS_ADDRESS]$DBUS_LAUNCH_RESULT" displayName: Setup system services - script: node build/setup-npm-registry.js $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry - - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js linux $VSCODE_ARCH > .build/packagelockhash + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key - task: Cache@2 @@ -286,16 +287,6 @@ steps: GITHUB_TOKEN: "$(github-distro-mixin-password)" displayName: Transpile client and extensions - - ${{ if or(eq(parameters.VSCODE_RUN_UNIT_TESTS, true), eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - template: product-build-linux-test.yml@self - parameters: - VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} - VSCODE_RUN_UNIT_TESTS: ${{ parameters.VSCODE_RUN_UNIT_TESTS }} - VSCODE_RUN_INTEGRATION_TESTS: ${{ parameters.VSCODE_RUN_INTEGRATION_TESTS }} - VSCODE_RUN_SMOKE_TESTS: ${{ parameters.VSCODE_RUN_SMOKE_TESTS }} - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - PUBLISH_TASK_NAME: 1ES.PublishPipelineArtifact@1 - - ${{ if and(ne(parameters.VSCODE_CIBUILD, true), ne(parameters.VSCODE_QUALITY, 'oss')) }}: - script: | set -e @@ -338,14 +329,31 @@ steps: echo "##vso[task.setvariable variable=RPM_PATH]$(ls .build/linux/rpm/*/*.rpm)" displayName: Build rpm package - - script: | - set -e - npm run gulp "vscode-linux-$(VSCODE_ARCH)-prepare-snap" - ARCHIVE_PATH=".build/linux/snap-tarball/snap-$(VSCODE_ARCH).tar.gz" - mkdir -p $(dirname $ARCHIVE_PATH) - tar -czf $ARCHIVE_PATH -C .build/linux snap - echo "##vso[task.setvariable variable=SNAP_PATH]$ARCHIVE_PATH" - displayName: Prepare snap package + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - task: Docker@1 + inputs: + azureSubscriptionEndpoint: vscode + azureContainerRegistry: vscodehub.azurecr.io + command: login + displayName: Login to Container Registry + + - script: | + set -e + npm run gulp "vscode-linux-$(VSCODE_ARCH)-prepare-snap" + sudo -E docker run -e VSCODE_ARCH -e VSCODE_QUALITY -v $(pwd):/work -w /work vscodehub.azurecr.io/vscode-linux-build-agent:snapcraft-x64 /bin/bash -c "./build/azure-pipelines/linux/build-snap.sh" + + SNAP_ROOT="$(pwd)/.build/linux/snap/$(VSCODE_ARCH)" + SNAP_EXTRACTED_PATH=$(find $SNAP_ROOT -maxdepth 1 -type d -name 'code-*') + SNAP_PATH=$(find $SNAP_ROOT -maxdepth 1 -type f -name '*.snap') + + # SBOM tool doesn't like recursive symlinks + sudo find $SNAP_EXTRACTED_PATH -type l -delete + + echo "##vso[task.setvariable variable=SNAP_EXTRACTED_PATH]$SNAP_EXTRACTED_PATH" + echo "##vso[task.setvariable variable=SNAP_PATH]$SNAP_PATH" + env: + VSCODE_ARCH: $(VSCODE_ARCH) + displayName: Build snap package - task: UseDotNet@2 inputs: @@ -363,15 +371,35 @@ steps: Pattern: noop displayName: 'Install ESRP Tooling' - - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-pgp .build/linux/deb '*.deb' - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Codesign deb + - pwsh: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + $EsrpCodeSigningTool = (gci -directory -filter EsrpCodeSigning_* $(Agent.RootDirectory)/_tasks | Select-Object -last 1).FullName + $Version = (gci -directory $EsrpCodeSigningTool | Select-Object -last 1).FullName + echo "##vso[task.setvariable variable=EsrpCliDllPath]$Version/net6.0/esrpcli.dll" + displayName: Find ESRP CLI - - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-pgp .build/linux/rpm '*.rpm' + - script: npx deemon --detach --wait node build/azure-pipelines/linux/codesign.js env: + EsrpCliDllPath: $(EsrpCliDllPath) SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Codesign rpm + displayName: ✍️ Codesign deb & rpm + + - ${{ if or(eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true), eq(parameters.VSCODE_RUN_BROWSER_TESTS, true), eq(parameters.VSCODE_RUN_REMOTE_TESTS, true)) }}: + - template: product-build-linux-test.yml@self + parameters: + VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} + VSCODE_RUN_ELECTRON_TESTS: ${{ parameters.VSCODE_RUN_ELECTRON_TESTS }} + VSCODE_RUN_BROWSER_TESTS: ${{ parameters.VSCODE_RUN_BROWSER_TESTS }} + VSCODE_RUN_REMOTE_TESTS: ${{ parameters.VSCODE_RUN_REMOTE_TESTS }} + VSCODE_TEST_ARTIFACT_NAME: ${{ parameters.VSCODE_TEST_ARTIFACT_NAME }} + ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + PUBLISH_TASK_NAME: 1ES.PublishPipelineArtifact@1 + + - ${{ if and(ne(parameters.VSCODE_CIBUILD, true), ne(parameters.VSCODE_QUALITY, 'oss')) }}: + - script: npx deemon --attach node build/azure-pipelines/linux/codesign.js + condition: succeededOrFailed() + displayName: "✍️ Post-job: Codesign deb & rpm" - script: echo "##vso[task.setvariable variable=ARTIFACT_PREFIX]attempt$(System.JobAttempt)_" condition: and(succeededOrFailed(), notIn(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues')) @@ -430,7 +458,9 @@ steps: - task: 1ES.PublishPipelineArtifact@1 inputs: targetPath: $(SNAP_PATH) - artifactName: $(ARTIFACT_PREFIX)snap-$(VSCODE_ARCH) - sbomEnabled: false + artifactName: vscode_client_linux_$(VSCODE_ARCH)_snap + sbomBuildDropPath: $(SNAP_EXTRACTED_PATH) + sbomPackageName: "VS Code Linux $(VSCODE_ARCH) SNAP" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['SNAP_PATH'], '')) - displayName: Publish snap pre-package + displayName: Publish snap package diff --git a/build/azure-pipelines/linux/snap-build-linux.yml b/build/azure-pipelines/linux/snap-build-linux.yml deleted file mode 100644 index 4d0d26411c3..00000000000 --- a/build/azure-pipelines/linux/snap-build-linux.yml +++ /dev/null @@ -1,64 +0,0 @@ -steps: - - task: NodeTool@0 - inputs: - versionSource: fromFile - versionFilePath: .nvmrc - nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - - - task: DownloadPipelineArtifact@2 - displayName: "Download Pipeline Artifact" - inputs: - artifact: snap-$(VSCODE_ARCH) - path: .build/linux/snap-tarball - - - script: | - set -e - - # Get snapcraft version - snapcraft --version - - # Make sure we get latest packages - sudo apt-get update - sudo apt-get upgrade -y - sudo apt-get install -y curl apt-transport-https ca-certificates - - # Define variables - SNAP_ROOT="$(pwd)/.build/linux/snap/$(VSCODE_ARCH)" - - # Unpack snap tarball artifact, in order to preserve file perms - (cd .build/linux && tar -xzf snap-tarball/snap-$(VSCODE_ARCH).tar.gz) - - # Create snap package - BUILD_VERSION="$(date +%s)" - SNAP_FILENAME="code-$VSCODE_QUALITY-$(VSCODE_ARCH)-$BUILD_VERSION.snap" - SNAP_PATH="$SNAP_ROOT/$SNAP_FILENAME" - case $(VSCODE_ARCH) in - x64) SNAPCRAFT_TARGET_ARGS="" ;; - *) SNAPCRAFT_TARGET_ARGS="--target-arch $(VSCODE_ARCH)" ;; - esac - (cd $SNAP_ROOT/code-* && sudo --preserve-env snapcraft snap $SNAPCRAFT_TARGET_ARGS --output "$SNAP_PATH") - displayName: Prepare for publish - - - script: | - set -e - SNAP_ROOT="$(pwd)/.build/linux/snap/$(VSCODE_ARCH)" - SNAP_EXTRACTED_PATH=$(find $SNAP_ROOT -maxdepth 1 -type d -name 'code-*') - SNAP_PATH=$(find $SNAP_ROOT -maxdepth 1 -type f -name '*.snap') - - # SBOM tool doesn't like recursive symlinks - sudo find $SNAP_EXTRACTED_PATH -type l -delete - - echo "##vso[task.setvariable variable=SNAP_EXTRACTED_PATH]$SNAP_EXTRACTED_PATH" - echo "##vso[task.setvariable variable=SNAP_PATH]$SNAP_PATH" - target: - container: host - displayName: Find host snap path & prepare for SBOM - - - task: 1ES.PublishPipelineArtifact@1 - inputs: - targetPath: $(SNAP_PATH) - artifactName: vscode_client_linux_$(VSCODE_ARCH)_snap - sbomBuildDropPath: $(SNAP_EXTRACTED_PATH) - sbomPackageName: "VS Code Linux $(VSCODE_ARCH) SNAP" - sbomPackageVersion: $(Build.SourceVersion) - displayName: Publish snap package diff --git a/build/azure-pipelines/oss/product-build-pr-cache-darwin.yml b/build/azure-pipelines/oss/product-build-pr-cache-darwin.yml new file mode 100644 index 00000000000..d382918a6c3 --- /dev/null +++ b/build/azure-pipelines/oss/product-build-pr-cache-darwin.yml @@ -0,0 +1,79 @@ +steps: + - checkout: self + fetchDepth: 1 + retryCountOnTaskFailure: 3 + + - task: NodeTool@0 + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download + + - script: node build/setup-npm-registry.js $NPM_REGISTRY + condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) + displayName: Setup NPM Registry + + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash + displayName: Prepare node_modules cache key + + - task: Cache@2 + inputs: + key: '"node_modules" | .build/packagelockhash' + path: .build/node_modules_cache + cacheHitVar: NODE_MODULES_RESTORED + displayName: Restore node_modules cache + + - script: tar -xzf .build/node_modules_cache/cache.tgz + condition: and(succeeded(), eq(variables.NODE_MODULES_RESTORED, 'true')) + displayName: Extract node_modules cache + + - script: | + set -e + # Set the private NPM registry to the global npmrc file + # so that authentication works for subfolders like build/, remote/, extensions/ etc + # which does not have their own .npmrc file + npm config set registry "$NPM_REGISTRY" + echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) + displayName: Setup NPM + + - task: npmAuthenticate@0 + inputs: + workingFile: $(NPMRC_PATH) + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) + displayName: Setup NPM Authentication + + - script: | + set -e + c++ --version + xcode-select -print-path + python3 -m pip install setuptools + + for i in {1..5}; do # try 5 times + npm ci && break + if [ $i -eq 5 ]; then + echo "Npm install failed too many times" >&2 + exit 1 + fi + echo "Npm install failed $i, trying again..." + done + env: + npm_config_arch: $(VSCODE_ARCH) + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: "$(github-distro-mixin-password)" + # Avoid using dlopen to load Kerberos on macOS which can cause missing libraries + # https://github.com/mongodb-js/kerberos/commit/04044d2814ad1d01e77f1ce87f26b03d86692cf2 + # flipped the default to support legacy linux distros which shouldn't happen + # on macOS. + GYP_DEFINES: "kerberos_use_rtld=false" + displayName: Install dependencies + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + + - script: | + set -e + node build/azure-pipelines/common/listNodeModules.js .build/node_modules_list.txt + mkdir -p .build/node_modules_cache + tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + displayName: Create node_modules archive diff --git a/build/azure-pipelines/oss/product-build-pr-cache-linux.yml b/build/azure-pipelines/oss/product-build-pr-cache-linux.yml index 72cd33cdd75..b4a2cc3a480 100644 --- a/build/azure-pipelines/oss/product-build-pr-cache-linux.yml +++ b/build/azure-pipelines/oss/product-build-pr-cache-linux.yml @@ -13,7 +13,7 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry - - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js linux $VSCODE_ARCH > .build/packagelockhash + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key - task: Cache@2 diff --git a/build/azure-pipelines/oss/product-build-pr-cache-win32.yml b/build/azure-pipelines/oss/product-build-pr-cache-win32.yml index 76944f69b14..f4a82587567 100644 --- a/build/azure-pipelines/oss/product-build-pr-cache-win32.yml +++ b/build/azure-pipelines/oss/product-build-pr-cache-win32.yml @@ -15,7 +15,7 @@ steps: - pwsh: | mkdir .build -ea 0 - node build/azure-pipelines/common/computeNodeModulesCacheKey.js win32 $(VSCODE_ARCH) > .build/packagelockhash + node build/azure-pipelines/common/computeNodeModulesCacheKey.js win32 $(VSCODE_ARCH) $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key - task: Cache@2 diff --git a/build/azure-pipelines/product-build-pr.yml b/build/azure-pipelines/product-build-pr.yml index 2d66ff3945d..e851856eb12 100644 --- a/build/azure-pipelines/product-build-pr.yml +++ b/build/azure-pipelines/product-build-pr.yml @@ -22,191 +22,211 @@ variables: - name: VSCODE_STEP_ON_IT value: false -jobs: +stages: - ${{ if ne(variables['VSCODE_CIBUILD'], true) }}: - - job: Compile + - stage: Compile displayName: Compile & Hygiene - pool: 1es-oss-ubuntu-22.04-x64 - timeoutInMinutes: 30 - variables: - VSCODE_ARCH: x64 - steps: - - template: product-compile.yml@self - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + dependsOn: [] + jobs: + - job: Compile + displayName: Compile & Hygiene + pool: 1es-oss-ubuntu-22.04-x64 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + steps: + - template: product-compile.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - job: Linuxx64UnitTest - displayName: Linux (Unit Tests) - pool: 1es-oss-ubuntu-22.04-x64 - timeoutInMinutes: 30 - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - DISPLAY: ":10" - steps: - - template: linux/product-build-linux.yml@self - parameters: - VSCODE_ARCH: x64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: true - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false + - stage: Test + displayName: Test + dependsOn: [] + jobs: + - job: Linuxx64ElectronTest + displayName: Linux (Electron) + pool: 1es-oss-ubuntu-22.04-x64 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + DISPLAY: ":10" + steps: + - template: linux/product-build-linux.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_TEST_ARTIFACT_NAME: electron + VSCODE_RUN_ELECTRON_TESTS: true - - job: Linuxx64IntegrationTest - displayName: Linux (Integration Tests) - pool: 1es-oss-ubuntu-22.04-x64 - timeoutInMinutes: 30 - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - DISPLAY: ":10" - steps: - - template: linux/product-build-linux.yml@self - parameters: - VSCODE_ARCH: x64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: true - VSCODE_RUN_SMOKE_TESTS: false + - job: Linuxx64BrowserTest + displayName: Linux (Browser) + pool: 1es-oss-ubuntu-22.04-x64 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + DISPLAY: ":10" + steps: + - template: linux/product-build-linux.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_TEST_ARTIFACT_NAME: browser + VSCODE_RUN_BROWSER_TESTS: true - - job: Linuxx64SmokeTest - displayName: Linux (Smoke Tests) - pool: 1es-oss-ubuntu-22.04-x64 - timeoutInMinutes: 30 - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - DISPLAY: ":10" - steps: - - template: linux/product-build-linux.yml@self - parameters: - VSCODE_ARCH: x64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: true + - job: Linuxx64RemoteTest + displayName: Linux (Remote) + pool: 1es-oss-ubuntu-22.04-x64 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + DISPLAY: ":10" + steps: + - template: linux/product-build-linux.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_TEST_ARTIFACT_NAME: remote + VSCODE_RUN_REMOTE_TESTS: true - - job: LinuxCLI - displayName: Linux (CLI) - pool: 1es-oss-ubuntu-22.04-x64 - timeoutInMinutes: 30 - steps: - - template: cli/test.yml@self + - job: LinuxCLI + displayName: Linux (CLI) + pool: 1es-oss-ubuntu-22.04-x64 + timeoutInMinutes: 30 + steps: + - template: cli/test.yml@self - - job: Windowsx64UnitTests - displayName: Windows (Unit Tests) - pool: 1es-oss-windows-2022-x64 - timeoutInMinutes: 30 - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - steps: - - template: win32/product-build-win32.yml@self - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_ARCH: x64 - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: true - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false + - job: Windowsx64ElectronTests + displayName: Windows (Electron) + pool: 1es-oss-windows-2022-x64 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + steps: + - template: win32/product-build-win32.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_TEST_ARTIFACT_NAME: electron + VSCODE_RUN_ELECTRON_TESTS: true - - job: Windowsx64IntegrationTests - displayName: Windows (Integration Tests) - pool: 1es-oss-windows-2022-x64 - timeoutInMinutes: 60 - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - steps: - - template: win32/product-build-win32.yml@self - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_ARCH: x64 - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: true - VSCODE_RUN_SMOKE_TESTS: false + - job: Windowsx64BrowserTests + displayName: Windows (Browser) + pool: 1es-oss-windows-2022-x64 + timeoutInMinutes: 60 + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + steps: + - template: win32/product-build-win32.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_TEST_ARTIFACT_NAME: browser + VSCODE_RUN_BROWSER_TESTS: true - # - job: Windowsx64SmokeTests - # displayName: Windows (Smoke Tests) - # pool: 1es-oss-windows-2022-x64 - # timeoutInMinutes: 30 - # variables: - # VSCODE_ARCH: x64 - # NPM_ARCH: x64 - # steps: - # - template: win32/product-build-win32.yml@self - # parameters: - # VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - # VSCODE_ARCH: x64 - # VSCODE_RUN_UNIT_TESTS: false - # VSCODE_RUN_INTEGRATION_TESTS: false - # VSCODE_RUN_SMOKE_TESTS: true + - job: Windowsx64RemoteTests + displayName: Windows (Remote) + pool: 1es-oss-windows-2022-x64 + timeoutInMinutes: 60 + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + steps: + - template: win32/product-build-win32.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_TEST_ARTIFACT_NAME: remote + VSCODE_RUN_REMOTE_TESTS: true + + - job: macOSx64ElectronTests + displayName: macOS (Electron) + pool: + vmImage: macOS-14 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + steps: + - template: darwin/product-build-darwin.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_TEST_ARTIFACT_NAME: electron + VSCODE_RUN_ELECTRON_TESTS: true + + - job: macOSx64BrowserTests + displayName: macOS (Browser) + pool: + vmImage: macOS-14 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + steps: + - template: darwin/product-build-darwin.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_TEST_ARTIFACT_NAME: browser + VSCODE_RUN_BROWSER_TESTS: true + + - job: macOSx64RemoteTests + displayName: macOS (Remote) + pool: + vmImage: macOS-14 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + steps: + - template: darwin/product-build-darwin.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_TEST_ARTIFACT_NAME: remote + VSCODE_RUN_REMOTE_TESTS: true - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - - job: Linuxx64MaintainNodeModulesCache - displayName: Linux (Maintain node_modules cache) - pool: 1es-oss-ubuntu-22.04-x64 - timeoutInMinutes: 30 - variables: - VSCODE_ARCH: x64 - steps: - - template: oss/product-build-pr-cache-linux.yml@self + - stage: NodeModuleCache + jobs: + - job: Linuxx64MaintainNodeModulesCache + displayName: Linux (Maintain node_modules cache) + pool: 1es-oss-ubuntu-22.04-x64 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + steps: + - template: oss/product-build-pr-cache-linux.yml@self - - job: Windowsx64MaintainNodeModulesCache - displayName: Windows (Maintain node_modules cache) - pool: 1es-oss-windows-2022-x64 - timeoutInMinutes: 30 - variables: - VSCODE_ARCH: x64 - steps: - - template: oss/product-build-pr-cache-win32.yml@self + - job: Windowsx64MaintainNodeModulesCache + displayName: Windows (Maintain node_modules cache) + pool: 1es-oss-windows-2022-x64 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + steps: + - template: oss/product-build-pr-cache-win32.yml@self - # - job: macOSUnitTest - # displayName: macOS (Unit Tests) - # pool: - # vmImage: macOS-11 - # timeoutInMinutes: 60 - # variables: - # BUILDSECMON_OPT_IN: true - # VSCODE_ARCH: x64 - # steps: - # - template: darwin/product-build-darwin.yml@self - # parameters: - # VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - # VSCODE_RUN_UNIT_TESTS: true - # VSCODE_RUN_INTEGRATION_TESTS: false - # VSCODE_RUN_SMOKE_TESTS: false - # - job: macOSIntegrationTest - # displayName: macOS (Integration Tests) - # pool: - # vmImage: macOS-11 - # timeoutInMinutes: 60 - # variables: - # BUILDSECMON_OPT_IN: true - # VSCODE_ARCH: x64 - # steps: - # - template: darwin/product-build-darwin.yml@self - # parameters: - # VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - # VSCODE_RUN_UNIT_TESTS: false - # VSCODE_RUN_INTEGRATION_TESTS: true - # VSCODE_RUN_SMOKE_TESTS: false - # - job: macOSSmokeTest - # displayName: macOS (Smoke Tests) - # pool: - # vmImage: macOS-11 - # timeoutInMinutes: 60 - # variables: - # BUILDSECMON_OPT_IN: true - # VSCODE_ARCH: x64 - # steps: - # - template: darwin/product-build-darwin.yml@self - # parameters: - # VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - # VSCODE_RUN_UNIT_TESTS: false - # VSCODE_RUN_INTEGRATION_TESTS: false - # VSCODE_RUN_SMOKE_TESTS: true + - job: macOSx64MaintainNodeModulesCache + displayName: macOS (Maintain node_modules cache) + pool: + vmImage: macOS-14 + timeoutInMinutes: 30 + variables: + VSCODE_ARCH: x64 + steps: + - template: oss/product-build-pr-cache-darwin.yml@self diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index bf25633b5c8..453849168c8 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -185,8 +185,6 @@ extends: sourceAnalysisPool: 1es-windows-2022-x64 createAdoIssuesForJustificationsForDisablement: false containers: - snapcraft: - image: vscodehub.azurecr.io/vscode-linux-build-agent:snapcraft-x64 ubuntu-2004-arm64: image: onebranch.azurecr.io/linux/ubuntu-2004-arm64:latest stages: @@ -197,8 +195,6 @@ extends: pool: name: AcesShared os: macOS - variables: - VSCODE_ARCH: arm64 steps: - template: build/azure-pipelines/product-compile.yml@self parameters: @@ -220,7 +216,7 @@ extends: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_BUILD_LINUX: ${{ parameters.VSCODE_BUILD_LINUX }} - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), or(eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true))) }}: + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true)) }}: - job: CLILinuxGnuARM pool: name: 1es-ubuntu-22.04-x64 @@ -230,6 +226,16 @@ extends: parameters: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_BUILD_LINUX_ARMHF: ${{ parameters.VSCODE_BUILD_LINUX_ARMHF }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true)) }}: + - job: CLILinuxGnuAarch64 + pool: + name: 1es-ubuntu-22.04-x64 + os: linux + steps: + - template: build/azure-pipelines/linux/cli-build-linux.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_BUILD_LINUX_ARM64: ${{ parameters.VSCODE_BUILD_LINUX_ARM64 }} - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_ALPINE, true)) }}: @@ -246,15 +252,8 @@ extends: - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true)) }}: - job: CLIAlpineARM64 pool: - name: 1es-mariner-2.0-arm64 + name: 1es-ubuntu-22.04-x64 os: linux - hostArchitecture: arm64 - container: ubuntu-2004-arm64 - templateContext: - authenticatedContainerRegistries: - - registry: onebranch.azurecr.io - tenant: AME - identity: 1ESPipelineIdentity steps: - template: build/azure-pipelines/alpine/cli-build-alpine.yml@self parameters: @@ -342,9 +341,9 @@ extends: os: windows jobs: - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - - job: WindowsUnitTests - displayName: Unit Tests - timeoutInMinutes: 60 + - job: WindowsElectronTests + displayName: Electron Tests + timeoutInMinutes: 30 variables: VSCODE_ARCH: x64 steps: @@ -353,12 +352,11 @@ extends: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_ARCH: x64 VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: true - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - job: WindowsIntegrationTests - displayName: Integration Tests - timeoutInMinutes: 60 + VSCODE_TEST_ARTIFACT_NAME: electron + VSCODE_RUN_ELECTRON_TESTS: true + - job: WindowsBrowserTests + displayName: Browser Tests + timeoutInMinutes: 30 variables: VSCODE_ARCH: x64 steps: @@ -367,12 +365,11 @@ extends: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_ARCH: x64 VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: true - VSCODE_RUN_SMOKE_TESTS: false - - job: WindowsSmokeTests - displayName: Smoke Tests - timeoutInMinutes: 60 + VSCODE_TEST_ARTIFACT_NAME: browser + VSCODE_RUN_BROWSER_TESTS: true + - job: WindowsRemoteTests + displayName: Remote Tests + timeoutInMinutes: 30 variables: VSCODE_ARCH: x64 steps: @@ -381,9 +378,8 @@ extends: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_ARCH: x64 VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: true + VSCODE_TEST_ARTIFACT_NAME: remote + VSCODE_RUN_REMOTE_TESTS: true - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_WIN32, true)) }}: - job: Windows @@ -400,9 +396,9 @@ extends: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_ARCH: x64 VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_SMOKE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_ELECTRON_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_BROWSER_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_REMOTE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - job: WindowsCLISign timeoutInMinutes: 90 @@ -428,9 +424,6 @@ extends: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_ARCH: arm64 VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_LINUX'], true)) }}: - stage: Linux @@ -443,8 +436,9 @@ extends: os: linux jobs: - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - - job: Linuxx64UnitTest - displayName: Unit Tests + - job: Linuxx64ElectronTest + displayName: Electron Tests + timeoutInMinutes: 30 variables: VSCODE_ARCH: x64 NPM_ARCH: x64 @@ -455,11 +449,11 @@ extends: VSCODE_ARCH: x64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: true - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - job: Linuxx64IntegrationTest - displayName: Integration Tests + VSCODE_TEST_ARTIFACT_NAME: electron + VSCODE_RUN_ELECTRON_TESTS: true + - job: Linuxx64BrowserTest + displayName: Browser Tests + timeoutInMinutes: 30 variables: VSCODE_ARCH: x64 NPM_ARCH: x64 @@ -470,11 +464,11 @@ extends: VSCODE_ARCH: x64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: true - VSCODE_RUN_SMOKE_TESTS: false - - job: Linuxx64SmokeTest - displayName: Smoke Tests + VSCODE_TEST_ARTIFACT_NAME: browser + VSCODE_RUN_BROWSER_TESTS: true + - job: Linuxx64RemoteTest + displayName: Remote Tests + timeoutInMinutes: 30 variables: VSCODE_ARCH: x64 NPM_ARCH: x64 @@ -485,9 +479,8 @@ extends: VSCODE_ARCH: x64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: true + VSCODE_TEST_ARTIFACT_NAME: remote + VSCODE_RUN_REMOTE_TESTS: true - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX, true)) }}: - job: Linuxx64 @@ -502,24 +495,9 @@ extends: VSCODE_ARCH: x64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_SMOKE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX, true)) }}: - - job: LinuxSnap - dependsOn: - - Linuxx64 - container: snapcraft - variables: - VSCODE_ARCH: x64 - templateContext: - authenticatedContainerRegistries: - - registry: onebranch.azurecr.io - tenant: AME - identity: 1ESPipelineIdentity - steps: - - template: build/azure-pipelines/linux/snap-build-linux.yml@self + VSCODE_RUN_ELECTRON_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_BROWSER_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_REMOTE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true)) }}: - job: LinuxArmhf @@ -532,9 +510,6 @@ extends: VSCODE_ARCH: armhf VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true)) }}: - job: LinuxArm64 @@ -547,9 +522,6 @@ extends: VSCODE_ARCH: arm64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_ALPINE'], true)) }}: - stage: Alpine @@ -585,91 +557,66 @@ extends: - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_ALPINE, true),eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - CompileCLI pool: - name: Azure Pipelines - image: macOS-13 + name: AcesShared os: macOS variables: BUILDSECMON_OPT_IN: true jobs: - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - - job: macOSUnitTest - displayName: Unit Tests - timeoutInMinutes: 90 + - job: macOSElectronTest + displayName: Electron Tests + timeoutInMinutes: 30 variables: - VSCODE_ARCH: x64 + VSCODE_ARCH: arm64 steps: - template: build/azure-pipelines/darwin/product-build-darwin.yml@self parameters: + VSCODE_ARCH: arm64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: true - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - job: macOSIntegrationTest - displayName: Integration Tests - timeoutInMinutes: 90 + VSCODE_TEST_ARTIFACT_NAME: electron + VSCODE_RUN_ELECTRON_TESTS: true + - job: macOSBrowserTest + displayName: Browser Tests + timeoutInMinutes: 30 variables: - VSCODE_ARCH: x64 + VSCODE_ARCH: arm64 steps: - template: build/azure-pipelines/darwin/product-build-darwin.yml@self parameters: + VSCODE_ARCH: arm64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: true - VSCODE_RUN_SMOKE_TESTS: false - - job: macOSSmokeTest - displayName: Smoke Tests - timeoutInMinutes: 90 + VSCODE_TEST_ARTIFACT_NAME: browser + VSCODE_RUN_BROWSER_TESTS: true + - job: macOSRemoteTest + displayName: Remote Tests + timeoutInMinutes: 30 variables: - VSCODE_ARCH: x64 + VSCODE_ARCH: arm64 steps: - template: build/azure-pipelines/darwin/product-build-darwin.yml@self parameters: + VSCODE_ARCH: arm64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: true + VSCODE_TEST_ARTIFACT_NAME: remote + VSCODE_RUN_REMOTE_TESTS: true - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_MACOS, true)) }}: - job: macOS timeoutInMinutes: 90 variables: VSCODE_ARCH: x64 + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ steps: - template: build/azure-pipelines/darwin/product-build-darwin.yml@self parameters: + VSCODE_ARCH: x64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - ${{ if eq(parameters.VSCODE_STEP_ON_IT, false) }}: - - job: macOSTest - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: x64 - steps: - - template: build/azure-pipelines/darwin/product-build-darwin.yml@self - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_SMOKE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - - - job: macOSSign - dependsOn: - - macOS - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: x64 - steps: - - template: build/azure-pipelines/darwin/product-build-darwin-sign.yml@self - - - job: macOSCLISign + - job: macOSCLI timeoutInMinutes: 90 steps: - template: build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml@self @@ -683,44 +630,26 @@ extends: timeoutInMinutes: 90 variables: VSCODE_ARCH: arm64 + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ steps: - template: build/azure-pipelines/darwin/product-build-darwin.yml@self parameters: + VSCODE_ARCH: arm64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - - job: macOSARM64Sign - dependsOn: - - macOSARM64 - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: arm64 - steps: - - template: build/azure-pipelines/darwin/product-build-darwin-sign.yml@self + VSCODE_RUN_ELECTRON_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_BROWSER_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_REMOTE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(variables['VSCODE_BUILD_MACOS_UNIVERSAL'], true)) }}: - job: macOSUniversal - dependsOn: - - macOS - - macOSARM64 timeoutInMinutes: 90 variables: VSCODE_ARCH: universal + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ steps: - template: build/azure-pipelines/darwin/product-build-darwin-universal.yml@self - - job: macOSUniversalSign - dependsOn: - - macOSUniversal - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: universal - steps: - - template: build/azure-pipelines/darwin/product-build-darwin-sign.yml@self - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_WEB'], true)) }}: - stage: Web dependsOn: diff --git a/build/azure-pipelines/product-compile.yml b/build/azure-pipelines/product-compile.yml index 6096157ad8d..fba31eefcd1 100644 --- a/build/azure-pipelines/product-compile.yml +++ b/build/azure-pipelines/product-compile.yml @@ -23,7 +23,7 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry - - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js compile $VSCODE_ARCH > .build/packagelockhash + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js compile $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key - task: Cache@2 diff --git a/build/azure-pipelines/product-publish.yml b/build/azure-pipelines/product-publish.yml index 1127e5212e9..27d6c2b366b 100644 --- a/build/azure-pipelines/product-publish.yml +++ b/build/azure-pipelines/product-publish.yml @@ -94,43 +94,3 @@ steps: sbomEnabled: false displayName: Publish the artifacts processed for this stage attempt condition: always() - - - pwsh: | - $ErrorActionPreference = 'Stop' - - # Determine which stages we need to watch - $stages = @( - if ($env:VSCODE_BUILD_STAGE_WINDOWS -eq 'True') { 'Windows' } - if ($env:VSCODE_BUILD_STAGE_LINUX -eq 'True') { 'Linux' } - if ($env:VSCODE_BUILD_STAGE_ALPINE -eq 'True') { 'Alpine' } - if ($env:VSCODE_BUILD_STAGE_MACOS -eq 'True') { 'macOS' } - if ($env:VSCODE_BUILD_STAGE_WEB -eq 'True') { 'Web' } - ) - Write-Host "Stages to check: $stages" - - # Get the timeline and see if it says the other stage completed - $timeline = Invoke-RestMethod "$($env:BUILDS_API_URL)timeline?api-version=6.0" -Headers @{ - Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" - } -MaximumRetryCount 5 -RetryIntervalSec 1 - - $failedStages = @() - foreach ($stage in $stages) { - $didStageFail = $timeline.records | Where-Object { - $_.name -eq $stage -and $_.type -eq 'stage' -and $_.result -ne 'succeeded' -and $_.result -ne 'succeededWithIssues' - } - - if($didStageFail) { - $failedStages += $stage - Write-Host "'$stage' failed!" - Write-Host $didStageFail - } else { - Write-Host "'$stage' did not fail." - } - } - - if ($failedStages.Length) { - throw "Failed stages: $($failedStages -join ', '). This stage will now fail so that it is easier to retry failed jobs." - } - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Determine if stage should succeed diff --git a/build/azure-pipelines/web/product-build-web.yml b/build/azure-pipelines/web/product-build-web.yml index e0e91c1c589..3f94460dfaf 100644 --- a/build/azure-pipelines/web/product-build-web.yml +++ b/build/azure-pipelines/web/product-build-web.yml @@ -27,7 +27,7 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry - - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js web > .build/packagelockhash + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js web $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key - task: Cache@2 diff --git a/build/azure-pipelines/win32/codesign.js b/build/azure-pipelines/win32/codesign.js new file mode 100644 index 00000000000..630f9a64ba1 --- /dev/null +++ b/build/azure-pipelines/win32/codesign.js @@ -0,0 +1,73 @@ +"use strict"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +Object.defineProperty(exports, "__esModule", { value: true }); +const zx_1 = require("zx"); +const codesign_1 = require("../common/codesign"); +const publish_1 = require("../common/publish"); +async function main() { + (0, zx_1.usePwsh)(); + const arch = (0, publish_1.e)('VSCODE_ARCH'); + const esrpCliDLLPath = (0, publish_1.e)('EsrpCliDllPath'); + const codeSigningFolderPath = (0, publish_1.e)('CodeSigningFolderPath'); + // Start the code sign processes in parallel + // 1. Codesign executables and shared libraries + // 2. Codesign Powershell scripts + // 3. Codesign context menu appx package (insiders only) + const codesignTask1 = (0, codesign_1.spawnCodesignProcess)(esrpCliDLLPath, 'sign-windows', codeSigningFolderPath, '*.dll,*.exe,*.node'); + const codesignTask2 = (0, codesign_1.spawnCodesignProcess)(esrpCliDLLPath, 'sign-windows-appx', codeSigningFolderPath, '*.ps1'); + const codesignTask3 = process.env['VSCODE_QUALITY'] === 'insider' + ? (0, codesign_1.spawnCodesignProcess)(esrpCliDLLPath, 'sign-windows-appx', codeSigningFolderPath, '*.appx') + : undefined; + // Codesign executables and shared libraries + (0, codesign_1.printBanner)('Codesign executables and shared libraries'); + await (0, codesign_1.streamProcessOutputAndCheckResult)('Codesign executables and shared libraries', codesignTask1); + // Codesign Powershell scripts + (0, codesign_1.printBanner)('Codesign Powershell scripts'); + await (0, codesign_1.streamProcessOutputAndCheckResult)('Codesign Powershell scripts', codesignTask2); + if (codesignTask3) { + // Codesign context menu appx package + (0, codesign_1.printBanner)('Codesign context menu appx package'); + await (0, codesign_1.streamProcessOutputAndCheckResult)('Codesign context menu appx package', codesignTask3); + } + // Create build artifact directory + await (0, zx_1.$) `New-Item -ItemType Directory -Path .build/win32-${arch} -Force`; + // Package client + if (process.env['BUILT_CLIENT']) { + // Product version + const version = await (0, zx_1.$) `node -p "require('../VSCode-win32-${arch}/resources/app/package.json').version"`; + (0, codesign_1.printBanner)('Package client'); + const clientArchivePath = `.build/win32-${arch}/VSCode-win32-${arch}-${version}.zip`; + await (0, zx_1.$) `7z.exe a -tzip ${clientArchivePath} ../VSCode-win32-${arch}/* "-xr!CodeSignSummary*.md"`.pipe(process.stdout); + await (0, zx_1.$) `7z.exe l ${clientArchivePath}`.pipe(process.stdout); + } + // Package server + if (process.env['BUILT_SERVER']) { + (0, codesign_1.printBanner)('Package server'); + const serverArchivePath = `.build/win32-${arch}/vscode-server-win32-${arch}.zip`; + await (0, zx_1.$) `7z.exe a -tzip ${serverArchivePath} ../vscode-server-win32-${arch}`.pipe(process.stdout); + await (0, zx_1.$) `7z.exe l ${serverArchivePath}`.pipe(process.stdout); + } + // Package server (web) + if (process.env['BUILT_WEB']) { + (0, codesign_1.printBanner)('Package server (web)'); + const webArchivePath = `.build/win32-${arch}/vscode-server-win32-${arch}-web.zip`; + await (0, zx_1.$) `7z.exe a -tzip ${webArchivePath} ../vscode-server-win32-${arch}-web`.pipe(process.stdout); + await (0, zx_1.$) `7z.exe l ${webArchivePath}`.pipe(process.stdout); + } + // Sign setup + if (process.env['BUILT_CLIENT']) { + (0, codesign_1.printBanner)('Sign setup packages (system, user)'); + const task = (0, zx_1.$) `npm exec -- npm-run-all -lp "gulp vscode-win32-${arch}-system-setup -- --sign" "gulp vscode-win32-${arch}-user-setup -- --sign"`; + await (0, codesign_1.streamProcessOutputAndCheckResult)('Sign setup packages (system, user)', task); + } +} +main().then(() => { + process.exit(0); +}, err => { + console.error(`ERROR: ${err}`); + process.exit(1); +}); +//# sourceMappingURL=codesign.js.map \ No newline at end of file diff --git a/build/azure-pipelines/win32/codesign.ts b/build/azure-pipelines/win32/codesign.ts new file mode 100644 index 00000000000..7e7170709b5 --- /dev/null +++ b/build/azure-pipelines/win32/codesign.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, usePwsh } from 'zx'; +import { printBanner, spawnCodesignProcess, streamProcessOutputAndCheckResult } from '../common/codesign'; +import { e } from '../common/publish'; + +async function main() { + usePwsh(); + + const arch = e('VSCODE_ARCH'); + const esrpCliDLLPath = e('EsrpCliDllPath'); + const codeSigningFolderPath = e('CodeSigningFolderPath'); + + // Start the code sign processes in parallel + // 1. Codesign executables and shared libraries + // 2. Codesign Powershell scripts + // 3. Codesign context menu appx package (insiders only) + const codesignTask1 = spawnCodesignProcess(esrpCliDLLPath, 'sign-windows', codeSigningFolderPath, '*.dll,*.exe,*.node'); + const codesignTask2 = spawnCodesignProcess(esrpCliDLLPath, 'sign-windows-appx', codeSigningFolderPath, '*.ps1'); + const codesignTask3 = process.env['VSCODE_QUALITY'] === 'insider' + ? spawnCodesignProcess(esrpCliDLLPath, 'sign-windows-appx', codeSigningFolderPath, '*.appx') + : undefined; + + // Codesign executables and shared libraries + printBanner('Codesign executables and shared libraries'); + await streamProcessOutputAndCheckResult('Codesign executables and shared libraries', codesignTask1); + + // Codesign Powershell scripts + printBanner('Codesign Powershell scripts'); + await streamProcessOutputAndCheckResult('Codesign Powershell scripts', codesignTask2); + + if (codesignTask3) { + // Codesign context menu appx package + printBanner('Codesign context menu appx package'); + await streamProcessOutputAndCheckResult('Codesign context menu appx package', codesignTask3); + } + + // Create build artifact directory + await $`New-Item -ItemType Directory -Path .build/win32-${arch} -Force`; + + // Package client + if (process.env['BUILT_CLIENT']) { + // Product version + const version = await $`node -p "require('../VSCode-win32-${arch}/resources/app/package.json').version"`; + + printBanner('Package client'); + const clientArchivePath = `.build/win32-${arch}/VSCode-win32-${arch}-${version}.zip`; + await $`7z.exe a -tzip ${clientArchivePath} ../VSCode-win32-${arch}/* "-xr!CodeSignSummary*.md"`.pipe(process.stdout); + await $`7z.exe l ${clientArchivePath}`.pipe(process.stdout); + } + + // Package server + if (process.env['BUILT_SERVER']) { + printBanner('Package server'); + const serverArchivePath = `.build/win32-${arch}/vscode-server-win32-${arch}.zip`; + await $`7z.exe a -tzip ${serverArchivePath} ../vscode-server-win32-${arch}`.pipe(process.stdout); + await $`7z.exe l ${serverArchivePath}`.pipe(process.stdout); + } + + // Package server (web) + if (process.env['BUILT_WEB']) { + printBanner('Package server (web)'); + const webArchivePath = `.build/win32-${arch}/vscode-server-win32-${arch}-web.zip`; + await $`7z.exe a -tzip ${webArchivePath} ../vscode-server-win32-${arch}-web`.pipe(process.stdout); + await $`7z.exe l ${webArchivePath}`.pipe(process.stdout); + } + + // Sign setup + if (process.env['BUILT_CLIENT']) { + printBanner('Sign setup packages (system, user)'); + const task = $`npm exec -- npm-run-all -lp "gulp vscode-win32-${arch}-system-setup -- --sign" "gulp vscode-win32-${arch}-user-setup -- --sign"`; + await streamProcessOutputAndCheckResult('Sign setup packages (system, user)', task); + } +} + +main().then(() => { + process.exit(0); +}, err => { + console.error(`ERROR: ${err}`); + process.exit(1); +}); diff --git a/build/azure-pipelines/win32/product-build-win32-test.yml b/build/azure-pipelines/win32/product-build-win32-test.yml index 09db30d1914..8af78682146 100644 --- a/build/azure-pipelines/win32/product-build-win32-test.yml +++ b/build/azure-pipelines/win32/product-build-win32-test.yml @@ -3,12 +3,14 @@ parameters: type: string - name: VSCODE_ARCH type: string - - name: VSCODE_RUN_UNIT_TESTS + - name: VSCODE_RUN_ELECTRON_TESTS type: boolean - - name: VSCODE_RUN_INTEGRATION_TESTS + - name: VSCODE_RUN_BROWSER_TESTS type: boolean - - name: VSCODE_RUN_SMOKE_TESTS + - name: VSCODE_RUN_REMOTE_TESTS type: boolean + - name: VSCODE_TEST_ARTIFACT_NAME + type: string - name: PUBLISH_TASK_NAME type: string default: PublishPipelineArtifact@0 @@ -20,73 +22,81 @@ steps: displayName: Download Electron and Playwright retryCountOnTaskFailure: 3 - - ${{ if eq(parameters.VSCODE_RUN_UNIT_TESTS, true) }}: - - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - powershell: .\scripts\test.bat --tfs "Unit Tests" - displayName: Run unit tests (Electron) + displayName: 🧪 Run unit tests (Electron) timeoutInMinutes: 15 - powershell: npm run test-node - displayName: Run unit tests (node.js) + displayName: 🧪 Run unit tests (node.js) timeoutInMinutes: 15 - - powershell: node test/unit/browser/index.js --sequential --browser chromium --tfs "Browser Unit Tests" - displayName: Run unit tests (Browser, Chromium) + + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: + - powershell: node test/unit/browser/index.js --browser chromium --tfs "Browser Unit Tests" + displayName: 🧪 Run unit tests (Browser, Chromium) timeoutInMinutes: 20 - - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - powershell: .\scripts\test.bat --build --tfs "Unit Tests" - displayName: Run unit tests (Electron) + displayName: 🧪 Run unit tests (Electron) timeoutInMinutes: 15 - powershell: npm run test-node -- --build - displayName: Run unit tests (node.js) + displayName: 🧪 Run unit tests (node.js) timeoutInMinutes: 15 - - powershell: npm run test-browser-no-install -- --sequential --build --browser chromium --tfs "Browser Unit Tests" - displayName: Run unit tests (Browser, Chromium) + + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: + - powershell: npm run test-browser-no-install -- --build --browser chromium --tfs "Browser Unit Tests" + displayName: 🧪 Run unit tests (Browser, Chromium) timeoutInMinutes: 20 - - ${{ if eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true) }}: - - powershell: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - exec { npm run gulp ` - compile-extension:configuration-editing ` - compile-extension:css-language-features-server ` - compile-extension:emmet ` - compile-extension:git ` - compile-extension:github-authentication ` - compile-extension:html-language-features-server ` - compile-extension:ipynb ` - compile-extension:notebook-renderers ` - compile-extension:json-language-features-server ` - compile-extension:markdown-language-features ` - compile-extension-media ` - compile-extension:microsoft-authentication ` - compile-extension:typescript-language-features ` - compile-extension:vscode-api-tests ` - compile-extension:vscode-colorize-tests ` - compile-extension:vscode-colorize-perf-tests ` - compile-extension:vscode-test-resolver ` - } - displayName: Build integration tests + - powershell: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + exec { npm run gulp ` + compile-extension:configuration-editing ` + compile-extension:css-language-features-server ` + compile-extension:emmet ` + compile-extension:git ` + compile-extension:github-authentication ` + compile-extension:html-language-features-server ` + compile-extension:ipynb ` + compile-extension:notebook-renderers ` + compile-extension:json-language-features-server ` + compile-extension:markdown-language-features ` + compile-extension-media ` + compile-extension:microsoft-authentication ` + compile-extension:typescript-language-features ` + compile-extension:vscode-api-tests ` + compile-extension:vscode-colorize-tests ` + compile-extension:vscode-colorize-perf-tests ` + compile-extension:vscode-test-resolver ` + } + displayName: Build integration tests - - powershell: .\build\azure-pipelines\win32\listprocesses.bat - displayName: Diagnostics before integration test runs - continueOnError: true - condition: succeededOrFailed() + - powershell: .\build\azure-pipelines\win32\listprocesses.bat + displayName: Diagnostics before integration test runs + continueOnError: true + condition: succeededOrFailed() - - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - powershell: .\scripts\test-integration.bat --tfs "Integration Tests" - displayName: Run integration tests (Electron) + displayName: 🧪 Run integration tests (Electron) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - powershell: .\scripts\test-web-integration.bat --browser chromium - displayName: Run integration tests (Browser, Chromium) + displayName: 🧪 Run integration tests (Browser, Chromium) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: - powershell: .\scripts\test-remote-integration.bat - displayName: Run integration tests (Remote) + displayName: 🧪 Run integration tests (Remote) timeoutInMinutes: 20 - - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - powershell: | # Figure out the full absolute path of the product we just built # including the remote server and configure the integration tests @@ -99,17 +109,19 @@ steps: $env:INTEGRATION_TEST_ELECTRON_PATH = "$AppRoot\$AppNameShort.exe" $env:VSCODE_REMOTE_SERVER_PATH = "$(agent.builddirectory)\vscode-server-win32-$(VSCODE_ARCH)" exec { .\scripts\test-integration.bat --build --tfs "Integration Tests" } - displayName: Run integration tests (Electron) + displayName: 🧪 Run integration tests (Electron) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" $env:VSCODE_REMOTE_SERVER_PATH = "$(agent.builddirectory)\vscode-server-win32-$(VSCODE_ARCH)-web" exec { .\scripts\test-web-integration.bat --browser firefox } - displayName: Run integration tests (Browser, Firefox) + displayName: 🧪 Run integration tests (Browser, Firefox) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" @@ -119,102 +131,94 @@ steps: $env:INTEGRATION_TEST_ELECTRON_PATH = "$AppRoot\$AppNameShort.exe" $env:VSCODE_REMOTE_SERVER_PATH = "$(agent.builddirectory)\vscode-server-win32-$(VSCODE_ARCH)" exec { .\scripts\test-remote-integration.bat } - displayName: Run integration tests (Remote) + displayName: 🧪 Run integration tests (Remote) timeoutInMinutes: 20 - - powershell: .\build\azure-pipelines\win32\listprocesses.bat - displayName: Diagnostics after integration test runs - continueOnError: true - condition: succeededOrFailed() + - powershell: .\build\azure-pipelines\win32\listprocesses.bat + displayName: Diagnostics after integration test runs + continueOnError: true + condition: succeededOrFailed() - - ${{ if eq(parameters.VSCODE_RUN_SMOKE_TESTS, true) }}: - - powershell: .\build\azure-pipelines\win32\listprocesses.bat - displayName: Diagnostics before smoke test run - continueOnError: true - condition: succeededOrFailed() + - powershell: .\build\azure-pipelines\win32\listprocesses.bat + displayName: Diagnostics before smoke test run + continueOnError: true + condition: succeededOrFailed() - - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: - - powershell: npm run compile - workingDirectory: test/smoke - displayName: Compile smoke tests + # - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: + # - powershell: npm run compile + # workingDirectory: test/smoke + # displayName: Compile smoke tests - - powershell: npm run gulp compile-extension-media - displayName: Build extensions for smoke tests + # - powershell: npm run gulp compile-extension-media + # displayName: Build extensions for smoke tests - - powershell: npm run smoketest-no-compile -- --tracing - displayName: Run smoke tests (Electron) - timeoutInMinutes: 20 + # - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: + # - powershell: npm run smoketest-no-compile -- --tracing + # displayName: 🧪 Run smoke tests (Electron) + # timeoutInMinutes: 20 - - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - powershell: npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)" - displayName: Run smoke tests (Electron) + displayName: 🧪 Run smoke tests (Electron) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - powershell: npm run smoketest-no-compile -- --web --tracing --headless env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)\vscode-server-win32-$(VSCODE_ARCH)-web - displayName: Run smoke tests (Browser, Chromium) - timeoutInMinutes: 20 - - - powershell: npm run gulp compile-extension:vscode-test-resolver - displayName: Compile test resolver extension + displayName: 🧪 Run smoke tests (Browser, Chromium) timeoutInMinutes: 20 + - ${{ if eq(parameters.VSCODE_RUN_REMOTE_TESTS, true) }}: - powershell: npm run smoketest-no-compile -- --tracing --remote --build "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)" env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)\vscode-server-win32-$(VSCODE_ARCH) - displayName: Run smoke tests (Remote) + displayName: 🧪 Run smoke tests (Remote) timeoutInMinutes: 20 - - powershell: .\build\azure-pipelines\win32\listprocesses.bat - displayName: Diagnostics after smoke test run - continueOnError: true - condition: succeededOrFailed() + - powershell: .\build\azure-pipelines\win32\listprocesses.bat + displayName: Diagnostics after smoke test run + continueOnError: true + condition: succeededOrFailed() - - ${{ if or(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - task: ${{ parameters.PUBLISH_TASK_NAME }} - inputs: - targetPath: .build\crashes - ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: - artifactName: crash-dump-windows-$(VSCODE_ARCH)-integration-$(System.JobAttempt) - ${{ elseif and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, false), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - artifactName: crash-dump-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) - ${{ else }}: - artifactName: crash-dump-windows-$(VSCODE_ARCH)-$(System.JobAttempt) - sbomEnabled: false - displayName: "Publish Crash Reports" - continueOnError: true - condition: failed() + - task: ${{ parameters.PUBLISH_TASK_NAME }} + inputs: + targetPath: .build\crashes + ${{ if eq(parameters.VSCODE_TEST_ARTIFACT_NAME, '') }}: + artifactName: crash-dump-windows-$(VSCODE_ARCH)-$(System.JobAttempt) + ${{ else }}: + artifactName: crash-dump-windows-$(VSCODE_ARCH)-${{ parameters.VSCODE_TEST_ARTIFACT_NAME }}-$(System.JobAttempt) + sbomEnabled: false + displayName: "Publish Crash Reports" + continueOnError: true + condition: failed() - # In order to properly symbolify above crash reports - # (if any), we need the compiled native modules too - - task: ${{ parameters.PUBLISH_TASK_NAME }} - inputs: - targetPath: node_modules - ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: - artifactName: node-modules-windows-$(VSCODE_ARCH)-integration-$(System.JobAttempt) - ${{ elseif and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, false), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - artifactName: node-modules-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) - ${{ else }}: - artifactName: node-modules-windows-$(VSCODE_ARCH)-$(System.JobAttempt) - sbomEnabled: false - displayName: "Publish Node Modules" - continueOnError: true - condition: failed() + # In order to properly symbolify above crash reports + # (if any), we need the compiled native modules too + - task: ${{ parameters.PUBLISH_TASK_NAME }} + inputs: + targetPath: node_modules + ${{ if eq(parameters.VSCODE_TEST_ARTIFACT_NAME, '') }}: + artifactName: node-modules-windows-$(VSCODE_ARCH)-$(System.JobAttempt) + ${{ else }}: + artifactName: node-modules-windows-$(VSCODE_ARCH)-${{ parameters.VSCODE_TEST_ARTIFACT_NAME }}-$(System.JobAttempt) + sbomEnabled: false + displayName: "Publish Node Modules" + continueOnError: true + condition: failed() - - task: ${{ parameters.PUBLISH_TASK_NAME }} - inputs: - targetPath: .build\logs - ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: - artifactName: logs-windows-$(VSCODE_ARCH)-integration-$(System.JobAttempt) - ${{ elseif and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, false), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - artifactName: logs-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) - ${{ else }}: - artifactName: logs-windows-$(VSCODE_ARCH)-$(System.JobAttempt) - sbomEnabled: false - displayName: "Publish Log Files" - continueOnError: true - condition: succeededOrFailed() + - task: ${{ parameters.PUBLISH_TASK_NAME }} + inputs: + targetPath: .build\logs + ${{ if eq(parameters.VSCODE_TEST_ARTIFACT_NAME, '') }}: + artifactName: logs-windows-$(VSCODE_ARCH)-$(System.JobAttempt) + ${{ else }}: + artifactName: logs-windows-$(VSCODE_ARCH)-${{ parameters.VSCODE_TEST_ARTIFACT_NAME }}-$(System.JobAttempt) + sbomEnabled: false + displayName: "Publish Log Files" + continueOnError: true + condition: succeededOrFailed() - task: PublishTestResults@2 displayName: Publish Tests Results diff --git a/build/azure-pipelines/win32/product-build-win32.yml b/build/azure-pipelines/win32/product-build-win32.yml index ab0b6cbb4df..e561d8e09b1 100644 --- a/build/azure-pipelines/win32/product-build-win32.yml +++ b/build/azure-pipelines/win32/product-build-win32.yml @@ -5,12 +5,18 @@ parameters: type: string - name: VSCODE_CIBUILD type: boolean - - name: VSCODE_RUN_UNIT_TESTS + - name: VSCODE_RUN_ELECTRON_TESTS type: boolean - - name: VSCODE_RUN_INTEGRATION_TESTS + default: false + - name: VSCODE_RUN_BROWSER_TESTS type: boolean - - name: VSCODE_RUN_SMOKE_TESTS + default: false + - name: VSCODE_RUN_REMOTE_TESTS type: boolean + default: false + - name: VSCODE_TEST_ARTIFACT_NAME + type: string + default: "" steps: - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: @@ -57,7 +63,7 @@ steps: - pwsh: | mkdir .build -ea 0 - node build/azure-pipelines/common/computeNodeModulesCacheKey.js win32 $(VSCODE_ARCH) > .build/packagelockhash + node build/azure-pipelines/common/computeNodeModulesCacheKey.js win32 $(VSCODE_ARCH) $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key - task: Cache@2 @@ -171,17 +177,6 @@ steps: GITHUB_TOKEN: "$(github-distro-mixin-password)" displayName: Build server (web) - - ${{ if or(eq(parameters.VSCODE_RUN_UNIT_TESTS, true), eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - template: product-build-win32-test.yml@self - parameters: - VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} - VSCODE_ARCH: ${{ parameters.VSCODE_ARCH }} - VSCODE_RUN_UNIT_TESTS: ${{ parameters.VSCODE_RUN_UNIT_TESTS }} - VSCODE_RUN_INTEGRATION_TESTS: ${{ parameters.VSCODE_RUN_INTEGRATION_TESTS }} - VSCODE_RUN_SMOKE_TESTS: ${{ parameters.VSCODE_RUN_SMOKE_TESTS }} - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - PUBLISH_TASK_NAME: 1ES.PublishPipelineArtifact@1 - - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - task: DownloadPipelineArtifact@2 @@ -226,87 +221,59 @@ steps: echo "##vso[task.setvariable variable=EsrpCliDllPath]$Version\net6.0\esrpcli.dll" displayName: Find ESRP CLI - - powershell: node build\azure-pipelines\common\sign $env:EsrpCliDllPath sign-windows $(CodeSigningFolderPath) '*.dll,*.exe,*.node' + - powershell: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + exec { npx deemon --detach --wait -- npx zx build/azure-pipelines/win32/codesign.js } env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Codesign executables and shared libraries + displayName: ✍️ Codesign - - ${{ if eq(parameters.VSCODE_QUALITY, 'insider') }}: - - powershell: node build\azure-pipelines\common\sign $env:EsrpCliDllPath sign-windows-appx $(CodeSigningFolderPath) '*.appx' - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Codesign context menu appx package + - ${{ if or(eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true), eq(parameters.VSCODE_RUN_BROWSER_TESTS, true), eq(parameters.VSCODE_RUN_REMOTE_TESTS, true)) }}: + - template: product-build-win32-test.yml@self + parameters: + VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} + VSCODE_ARCH: ${{ parameters.VSCODE_ARCH }} + VSCODE_RUN_ELECTRON_TESTS: ${{ parameters.VSCODE_RUN_ELECTRON_TESTS }} + VSCODE_RUN_BROWSER_TESTS: ${{ parameters.VSCODE_RUN_BROWSER_TESTS }} + VSCODE_RUN_REMOTE_TESTS: ${{ parameters.VSCODE_RUN_REMOTE_TESTS }} + VSCODE_TEST_ARTIFACT_NAME: ${{ parameters.VSCODE_TEST_ARTIFACT_NAME }} + ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + PUBLISH_TASK_NAME: 1ES.PublishPipelineArtifact@1 + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" + exec { npx deemon --attach -- npx zx build/azure-pipelines/win32/codesign.js } + condition: succeededOrFailed() + displayName: "✍️ Post-job: Codesign" + + - powershell: | + $ErrorActionPreference = "Stop" + $PackageJson = Get-Content -Raw -Path ..\VSCode-win32-$(VSCODE_ARCH)\resources\app\package.json | ConvertFrom-Json $Version = $PackageJson.version - echo "##vso[task.setvariable variable=VSCODE_VERSION]$Version" + + $ClientArchivePath = ".build\win32-$(VSCODE_ARCH)\VSCode-win32-$(VSCODE_ARCH)-$Version.zip" + $ServerArchivePath = ".build\win32-$(VSCODE_ARCH)\vscode-server-win32-$(VSCODE_ARCH).zip" + $WebArchivePath = ".build\win32-$(VSCODE_ARCH)\vscode-server-win32-$(VSCODE_ARCH)-web.zip" + + $SystemSetupPath = ".build\win32-$(VSCODE_ARCH)\system-setup\VSCodeSetup-$(VSCODE_ARCH)-$Version.exe" + $UserSetupPath = ".build\win32-$(VSCODE_ARCH)\user-setup\VSCodeUserSetup-$(VSCODE_ARCH)-$Version.exe" + + mv .build\win32-$(VSCODE_ARCH)\system-setup\VSCodeSetup.exe $SystemSetupPath + mv .build\win32-$(VSCODE_ARCH)\user-setup\VSCodeSetup.exe $UserSetupPath + + echo "##vso[task.setvariable variable=CLIENT_PATH]$ClientArchivePath" + echo "##vso[task.setvariable variable=SERVER_PATH]$ServerArchivePath" + echo "##vso[task.setvariable variable=WEB_PATH]$WebArchivePath" + + echo "##vso[task.setvariable variable=SYSTEM_SETUP_PATH]$SystemSetupPath" + echo "##vso[task.setvariable variable=USER_SETUP_PATH]$UserSetupPath" condition: succeededOrFailed() - displayName: Get product version - - - powershell: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - $ArchivePath = ".build\win32-$(VSCODE_ARCH)\VSCode-win32-$(VSCODE_ARCH)-$(VSCODE_VERSION).zip" - New-Item -ItemType Directory -Path .build\win32-$(VSCODE_ARCH) -Force - exec { 7z.exe a -tzip $ArchivePath ..\VSCode-win32-$(VSCODE_ARCH)\* "-xr!CodeSignSummary*.md" } - echo "##vso[task.setvariable variable=CLIENT_PATH]$ArchivePath" - - echo "Listing archive contents" - 7z.exe l $ArchivePath - condition: and(succeededOrFailed(), eq(variables['BUILT_CLIENT'], 'true')) - displayName: Package client - - - powershell: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - $ArchivePath = ".build\win32-$(VSCODE_ARCH)\vscode-server-win32-$(VSCODE_ARCH).zip" - New-Item -ItemType Directory -Path .build\win32-$(VSCODE_ARCH) -Force - exec { 7z.exe a -tzip $ArchivePath ..\vscode-server-win32-$(VSCODE_ARCH) } - echo "##vso[task.setvariable variable=SERVER_PATH]$ArchivePath" - - echo "Listing archive contents" - 7z.exe l $ArchivePath - condition: and(succeededOrFailed(), eq(variables['BUILT_SERVER'], 'true')) - displayName: Package server - - - powershell: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - $ArchivePath = ".build\win32-$(VSCODE_ARCH)\vscode-server-win32-$(VSCODE_ARCH)-web.zip" - New-Item -ItemType Directory -Path .build\win32-$(VSCODE_ARCH) -Force - exec { 7z.exe a -tzip $ArchivePath ..\vscode-server-win32-$(VSCODE_ARCH)-web } - echo "##vso[task.setvariable variable=WEB_PATH]$ArchivePath" - - echo "Listing archive contents" - 7z.exe l $ArchivePath - condition: and(succeededOrFailed(), eq(variables['BUILT_WEB'], 'true')) - displayName: Package server (web) - - - powershell: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - exec { npm run -- gulp "vscode-win32-$(VSCODE_ARCH)-system-setup" --sign } - $SetupPath = ".build\win32-$(VSCODE_ARCH)\system-setup\VSCodeSetup-$(VSCODE_ARCH)-$(VSCODE_VERSION).exe" - mv .build\win32-$(VSCODE_ARCH)\system-setup\VSCodeSetup.exe $SetupPath - echo "##vso[task.setvariable variable=SYSTEM_SETUP_PATH]$SetupPath" - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Build system setup - - - powershell: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - exec { npm run -- gulp "vscode-win32-$(VSCODE_ARCH)-user-setup" --sign } - $SetupPath = ".build\win32-$(VSCODE_ARCH)\user-setup\VSCodeUserSetup-$(VSCODE_ARCH)-$(VSCODE_VERSION).exe" - mv .build\win32-$(VSCODE_ARCH)\user-setup\VSCodeSetup.exe $SetupPath - echo "##vso[task.setvariable variable=USER_SETUP_PATH]$SetupPath" - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Build user setup + displayName: Move setup packages - powershell: echo "##vso[task.setvariable variable=ARTIFACT_PREFIX]attempt$(System.JobAttempt)_" condition: and(succeededOrFailed(), notIn(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues')) diff --git a/build/buildfile.js b/build/buildfile.js index 683e20fc46b..9430fb3d7be 100644 --- a/build/buildfile.js +++ b/build/buildfile.js @@ -5,31 +5,22 @@ /** * @param {string} name - * @param {string[]=} exclude * @returns {import('./lib/bundle').IEntryPoint} */ -function createModuleDescription(name, exclude) { +function createModuleDescription(name) { return { - name, - exclude + name }; } -/** - * @param {string} name - */ -function createEditorWorkerModuleDescription(name) { - return createModuleDescription(name, ['vs/base/common/worker/simpleWorker', 'vs/editor/common/services/editorSimpleWorker']); -} - -exports.workerEditor = createEditorWorkerModuleDescription('vs/editor/common/services/editorSimpleWorkerMain'); -exports.workerExtensionHost = createEditorWorkerModuleDescription('vs/workbench/api/worker/extensionHostWorkerMain'); -exports.workerNotebook = createEditorWorkerModuleDescription('vs/workbench/contrib/notebook/common/services/notebookSimpleWorkerMain'); -exports.workerLanguageDetection = createEditorWorkerModuleDescription('vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorkerMain'); -exports.workerLocalFileSearch = createEditorWorkerModuleDescription('vs/workbench/services/search/worker/localFileSearchMain'); -exports.workerProfileAnalysis = createEditorWorkerModuleDescription('vs/platform/profiling/electron-sandbox/profileAnalysisWorkerMain'); -exports.workerOutputLinks = createEditorWorkerModuleDescription('vs/workbench/contrib/output/common/outputLinkComputerMain'); -exports.workerBackgroundTokenization = createEditorWorkerModuleDescription('vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain'); +exports.workerEditor = createModuleDescription('vs/editor/common/services/editorWebWorkerMain'); +exports.workerExtensionHost = createModuleDescription('vs/workbench/api/worker/extensionHostWorkerMain'); +exports.workerNotebook = createModuleDescription('vs/workbench/contrib/notebook/common/services/notebookWebWorkerMain'); +exports.workerLanguageDetection = createModuleDescription('vs/workbench/services/languageDetection/browser/languageDetectionWebWorkerMain'); +exports.workerLocalFileSearch = createModuleDescription('vs/workbench/services/search/worker/localFileSearchMain'); +exports.workerProfileAnalysis = createModuleDescription('vs/platform/profiling/electron-sandbox/profileAnalysisWorkerMain'); +exports.workerOutputLinks = createModuleDescription('vs/workbench/contrib/output/common/outputLinkComputerMain'); +exports.workerBackgroundTokenization = createModuleDescription('vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain'); exports.workbenchDesktop = [ createModuleDescription('vs/workbench/contrib/debug/node/telemetryApp'), diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index d390d7a233c..9bfbc95a4c8 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -c9b82c9f381742e839fea00aeb14f24519bcaf38a0f4eed25532191701f9535b *chromedriver-v34.3.2-darwin-arm64.zip -d556c1e2b06f1bf131e83c2fb981de755c28e1083a884d257eb964815be16b0c *chromedriver-v34.3.2-darwin-x64.zip -1cabad4f3303ac2ff172a9f22185f64944dbaa6fc68271609077158eaefdee35 *chromedriver-v34.3.2-linux-arm64.zip -4213ce52c72ef414179b5c5c22ae8423847ff030d438296bd6c2aac763930a7b *chromedriver-v34.3.2-linux-armv7l.zip -3c64c08221fdfc0f4be60ea8b1b126f2ecca45f60001b63778522f711022c6ea *chromedriver-v34.3.2-linux-x64.zip -e8388734d88e011cb6cd79795431de9206820749219d80565ee49d90501d2bf3 *chromedriver-v34.3.2-mas-arm64.zip -3ad1dd37bd6e0bb37e8503898db7aedd56bd5213e6d6760b05c3d11f4625062b *chromedriver-v34.3.2-mas-x64.zip -d567b481a0f5d88e84bba7718f89fb08f56363bfc4cb5914e1c2086358a5c252 *chromedriver-v34.3.2-win32-arm64.zip -df6732e9dc61cb20a3c0b2a2de453aac7e2bd54e7cbff43512afa614852c15fa *chromedriver-v34.3.2-win32-ia32.zip -dda0765c8d064924632e18cd152014ecd767f3808fc51c8249a053bfb7ca70a2 *chromedriver-v34.3.2-win32-x64.zip -1945f15caff98f2e0f1ee539c483d352fb8d4d0c13f342caa7abe247676d828c *electron-api.json -c078bbf727b3c3026f60e07a0f4643b85c06c581b54be017d0a6c284ba6772d3 *electron-v34.3.2-darwin-arm64-dsym-snapshot.zip -35f587754d6a3272606258386bf73688d63dd53c7e572d3a7cbaae6f3f60bdae *electron-v34.3.2-darwin-arm64-dsym.zip -08b14ee02c98353de3c738120dfd017322666e82b914a7f6de9b9888dcc5c0f0 *electron-v34.3.2-darwin-arm64-symbols.zip -2a4aa7e8fa30f229e465ebd18d3e4722e2b41529dc51a68a954d333a7e556ffe *electron-v34.3.2-darwin-arm64.zip -1509ccdeb80024f5e3edd5ecf804b4cef4e47ea2bd74e33ef0b39044b0ccf892 *electron-v34.3.2-darwin-x64-dsym-snapshot.zip -3bbe5d587c3f582ed8c126b0fb635cc02ad9a14d077b04892fe6f862092445b0 *electron-v34.3.2-darwin-x64-dsym.zip -fa7ece82e6ecaf1c94ed341e8ebff98e64687c68fe113f52cd9a21400302e22f *electron-v34.3.2-darwin-x64-symbols.zip -23938c62257a65a863ed7aa7c7966ba5f257a7d3dc16c78293e826962cc39c5c *electron-v34.3.2-darwin-x64.zip -0547eecf8ab538d74fa854f591ce8b888a3dbb339256d2db3038e7bb2c6dd929 *electron-v34.3.2-linux-arm64-debug.zip -676d0dc2b1c1c85c8b2abbb8cd5376ee22ecdb910493b910d9ae5a998532136a *electron-v34.3.2-linux-arm64-symbols.zip -774e4ccb39d553e5487994a9f8c60774a90f08cdb049ff65f3963fc27c969ff2 *electron-v34.3.2-linux-arm64.zip -0547eecf8ab538d74fa854f591ce8b888a3dbb339256d2db3038e7bb2c6dd929 *electron-v34.3.2-linux-armv7l-debug.zip -ba33bf53fcb35dea568a2795f5b23ecf46c218abe8258946611c72a1f42f716c *electron-v34.3.2-linux-armv7l-symbols.zip -73ae92c8fffb351d3a455569cf57ce9a3f676f42bf61939c613c607fe0fc3bfb *electron-v34.3.2-linux-armv7l.zip -e61a9a69dd7ea6f2687708a8e83516670cdea53c728226e598e2f6f1fad5b77b *electron-v34.3.2-linux-x64-debug.zip -f1a04df7fe67dd1cd29e7b87871525458d2eb24c0cf3b5835a1c56974707562a *electron-v34.3.2-linux-x64-symbols.zip -7b74c0c4fae82e27c7e9cbca13e9763e046113dba8737d3e27de9a0b300ac87e *electron-v34.3.2-linux-x64.zip -8571a6aa83e00925ceb39fdc5a45a9f6b9aa3d92fd84951c6f252ed723aea4ae *electron-v34.3.2-mas-arm64-dsym-snapshot.zip -477410c6f9a6c5eeaedf376058a02c2996fc0a334aa40eeec7d3734c09347f4d *electron-v34.3.2-mas-arm64-dsym.zip -c2e62dcd6630cb51b2d8e2869e74e47d29bda785521cea6e82e546d0fc58aabb *electron-v34.3.2-mas-arm64-symbols.zip -a1698e8546a062fd59b7f8e5507a7f3220fb00b347f2377de83fc9a07f7f3507 *electron-v34.3.2-mas-arm64.zip -741a24ac230a3651dca81d211f9f00b835c428a5ed0c5f67d370d4e88b62f8d6 *electron-v34.3.2-mas-x64-dsym-snapshot.zip -aeff97ec9e5c9e173ac89e38acd94476025c5640d5f27be1e8c2abd54398bab3 *electron-v34.3.2-mas-x64-dsym.zip -9f14b66b1d612ac66697288e8763171c388f7f200854871a5f0ab464a6a921c2 *electron-v34.3.2-mas-x64-symbols.zip -c979d7e7175f1e8e03ca187997d4c156b878189fc3611b347fadebcb16f3e027 *electron-v34.3.2-mas-x64.zip -f43c700641e8220205dd356952e32718d113cf530520c4ed7209b59851eac266 *electron-v34.3.2-win32-arm64-pdb.zip -3ba6e01c99bffac6b5dd3fd6f122ecdb571cf6f675dc5498c65050bd7a382ef8 *electron-v34.3.2-win32-arm64-symbols.zip -c23f84aabb09c24cd2ae759a547fdba4206af19a3bb0f4554a91cd9528648ad0 *electron-v34.3.2-win32-arm64-toolchain-profile.zip -9b9cb65d75a16782088b492f9ef3bb4d27525012b819c12bf29bd27e159d749b *electron-v34.3.2-win32-arm64.zip -1006e7af4c149114b5ebc3497617aaa6cd1bb0b131e0a225fd73709ff308f9c5 *electron-v34.3.2-win32-ia32-pdb.zip -1ecb6430cd04454f08f557c9579163f3552144bfcc0b67b768dad8868b5b891d *electron-v34.3.2-win32-ia32-symbols.zip -c23f84aabb09c24cd2ae759a547fdba4206af19a3bb0f4554a91cd9528648ad0 *electron-v34.3.2-win32-ia32-toolchain-profile.zip -d004fd5f853754001fafaec33e383d1950b30c935ee71b297ec1c9e084355e9b *electron-v34.3.2-win32-ia32.zip -4e0721552fd2f09e9466e88089af8b965f1bfbc4ae00a59aaf6245b1d1efabfd *electron-v34.3.2-win32-x64-pdb.zip -9dea812a7e7cd0fb18e5fed9a99db5531959a068c24d3c0ecedceb644cd3ffa0 *electron-v34.3.2-win32-x64-symbols.zip -c23f84aabb09c24cd2ae759a547fdba4206af19a3bb0f4554a91cd9528648ad0 *electron-v34.3.2-win32-x64-toolchain-profile.zip -1785e161420fb90d2331c26e50bba3413cae9625b7db3c8524ea02ade631efba *electron-v34.3.2-win32-x64.zip -722b304c31ddac58b0083d94a241c5276464f04bd8ea4fcbfd33051d197be103 *electron.d.ts -31ce159b2e47d1de5bc907d8e1c89726b0f2ba530ec2e9d7a8e5c723b1ccf6e0 *ffmpeg-v34.3.2-darwin-arm64.zip -565539bac64a6ee9cf6f188070f520210a1507341718f5dc388ac7c454b1e1d5 *ffmpeg-v34.3.2-darwin-x64.zip -6006ea0f46ab229feb2685be086b0fafd65981e2939dd2218a078459c75ab527 *ffmpeg-v34.3.2-linux-arm64.zip -9404ce2e85df7c40f809f2cf62c7af607de299839fe6b7ae978c3015500abcc8 *ffmpeg-v34.3.2-linux-armv7l.zip -79aec96898b7e2462826780ee0b52b9ab299dc662af333e128a34fd5ddae87f1 *ffmpeg-v34.3.2-linux-x64.zip -9190743c78210574faf5d5ecb82a00f8fa15e5f2253378cb925a99ca9d39961b *ffmpeg-v34.3.2-mas-arm64.zip -48915adcb1a6342efeda896035101300f0432c0304cfb38f2378e98c6309ebae *ffmpeg-v34.3.2-mas-x64.zip -745d5ef786de6d4a720475079836e2fda7b501cfcd255819485a47de5b24b74e *ffmpeg-v34.3.2-win32-arm64.zip -d0d86d60978439dc8ae4a723d4e4c1f853891d596bfd84033440a232fa762e2f *ffmpeg-v34.3.2-win32-ia32.zip -4441539fd8c9cbe79880ff1bade9bdc0c3744c33d7409130af6404e57ee401ff *ffmpeg-v34.3.2-win32-x64.zip -39edd1eeefe881aa75af0e438204e0b1c6e6724e34fa5819109276331c0c2c9a *hunspell_dictionaries.zip -20dd417536e5f4ebc01f480221284c0673729c27b082bc04e2922f16cd571719 *libcxx-objects-v34.3.2-linux-arm64.zip -7e53c5779c04f895f8282c0450ec4a63074d15a0e910e41006cfea133d0288af *libcxx-objects-v34.3.2-linux-armv7l.zip -92e2283c924ab523ffec3ea22513beaab6417f7fc3d570f57d51a1e1ceb7f510 *libcxx-objects-v34.3.2-linux-x64.zip -9bf3c6e8ad68f523fe086fada4148dd04e5bb0b9290d104873e66f2584a5cf50 *libcxx_headers.zip -34e4b44f9c5e08b557a2caed55456ce7690abab910196a783a2a47b58d2b9ac9 *libcxxabi_headers.zip -11f67635e6659f9188198e4086c51b89890b61a22f6c17c99eff35595ee8f51d *mksnapshot-v34.3.2-darwin-arm64.zip -c0add9ef4ac27c73fa043d04b4c9635fd3fd9f5c16d7a03e961864ba05252813 *mksnapshot-v34.3.2-darwin-x64.zip -6262adf86a340d8d28059937b01ef1117b93212e945fddbceea5c18d7e7c64f0 *mksnapshot-v34.3.2-linux-arm64-x64.zip -f7db8ebe91a1cc8d24ef6aad12949a18d8e4975ac296e3e5e9ecd88c9bccb143 *mksnapshot-v34.3.2-linux-armv7l-x64.zip -6642038e86bda362980ff1c8973a404e2b02efdd87de9e35b650fc1e743833da *mksnapshot-v34.3.2-linux-x64.zip -15883bf8e8cd737c3682d1e719d7cbac92f50b525681aac324dca876861dfc7d *mksnapshot-v34.3.2-mas-arm64.zip -4da23a950bfcc377ef21c37d496017ab4c36da03f3b41049ac114042c42608ce *mksnapshot-v34.3.2-mas-x64.zip -fab59573d3c2f9bdf31146a1896d24ac0c51f736aad86d2f3c7ecef13c05a7fd *mksnapshot-v34.3.2-win32-arm64-x64.zip -66f25e07c6f8d5d2009577a129440255a3baf63c929a5b60b2e77cd52e46105b *mksnapshot-v34.3.2-win32-ia32.zip -8168bfbf61882cfac80aed1e71e364e1c7f2fccd11eac298e6abade8b46894ea *mksnapshot-v34.3.2-win32-x64.zip +249f89d35cb6bd74edc07551b141bffc2045847c4cf9e57e21089d5082bdb4b4 *chromedriver-v34.5.1-darwin-arm64.zip +7ff68fd26f225deaa8c6fbcd76dc80a00f9ef73f9118075f3e2ab54dfb0c810e *chromedriver-v34.5.1-darwin-x64.zip +749f692603527e8743c81d05eb2de2e281e2b03b148ec00379f13e8da17ca7a4 *chromedriver-v34.5.1-linux-arm64.zip +14bcc062457cf31d606451aa7fae1baae720a944dead06231fe2a55f17d39966 *chromedriver-v34.5.1-linux-armv7l.zip +57cf85eb9dafe28ccdd8ba4a095cb1fd5b8c71f0743bf532b132bc45e56630ef *chromedriver-v34.5.1-linux-x64.zip +e90e10cf45f4aaba1d8b763279b7c4b85e1132bdc9faef834ffda41ee1460df8 *chromedriver-v34.5.1-mas-arm64.zip +1206e1c71ec0360be9531e48c0292ffac37e40d8d7a48dd38f1108d3d3ccc0c0 *chromedriver-v34.5.1-mas-x64.zip +1b226994cfa02663f23edfb0c8a4d3e218b7c4d037a90bbb4800a7c396b67d9f *chromedriver-v34.5.1-win32-arm64.zip +dc38291ccad6f715a82cc2ce0cfffe3bb37612fa86013d405e878ea74e4c5fb8 *chromedriver-v34.5.1-win32-ia32.zip +3ccc7e4b65adde12e26b7affeea30b9597b8841fc2a4d3c50c042e80b85853ac *chromedriver-v34.5.1-win32-x64.zip +71fe75d29208ca9e38754d903af4d5d6e80c62b04097605c36ebf722c2447842 *electron-api.json +009c833bd014b6f873974c5d3189905e705ebcb188a90ae05b60ea252319a46e *electron-v34.5.1-darwin-arm64-dsym-snapshot.zip +c5f5722c55e75e9860cb203e03626c04f30f272ef17b735946fd723600ee07ea *electron-v34.5.1-darwin-arm64-dsym.zip +06de49512ac4b0b4e374bdcd296e8c70584fb47207bb6caed9122e3cef5da8f7 *electron-v34.5.1-darwin-arm64-symbols.zip +78411442b5bd2252cf4605b6a44c35ad6a06807d03c63c61726ad7693c6d5893 *electron-v34.5.1-darwin-arm64.zip +e90b292974251336ae8990a74977065ac4dd6388836ccd1cfee3a1599a37bd39 *electron-v34.5.1-darwin-x64-dsym-snapshot.zip +35a0ac52f6036cd0a7d4bc9848477b653095b210497e36797427ff8fe3194c7f *electron-v34.5.1-darwin-x64-dsym.zip +0457bb7413c770245912342a6dd07c3588f586e8d868e0dd534179e22b07898c *electron-v34.5.1-darwin-x64-symbols.zip +8d4bc5f4495ef952589891b6c70a86d8a9d143a1d4d90d15dd81926639822031 *electron-v34.5.1-darwin-x64.zip +73be60acd1f3773f87b283eef8c26e257f16efd46a179c143311b1b9fcb4a61a *electron-v34.5.1-linux-arm64-debug.zip +53677a8f437b36b79481eb6c6f9f7557606af04ef94cce751620e8206dad36a8 *electron-v34.5.1-linux-arm64-symbols.zip +4c0d5833faa01cc3a586087b82f719c2fe331515d26bb3fa098dc79bd3ea153f *electron-v34.5.1-linux-arm64.zip +73be60acd1f3773f87b283eef8c26e257f16efd46a179c143311b1b9fcb4a61a *electron-v34.5.1-linux-armv7l-debug.zip +6eb39e79bd52f566d18a1140242c7484b89d7cd77573b92fc2e2993b51d6fbf1 *electron-v34.5.1-linux-armv7l-symbols.zip +7ed517eeaff56960a01fe53fc445e4118135eeb8267d61c37ef9df943dcc35fb *electron-v34.5.1-linux-armv7l.zip +582a2206cf1e09baa8511ca21b697cc49fddd76ef7723406b449b130b3d21730 *electron-v34.5.1-linux-x64-debug.zip +7b5d60f3d6c4ef84b0855148f14295624527cc27ab395bf54640a06eb3f7a5b0 *electron-v34.5.1-linux-x64-symbols.zip +3ae6f75fa08f5c1bdb7bbcec4dc9cf7d7f53ffcf6a4292e4a482b2ce515505e7 *electron-v34.5.1-linux-x64.zip +e6ff5c411167c0cf8c82cd737f8d0c863f4371e8e1fe213d04b502584411d239 *electron-v34.5.1-mas-arm64-dsym-snapshot.zip +8d1cb700f23d8ac7ec078d4d5d07018dfae594346e7bc5652356a5fe242a2b44 *electron-v34.5.1-mas-arm64-dsym.zip +3b74614ef81382e63f189aceb87f6c3830a23ffed046d06f672d0c1a1b361e96 *electron-v34.5.1-mas-arm64-symbols.zip +eabc29959b914f623f5f2e4011cb4e35182ed9528dc30664e59ca37c806c1d7d *electron-v34.5.1-mas-arm64.zip +ee3de3f5a96efb0197022557ec2de36d92d7423426636577864b1ae744053dea *electron-v34.5.1-mas-x64-dsym-snapshot.zip +a3db9cc489720701e3f35d2f7425c97e24f74fdb78a38bc0950b68b3f82aebb2 *electron-v34.5.1-mas-x64-dsym.zip +a9131003b1ac4a3c3327ff405e1cd8f3e61dc8a73cfae3e05cb5eb0f2d872bee *electron-v34.5.1-mas-x64-symbols.zip +1b44d42dbe9cb6bc5c2fb77f708d639e01f8ec6f74b95710fc6c8dbd70181f3b *electron-v34.5.1-mas-x64.zip +4495d8bf4d3dbb5ebc3ad135f4658e09d706368d002af9f24d236e1a0a28e994 *electron-v34.5.1-win32-arm64-pdb.zip +2c31fa61d24e736f3e327eba4d354c09471fba5aa277e215f7e2ea275b323a80 *electron-v34.5.1-win32-arm64-symbols.zip +c23f84aabb09c24cd2ae759a547fdba4206af19a3bb0f4554a91cd9528648ad0 *electron-v34.5.1-win32-arm64-toolchain-profile.zip +c0cff1c83094a430f1b202bb5035b51ebcefa54cd53d798bb63de9cb96abf223 *electron-v34.5.1-win32-arm64.zip +d662fb7afc288aa15d929fecbb391c7067448ea86b4bf01e941fa8da744a8167 *electron-v34.5.1-win32-ia32-pdb.zip +2cd1f41a3297fc271e426bd0cc5f8c3474f73438a7a303186701cb7d8b26bdb6 *electron-v34.5.1-win32-ia32-symbols.zip +c23f84aabb09c24cd2ae759a547fdba4206af19a3bb0f4554a91cd9528648ad0 *electron-v34.5.1-win32-ia32-toolchain-profile.zip +cf86edf6cdb47d5cfb00c4eb68f7a18d70bf9e33f1f6a0481d51673cf6af7050 *electron-v34.5.1-win32-ia32.zip +9dd0e6f6ef53f8bd4d7ecd97a3bfc7e8a98de8771986071692afc57d57d199d6 *electron-v34.5.1-win32-x64-pdb.zip +f50ab96420bddd43bd5dbd56130cfcd69eea2dba18bfd3c8c3b4bb189bb033e6 *electron-v34.5.1-win32-x64-symbols.zip +c23f84aabb09c24cd2ae759a547fdba4206af19a3bb0f4554a91cd9528648ad0 *electron-v34.5.1-win32-x64-toolchain-profile.zip +da606d1a085a52ddf5592110b58284fc3bf49f273f6f2e7d6a8341c98af8498e *electron-v34.5.1-win32-x64.zip +793ae7822cbdad6270c318f3c93c0e8b4f9276dea6dd87db2d1297cadc7381c6 *electron.d.ts +1d1465b4f6a3919a6e8e2fb8612b29f61b09d80f386a8fa2b806859b9be0d0bf *ffmpeg-v34.5.1-darwin-arm64.zip +e138b6422dd1648cbe817b99f59476c65ed9946d50e50094124eae660b416378 *ffmpeg-v34.5.1-darwin-x64.zip +346101611df565cabcfaa3515b1db3f70d0891ba8f1241074dd09b69e12630d2 *ffmpeg-v34.5.1-linux-arm64.zip +5c77c712ee93bd26706daa78f0651d9b4ba8e4b46a115908f29d2742a2e1b9f0 *ffmpeg-v34.5.1-linux-armv7l.zip +f5ab70d399d528450c9499966e88ce02a368bb8c7dd7ac0676a6628fa29b3f14 *ffmpeg-v34.5.1-linux-x64.zip +10e3424c01b946274fa8c651d4ea79032637feca4c8712ebb1c00f392711594f *ffmpeg-v34.5.1-mas-arm64.zip +4db0373915c2c2a055bd04755acdbcd08e00456f1fb92fefc0e05cd7fb48e4fa *ffmpeg-v34.5.1-mas-x64.zip +c8cca82fc9315f86ffb60b39e824ebec7f98361f8773ea0618d9feea92b88412 *ffmpeg-v34.5.1-win32-arm64.zip +c8cca82fc9315f86ffb60b39e824ebec7f98361f8773ea0618d9feea92b88412 *ffmpeg-v34.5.1-win32-ia32.zip +c8cca82fc9315f86ffb60b39e824ebec7f98361f8773ea0618d9feea92b88412 *ffmpeg-v34.5.1-win32-x64.zip +9ae3a56bf29d9704cd8cf32924aad89414f28d439e61dd54bdd8b4259b8d0b1d *hunspell_dictionaries.zip +691e23913b7dbde1f9c9b6e9f13f06353d5c7927cbab6d48b7de43e76e5eacd8 *libcxx-objects-v34.5.1-linux-arm64.zip +eaaa18779a96873daeece21c7c823d1f5d4759f8eca79dbbbf2055635df6112f *libcxx-objects-v34.5.1-linux-armv7l.zip +6a2e3dfcea9d0582ecbc2a6be124f0e830e2194111bd9aa6a9843cb956c946c4 *libcxx-objects-v34.5.1-linux-x64.zip +d4b70d94523ebd770009dba04c842450539a9bdc856de660a7391620d3bcc1fb *libcxx_headers.zip +0ed01bc1908fd8f7519ccdf636b5732c6fe2c095a6dc35a13eb6c79b9e87d7d1 *libcxxabi_headers.zip +f633cd0df0b08a15938ccdc77480bc28eb96fd85936ef76c343cc3f47fe74f3c *mksnapshot-v34.5.1-darwin-arm64.zip +a8643285a2386960ceb608ff34d6dac33942142e821e2e0c670b282389a87e53 *mksnapshot-v34.5.1-darwin-x64.zip +70863b79d4b7ab75d013a9192f7b23165e3e523b243632c7b55418767527e022 *mksnapshot-v34.5.1-linux-arm64-x64.zip +c30319434ea16416c38bbdf847432fd37fd8e1aa78c1c22b6345d02e3743c016 *mksnapshot-v34.5.1-linux-armv7l-x64.zip +e882e32b67501d36710da396167274158c1940afe67459ffa1d9df534a8f6df1 *mksnapshot-v34.5.1-linux-x64.zip +af1d08dbd3c572ae10db0d24203a28d83c87e92e966064134ec5d7770c74e3ac *mksnapshot-v34.5.1-mas-arm64.zip +238058875abebcb9233e609fadad76e85b79530f1bdfb60498b144fec82ff8fc *mksnapshot-v34.5.1-mas-x64.zip +779e494cf2088ee386bb3ffd68d5efc2de3d43e5a2e6a5a768638799c460fdab *mksnapshot-v34.5.1-win32-arm64-x64.zip +9f9790fab86209ca76ecfae3e20dc028bc0e49574872f6ac17b8856093811357 *mksnapshot-v34.5.1-win32-ia32.zip +5c39077fd59426108f15e4981c7be5ebe56aa706b9d166853225de882fee8d6e *mksnapshot-v34.5.1-win32-x64.zip diff --git a/build/checksums/nodejs.txt b/build/checksums/nodejs.txt index d394605dda3..efada69028b 100644 --- a/build/checksums/nodejs.txt +++ b/build/checksums/nodejs.txt @@ -1,7 +1,7 @@ -1f15b7ed18a580af31cf32bc126572292d820f547bf55bf9cdce08041a24e1d9 node-v20.18.3-darwin-arm64.tar.gz -ba668f64df9239843fefcef095ee539f5ac5aa1b0fc15a71f1ecca16abedec7a node-v20.18.3-darwin-x64.tar.gz -93a9df19238adfaa289f4784041d03edaf2fdd89fbb247faffca2fe4a1000703 node-v20.18.3-linux-arm64.tar.gz -8a84eb34287db6a273066934d7195e429f57b91686b62fc19497210204a2b3de node-v20.18.3-linux-armv7l.tar.gz -9fc3952da39b20d1fcfdb777b198cc035485afbbb1004b4df93f35245d61151e node-v20.18.3-linux-x64.tar.gz -4258e333f4b95060681d61bffa762542a8068547d3dffebe57c575b38d380dda win-arm64/node.exe -528a9aa64888a2a3ba71c6aea89434dd5ab5cb3caa9f0f31345cf5facf685ab0 win-x64/node.exe +c016cd1975a264a29dc1b07c6fbe60d5df0a0c2beb4113c0450e3d998d1a0d9c node-v20.19.0-darwin-arm64.tar.gz +a8554af97d6491fdbdabe63d3a1cfb9571228d25a3ad9aed2df856facb131b20 node-v20.19.0-darwin-x64.tar.gz +618e4294602b78e97118a39050116b70d088b16197cd3819bba1fc18b473dfc4 node-v20.19.0-linux-arm64.tar.gz +2deb2f333b42fcdeb0d215800b3d2b9af64dd88c1d0b05e67b980398d43c4dce node-v20.19.0-linux-armv7l.tar.gz +8a4dbcdd8bccef3132d21e8543940557e55dcf44f00f0a99ba8a062f4552e722 node-v20.19.0-linux-x64.tar.gz +4ec1ae34fc7c0c65b35ec3688b9dc6d8ad5feca69d5ba45f7d72d559dc850fbb win-arm64/node.exe +6e3a39787e667d50487f7335c85636c2823a53e636d73c2c841d45da4e57906c win-x64/node.exe diff --git a/build/checksums/vscode-sysroot.txt b/build/checksums/vscode-sysroot.txt index 67182b078ed..5744a5f77d4 100644 --- a/build/checksums/vscode-sysroot.txt +++ b/build/checksums/vscode-sysroot.txt @@ -1,3 +1,7 @@ -0de422a81683cf9e8cf875dbd1e0c27545ac3c775b2d53015daf3ca2b31d3f15 aarch64-linux-gnu-glibc-2.28.tar.gz -7aea163f7fad8cc50000c86b5108be880121d35e2f55d016ef8c96bbe54129eb arm-rpi-linux-gnueabihf-glibc-2.28.tar.gz -dbb927408393041664a020661f2641c9785741be3d29b050b9dac58980967784 x86_64-linux-gnu-glibc-2.28.tar.gz +3baac81a39b69e0929e4700f4f78f022adefc515010054ec393565657c4fff32 aarch64-linux-gnu-glibc-2.28-gcc-10.5.0.tar.gz +b4fb7a62ee7a474cfb11d5fb2b73accd6a8c875a559db81d6dfccd0b4a3da442 aarch64-linux-gnu-glibc-2.28-gcc-8.5.0.tar.gz +633e88658561ab4643bc5998c88e565a26553b0e97fd07672cb452afb4d9b276 aarch64-linux-musl-gcc-10.3.0.tar.gz +6e251200607ac4c4709ebd08b2dc0d9a353ddcfdb47f43a10c2b4cc4b49920c0 arm-rpi-linux-gnueabihf-glibc-2.28-gcc-10.5.0.tar.gz +f82c8dacbb9dd85819e4801909eb4e842ac12c899632aa75b4839383a18c7501 arm-rpi-linux-gnueabihf-glibc-2.28-gcc-8.5.0.tar.gz +3122af49c493c5c767c2b0772a41119cbdc9803125a705683445b4066dc88b82 x86_64-linux-gnu-glibc-2.28-gcc-10.5.0.tar.gz +84acc5a15566c98ddf80631731d672e0ce9febcf3f2e969101e0dfd7ef2405e3 x86_64-linux-gnu-glibc-2.28-gcc-8.5.0.tar.gz diff --git a/build/filters.js b/build/filters.js index ece41209baf..4f18bf6607a 100644 --- a/build/filters.js +++ b/build/filters.js @@ -80,6 +80,7 @@ module.exports.indentationFilter = [ '!src/vs/base/node/terminateProcess.sh', '!src/vs/base/node/cpuUsage.sh', '!src/vs/editor/common/languages/highlights/*.scm', + '!src/vs/editor/common/languages/injections/*.scm', '!test/unit/assert.js', '!resources/linux/snap/electron-launch', '!build/ext.js', @@ -205,6 +206,7 @@ module.exports.eslintFilter = [ '**/*.cjs', '**/*.mjs', '**/*.ts', + '.eslint-plugin-local/**/*.ts', ...readFileSync(join(__dirname, '..', '.eslint-ignore')) .toString() .split(/\r\n|\n/) diff --git a/build/gulpfile.compile.js b/build/gulpfile.compile.js index e40b05f8d39..0c0a024c8fc 100644 --- a/build/gulpfile.compile.js +++ b/build/gulpfile.compile.js @@ -24,12 +24,12 @@ function makeCompileBuildTask(disableMangle) { ); } -// Full compile, including nls and inline sources in sourcemaps, mangling, minification, for build -const compileBuildTask = task.define('compile-build', makeCompileBuildTask(false)); -gulp.task(compileBuildTask); -exports.compileBuildTask = compileBuildTask; +// Local/PR compile, including nls and inline sources in sourcemaps, minification, no mangling +const compileBuildWithoutManglingTask = task.define('compile-build-without-mangling', makeCompileBuildTask(true)); +gulp.task(compileBuildWithoutManglingTask); +exports.compileBuildWithoutManglingTask = compileBuildWithoutManglingTask; -// Full compile for PR ci, e.g no mangling -const compileBuildTaskPullRequest = task.define('compile-build-pr', makeCompileBuildTask(true)); -gulp.task(compileBuildTaskPullRequest); -exports.compileBuildTaskPullRequest = compileBuildTaskPullRequest; +// CI compile, including nls and inline sources in sourcemaps, mangling, minification, for build +const compileBuildWithManglingTask = task.define('compile-build-with-mangling', makeCompileBuildTask(false)); +gulp.task(compileBuildWithManglingTask); +exports.compileBuildWithManglingTask = compileBuildWithManglingTask; diff --git a/build/gulpfile.editor.js b/build/gulpfile.editor.js index 9f7ea57465b..4787605d068 100644 --- a/build/gulpfile.editor.js +++ b/build/gulpfile.editor.js @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +//@ts-check + const gulp = require('gulp'); const path = require('path'); const util = require('./lib/util'); const { getVersion } = require('./lib/getVersion'); const task = require('./lib/task'); -const optimize = require('./lib/optimize'); const es = require('event-stream'); const File = require('vinyl'); const i18n = require('./lib/i18n'); @@ -17,39 +18,13 @@ const cp = require('child_process'); const compilation = require('./lib/compilation'); const monacoapi = require('./lib/monaco-api'); const fs = require('fs'); +const filter = require('gulp-filter'); const root = path.dirname(__dirname); const sha1 = getVersion(root); const semver = require('./monaco/package.json').version; const headerVersion = semver + '(' + sha1 + ')'; -// Build - -const editorEntryPoints = [ - { - name: 'vs/editor/editor.main', - include: [], - exclude: ['vs/css'], - prepend: [ - { path: 'out-editor-build/vs/css.js', amdModuleId: 'vs/css' } - ], - }, - { - name: 'vs/base/common/worker/simpleWorker', - include: ['vs/editor/common/services/editorSimpleWorker'], - exclude: [], - prepend: [ - { path: 'vs/loader.js' }, - { path: 'vs/base/worker/workerMain.js' } - ], - dest: 'vs/base/worker/workerMain.js' - } -]; - -const editorResources = [ - 'out-editor-build/vs/base/browser/ui/codicons/**/*.ttf' -]; - const BUNDLED_FILE_HEADER = [ '/*!-----------------------------------------------------------', ' * Copyright (c) Microsoft Corporation. All rights reserved.', @@ -60,8 +35,6 @@ const BUNDLED_FILE_HEADER = [ '' ].join('\n'); -const languages = i18n.defaultLanguages.concat([]); // i18n.defaultLanguages.concat(process.env.VSCODE_QUALITY !== 'stable' ? i18n.extraLanguages : []); - const extractEditorSrcTask = task.define('extract-editor-src', () => { const apiusages = monacoapi.execute().usageContent; const extrausages = fs.readFileSync(path.join(root, 'build', 'monaco', 'monaco.usage.recipe')).toString(); @@ -69,128 +42,43 @@ const extractEditorSrcTask = task.define('extract-editor-src', () => { sourcesRoot: path.join(root, 'src'), entryPoints: [ 'vs/editor/editor.main', - 'vs/editor/editor.worker', - 'vs/base/worker/workerMain', + 'vs/editor/editor.worker.start', + 'vs/editor/common/services/editorWebWorkerMain', ], inlineEntryPoints: [ apiusages, extrausages ], + typings: [], shakeLevel: 2, // 0-Files, 1-InnerFile, 2-ClassMembers importIgnorePattern: /\.css$/, destRoot: path.join(root, 'out-editor-src'), + tsOutDir: '../out-monaco-editor-core/esm/vs', redirects: { '@vscode/tree-sitter-wasm': '../node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter', } }); }); -// Disable mangling for the editor, as it complicates debugging & quite a few users rely on private/protected fields. -// Disable NLS task to remove english strings to preserve backwards compatibility when we removed the `vs/nls!` AMD plugin. -const compileEditorAMDTask = task.define('compile-editor-amd', compilation.compileTask('out-editor-src', 'out-editor-build', true, { disableMangle: true, preserveEnglish: true })); - -const bundleEditorAMDTask = task.define('bundle-editor-amd', optimize.bundleTask( - { - out: 'out-editor', - esm: { - src: 'out-editor-build', - entryPoints: editorEntryPoints, - resources: editorResources - } - } -)); - -const minifyEditorAMDTask = task.define('minify-editor-amd', optimize.minifyTask('out-editor')); - -const createESMSourcesAndResourcesTask = task.define('extract-editor-esm', () => { - standalone.createESMSourcesAndResources2({ - srcFolder: './out-editor-src', - outFolder: './out-editor-esm', - outResourcesFolder: './out-monaco-editor-core/esm', - ignores: [ - 'inlineEntryPoint:0.ts', - 'inlineEntryPoint:1.ts', - 'vs/loader.js', - 'vs/base/worker/workerMain.ts', - ], - renames: { - } - }); -}); - const compileEditorESMTask = task.define('compile-editor-esm', () => { - const KEEP_PREV_ANALYSIS = false; - const FAIL_ON_PURPOSE = false; - console.log(`Launching the TS compiler at ${path.join(__dirname, '../out-editor-esm')}...`); - let result; - if (process.platform === 'win32') { - result = cp.spawnSync(`..\\node_modules\\.bin\\tsc.cmd`, { - cwd: path.join(__dirname, '../out-editor-esm'), - shell: true - }); - } else { - result = cp.spawnSync(`node`, [`../node_modules/.bin/tsc`], { - cwd: path.join(__dirname, '../out-editor-esm') - }); - } - console.log(result.stdout.toString()); - console.log(result.stderr.toString()); + const src = 'out-editor-src'; + const out = 'out-monaco-editor-core/esm'; - if (FAIL_ON_PURPOSE || result.status !== 0) { - console.log(`The TS Compilation failed, preparing analysis folder...`); - const destPath = path.join(__dirname, '../../vscode-monaco-editor-esm-analysis'); - const keepPrevAnalysis = (KEEP_PREV_ANALYSIS && fs.existsSync(destPath)); - const cleanDestPath = (keepPrevAnalysis ? Promise.resolve() : util.rimraf(destPath)()); - return cleanDestPath.then(() => { - // build a list of files to copy - const files = util.rreddir(path.join(__dirname, '../out-editor-esm')); + const compile = compilation.createCompile(src, { build: true, emitError: true, transpileOnly: false, preserveEnglish: true }); + const srcPipe = gulp.src(`${src}/**`, { base: `${src}` }); - if (!keepPrevAnalysis) { - fs.mkdirSync(destPath); - - // initialize a new repository - cp.spawnSync(`git`, [`init`], { - cwd: destPath - }); - - // copy files from src - for (const file of files) { - const srcFilePath = path.join(__dirname, '../src', file); - const dstFilePath = path.join(destPath, file); - if (fs.existsSync(srcFilePath)) { - util.ensureDir(path.dirname(dstFilePath)); - const contents = fs.readFileSync(srcFilePath).toString().replace(/\r\n|\r|\n/g, '\n'); - fs.writeFileSync(dstFilePath, contents); - } - } - - // create an initial commit to diff against - cp.spawnSync(`git`, [`add`, `.`], { - cwd: destPath - }); - - // create the commit - cp.spawnSync(`git`, [`commit`, `-m`, `"original sources"`, `--no-gpg-sign`], { - cwd: destPath - }); - } - - // copy files from tree shaken src - for (const file of files) { - const srcFilePath = path.join(__dirname, '../out-editor-src', file); - const dstFilePath = path.join(destPath, file); - if (fs.existsSync(srcFilePath)) { - util.ensureDir(path.dirname(dstFilePath)); - const contents = fs.readFileSync(srcFilePath).toString().replace(/\r\n|\r|\n/g, '\n'); - fs.writeFileSync(dstFilePath, contents); - } - } - - console.log(`Open in VS Code the folder at '${destPath}' and you can analyze the compilation error`); - throw new Error('Standalone Editor compilation failed. If this is the build machine, simply launch `npm run gulp editor-distro` on your machine to further analyze the compilation problem.'); - }); - } + return ( + srcPipe + .pipe(compile()) + .pipe(i18n.processNlsFiles({ + out, + fileHeader: BUNDLED_FILE_HEADER, + languages: i18n.defaultLanguages, + })) + .pipe(filter(['**', '!**/inlineEntryPoint*', '!**/tsconfig.json', '!**/loader.js'])) + .pipe(gulp.dest(out)) + ); }); /** @@ -239,18 +127,6 @@ function toExternalDTS(contents) { return lines.join('\n').replace(/\n\n\n+/g, '\n\n'); } -/** - * @param {{ (path: string): boolean }} testFunc - */ -function filterStream(testFunc) { - return es.through(function (data) { - if (!testFunc(data.relative)) { - return; - } - this.emit('data', data); - }); -} - const finalEditorResourcesTask = task.define('final-editor-resources', () => { return es.merge( // other assets @@ -299,42 +175,6 @@ const finalEditorResourcesTask = task.define('final-editor-resources', () => { })); })) .pipe(gulp.dest('out-monaco-editor-core')), - - // dev folder - es.merge( - gulp.src('out-editor/**/*') - ).pipe(gulp.dest('out-monaco-editor-core/dev')), - - // min folder - es.merge( - gulp.src('out-editor-min/**/*') - ).pipe(filterStream(function (path) { - // no map files - return !/(\.js\.map$)|(nls\.metadata\.json$)|(bundleInfo\.json$)/.test(path); - })).pipe(es.through(function (data) { - // tweak the sourceMappingURL - if (!/\.js$/.test(data.path)) { - this.emit('data', data); - return; - } - - const relativePathToMap = path.relative(path.join(data.relative), path.join('min-maps', data.relative + '.map')); - - let strContents = data.contents.toString(); - const newStr = '//# sourceMappingURL=' + relativePathToMap.replace(/\\/g, '/'); - strContents = strContents.replace(/\/\/# sourceMappingURL=[^ ]+$/, newStr); - - data.contents = Buffer.from(strContents); - this.emit('data', data); - })).pipe(gulp.dest('out-monaco-editor-core/min')), - - // min-maps folder - es.merge( - gulp.src('out-editor-min/**/*') - ).pipe(filterStream(function (path) { - // no map files - return /\.js\.map$/.test(path); - })).pipe(gulp.dest('out-monaco-editor-core/min-maps')) ); }); @@ -349,38 +189,11 @@ gulp.task('editor-distro', task.series( task.parallel( util.rimraf('out-editor-src'), - util.rimraf('out-editor-build'), - util.rimraf('out-editor-esm'), - util.rimraf('out-monaco-editor-core'), - util.rimraf('out-editor'), - util.rimraf('out-editor-min') - ), - extractEditorSrcTask, - task.parallel( - task.series( - compileEditorAMDTask, - bundleEditorAMDTask, - minifyEditorAMDTask - ), - task.series( - createESMSourcesAndResourcesTask, - compileEditorESMTask, - ) - ), - finalEditorResourcesTask - ) -); - -gulp.task('editor-esm', - task.series( - task.parallel( - util.rimraf('out-editor-src'), - util.rimraf('out-editor-esm'), util.rimraf('out-monaco-editor-core'), ), extractEditorSrcTask, - createESMSourcesAndResourcesTask, compileEditorESMTask, + finalEditorResourcesTask ) ); @@ -393,6 +206,9 @@ gulp.task('monacodts', task.define('monacodts', () => { //#region monaco type checking +/** + * @param {boolean} watch + */ function createTscCompileTask(watch) { return () => { const createReporter = require('./lib/reporter').createReporter; @@ -421,6 +237,7 @@ function createTscCompileTask(watch) { report = reporter.end(false); } else if (str.indexOf('Compilation complete') >= 0) { + // @ts-ignore report.end(); } else if (str) { diff --git a/build/gulpfile.reh.js b/build/gulpfile.reh.js index 44add792d14..c1d64c00338 100644 --- a/build/gulpfile.reh.js +++ b/build/gulpfile.reh.js @@ -26,7 +26,7 @@ const gunzip = require('gulp-gunzip'); const File = require('vinyl'); const fs = require('fs'); const glob = require('glob'); -const { compileBuildTask } = require('./gulpfile.compile'); +const { compileBuildWithManglingTask } = require('./gulpfile.compile'); const { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileExtensionMediaBuildTask } = require('./gulpfile.extensions'); const { vscodeWebResourceIncludes, createVSCodeWebFileContentMapper } = require('./gulpfile.vscode.web'); const cp = require('child_process'); @@ -473,7 +473,7 @@ function tweakProductForServerWeb(product) { gulp.task(serverTaskCI); const serverTask = task.define(`vscode-${type}${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( - compileBuildTask, + compileBuildWithManglingTask, cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, diff --git a/build/gulpfile.scan.js b/build/gulpfile.scan.js index cbcdddb74bc..aafc64e81c2 100644 --- a/build/gulpfile.scan.js +++ b/build/gulpfile.scan.js @@ -84,9 +84,8 @@ function nodeModules(destinationExe, destinationPdb, platform) { '**/*.node', // Exclude these paths. // We don't build the prebuilt node files so we don't scan them - '!**/prebuilds/**/*.node', - // These are 3rd party modules that we should ignore - '!**/@parcel/watcher/**/*'])) + '!**/prebuilds/**/*.node' + ])) .pipe(gulp.dest(destinationExe)); }; diff --git a/build/gulpfile.vscode.js b/build/gulpfile.vscode.js index 0624f5f5a67..7046ee004bb 100644 --- a/build/gulpfile.vscode.js +++ b/build/gulpfile.vscode.js @@ -30,7 +30,7 @@ const { getProductionDependencies } = require('./lib/dependencies'); const { config } = require('./lib/electron'); const createAsar = require('./lib/asar').createAsar; const minimist = require('minimist'); -const { compileBuildTask } = require('./gulpfile.compile'); +const { compileBuildWithoutManglingTask, compileBuildWithManglingTask } = require('./gulpfile.compile'); const { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } = require('./gulpfile.extensions'); const { promisify } = require('util'); const glob = promisify(require('glob')); @@ -101,6 +101,9 @@ const vscodeResourceIncludes = [ // Tree Sitter highlights 'out-build/vs/editor/common/languages/highlights/*.scm', + + // Tree Sitter injection queries + 'out-build/vs/editor/common/languages/injections/*.scm', ]; const vscodeResources = [ @@ -166,25 +169,25 @@ const minifyVSCodeTask = task.define('minify-vscode', task.series( )); gulp.task(minifyVSCodeTask); -const core = task.define('core-ci', task.series( - gulp.task('compile-build'), +const coreCI = task.define('core-ci', task.series( + gulp.task('compile-build-with-mangling'), task.parallel( gulp.task('minify-vscode'), gulp.task('minify-vscode-reh'), gulp.task('minify-vscode-reh-web'), ) )); -gulp.task(core); +gulp.task(coreCI); -const corePr = task.define('core-ci-pr', task.series( - gulp.task('compile-build-pr'), +const coreCIPR = task.define('core-ci-pr', task.series( + gulp.task('compile-build-without-mangling'), task.parallel( gulp.task('minify-vscode'), gulp.task('minify-vscode-reh'), gulp.task('minify-vscode-reh-web'), ) )); -gulp.task(corePr); +gulp.task(coreCIPR); /** * Compute checksums for some files. @@ -501,7 +504,7 @@ BUILD_TARGETS.forEach(buildTarget => { gulp.task(vscodeTaskCI); const vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( - compileBuildTask, + minified ? compileBuildWithManglingTask : compileBuildWithoutManglingTask, cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, @@ -539,7 +542,7 @@ const innoSetupConfig = { gulp.task(task.define( 'vscode-translations-export', task.series( - core, + coreCI, compileAllExtensionsBuildTask, function () { const pathToMetadata = './out-build/nls.metadata.json'; diff --git a/build/gulpfile.vscode.web.js b/build/gulpfile.vscode.web.js index 02b17022fa8..2828f507c60 100644 --- a/build/gulpfile.vscode.web.js +++ b/build/gulpfile.vscode.web.js @@ -19,7 +19,7 @@ const filter = require('gulp-filter'); const { getProductionDependencies } = require('./lib/dependencies'); const vfs = require('vinyl-fs'); const packageJson = require('../package.json'); -const { compileBuildTask } = require('./gulpfile.compile'); +const { compileBuildWithManglingTask } = require('./gulpfile.compile'); const extensions = require('./lib/extensions'); const VinylFile = require('vinyl'); @@ -52,6 +52,9 @@ const vscodeWebResourceIncludes = [ // Tree Sitter highlights 'out-build/vs/editor/common/languages/highlights/*.scm', + // Tree Sitter injections + 'out-build/vs/editor/common/languages/injections/*.scm', + // Extension Host Worker 'out-build/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html', ]; @@ -223,7 +226,7 @@ const dashed = (/** @type {string} */ str) => (str ? `-${str}` : ``); gulp.task(vscodeWebTaskCI); const vscodeWebTask = task.define(`vscode-web${dashed(minified)}`, task.series( - compileBuildTask, + compileBuildWithManglingTask, vscodeWebTaskCI )); gulp.task(vscodeWebTask); diff --git a/build/hygiene.js b/build/hygiene.js index fe32fe33e12..c844ebd574b 100644 --- a/build/hygiene.js +++ b/build/hygiene.js @@ -63,7 +63,7 @@ function hygiene(some, linting = true) { } // Please do not add symbols that resemble ASCII letters! // eslint-disable-next-line no-misleading-character-class - const m = /([^\t\n\r\x20-\x7E⊃⊇✔︎✓🎯⚠️🛑🔴🚗🚙🚕🎉✨❗⇧⌥⌘×÷¦⋯…↑↓→→←↔⟷·•●◆▼⟪⟫┌└├⏎↩√φ]+)/g.exec(line); + const m = /([^\t\n\r\x20-\x7E⊃⊇✔︎✓🎯🧪✍️⚠️🛑🔴🚗🚙🚕🎉✨❗⇧⌥⌘×÷¦⋯…↑↓→→←↔⟷·•●◆▼⟪⟫┌└├⏎↩√φ]+)/g.exec(line); if (m) { console.error( file.relative + `(${i + 1},${m.index + 1}): Unexpected unicode character: "${m[0]}" (charCode: ${m[0].charCodeAt(0)}). To suppress, use // allow-any-unicode-next-line` diff --git a/build/lib/bundle.js b/build/lib/bundle.js index f1490f4ad4b..382b648defb 100644 --- a/build/lib/bundle.js +++ b/build/lib/bundle.js @@ -3,229 +3,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; Object.defineProperty(exports, "__esModule", { value: true }); -exports.bundle = bundle; exports.removeAllTSBoilerplate = removeAllTSBoilerplate; -const fs_1 = __importDefault(require("fs")); -const path_1 = __importDefault(require("path")); -const vm_1 = __importDefault(require("vm")); -/** - * Bundle `entryPoints` given config `config`. - */ -function bundle(entryPoints, config, callback) { - const entryPointsMap = {}; - entryPoints.forEach((module) => { - if (entryPointsMap[module.name]) { - throw new Error(`Cannot have two entry points with the same name '${module.name}'`); - } - entryPointsMap[module.name] = module; - }); - const allMentionedModulesMap = {}; - entryPoints.forEach((module) => { - allMentionedModulesMap[module.name] = true; - module.include?.forEach(function (includedModule) { - allMentionedModulesMap[includedModule] = true; - }); - module.exclude?.forEach(function (excludedModule) { - allMentionedModulesMap[excludedModule] = true; - }); - }); - const code = require('fs').readFileSync(path_1.default.join(__dirname, '../../src/vs/loader.js')); - const r = vm_1.default.runInThisContext('(function(require, module, exports) { ' + code + '\n});'); - const loaderModule = { exports: {} }; - r.call({}, require, loaderModule, loaderModule.exports); - const loader = loaderModule.exports; - config.isBuild = true; - config.paths = config.paths || {}; - if (!config.paths['vs/css']) { - config.paths['vs/css'] = 'out-build/vs/css.build'; - } - config.buildForceInvokeFactory = config.buildForceInvokeFactory || {}; - config.buildForceInvokeFactory['vs/css'] = true; - loader.config(config); - loader(['require'], (localRequire) => { - const resolvePath = (entry) => { - let r = localRequire.toUrl(entry.path); - if (!r.endsWith('.js')) { - r += '.js'; - } - // avoid packaging the build version of plugins: - r = r.replace('vs/css.build.js', 'vs/css.js'); - return { path: r, amdModuleId: entry.amdModuleId }; - }; - for (const moduleId in entryPointsMap) { - const entryPoint = entryPointsMap[moduleId]; - if (entryPoint.prepend) { - entryPoint.prepend = entryPoint.prepend.map(resolvePath); - } - } - }); - loader(Object.keys(allMentionedModulesMap), () => { - const modules = loader.getBuildInfo(); - const partialResult = emitEntryPoints(modules, entryPointsMap); - const cssInlinedResources = loader('vs/css').getInlinedResources(); - callback(null, { - files: partialResult.files, - cssInlinedResources: cssInlinedResources, - bundleData: partialResult.bundleData - }); - }, (err) => callback(err, null)); -} -function emitEntryPoints(modules, entryPoints) { - const modulesMap = {}; - modules.forEach((m) => { - modulesMap[m.id] = m; - }); - const modulesGraph = {}; - modules.forEach((m) => { - modulesGraph[m.id] = m.dependencies; - }); - const sortedModules = topologicalSort(modulesGraph); - let result = []; - const usedPlugins = {}; - const bundleData = { - graph: modulesGraph, - bundles: {} - }; - Object.keys(entryPoints).forEach((moduleToBundle) => { - const info = entryPoints[moduleToBundle]; - const rootNodes = [moduleToBundle].concat(info.include || []); - const allDependencies = visit(rootNodes, modulesGraph); - const excludes = ['require', 'exports', 'module'].concat(info.exclude || []); - excludes.forEach((excludeRoot) => { - const allExcludes = visit([excludeRoot], modulesGraph); - Object.keys(allExcludes).forEach((exclude) => { - delete allDependencies[exclude]; - }); - }); - const includedModules = sortedModules.filter((module) => { - return allDependencies[module]; - }); - bundleData.bundles[moduleToBundle] = includedModules; - const res = emitEntryPoint(modulesMap, modulesGraph, moduleToBundle, includedModules, info.prepend || [], info.dest); - result = result.concat(res.files); - for (const pluginName in res.usedPlugins) { - usedPlugins[pluginName] = usedPlugins[pluginName] || res.usedPlugins[pluginName]; - } - }); - Object.keys(usedPlugins).forEach((pluginName) => { - const plugin = usedPlugins[pluginName]; - if (typeof plugin.finishBuild === 'function') { - const write = (filename, contents) => { - result.push({ - dest: filename, - sources: [{ - path: null, - contents: contents - }] - }); - }; - plugin.finishBuild(write); - } - }); - return { - // TODO@TS 2.1.2 - files: extractStrings(removeAllDuplicateTSBoilerplate(result)), - bundleData: bundleData - }; -} -function extractStrings(destFiles) { - const parseDefineCall = (moduleMatch, depsMatch) => { - const module = moduleMatch.replace(/^"|"$/g, ''); - let deps = depsMatch.split(','); - deps = deps.map((dep) => { - dep = dep.trim(); - dep = dep.replace(/^"|"$/g, ''); - dep = dep.replace(/^'|'$/g, ''); - let prefix = null; - let _path = null; - const pieces = dep.split('!'); - if (pieces.length > 1) { - prefix = pieces[0] + '!'; - _path = pieces[1]; - } - else { - prefix = ''; - _path = pieces[0]; - } - if (/^\.\//.test(_path) || /^\.\.\//.test(_path)) { - const res = path_1.default.join(path_1.default.dirname(module), _path).replace(/\\/g, '/'); - return prefix + res; - } - return prefix + _path; - }); - return { - module: module, - deps: deps - }; - }; - destFiles.forEach((destFile) => { - if (!/\.js$/.test(destFile.dest)) { - return; - } - if (/\.nls\.js$/.test(destFile.dest)) { - return; - } - // Do one pass to record the usage counts for each module id - const useCounts = {}; - destFile.sources.forEach((source) => { - const matches = source.contents.match(/define\(("[^"]+"),\s*\[(((, )?("|')[^"']+("|'))+)\]/); - if (!matches) { - return; - } - const defineCall = parseDefineCall(matches[1], matches[2]); - useCounts[defineCall.module] = (useCounts[defineCall.module] || 0) + 1; - defineCall.deps.forEach((dep) => { - useCounts[dep] = (useCounts[dep] || 0) + 1; - }); - }); - const sortedByUseModules = Object.keys(useCounts); - sortedByUseModules.sort((a, b) => { - return useCounts[b] - useCounts[a]; - }); - const replacementMap = {}; - sortedByUseModules.forEach((module, index) => { - replacementMap[module] = index; - }); - destFile.sources.forEach((source) => { - source.contents = source.contents.replace(/define\(("[^"]+"),\s*\[(((, )?("|')[^"']+("|'))+)\]/, (_, moduleMatch, depsMatch) => { - const defineCall = parseDefineCall(moduleMatch, depsMatch); - return `define(__m[${replacementMap[defineCall.module]}/*${defineCall.module}*/], __M([${defineCall.deps.map(dep => replacementMap[dep] + '/*' + dep + '*/').join(',')}])`; - }); - }); - destFile.sources.unshift({ - path: null, - contents: [ - '(function() {', - `var __m = ${JSON.stringify(sortedByUseModules)};`, - `var __M = function(deps) {`, - ` var result = [];`, - ` for (var i = 0, len = deps.length; i < len; i++) {`, - ` result[i] = __m[deps[i]];`, - ` }`, - ` return result;`, - `};` - ].join('\n') - }); - destFile.sources.push({ - path: null, - contents: '}).call(this);' - }); - }); - return destFiles; -} -function removeAllDuplicateTSBoilerplate(destFiles) { - destFiles.forEach((destFile) => { - const SEEN_BOILERPLATE = []; - destFile.sources.forEach((source) => { - source.contents = removeDuplicateTSBoilerplate(source.contents, SEEN_BOILERPLATE); - }); - }); - return destFiles; -} function removeAllTSBoilerplate(source) { const seen = new Array(BOILERPLATE.length).fill(true, 0, BOILERPLATE.length); return removeDuplicateTSBoilerplate(source, seen); @@ -280,213 +59,4 @@ function removeDuplicateTSBoilerplate(source, SEEN_BOILERPLATE = []) { } return newLines.join('\n'); } -function emitEntryPoint(modulesMap, deps, entryPoint, includedModules, prepend, dest) { - if (!dest) { - dest = entryPoint + '.js'; - } - const mainResult = { - sources: [], - dest: dest - }, results = [mainResult]; - const usedPlugins = {}; - const getLoaderPlugin = (pluginName) => { - if (!usedPlugins[pluginName]) { - usedPlugins[pluginName] = modulesMap[pluginName].exports; - } - return usedPlugins[pluginName]; - }; - includedModules.forEach((c) => { - const bangIndex = c.indexOf('!'); - if (bangIndex >= 0) { - const pluginName = c.substr(0, bangIndex); - const plugin = getLoaderPlugin(pluginName); - mainResult.sources.push(emitPlugin(entryPoint, plugin, pluginName, c.substr(bangIndex + 1))); - return; - } - const module = modulesMap[c]; - if (module.path === 'empty:') { - return; - } - const contents = readFileAndRemoveBOM(module.path); - if (module.shim) { - mainResult.sources.push(emitShimmedModule(c, deps[c], module.shim, module.path, contents)); - } - else if (module.defineLocation) { - mainResult.sources.push(emitNamedModule(c, module.defineLocation, module.path, contents)); - } - else { - const moduleCopy = { - id: module.id, - path: module.path, - defineLocation: module.defineLocation, - dependencies: module.dependencies - }; - throw new Error(`Cannot bundle module '${module.id}' for entry point '${entryPoint}' because it has no shim and it lacks a defineLocation: ${JSON.stringify(moduleCopy)}`); - } - }); - Object.keys(usedPlugins).forEach((pluginName) => { - const plugin = usedPlugins[pluginName]; - if (typeof plugin.writeFile === 'function') { - const req = (() => { - throw new Error('no-no!'); - }); - req.toUrl = something => something; - const write = (filename, contents) => { - results.push({ - dest: filename, - sources: [{ - path: null, - contents: contents - }] - }); - }; - plugin.writeFile(pluginName, entryPoint, req, write, {}); - } - }); - const toIFile = (entry) => { - let contents = readFileAndRemoveBOM(entry.path); - if (entry.amdModuleId) { - contents = contents.replace(/^define\(/m, `define("${entry.amdModuleId}",`); - } - return { - path: entry.path, - contents: contents - }; - }; - const toPrepend = (prepend || []).map(toIFile); - mainResult.sources = toPrepend.concat(mainResult.sources); - return { - files: results, - usedPlugins: usedPlugins - }; -} -function readFileAndRemoveBOM(path) { - const BOM_CHAR_CODE = 65279; - let contents = fs_1.default.readFileSync(path, 'utf8'); - // Remove BOM - if (contents.charCodeAt(0) === BOM_CHAR_CODE) { - contents = contents.substring(1); - } - return contents; -} -function emitPlugin(entryPoint, plugin, pluginName, moduleName) { - let result = ''; - if (typeof plugin.write === 'function') { - const write = ((what) => { - result += what; - }); - write.getEntryPoint = () => { - return entryPoint; - }; - write.asModule = (moduleId, code) => { - code = code.replace(/^define\(/, 'define("' + moduleId + '",'); - result += code; - }; - plugin.write(pluginName, moduleName, write); - } - return { - path: null, - contents: result - }; -} -function emitNamedModule(moduleId, defineCallPosition, path, contents) { - // `defineCallPosition` is the position in code: |define() - const defineCallOffset = positionToOffset(contents, defineCallPosition.line, defineCallPosition.col); - // `parensOffset` is the position in code: define|() - const parensOffset = contents.indexOf('(', defineCallOffset); - const insertStr = '"' + moduleId + '", '; - return { - path: path, - contents: contents.substr(0, parensOffset + 1) + insertStr + contents.substr(parensOffset + 1) - }; -} -function emitShimmedModule(moduleId, myDeps, factory, path, contents) { - const strDeps = (myDeps.length > 0 ? '"' + myDeps.join('", "') + '"' : ''); - const strDefine = 'define("' + moduleId + '", [' + strDeps + '], ' + factory + ');'; - return { - path: path, - contents: contents + '\n;\n' + strDefine - }; -} -/** - * Convert a position (line:col) to (offset) in string `str` - */ -function positionToOffset(str, desiredLine, desiredCol) { - if (desiredLine === 1) { - return desiredCol - 1; - } - let line = 1; - let lastNewLineOffset = -1; - do { - if (desiredLine === line) { - return lastNewLineOffset + 1 + desiredCol - 1; - } - lastNewLineOffset = str.indexOf('\n', lastNewLineOffset + 1); - line++; - } while (lastNewLineOffset >= 0); - return -1; -} -/** - * Return a set of reachable nodes in `graph` starting from `rootNodes` - */ -function visit(rootNodes, graph) { - const result = {}; - const queue = rootNodes; - rootNodes.forEach((node) => { - result[node] = true; - }); - while (queue.length > 0) { - const el = queue.shift(); - const myEdges = graph[el] || []; - myEdges.forEach((toNode) => { - if (!result[toNode]) { - result[toNode] = true; - queue.push(toNode); - } - }); - } - return result; -} -/** - * Perform a topological sort on `graph` - */ -function topologicalSort(graph) { - const allNodes = {}, outgoingEdgeCount = {}, inverseEdges = {}; - Object.keys(graph).forEach((fromNode) => { - allNodes[fromNode] = true; - outgoingEdgeCount[fromNode] = graph[fromNode].length; - graph[fromNode].forEach((toNode) => { - allNodes[toNode] = true; - outgoingEdgeCount[toNode] = outgoingEdgeCount[toNode] || 0; - inverseEdges[toNode] = inverseEdges[toNode] || []; - inverseEdges[toNode].push(fromNode); - }); - }); - // https://en.wikipedia.org/wiki/Topological_sorting - const S = [], L = []; - Object.keys(allNodes).forEach((node) => { - if (outgoingEdgeCount[node] === 0) { - delete outgoingEdgeCount[node]; - S.push(node); - } - }); - while (S.length > 0) { - // Ensure the exact same order all the time with the same inputs - S.sort(); - const n = S.shift(); - L.push(n); - const myInverseEdges = inverseEdges[n] || []; - myInverseEdges.forEach((m) => { - outgoingEdgeCount[m]--; - if (outgoingEdgeCount[m] === 0) { - delete outgoingEdgeCount[m]; - S.push(m); - } - }); - } - if (Object.keys(outgoingEdgeCount).length > 0) { - throw new Error('Cannot do topological sort on cyclic graph, remaining nodes: ' + Object.keys(outgoingEdgeCount)); - } - return L; -} //# sourceMappingURL=bundle.js.map \ No newline at end of file diff --git a/build/lib/bundle.ts b/build/lib/bundle.ts index 68182e6b85d..708bb5e93d4 100644 --- a/build/lib/bundle.ts +++ b/build/lib/bundle.ts @@ -3,360 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; - -interface IPosition { - line: number; - col: number; -} - -interface IBuildModuleInfo { - id: string; - path: string; - defineLocation: IPosition | null; - dependencies: string[]; - shim: string; - exports: any; -} - -interface IBuildModuleInfoMap { - [moduleId: string]: IBuildModuleInfo; -} - -interface ILoaderPlugin { - write(pluginName: string, moduleName: string, write: ILoaderPluginWriteFunc): void; - writeFile(pluginName: string, entryPoint: string, req: ILoaderPluginReqFunc, write: (filename: string, contents: string) => void, config: any): void; - finishBuild(write: (filename: string, contents: string) => void): void; -} - -interface ILoaderPluginWriteFunc { - (something: string): void; - getEntryPoint(): string; - asModule(moduleId: string, code: string): void; -} - -interface ILoaderPluginReqFunc { - (something: string): void; - toUrl(something: string): string; -} - -export interface IExtraFile { - path: string; - amdModuleId?: string; -} - export interface IEntryPoint { name: string; include?: string[]; - exclude?: string[]; - /** @deprecated unsupported by ESM */ - prepend?: IExtraFile[]; dest?: string; } -interface IEntryPointMap { - [moduleId: string]: IEntryPoint; -} - -export interface IGraph { - [node: string]: string[]; -} - -interface INodeSet { - [node: string]: boolean; -} - -export interface IFile { - path: string | null; - contents: string; -} - -export interface IConcatFile { - dest: string; - sources: IFile[]; -} - -export interface IBundleData { - graph: IGraph; - bundles: { [moduleId: string]: string[] }; -} - -export interface IBundleResult { - files: IConcatFile[]; - cssInlinedResources: string[]; - bundleData: IBundleData; -} - -interface IPartialBundleResult { - files: IConcatFile[]; - bundleData: IBundleData; -} - -export interface ILoaderConfig { - isBuild?: boolean; - paths?: { [path: string]: any }; - /* - * Normally, during a build, no module factories are invoked. This can be used - * to forcefully execute a module's factory. - */ - buildForceInvokeFactory: { - [moduleId: string]: boolean; - }; -} - -/** - * Bundle `entryPoints` given config `config`. - */ -export function bundle(entryPoints: IEntryPoint[], config: ILoaderConfig, callback: (err: any, result: IBundleResult | null) => void): void { - const entryPointsMap: IEntryPointMap = {}; - entryPoints.forEach((module: IEntryPoint) => { - if (entryPointsMap[module.name]) { - throw new Error(`Cannot have two entry points with the same name '${module.name}'`); - } - entryPointsMap[module.name] = module; - }); - - const allMentionedModulesMap: { [modules: string]: boolean } = {}; - entryPoints.forEach((module: IEntryPoint) => { - allMentionedModulesMap[module.name] = true; - module.include?.forEach(function (includedModule) { - allMentionedModulesMap[includedModule] = true; - }); - module.exclude?.forEach(function (excludedModule) { - allMentionedModulesMap[excludedModule] = true; - }); - }); - - - const code = require('fs').readFileSync(path.join(__dirname, '../../src/vs/loader.js')); - const r: Function = vm.runInThisContext('(function(require, module, exports) { ' + code + '\n});'); - const loaderModule = { exports: {} }; - r.call({}, require, loaderModule, loaderModule.exports); - - const loader: any = loaderModule.exports; - config.isBuild = true; - config.paths = config.paths || {}; - if (!config.paths['vs/css']) { - config.paths['vs/css'] = 'out-build/vs/css.build'; - } - config.buildForceInvokeFactory = config.buildForceInvokeFactory || {}; - config.buildForceInvokeFactory['vs/css'] = true; - loader.config(config); - - loader(['require'], (localRequire: any) => { - const resolvePath = (entry: IExtraFile) => { - let r = localRequire.toUrl(entry.path); - if (!r.endsWith('.js')) { - r += '.js'; - } - // avoid packaging the build version of plugins: - r = r.replace('vs/css.build.js', 'vs/css.js'); - return { path: r, amdModuleId: entry.amdModuleId }; - }; - for (const moduleId in entryPointsMap) { - const entryPoint = entryPointsMap[moduleId]; - if (entryPoint.prepend) { - entryPoint.prepend = entryPoint.prepend.map(resolvePath); - } - } - }); - - loader(Object.keys(allMentionedModulesMap), () => { - const modules = loader.getBuildInfo(); - const partialResult = emitEntryPoints(modules, entryPointsMap); - const cssInlinedResources = loader('vs/css').getInlinedResources(); - callback(null, { - files: partialResult.files, - cssInlinedResources: cssInlinedResources, - bundleData: partialResult.bundleData - }); - }, (err: any) => callback(err, null)); -} - -function emitEntryPoints(modules: IBuildModuleInfo[], entryPoints: IEntryPointMap): IPartialBundleResult { - const modulesMap: IBuildModuleInfoMap = {}; - modules.forEach((m: IBuildModuleInfo) => { - modulesMap[m.id] = m; - }); - - const modulesGraph: IGraph = {}; - modules.forEach((m: IBuildModuleInfo) => { - modulesGraph[m.id] = m.dependencies; - }); - - const sortedModules = topologicalSort(modulesGraph); - - let result: IConcatFile[] = []; - const usedPlugins: IPluginMap = {}; - const bundleData: IBundleData = { - graph: modulesGraph, - bundles: {} - }; - - Object.keys(entryPoints).forEach((moduleToBundle: string) => { - const info = entryPoints[moduleToBundle]; - const rootNodes = [moduleToBundle].concat(info.include || []); - const allDependencies = visit(rootNodes, modulesGraph); - const excludes: string[] = ['require', 'exports', 'module'].concat(info.exclude || []); - - excludes.forEach((excludeRoot: string) => { - const allExcludes = visit([excludeRoot], modulesGraph); - Object.keys(allExcludes).forEach((exclude: string) => { - delete allDependencies[exclude]; - }); - }); - - const includedModules = sortedModules.filter((module: string) => { - return allDependencies[module]; - }); - - bundleData.bundles[moduleToBundle] = includedModules; - - const res = emitEntryPoint( - modulesMap, - modulesGraph, - moduleToBundle, - includedModules, - info.prepend || [], - info.dest - ); - - result = result.concat(res.files); - for (const pluginName in res.usedPlugins) { - usedPlugins[pluginName] = usedPlugins[pluginName] || res.usedPlugins[pluginName]; - } - }); - - Object.keys(usedPlugins).forEach((pluginName: string) => { - const plugin = usedPlugins[pluginName]; - if (typeof plugin.finishBuild === 'function') { - const write = (filename: string, contents: string) => { - result.push({ - dest: filename, - sources: [{ - path: null, - contents: contents - }] - }); - }; - plugin.finishBuild(write); - } - }); - - return { - // TODO@TS 2.1.2 - files: extractStrings(removeAllDuplicateTSBoilerplate(result)), - bundleData: bundleData - }; -} - -function extractStrings(destFiles: IConcatFile[]): IConcatFile[] { - const parseDefineCall = (moduleMatch: string, depsMatch: string) => { - const module = moduleMatch.replace(/^"|"$/g, ''); - let deps = depsMatch.split(','); - deps = deps.map((dep) => { - dep = dep.trim(); - dep = dep.replace(/^"|"$/g, ''); - dep = dep.replace(/^'|'$/g, ''); - let prefix: string | null = null; - let _path: string | null = null; - const pieces = dep.split('!'); - if (pieces.length > 1) { - prefix = pieces[0] + '!'; - _path = pieces[1]; - } else { - prefix = ''; - _path = pieces[0]; - } - - if (/^\.\//.test(_path) || /^\.\.\//.test(_path)) { - const res = path.join(path.dirname(module), _path).replace(/\\/g, '/'); - return prefix + res; - } - return prefix + _path; - }); - return { - module: module, - deps: deps - }; - }; - - destFiles.forEach((destFile) => { - if (!/\.js$/.test(destFile.dest)) { - return; - } - if (/\.nls\.js$/.test(destFile.dest)) { - return; - } - - // Do one pass to record the usage counts for each module id - const useCounts: { [moduleId: string]: number } = {}; - destFile.sources.forEach((source) => { - const matches = source.contents.match(/define\(("[^"]+"),\s*\[(((, )?("|')[^"']+("|'))+)\]/); - if (!matches) { - return; - } - - const defineCall = parseDefineCall(matches[1], matches[2]); - useCounts[defineCall.module] = (useCounts[defineCall.module] || 0) + 1; - defineCall.deps.forEach((dep) => { - useCounts[dep] = (useCounts[dep] || 0) + 1; - }); - }); - - const sortedByUseModules = Object.keys(useCounts); - sortedByUseModules.sort((a, b) => { - return useCounts[b] - useCounts[a]; - }); - - const replacementMap: { [moduleId: string]: number } = {}; - sortedByUseModules.forEach((module, index) => { - replacementMap[module] = index; - }); - - destFile.sources.forEach((source) => { - source.contents = source.contents.replace(/define\(("[^"]+"),\s*\[(((, )?("|')[^"']+("|'))+)\]/, (_, moduleMatch, depsMatch) => { - const defineCall = parseDefineCall(moduleMatch, depsMatch); - return `define(__m[${replacementMap[defineCall.module]}/*${defineCall.module}*/], __M([${defineCall.deps.map(dep => replacementMap[dep] + '/*' + dep + '*/').join(',')}])`; - }); - }); - - destFile.sources.unshift({ - path: null, - contents: [ - '(function() {', - `var __m = ${JSON.stringify(sortedByUseModules)};`, - `var __M = function(deps) {`, - ` var result = [];`, - ` for (var i = 0, len = deps.length; i < len; i++) {`, - ` result[i] = __m[deps[i]];`, - ` }`, - ` return result;`, - `};` - ].join('\n') - }); - - destFile.sources.push({ - path: null, - contents: '}).call(this);' - }); - }); - return destFiles; -} - -function removeAllDuplicateTSBoilerplate(destFiles: IConcatFile[]): IConcatFile[] { - destFiles.forEach((destFile) => { - const SEEN_BOILERPLATE: boolean[] = []; - destFile.sources.forEach((source) => { - source.contents = removeDuplicateTSBoilerplate(source.contents, SEEN_BOILERPLATE); - }); - }); - - return destFiles; -} - export function removeAllTSBoilerplate(source: string) { const seen = new Array(BOILERPLATE.length).fill(true, 0, BOILERPLATE.length); return removeDuplicateTSBoilerplate(source, seen); @@ -411,273 +63,3 @@ function removeDuplicateTSBoilerplate(source: string, SEEN_BOILERPLATE: boolean[ } return newLines.join('\n'); } - -interface IPluginMap { - [moduleId: string]: ILoaderPlugin; -} - -interface IEmitEntryPointResult { - files: IConcatFile[]; - usedPlugins: IPluginMap; -} - -function emitEntryPoint( - modulesMap: IBuildModuleInfoMap, - deps: IGraph, - entryPoint: string, - includedModules: string[], - prepend: IExtraFile[], - dest: string | undefined -): IEmitEntryPointResult { - if (!dest) { - dest = entryPoint + '.js'; - } - const mainResult: IConcatFile = { - sources: [], - dest: dest - }, - results: IConcatFile[] = [mainResult]; - - const usedPlugins: IPluginMap = {}; - const getLoaderPlugin = (pluginName: string): ILoaderPlugin => { - if (!usedPlugins[pluginName]) { - usedPlugins[pluginName] = modulesMap[pluginName].exports; - } - return usedPlugins[pluginName]; - }; - - includedModules.forEach((c: string) => { - const bangIndex = c.indexOf('!'); - - if (bangIndex >= 0) { - const pluginName = c.substr(0, bangIndex); - const plugin = getLoaderPlugin(pluginName); - mainResult.sources.push(emitPlugin(entryPoint, plugin, pluginName, c.substr(bangIndex + 1))); - return; - } - - const module = modulesMap[c]; - - if (module.path === 'empty:') { - return; - } - - const contents = readFileAndRemoveBOM(module.path); - - if (module.shim) { - mainResult.sources.push(emitShimmedModule(c, deps[c], module.shim, module.path, contents)); - } else if (module.defineLocation) { - mainResult.sources.push(emitNamedModule(c, module.defineLocation, module.path, contents)); - } else { - const moduleCopy = { - id: module.id, - path: module.path, - defineLocation: module.defineLocation, - dependencies: module.dependencies - }; - throw new Error(`Cannot bundle module '${module.id}' for entry point '${entryPoint}' because it has no shim and it lacks a defineLocation: ${JSON.stringify(moduleCopy)}`); - } - }); - - Object.keys(usedPlugins).forEach((pluginName: string) => { - const plugin = usedPlugins[pluginName]; - if (typeof plugin.writeFile === 'function') { - const req: ILoaderPluginReqFunc = (() => { - throw new Error('no-no!'); - }); - req.toUrl = something => something; - - const write = (filename: string, contents: string) => { - results.push({ - dest: filename, - sources: [{ - path: null, - contents: contents - }] - }); - }; - plugin.writeFile(pluginName, entryPoint, req, write, {}); - } - }); - - const toIFile = (entry: IExtraFile): IFile => { - let contents = readFileAndRemoveBOM(entry.path); - if (entry.amdModuleId) { - contents = contents.replace(/^define\(/m, `define("${entry.amdModuleId}",`); - } - return { - path: entry.path, - contents: contents - }; - }; - - const toPrepend = (prepend || []).map(toIFile); - - mainResult.sources = toPrepend.concat(mainResult.sources); - - return { - files: results, - usedPlugins: usedPlugins - }; -} - -function readFileAndRemoveBOM(path: string): string { - const BOM_CHAR_CODE = 65279; - let contents = fs.readFileSync(path, 'utf8'); - // Remove BOM - if (contents.charCodeAt(0) === BOM_CHAR_CODE) { - contents = contents.substring(1); - } - return contents; -} - -function emitPlugin(entryPoint: string, plugin: ILoaderPlugin, pluginName: string, moduleName: string): IFile { - let result = ''; - if (typeof plugin.write === 'function') { - const write: ILoaderPluginWriteFunc = ((what: string) => { - result += what; - }); - write.getEntryPoint = () => { - return entryPoint; - }; - write.asModule = (moduleId: string, code: string) => { - code = code.replace(/^define\(/, 'define("' + moduleId + '",'); - result += code; - }; - plugin.write(pluginName, moduleName, write); - } - return { - path: null, - contents: result - }; -} - -function emitNamedModule(moduleId: string, defineCallPosition: IPosition, path: string, contents: string): IFile { - - // `defineCallPosition` is the position in code: |define() - const defineCallOffset = positionToOffset(contents, defineCallPosition.line, defineCallPosition.col); - - // `parensOffset` is the position in code: define|() - const parensOffset = contents.indexOf('(', defineCallOffset); - - const insertStr = '"' + moduleId + '", '; - - return { - path: path, - contents: contents.substr(0, parensOffset + 1) + insertStr + contents.substr(parensOffset + 1) - }; -} - -function emitShimmedModule(moduleId: string, myDeps: string[], factory: string, path: string, contents: string): IFile { - const strDeps = (myDeps.length > 0 ? '"' + myDeps.join('", "') + '"' : ''); - const strDefine = 'define("' + moduleId + '", [' + strDeps + '], ' + factory + ');'; - return { - path: path, - contents: contents + '\n;\n' + strDefine - }; -} - -/** - * Convert a position (line:col) to (offset) in string `str` - */ -function positionToOffset(str: string, desiredLine: number, desiredCol: number): number { - if (desiredLine === 1) { - return desiredCol - 1; - } - - let line = 1; - let lastNewLineOffset = -1; - - do { - if (desiredLine === line) { - return lastNewLineOffset + 1 + desiredCol - 1; - } - lastNewLineOffset = str.indexOf('\n', lastNewLineOffset + 1); - line++; - } while (lastNewLineOffset >= 0); - - return -1; -} - - -/** - * Return a set of reachable nodes in `graph` starting from `rootNodes` - */ -function visit(rootNodes: string[], graph: IGraph): INodeSet { - const result: INodeSet = {}; - const queue = rootNodes; - - rootNodes.forEach((node) => { - result[node] = true; - }); - - while (queue.length > 0) { - const el = queue.shift(); - const myEdges = graph[el!] || []; - myEdges.forEach((toNode) => { - if (!result[toNode]) { - result[toNode] = true; - queue.push(toNode); - } - }); - } - - return result; -} - -/** - * Perform a topological sort on `graph` - */ -function topologicalSort(graph: IGraph): string[] { - - const allNodes: INodeSet = {}, - outgoingEdgeCount: { [node: string]: number } = {}, - inverseEdges: IGraph = {}; - - Object.keys(graph).forEach((fromNode: string) => { - allNodes[fromNode] = true; - outgoingEdgeCount[fromNode] = graph[fromNode].length; - - graph[fromNode].forEach((toNode) => { - allNodes[toNode] = true; - outgoingEdgeCount[toNode] = outgoingEdgeCount[toNode] || 0; - - inverseEdges[toNode] = inverseEdges[toNode] || []; - inverseEdges[toNode].push(fromNode); - }); - }); - - // https://en.wikipedia.org/wiki/Topological_sorting - const S: string[] = [], - L: string[] = []; - - Object.keys(allNodes).forEach((node: string) => { - if (outgoingEdgeCount[node] === 0) { - delete outgoingEdgeCount[node]; - S.push(node); - } - }); - - while (S.length > 0) { - // Ensure the exact same order all the time with the same inputs - S.sort(); - - const n: string = S.shift()!; - L.push(n); - - const myInverseEdges = inverseEdges[n] || []; - myInverseEdges.forEach((m: string) => { - outgoingEdgeCount[m]--; - if (outgoingEdgeCount[m] === 0) { - delete outgoingEdgeCount[m]; - S.push(m); - } - }); - } - - if (Object.keys(outgoingEdgeCount).length > 0) { - throw new Error('Cannot do topological sort on cyclic graph, remaining nodes: ' + Object.keys(outgoingEdgeCount)); - } - - return L; -} diff --git a/build/lib/compilation.js b/build/lib/compilation.js index 841dbe13ecf..55fb148097c 100644 --- a/build/lib/compilation.js +++ b/build/lib/compilation.js @@ -41,6 +41,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); exports.watchApiProposalNamesTask = exports.compileApiProposalNamesTask = void 0; +exports.createCompile = createCompile; exports.transpileTask = transpileTask; exports.compileTask = compileTask; exports.watchTask = watchTask; diff --git a/build/lib/compilation.ts b/build/lib/compilation.ts index 6e1fcab5186..625fc430a94 100644 --- a/build/lib/compilation.ts +++ b/build/lib/compilation.ts @@ -49,7 +49,7 @@ interface ICompileTaskOptions { readonly preserveEnglish: boolean; } -function createCompile(src: string, { build, emitError, transpileOnly, preserveEnglish }: ICompileTaskOptions) { +export function createCompile(src: string, { build, emitError, transpileOnly, preserveEnglish }: ICompileTaskOptions) { const tsb = require('./tsb') as typeof import('./tsb'); const sourcemaps = require('gulp-sourcemaps') as typeof import('gulp-sourcemaps'); diff --git a/build/lib/i18n.js b/build/lib/i18n.js index 9483d319a50..1d3bfb901b8 100644 --- a/build/lib/i18n.js +++ b/build/lib/i18n.js @@ -319,9 +319,10 @@ globalThis._VSCODE_NLS_LANGUAGE=${JSON.stringify(language.id)};`), function processNlsFiles(opts) { return (0, event_stream_1.through)(function (file) { const fileName = path_1.default.basename(file.path); - if (fileName === 'bundleInfo.json') { // pick a root level file to put the core bundles (TODO@esm this file is not created anymore, pick another) + if (fileName === 'nls.keys.json') { try { - const json = JSON.parse(fs_1.default.readFileSync(path_1.default.join(REPO_ROOT_PATH, opts.out, 'nls.keys.json')).toString()); + const contents = file.contents.toString('utf8'); + const json = JSON.parse(contents); if (NLSKeysFormat.is(json)) { processCoreBundleFormat(file.base, opts.fileHeader, opts.languages, json, this); } diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 2b510757855..921137824ee 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -330,10 +330,6 @@ "name": "vs/workbench/contrib/accessibilitySignals", "project": "vscode-workbench" }, - { - "name": "vs/workbench/contrib/deprecatedExtensionMigrator", - "project": "vscode-workbench" - }, { "name": "vs/workbench/contrib/bracketPairColorizer2Telemetry", "project": "vscode-workbench" diff --git a/build/lib/i18n.ts b/build/lib/i18n.ts index d2904ccf0fb..96468033719 100644 --- a/build/lib/i18n.ts +++ b/build/lib/i18n.ts @@ -387,9 +387,10 @@ globalThis._VSCODE_NLS_LANGUAGE=${JSON.stringify(language.id)};`), export function processNlsFiles(opts: { out: string; fileHeader: string; languages: Language[] }): ThroughStream { return through(function (this: ThroughStream, file: File) { const fileName = path.basename(file.path); - if (fileName === 'bundleInfo.json') { // pick a root level file to put the core bundles (TODO@esm this file is not created anymore, pick another) + if (fileName === 'nls.keys.json') { try { - const json = JSON.parse(fs.readFileSync(path.join(REPO_ROOT_PATH, opts.out, 'nls.keys.json')).toString()); + const contents = file.contents.toString('utf8'); + const json = JSON.parse(contents); if (NLSKeysFormat.is(json)) { processCoreBundleFormat(file.base, opts.fileHeader, opts.languages, json, this); } diff --git a/build/lib/layersChecker.js b/build/lib/layersChecker.js index 2cab7b81832..83bd45e9d3f 100644 --- a/build/lib/layersChecker.js +++ b/build/lib/layersChecker.js @@ -79,11 +79,20 @@ const CORE_TYPES = [ 'PerformanceMark', 'PerformanceObserver', 'ImportMeta', + 'structuredClone', // webcrypto has been available since Node.js 19, but still live in dom.d.ts 'Crypto', 'SubtleCrypto', 'JsonWebKey', 'MessageEvent', + // node web types + 'ReadableStream', + 'ReadableStreamReadResult', + 'ReadableStreamGenericReader', + 'ReadableStreamDefaultReader', + 'value', + 'done', + 'DOMException', ]; // Types that are defined in a common layer but are known to be only // available in native environments should not be allowed in browser diff --git a/build/lib/layersChecker.ts b/build/lib/layersChecker.ts index 685dd59cb36..677b505ab22 100644 --- a/build/lib/layersChecker.ts +++ b/build/lib/layersChecker.ts @@ -77,12 +77,22 @@ const CORE_TYPES = [ 'PerformanceMark', 'PerformanceObserver', 'ImportMeta', + 'structuredClone', // webcrypto has been available since Node.js 19, but still live in dom.d.ts 'Crypto', 'SubtleCrypto', 'JsonWebKey', 'MessageEvent', + + // node web types + 'ReadableStream', + 'ReadableStreamReadResult', + 'ReadableStreamGenericReader', + 'ReadableStreamDefaultReader', + 'value', + 'done', + 'DOMException', ]; // Types that are defined in a common layer but are known to be only diff --git a/build/lib/optimize.js b/build/lib/optimize.js index d75a2978697..105dc02a79d 100644 --- a/build/lib/optimize.js +++ b/build/lib/optimize.js @@ -70,12 +70,6 @@ function bundleESMTask(opts) { } return entryPoint; }); - const allMentionedModules = new Set(); - for (const entryPoint of entryPoints) { - allMentionedModules.add(entryPoint.name); - entryPoint.include?.forEach(allMentionedModules.add, allMentionedModules); - entryPoint.exclude?.forEach(allMentionedModules.add, allMentionedModules); - } const bundleAsync = async () => { const files = []; const tasks = []; @@ -127,7 +121,6 @@ function bundleESMTask(opts) { }; const task = esbuild_1.default.build({ bundle: true, - external: entryPoint.exclude, packages: 'external', // "external all the things", see https://esbuild.github.io/api/#packages platform: 'neutral', // makes esm format: 'esm', diff --git a/build/lib/optimize.ts b/build/lib/optimize.ts index e12c85c3fa0..88a2a25dae4 100644 --- a/build/lib/optimize.ts +++ b/build/lib/optimize.ts @@ -62,13 +62,6 @@ function bundleESMTask(opts: IBundleESMTaskOpts): NodeJS.ReadWriteStream { return entryPoint; }); - const allMentionedModules = new Set(); - for (const entryPoint of entryPoints) { - allMentionedModules.add(entryPoint.name); - entryPoint.include?.forEach(allMentionedModules.add, allMentionedModules); - entryPoint.exclude?.forEach(allMentionedModules.add, allMentionedModules); - } - const bundleAsync = async () => { const files: VinylFile[] = []; const tasks: Promise[] = []; @@ -129,7 +122,6 @@ function bundleESMTask(opts: IBundleESMTaskOpts): NodeJS.ReadWriteStream { const task = esbuild.build({ bundle: true, - external: entryPoint.exclude, packages: 'external', // "external all the things", see https://esbuild.github.io/api/#packages platform: 'neutral', // makes esm format: 'esm', diff --git a/build/lib/policies.js b/build/lib/policies.js index bb7e45c1873..ac697629883 100644 --- a/build/lib/policies.js +++ b/build/lib/policies.js @@ -339,7 +339,16 @@ const NumberQ = { const StringQ = { Q: `[ (string (string_fragment) @value) - (call_expression function: (identifier) @localizeFn arguments: (arguments (string (string_fragment) @nlsKey) (string (string_fragment) @value)) (#eq? @localizeFn localize)) + (call_expression + function: [ + (identifier) @localizeFn (#eq? @localizeFn localize) + (member_expression + object: (identifier) @nlsObj (#eq? @nlsObj nls) + property: (property_identifier) @localizeFn (#eq? @localizeFn localize) + ) + ] + arguments: (arguments (string (string_fragment) @nlsKey) (string (string_fragment) @value)) + ) ]`, value(matches) { const match = matches[0]; @@ -379,7 +388,8 @@ function getProperty(qtype, moduleName, node, key) { (#any-of? @key "${key}" "'${key}'") )`); try { - return qtype.value(query.matches(node)); + const matches = query.matches(node).filter(m => m.captures[0].node.parent?.parent === node); + return qtype.value(matches); } catch (e) { throw new ParseError(e.message, moduleName, node); @@ -515,8 +525,8 @@ function renderADML(appName, versions, categories, policies, translations) { ${appName} - ${versions.map(v => `${appName} >= ${v}`)} - ${categories.map(c => renderADMLString('Category', c.moduleName, c.name, translations))} + ${versions.map(v => `${appName} >= ${v}`).join(`\n `)} + ${categories.map(c => renderADMLString('Category', c.moduleName, c.name, translations)).join(`\n `)} ${policies.map(p => p.renderADMLStrings(translations)).flat().join(`\n `)} diff --git a/build/lib/policies.ts b/build/lib/policies.ts index cf16ca2d511..34d20e93dc6 100644 --- a/build/lib/policies.ts +++ b/build/lib/policies.ts @@ -503,7 +503,16 @@ const NumberQ: QType = { const StringQ: QType = { Q: `[ (string (string_fragment) @value) - (call_expression function: (identifier) @localizeFn arguments: (arguments (string (string_fragment) @nlsKey) (string (string_fragment) @value)) (#eq? @localizeFn localize)) + (call_expression + function: [ + (identifier) @localizeFn (#eq? @localizeFn localize) + (member_expression + object: (identifier) @nlsObj (#eq? @nlsObj nls) + property: (property_identifier) @localizeFn (#eq? @localizeFn localize) + ) + ] + arguments: (arguments (string (string_fragment) @nlsKey) (string (string_fragment) @value)) + ) ]`, value(matches: Parser.QueryMatch[]): string | NlsString | undefined { @@ -556,7 +565,8 @@ function getProperty(qtype: QType, moduleName: string, node: Parser.Syntax ); try { - return qtype.value(query.matches(node)); + const matches = query.matches(node).filter(m => m.captures[0].node.parent?.parent === node); + return qtype.value(matches); } catch (e) { throw new ParseError(e.message, moduleName, node); } @@ -718,8 +728,8 @@ function renderADML(appName: string, versions: string[], categories: Category[], ${appName} - ${versions.map(v => `${appName} >= ${v}`)} - ${categories.map(c => renderADMLString('Category', c.moduleName, c.name, translations))} + ${versions.map(v => `${appName} >= ${v}`).join(`\n `)} + ${categories.map(c => renderADMLString('Category', c.moduleName, c.name, translations)).join(`\n `)} ${policies.map(p => p.renderADMLStrings(translations)).flat().join(`\n `)} diff --git a/build/lib/propertyInitOrderChecker.js b/build/lib/propertyInitOrderChecker.js index dbca887bc22..c4931788047 100644 --- a/build/lib/propertyInitOrderChecker.js +++ b/build/lib/propertyInitOrderChecker.js @@ -54,19 +54,12 @@ const TS_CONFIG_PATH = path.join(__dirname, '../../', 'src', 'tsconfig.json'); // ############################################################################################# // const ignored = new Set([ - 'vs/base/common/arrays.ts', - 'vs/platform/extensionManagement/common/extensionsScannerService.ts', - 'vs/platform/configuration/common/configurations.ts', 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/tokenizer.ts', 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/bracketPairsTree.ts', 'vs/editor/common/model/textModelTokens.ts', 'vs/editor/common/model/tokenizationTextModelPart.ts', 'vs/editor/common/core/textEdit.ts', - 'vs/workbench/contrib/debug/common/debugStorage.ts', - 'vs/workbench/contrib/debug/common/debugModel.ts', - 'vs/workbench/api/common/extHostCommands.ts', 'vs/editor/browser/view/viewLayer.ts', - 'vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts', 'vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts', 'vs/editor/browser/widget/diffEditor/utils.ts', 'vs/editor/browser/observableCodeEditor.ts', @@ -95,10 +88,8 @@ const ignored = new Set([ 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts', 'vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts', - 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsAccessibleView.ts', 'vs/editor/contrib/placeholderText/browser/placeholderTextContribution.ts', 'vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter.ts', - 'vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts', 'vs/workbench/contrib/files/browser/views/openEditorsView.ts', 'vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts', 'vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts', @@ -114,12 +105,6 @@ const ignored = new Set([ 'vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts', 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts', 'vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts', - 'vs/platform/terminal/common/capabilities/commandDetectionCapability.ts', - 'vs/workbench/contrib/testing/common/testExclusions.ts', - 'vs/workbench/contrib/testing/common/testResultStorage.ts', - 'vs/workbench/services/userDataProfile/browser/snippetsResource.ts', - 'vs/platform/quickinput/browser/quickInputController.ts', - 'vs/platform/userDataSync/common/abstractSynchronizer.ts', 'vs/workbench/services/authentication/browser/authenticationExtensionsService.ts', 'vs/workbench/services/textMate/browser/backgroundTokenization/textMateWorkerTokenizerController.ts', 'vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts', @@ -128,42 +113,17 @@ const ignored = new Set([ 'vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts', 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts', 'vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts', - 'vs/workbench/contrib/chat/common/promptSyntax/contentProviders/textModelContentsProvider.ts', - 'vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts', 'vs/workbench/contrib/search/common/cacheState.ts', - 'vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts', - 'vs/workbench/contrib/search/browser/anythingQuickAccess.ts', - 'vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts', - 'vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput.ts', - 'vs/workbench/contrib/testing/common/testExplorerFilterState.ts', - 'vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts', - 'vs/workbench/contrib/testing/browser/testingOutputPeek.ts', - 'vs/workbench/contrib/testing/browser/explorerProjections/index.ts', - 'vs/workbench/contrib/testing/browser/testingExplorerFilter.ts', - 'vs/workbench/contrib/testing/browser/testingExplorerView.ts', - 'vs/workbench/contrib/testing/common/testServiceImpl.ts', - 'vs/platform/quickinput/browser/commandsQuickAccess.ts', - 'vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts', 'vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts', - 'vs/workbench/contrib/debug/browser/debugMemory.ts', - 'vs/workbench/contrib/markers/browser/markersViewActions.ts', 'vs/workbench/contrib/mergeEditor/browser/view/viewZones.ts', 'vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts', - 'vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts', - 'vs/workbench/contrib/output/browser/outputServices.ts', - 'vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts', - 'vs/workbench/contrib/codeEditor/browser/quickaccess/gotoLineQuickAccess.ts', 'vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts', 'vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts', 'vs/workbench/contrib/inlineCompletions/browser/inlineCompletionLanguageStatusBarContribution.ts', - 'vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts', 'vs/workbench/contrib/welcomeDialog/browser/welcomeWidget.ts', - 'vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordInsertView.ts', - 'vs/platform/terminal/node/ptyService.ts', 'vs/workbench/api/common/extHostLanguageFeatures.ts', 'vs/workbench/api/common/extHostSearch.ts', - 'vs/workbench/contrib/testing/test/common/testStubs.ts' ]); const cancellationToken = { isCancellationRequested: () => false, diff --git a/build/lib/propertyInitOrderChecker.ts b/build/lib/propertyInitOrderChecker.ts index dc18213566f..bbc98c6f43f 100644 --- a/build/lib/propertyInitOrderChecker.ts +++ b/build/lib/propertyInitOrderChecker.ts @@ -23,19 +23,12 @@ const TS_CONFIG_PATH = path.join(__dirname, '../../', 'src', 'tsconfig.json'); // const ignored = new Set([ - 'vs/base/common/arrays.ts', - 'vs/platform/extensionManagement/common/extensionsScannerService.ts', - 'vs/platform/configuration/common/configurations.ts', 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/tokenizer.ts', 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/bracketPairsTree.ts', 'vs/editor/common/model/textModelTokens.ts', 'vs/editor/common/model/tokenizationTextModelPart.ts', 'vs/editor/common/core/textEdit.ts', - 'vs/workbench/contrib/debug/common/debugStorage.ts', - 'vs/workbench/contrib/debug/common/debugModel.ts', - 'vs/workbench/api/common/extHostCommands.ts', 'vs/editor/browser/view/viewLayer.ts', - 'vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts', 'vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts', 'vs/editor/browser/widget/diffEditor/utils.ts', 'vs/editor/browser/observableCodeEditor.ts', @@ -64,10 +57,8 @@ const ignored = new Set([ 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts', 'vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts', - 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsAccessibleView.ts', 'vs/editor/contrib/placeholderText/browser/placeholderTextContribution.ts', 'vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter.ts', - 'vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts', 'vs/workbench/contrib/files/browser/views/openEditorsView.ts', 'vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts', 'vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts', @@ -83,12 +74,6 @@ const ignored = new Set([ 'vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts', 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts', 'vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts', - 'vs/platform/terminal/common/capabilities/commandDetectionCapability.ts', - 'vs/workbench/contrib/testing/common/testExclusions.ts', - 'vs/workbench/contrib/testing/common/testResultStorage.ts', - 'vs/workbench/services/userDataProfile/browser/snippetsResource.ts', - 'vs/platform/quickinput/browser/quickInputController.ts', - 'vs/platform/userDataSync/common/abstractSynchronizer.ts', 'vs/workbench/services/authentication/browser/authenticationExtensionsService.ts', 'vs/workbench/services/textMate/browser/backgroundTokenization/textMateWorkerTokenizerController.ts', 'vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts', @@ -97,42 +82,17 @@ const ignored = new Set([ 'vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts', 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts', 'vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts', - 'vs/workbench/contrib/chat/common/promptSyntax/contentProviders/textModelContentsProvider.ts', - 'vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts', 'vs/workbench/contrib/search/common/cacheState.ts', - 'vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts', - 'vs/workbench/contrib/search/browser/anythingQuickAccess.ts', - 'vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts', - 'vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput.ts', - 'vs/workbench/contrib/testing/common/testExplorerFilterState.ts', - 'vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts', - 'vs/workbench/contrib/testing/browser/testingOutputPeek.ts', - 'vs/workbench/contrib/testing/browser/explorerProjections/index.ts', - 'vs/workbench/contrib/testing/browser/testingExplorerFilter.ts', - 'vs/workbench/contrib/testing/browser/testingExplorerView.ts', - 'vs/workbench/contrib/testing/common/testServiceImpl.ts', - 'vs/platform/quickinput/browser/commandsQuickAccess.ts', - 'vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts', 'vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts', - 'vs/workbench/contrib/debug/browser/debugMemory.ts', - 'vs/workbench/contrib/markers/browser/markersViewActions.ts', 'vs/workbench/contrib/mergeEditor/browser/view/viewZones.ts', 'vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts', - 'vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts', - 'vs/workbench/contrib/output/browser/outputServices.ts', - 'vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts', - 'vs/workbench/contrib/codeEditor/browser/quickaccess/gotoLineQuickAccess.ts', 'vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts', 'vs/workbench/contrib/accessibilitySignals/browser/editorTextPropertySignalsContribution.ts', 'vs/workbench/contrib/inlineCompletions/browser/inlineCompletionLanguageStatusBarContribution.ts', - 'vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts', 'vs/workbench/contrib/welcomeDialog/browser/welcomeWidget.ts', - 'vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts', 'vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordInsertView.ts', - 'vs/platform/terminal/node/ptyService.ts', 'vs/workbench/api/common/extHostLanguageFeatures.ts', 'vs/workbench/api/common/extHostSearch.ts', - 'vs/workbench/contrib/testing/test/common/testStubs.ts' ]); diff --git a/build/lib/standalone.js b/build/lib/standalone.js index 0e7a9ecc782..732a34228b9 100644 --- a/build/lib/standalone.js +++ b/build/lib/standalone.js @@ -41,7 +41,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); exports.extractEditor = extractEditor; -exports.createESMSourcesAndResources2 = createESMSourcesAndResources2; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const tss = __importStar(require("./treeshaking")); @@ -75,6 +74,9 @@ function extractEditor(options) { compilerOptions = tsConfig.compilerOptions; } tsConfig.compilerOptions = compilerOptions; + tsConfig.compilerOptions.sourceMap = true; + tsConfig.compilerOptions.module = 'es2022'; + tsConfig.compilerOptions.outDir = options.tsOutDir; compilerOptions.noEmit = false; compilerOptions.noUnusedLocals = false; compilerOptions.preserveConstEnums = false; @@ -139,105 +141,10 @@ function extractEditor(options) { delete tsConfig.compilerOptions.moduleResolution; writeOutputFile('tsconfig.json', JSON.stringify(tsConfig, null, '\t')); [ - 'vs/loader.js' + 'vs/loader.js', + 'typings/css.d.ts' ].forEach(copyFile); } -function createESMSourcesAndResources2(options) { - const SRC_FOLDER = path_1.default.join(REPO_ROOT, options.srcFolder); - const OUT_FOLDER = path_1.default.join(REPO_ROOT, options.outFolder); - const OUT_RESOURCES_FOLDER = path_1.default.join(REPO_ROOT, options.outResourcesFolder); - const getDestAbsoluteFilePath = (file) => { - const dest = options.renames[file.replace(/\\/g, '/')] || file; - if (dest === 'tsconfig.json') { - return path_1.default.join(OUT_FOLDER, `tsconfig.json`); - } - if (/\.ts$/.test(dest)) { - return path_1.default.join(OUT_FOLDER, dest); - } - return path_1.default.join(OUT_RESOURCES_FOLDER, dest); - }; - const allFiles = walkDirRecursive(SRC_FOLDER); - for (const file of allFiles) { - if (options.ignores.indexOf(file.replace(/\\/g, '/')) >= 0) { - continue; - } - if (file === 'tsconfig.json') { - const tsConfig = JSON.parse(fs_1.default.readFileSync(path_1.default.join(SRC_FOLDER, file)).toString()); - tsConfig.compilerOptions.module = 'es2022'; - tsConfig.compilerOptions.outDir = path_1.default.join(path_1.default.relative(OUT_FOLDER, OUT_RESOURCES_FOLDER), 'vs').replace(/\\/g, '/'); - write(getDestAbsoluteFilePath(file), JSON.stringify(tsConfig, null, '\t')); - continue; - } - if (/\.ts$/.test(file) || /\.d\.ts$/.test(file) || /\.css$/.test(file) || /\.js$/.test(file) || /\.ttf$/.test(file)) { - // Transport the files directly - write(getDestAbsoluteFilePath(file), fs_1.default.readFileSync(path_1.default.join(SRC_FOLDER, file))); - continue; - } - console.log(`UNKNOWN FILE: ${file}`); - } - function walkDirRecursive(dir) { - if (dir.charAt(dir.length - 1) !== '/' || dir.charAt(dir.length - 1) !== '\\') { - dir += '/'; - } - const result = []; - _walkDirRecursive(dir, result, dir.length); - return result; - } - function _walkDirRecursive(dir, result, trimPos) { - const files = fs_1.default.readdirSync(dir); - for (let i = 0; i < files.length; i++) { - const file = path_1.default.join(dir, files[i]); - if (fs_1.default.statSync(file).isDirectory()) { - _walkDirRecursive(file, result, trimPos); - } - else { - result.push(file.substr(trimPos)); - } - } - } - function write(absoluteFilePath, contents) { - if (/(\.ts$)|(\.js$)/.test(absoluteFilePath)) { - contents = toggleComments(contents.toString()); - } - writeFile(absoluteFilePath, contents); - function toggleComments(fileContents) { - const lines = fileContents.split(/\r\n|\r|\n/); - let mode = 0; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (mode === 0) { - if (/\/\/ ESM-comment-begin/.test(line)) { - mode = 1; - continue; - } - if (/\/\/ ESM-uncomment-begin/.test(line)) { - mode = 2; - continue; - } - continue; - } - if (mode === 1) { - if (/\/\/ ESM-comment-end/.test(line)) { - mode = 0; - continue; - } - lines[i] = '// ' + line; - continue; - } - if (mode === 2) { - if (/\/\/ ESM-uncomment-end/.test(line)) { - mode = 0; - continue; - } - lines[i] = line.replace(/^(\s*)\/\/ ?/, function (_, indent) { - return indent; - }); - } - } - return lines.join('\n'); - } - } -} function transportCSS(module, enqueue, write) { if (!/\.css/.test(module)) { return false; diff --git a/build/lib/standalone.ts b/build/lib/standalone.ts index b2ae02f1007..b18908dcb03 100644 --- a/build/lib/standalone.ts +++ b/build/lib/standalone.ts @@ -29,7 +29,7 @@ function writeFile(filePath: string, contents: Buffer | string): void { fs.writeFileSync(filePath, contents); } -export function extractEditor(options: tss.ITreeShakingOptions & { destRoot: string }): void { +export function extractEditor(options: tss.ITreeShakingOptions & { destRoot: string; tsOutDir: string }): void { const ts = require('typescript') as typeof import('typescript'); const tsConfig = JSON.parse(fs.readFileSync(path.join(options.sourcesRoot, 'tsconfig.monaco.json')).toString()); @@ -41,6 +41,9 @@ export function extractEditor(options: tss.ITreeShakingOptions & { destRoot: str compilerOptions = tsConfig.compilerOptions; } tsConfig.compilerOptions = compilerOptions; + tsConfig.compilerOptions.sourceMap = true; + tsConfig.compilerOptions.module = 'es2022'; + tsConfig.compilerOptions.outDir = options.tsOutDir; compilerOptions.noEmit = false; compilerOptions.noUnusedLocals = false; @@ -115,129 +118,11 @@ export function extractEditor(options: tss.ITreeShakingOptions & { destRoot: str writeOutputFile('tsconfig.json', JSON.stringify(tsConfig, null, '\t')); [ - 'vs/loader.js' + 'vs/loader.js', + 'typings/css.d.ts' ].forEach(copyFile); } -export interface IOptions2 { - srcFolder: string; - outFolder: string; - outResourcesFolder: string; - ignores: string[]; - renames: { [filename: string]: string }; -} - -export function createESMSourcesAndResources2(options: IOptions2): void { - - const SRC_FOLDER = path.join(REPO_ROOT, options.srcFolder); - const OUT_FOLDER = path.join(REPO_ROOT, options.outFolder); - const OUT_RESOURCES_FOLDER = path.join(REPO_ROOT, options.outResourcesFolder); - - const getDestAbsoluteFilePath = (file: string): string => { - const dest = options.renames[file.replace(/\\/g, '/')] || file; - if (dest === 'tsconfig.json') { - return path.join(OUT_FOLDER, `tsconfig.json`); - } - if (/\.ts$/.test(dest)) { - return path.join(OUT_FOLDER, dest); - } - return path.join(OUT_RESOURCES_FOLDER, dest); - }; - - const allFiles = walkDirRecursive(SRC_FOLDER); - for (const file of allFiles) { - - if (options.ignores.indexOf(file.replace(/\\/g, '/')) >= 0) { - continue; - } - - if (file === 'tsconfig.json') { - const tsConfig = JSON.parse(fs.readFileSync(path.join(SRC_FOLDER, file)).toString()); - tsConfig.compilerOptions.module = 'es2022'; - tsConfig.compilerOptions.outDir = path.join(path.relative(OUT_FOLDER, OUT_RESOURCES_FOLDER), 'vs').replace(/\\/g, '/'); - write(getDestAbsoluteFilePath(file), JSON.stringify(tsConfig, null, '\t')); - continue; - } - - if (/\.ts$/.test(file) || /\.d\.ts$/.test(file) || /\.css$/.test(file) || /\.js$/.test(file) || /\.ttf$/.test(file)) { - // Transport the files directly - write(getDestAbsoluteFilePath(file), fs.readFileSync(path.join(SRC_FOLDER, file))); - continue; - } - - console.log(`UNKNOWN FILE: ${file}`); - } - - - function walkDirRecursive(dir: string): string[] { - if (dir.charAt(dir.length - 1) !== '/' || dir.charAt(dir.length - 1) !== '\\') { - dir += '/'; - } - const result: string[] = []; - _walkDirRecursive(dir, result, dir.length); - return result; - } - - function _walkDirRecursive(dir: string, result: string[], trimPos: number): void { - const files = fs.readdirSync(dir); - for (let i = 0; i < files.length; i++) { - const file = path.join(dir, files[i]); - if (fs.statSync(file).isDirectory()) { - _walkDirRecursive(file, result, trimPos); - } else { - result.push(file.substr(trimPos)); - } - } - } - - function write(absoluteFilePath: string, contents: string | Buffer): void { - if (/(\.ts$)|(\.js$)/.test(absoluteFilePath)) { - contents = toggleComments(contents.toString()); - } - writeFile(absoluteFilePath, contents); - - function toggleComments(fileContents: string): string { - const lines = fileContents.split(/\r\n|\r|\n/); - let mode = 0; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (mode === 0) { - if (/\/\/ ESM-comment-begin/.test(line)) { - mode = 1; - continue; - } - if (/\/\/ ESM-uncomment-begin/.test(line)) { - mode = 2; - continue; - } - continue; - } - - if (mode === 1) { - if (/\/\/ ESM-comment-end/.test(line)) { - mode = 0; - continue; - } - lines[i] = '// ' + line; - continue; - } - - if (mode === 2) { - if (/\/\/ ESM-uncomment-end/.test(line)) { - mode = 0; - continue; - } - lines[i] = line.replace(/^(\s*)\/\/ ?/, function (_, indent) { - return indent; - }); - } - } - - return lines.join('\n'); - } - } -} - function transportCSS(module: string, enqueue: (module: string) => void, write: (path: string, contents: string | Buffer) => void): boolean { if (!/\.css/.test(module)) { diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 6b8419291cb..39cc1d12a30 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -228,15 +228,18 @@ "--vscode-editorGroupHeader-tabsBackground", "--vscode-editorGroupHeader-tabsBorder", "--vscode-editorGutter-addedBackground", + "--vscode-editorGutter-addedSecondaryBackground", "--vscode-editorGutter-background", "--vscode-editorGutter-commentGlyphForeground", "--vscode-editorGutter-commentRangeForeground", "--vscode-editorGutter-commentUnresolvedGlyphForeground", - "--vscode-editorGutter-itemGlyphForeground", - "--vscode-editorGutter-itemBackground", "--vscode-editorGutter-deletedBackground", + "--vscode-editorGutter-deletedSecondaryBackground", "--vscode-editorGutter-foldingControlForeground", + "--vscode-editorGutter-itemBackground", + "--vscode-editorGutter-itemGlyphForeground", "--vscode-editorGutter-modifiedBackground", + "--vscode-editorGutter-modifiedSecondaryBackground", "--vscode-editorHint-border", "--vscode-editorHint-foreground", "--vscode-editorHoverWidget-background", @@ -347,6 +350,7 @@ "--vscode-extensionButton-prominentHoverBackground", "--vscode-extensionButton-separator", "--vscode-extensionIcon-preReleaseForeground", + "--vscode-extensionIcon-privateForeground", "--vscode-extensionIcon-sponsorForeground", "--vscode-extensionIcon-starForeground", "--vscode-extensionIcon-verifiedForeground", @@ -372,14 +376,14 @@ "--vscode-inlineChatInput-placeholderForeground", "--vscode-inlineEdit-gutterIndicator-background", "--vscode-inlineEdit-gutterIndicator-primaryBackground", + "--vscode-inlineEdit-gutterIndicator-primaryBorder", "--vscode-inlineEdit-gutterIndicator-primaryForeground", "--vscode-inlineEdit-gutterIndicator-secondaryBackground", + "--vscode-inlineEdit-gutterIndicator-secondaryBorder", "--vscode-inlineEdit-gutterIndicator-secondaryForeground", "--vscode-inlineEdit-gutterIndicator-successfulBackground", + "--vscode-inlineEdit-gutterIndicator-successfulBorder", "--vscode-inlineEdit-gutterIndicator-successfulForeground", - "--vscode-inlineEdit-indicator-background", - "--vscode-inlineEdit-indicator-border", - "--vscode-inlineEdit-indicator-foreground", "--vscode-inlineEdit-modifiedBackground", "--vscode-inlineEdit-modifiedBorder", "--vscode-inlineEdit-modifiedChangedLineBackground", @@ -388,8 +392,8 @@ "--vscode-inlineEdit-originalBorder", "--vscode-inlineEdit-originalChangedLineBackground", "--vscode-inlineEdit-originalChangedTextBackground", - "--vscode-inlineEdit-tabWillAcceptBorder", - "--vscode-inlineEdit-wordReplacementView-background", + "--vscode-inlineEdit-tabWillAcceptModifiedBorder", + "--vscode-inlineEdit-tabWillAcceptOriginalBorder", "--vscode-input-background", "--vscode-input-border", "--vscode-input-foreground", @@ -571,6 +575,8 @@ "--vscode-profileBadge-foreground", "--vscode-profiles-sashBorder", "--vscode-progressBar-background", + "--vscode-prompt-frontMatter-background", + "--vscode-prompt-frontMatter-inactiveBackground", "--vscode-quickInput-background", "--vscode-quickInput-foreground", "--vscode-quickInput-list-focusBackground", @@ -785,7 +791,14 @@ "--vscode-terminalStickyScroll-border", "--vscode-terminalStickyScrollHover-background", "--vscode-terminalSymbolIcon-aliasForeground", + "--vscode-terminalSymbolIcon-argumentForeground", + "--vscode-terminalSymbolIcon-fileForeground", "--vscode-terminalSymbolIcon-flagForeground", + "--vscode-terminalSymbolIcon-folderForeground", + "--vscode-terminalSymbolIcon-inlineSuggestionForeground", + "--vscode-terminalSymbolIcon-methodForeground", + "--vscode-terminalSymbolIcon-optionForeground", + "--vscode-terminalSymbolIcon-optionValueForeground", "--vscode-testing-coverCountBadgeBackground", "--vscode-testing-coverCountBadgeForeground", "--vscode-testing-coveredBackground", diff --git a/build/lib/tsb/builder.js b/build/lib/tsb/builder.js index 84308191361..0da1f5c09e6 100644 --- a/build/lib/tsb/builder.js +++ b/build/lib/tsb/builder.js @@ -558,10 +558,11 @@ class LanguageServiceHost { return old; } removeScriptSnapshot(filename) { + filename = normalize(filename); + this._log('removeScriptSnapshot', filename); this._filesInProject.delete(filename); this._filesAdded.delete(filename); this._projectVersion++; - filename = normalize(filename); delete this._fileNameToDeclaredModule[filename]; return delete this._snapshots[filename]; } @@ -622,6 +623,9 @@ class LanguageServiceHost { // node module? return; } + if (ref.fileName.endsWith('.css')) { + return; + } const stopDirname = normalize(this.getCurrentDirectory()); let dirname = filename; let found = false; diff --git a/build/lib/tsb/builder.ts b/build/lib/tsb/builder.ts index 7a1b0e0cbb4..1a68131f86d 100644 --- a/build/lib/tsb/builder.ts +++ b/build/lib/tsb/builder.ts @@ -630,10 +630,11 @@ class LanguageServiceHost implements ts.LanguageServiceHost { } removeScriptSnapshot(filename: string): boolean { + filename = normalize(filename); + this._log('removeScriptSnapshot', filename); this._filesInProject.delete(filename); this._filesAdded.delete(filename); this._projectVersion++; - filename = normalize(filename); delete this._fileNameToDeclaredModule[filename]; return delete this._snapshots[filename]; } @@ -706,7 +707,9 @@ class LanguageServiceHost implements ts.LanguageServiceHost { // node module? return; } - + if (ref.fileName.endsWith('.css')) { + return; + } const stopDirname = normalize(this.getCurrentDirectory()); let dirname = filename; diff --git a/build/linux/debian/install-sysroot.js b/build/linux/debian/install-sysroot.js index 16d8d01468f..230fbda4de6 100644 --- a/build/linux/debian/install-sysroot.js +++ b/build/linux/debian/install-sysroot.js @@ -70,7 +70,7 @@ async function fetchUrl(options, retries = 10, retryDelay = 1000) { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30 * 1000); - const version = '20240129-253798'; + const version = '20250407-330404'; try { const response = await fetch(`https://api.github.com/repos/Microsoft/vscode-linux-build-agent/releases/tags/v${version}`, { headers: ghApiHeaders, @@ -119,18 +119,24 @@ async function fetchUrl(options, retries = 10, retryDelay = 1000) { throw e; } } -async function getVSCodeSysroot(arch) { +async function getVSCodeSysroot(arch, isMusl = false) { let expectedName; let triple; - const prefix = process.env['VSCODE_SYSROOT_PREFIX'] ?? '-glibc-2.28'; + const prefix = process.env['VSCODE_SYSROOT_PREFIX'] ?? '-glibc-2.28-gcc-8.5.0'; switch (arch) { case 'amd64': expectedName = `x86_64-linux-gnu${prefix}.tar.gz`; triple = 'x86_64-linux-gnu'; break; case 'arm64': - expectedName = `aarch64-linux-gnu${prefix}.tar.gz`; - triple = 'aarch64-linux-gnu'; + if (isMusl) { + expectedName = 'aarch64-linux-musl-gcc-10.3.0.tar.gz'; + triple = 'aarch64-linux-musl'; + } + else { + expectedName = `aarch64-linux-gnu${prefix}.tar.gz`; + triple = 'aarch64-linux-gnu'; + } break; case 'armhf': expectedName = `arm-rpi-linux-gnueabihf${prefix}.tar.gz`; @@ -144,7 +150,10 @@ async function getVSCodeSysroot(arch) { } const sysroot = process.env['VSCODE_SYSROOT_DIR'] ?? path_1.default.join((0, os_1.tmpdir)(), `vscode-${arch}-sysroot`); const stamp = path_1.default.join(sysroot, '.stamp'); - const result = `${sysroot}/${triple}/${triple}/sysroot`; + let result = `${sysroot}/${triple}/${triple}/sysroot`; + if (isMusl) { + result = `${sysroot}/output/${triple}`; + } if (fs_1.default.existsSync(stamp) && fs_1.default.readFileSync(stamp).toString() === expectedName) { return result; } diff --git a/build/linux/debian/install-sysroot.ts b/build/linux/debian/install-sysroot.ts index aa10e39f95f..23cce9e1002 100644 --- a/build/linux/debian/install-sysroot.ts +++ b/build/linux/debian/install-sysroot.ts @@ -79,7 +79,7 @@ async function fetchUrl(options: IFetchOptions, retries = 10, retryDelay = 1000) try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30 * 1000); - const version = '20240129-253798'; + const version = '20250407-330404'; try { const response = await fetch(`https://api.github.com/repos/Microsoft/vscode-linux-build-agent/releases/tags/v${version}`, { headers: ghApiHeaders, @@ -133,18 +133,23 @@ type SysrootDictEntry = { Tarball: string; }; -export async function getVSCodeSysroot(arch: DebianArchString): Promise { +export async function getVSCodeSysroot(arch: DebianArchString, isMusl: boolean = false): Promise { let expectedName: string; let triple: string; - const prefix = process.env['VSCODE_SYSROOT_PREFIX'] ?? '-glibc-2.28'; + const prefix = process.env['VSCODE_SYSROOT_PREFIX'] ?? '-glibc-2.28-gcc-8.5.0'; switch (arch) { case 'amd64': expectedName = `x86_64-linux-gnu${prefix}.tar.gz`; triple = 'x86_64-linux-gnu'; break; case 'arm64': - expectedName = `aarch64-linux-gnu${prefix}.tar.gz`; - triple = 'aarch64-linux-gnu'; + if (isMusl) { + expectedName = 'aarch64-linux-musl-gcc-10.3.0.tar.gz'; + triple = 'aarch64-linux-musl'; + } else { + expectedName = `aarch64-linux-gnu${prefix}.tar.gz`; + triple = 'aarch64-linux-gnu'; + } break; case 'armhf': expectedName = `arm-rpi-linux-gnueabihf${prefix}.tar.gz`; @@ -158,7 +163,10 @@ export async function getVSCodeSysroot(arch: DebianArchString): Promise } const sysroot = process.env['VSCODE_SYSROOT_DIR'] ?? path.join(tmpdir(), `vscode-${arch}-sysroot`); const stamp = path.join(sysroot, '.stamp'); - const result = `${sysroot}/${triple}/${triple}/sysroot`; + let result = `${sysroot}/${triple}/${triple}/sysroot`; + if (isMusl) { + result = `${sysroot}/output/${triple}`; + } if (fs.existsSync(stamp) && fs.readFileSync(stamp).toString() === expectedName) { return result; } diff --git a/build/monaco/monaco.d.ts.recipe b/build/monaco/monaco.d.ts.recipe index 6192ad5b2ec..cb4c9082a11 100644 --- a/build/monaco/monaco.d.ts.recipe +++ b/build/monaco/monaco.d.ts.recipe @@ -92,7 +92,7 @@ declare namespace monaco.editor { #includeAll(vs/editor/standalone/browser/standaloneEditor;languages.Token=>Token): #include(vs/editor/standalone/common/standaloneTheme): BuiltinTheme, IStandaloneThemeData, IColors #include(vs/editor/common/languages/supports/tokenization): ITokenThemeRule -#include(vs/editor/standalone/browser/standaloneWebWorker): MonacoWebWorker, IWebWorkerOptions +#include(vs/editor/standalone/browser/standaloneWebWorker): MonacoWebWorker, IInternalWebWorkerOptions #include(vs/editor/standalone/browser/standaloneCodeEditor): IActionDescriptor, IGlobalEditorOptions, IStandaloneEditorConstructionOptions, IStandaloneDiffEditorConstructionOptions, IStandaloneCodeEditor, IStandaloneDiffEditor export interface ICommandHandler { (...args: any[]): void; @@ -145,7 +145,7 @@ declare namespace monaco.languages { declare namespace monaco.worker { #include(vs/editor/common/model/mirrorTextModel): IMirrorTextModel -#includeAll(vs/editor/common/services/editorSimpleWorker;): +#includeAll(vs/editor/common/services/editorWebWorker;): } diff --git a/build/monaco/monaco.usage.recipe b/build/monaco/monaco.usage.recipe index a3369eb25a7..9e96a68568a 100644 --- a/build/monaco/monaco.usage.recipe +++ b/build/monaco/monaco.usage.recipe @@ -4,8 +4,7 @@ import { IObservable } from './vs/base/common/observable'; import { ServiceIdentifier } from './vs/platform/instantiation/common/instantiation'; -import { create as create1 } from './vs/base/common/worker/simpleWorker'; -import { create as create2 } from './vs/editor/common/services/editorSimpleWorker'; +import { start } from './vs/editor/editor.worker.start'; import { SyncDescriptor0 } from './vs/platform/instantiation/common/descriptors'; import * as editorAPI from './vs/editor/editor.api'; @@ -13,8 +12,7 @@ import * as editorAPI from './vs/editor/editor.api'; var a: any; var b: any; a = (>b).type; - a = create1; - a = create2; + a = start; // injection madness a = (>b).ctor; diff --git a/build/package-lock.json b/build/package-lock.json index 445e842c5e3..4d18fff30af 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -60,7 +60,8 @@ "tree-sitter": "^0.22.4", "vscode-universal-bundler": "^0.1.3", "workerpool": "^6.4.0", - "yauzl": "^2.10.0" + "yauzl": "^2.10.0", + "zx": "8.5.0" }, "optionalDependencies": { "tree-sitter-typescript": "^0.23.2", @@ -1192,12 +1193,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/pump": { @@ -4141,10 +4143,11 @@ } }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "chownr": "^1.1.1", @@ -4385,10 +4388,11 @@ "dev": true }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/universal-user-agent": { "version": "6.0.0", @@ -4658,6 +4662,19 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zx": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/zx/-/zx-8.5.0.tgz", + "integrity": "sha512-XS5/oKOQxKNfG2sVO6TQQjZF5RqWGE5QGSUOCZZVTnvYr3RDBTdbX3IFmV9CrnycCAQWcY0hAD3DDUa4RJE4+w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "zx": "build/cli.js" + }, + "engines": { + "node": ">= 12.17.0" + } } } } diff --git a/build/package.json b/build/package.json index 73d4f42e843..cebae7e286d 100644 --- a/build/package.json +++ b/build/package.json @@ -54,7 +54,8 @@ "tree-sitter": "^0.22.4", "vscode-universal-bundler": "^0.1.3", "workerpool": "^6.4.0", - "yauzl": "^2.10.0" + "yauzl": "^2.10.0", + "zx": "8.5.0" }, "type": "commonjs", "scripts": { diff --git a/build/win32/Cargo.lock b/build/win32/Cargo.lock index 5437686ef94..11558ceaf04 100644 --- a/build/win32/Cargo.lock +++ b/build/win32/Cargo.lock @@ -95,7 +95,7 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "inno_updater" -version = "0.13.2" +version = "0.15.0" dependencies = [ "byteorder", "crc", diff --git a/build/win32/Cargo.toml b/build/win32/Cargo.toml index 42958b3124a..0724862e273 100644 --- a/build/win32/Cargo.toml +++ b/build/win32/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "inno_updater" -version = "0.13.2" +version = "0.15.0" authors = ["Microsoft "] build = "build.rs" diff --git a/build/win32/inno_updater.exe b/build/win32/inno_updater.exe index 2ca110dea1f..16854986847 100644 Binary files a/build/win32/inno_updater.exe and b/build/win32/inno_updater.exe differ diff --git a/cgmanifest.json b/cgmanifest.json index eb5b37d39a7..6ee72b3f757 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -516,11 +516,11 @@ "git": { "name": "nodejs", "repositoryUrl": "https://github.com/nodejs/node", - "commitHash": "4819c99baa28bf2c1baf411ba100c467fec3d486" + "commitHash": "bb1a61d8737feff534bb85368dab3b7c554c863d" } }, "isOnlyProductionDependency": true, - "version": "20.18.3" + "version": "20.19.0" }, { "component": { @@ -528,12 +528,12 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "f98501308a973e0aee2414315b426e5de2c03a60" + "commitHash": "d0594707ded4d564c95badf5322d5893295da4ed" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "34.3.2" + "version": "34.5.1" }, { "component": { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index ff45765a0c1..992e23a4410 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -536,9 +536,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] @@ -1717,9 +1717,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.70" +version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ "bitflags 2.5.0", "cfg-if", @@ -1749,9 +1749,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.105" +version = "0.9.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" dependencies = [ "cc", "libc", @@ -2676,9 +2676,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.37.0" +version = "1.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +checksum = "68722da18b0fc4a05fdc1120b302b82051265792a1e1b399086e9b204b10ad3d" dependencies = [ "backtrace", "bytes", @@ -2696,9 +2696,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2c87d662e07..f6e2f96dbd4 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -16,7 +16,7 @@ futures = "0.3.28" clap = { version = "4.3.0", features = ["derive", "env"] } open = "4.1.0" reqwest = { version = "0.11.22", default-features = false, features = ["json", "stream", "native-tls"] } -tokio = { version = "1.28.2", features = ["full"] } +tokio = { version = "1.38.2", features = ["full"] } tokio-util = { version = "0.7.8", features = ["compat", "codec"] } flate2 = { version = "1.0.26", default-features = false, features = ["zlib"] } zip = { version = "0.6.6", default-features = false, features = ["time", "deflate-zlib"] } @@ -77,7 +77,6 @@ russh-keys = { git = "https://github.com/microsoft/vscode-russh", branch = "main [profile.release] strip = true lto = true -codegen-units = 1 [features] default = [] diff --git a/cli/ThirdPartyNotices.txt b/cli/ThirdPartyNotices.txt index cc255c04cfd..6111715cca3 100644 --- a/cli/ThirdPartyNotices.txt +++ b/cli/ThirdPartyNotices.txt @@ -574,7 +574,7 @@ https://github.com/marshallpierce/rust-base64 The MIT License (MIT) -Copyright (c) 2015 Alice Maz +Copyright (c) 2025 Alice Maz Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -693,6 +693,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [`collectable`]: ./collectable [`cpufeatures`]: ./cpufeatures [`dbl`]: ./dbl +[`digest-io`]: ./digest-io [`hex-literal`]: ./hex-literal [`inout`]: ./inout [`opaque-debug`]: ./opaque-debug @@ -737,6 +738,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [`collectable`]: ./collectable [`cpufeatures`]: ./cpufeatures [`dbl`]: ./dbl +[`digest-io`]: ./digest-io [`hex-literal`]: ./hex-literal [`inout`]: ./inout [`opaque-debug`]: ./opaque-debug @@ -1514,6 +1516,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [`collectable`]: ./collectable [`cpufeatures`]: ./cpufeatures [`dbl`]: ./dbl +[`digest-io`]: ./digest-io [`hex-literal`]: ./hex-literal [`inout`]: ./inout [`opaque-debug`]: ./opaque-debug @@ -1555,7 +1558,7 @@ SOFTWARE. --------------------------------------------------------- -crossbeam-channel 0.5.13 - MIT OR Apache-2.0 +crossbeam-channel 0.5.15 - MIT OR Apache-2.0 https://github.com/crossbeam-rs/crossbeam The MIT License (MIT) @@ -2949,7 +2952,7 @@ getrandom 0.1.16 - MIT OR Apache-2.0 getrandom 0.2.15 - MIT OR Apache-2.0 https://github.com/rust-random/getrandom -Copyright (c) 2018-2024 The rust-random Project Developers +Copyright (c) 2018-2025 The rust-random Project Developers Copyright (c) 2014 The Rust Project Developers Permission is hereby granted, free of charge, to any @@ -3198,6 +3201,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [`collectable`]: ./collectable [`cpufeatures`]: ./cpufeatures [`dbl`]: ./dbl +[`digest-io`]: ./digest-io [`hex-literal`]: ./hex-literal [`inout`]: ./inout [`opaque-debug`]: ./opaque-debug @@ -4181,6 +4185,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [`collectable`]: ./collectable [`cpufeatures`]: ./cpufeatures [`dbl`]: ./dbl +[`digest-io`]: ./digest-io [`hex-literal`]: ./hex-literal [`inout`]: ./inout [`opaque-debug`]: ./opaque-debug @@ -4890,8 +4895,7 @@ THE SOFTWARE. miniz_oxide 0.7.3 - MIT OR Zlib OR Apache-2.0 https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide -This library (excluding the miniz C code used for tests) is licensed under the MIT license. The library is based on the miniz C library, of which the parts used are dual-licensed under the [MIT license](https://github.com/Frommi/miniz_oxide/blob/master/miniz/miniz.c#L1) and also the [unlicense](https://github.com/Frommi/miniz_oxide/blob/master/miniz/miniz.c#L577). -The parts of miniz that are not covered by the unlicense is [some Zip64 code](https://github.com/richgel999/miniz/commit/224d207ce8fffb908e156d27478be3afb5d83e6a#diff-edc0e9ccfae3b5324b85b3ec0a53dc74) which is only MIT licensed. This and other Zip functionality in miniz is not part of the miniz_oxidde and miniz_oxide_c_api rust libraries. +This library (excluding the original miniz C code used for tests) is dual licensed under the MIT license and Apache 2.0 license. The library is based on the [miniz][MIT license](https://github.com/richgel999/miniz) C library by Rich Geldreich which is released under the MIT license. --------------------------------------------------------- --------------------------------------------------------- @@ -5399,7 +5403,7 @@ OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -openssl 0.10.70 - Apache-2.0 +openssl 0.10.72 - Apache-2.0 https://github.com/sfackler/rust-openssl Copyright 2011-2017 Google Inc. @@ -5478,7 +5482,7 @@ DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -openssl-sys 0.9.105 - MIT +openssl-sys 0.9.107 - MIT https://github.com/sfackler/rust-openssl The MIT License (MIT) @@ -5513,7 +5517,7 @@ DEALINGS IN THE SOFTWARE. --------------------------------------------------------- opentelemetry 0.19.0 - Apache-2.0 -https://github.com/open-telemetry/opentelemetry-rust +https://github.com/open-telemetry/opentelemetry-rust/tree/main/opentelemetry Apache License Version 2.0, January 2004 @@ -5929,7 +5933,7 @@ Apache License --------------------------------------------------------- opentelemetry_sdk 0.19.0 - Apache-2.0 -https://github.com/open-telemetry/opentelemetry-rust +https://github.com/open-telemetry/opentelemetry-rust/tree/main/opentelemetry-sdk Apache License Version 2.0, January 2004 @@ -8584,7 +8588,6 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [license-image]: https://img.shields.io/badge/license-Apache2.0/MIT-blue.svg [deps-image]: https://deps.rs/repo/github/RustCrypto/hashes/status.svg [deps-link]: https://deps.rs/repo/github/RustCrypto/hashes -[msrv-1.85]: https://img.shields.io/badge/rustc-1.85.0+-blue.svg [//]: # (crates) @@ -8596,6 +8599,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [`groestl`]: ./groestl [`jh`]: ./jh [`k12`]: ./k12 +[`kupyna`]: ./kupyna [`md2`]: ./md2 [`md4`]: ./md4 [`md5`]: ./md5 @@ -8638,6 +8642,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [Grøstl]: https://en.wikipedia.org/wiki/Grøstl [JH]: https://www3.ntu.edu.sg/home/wuhj/research/jh [KangarooTwelve]: https://keccak.team/kangarootwelve.html +[Kupyna]: https://eprint.iacr.org/2015/885.pdf [MD2]: https://en.wikipedia.org/wiki/MD2_(cryptography) [MD4]: https://en.wikipedia.org/wiki/MD4 [MD5]: https://en.wikipedia.org/wiki/MD5 @@ -8677,7 +8682,6 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [license-image]: https://img.shields.io/badge/license-Apache2.0/MIT-blue.svg [deps-image]: https://deps.rs/repo/github/RustCrypto/hashes/status.svg [deps-link]: https://deps.rs/repo/github/RustCrypto/hashes -[msrv-1.85]: https://img.shields.io/badge/rustc-1.85.0+-blue.svg [//]: # (crates) @@ -8689,6 +8693,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [`groestl`]: ./groestl [`jh`]: ./jh [`k12`]: ./k12 +[`kupyna`]: ./kupyna [`md2`]: ./md2 [`md4`]: ./md4 [`md5`]: ./md5 @@ -8731,6 +8736,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [Grøstl]: https://en.wikipedia.org/wiki/Grøstl [JH]: https://www3.ntu.edu.sg/home/wuhj/research/jh [KangarooTwelve]: https://keccak.team/kangarootwelve.html +[Kupyna]: https://eprint.iacr.org/2015/885.pdf [MD2]: https://en.wikipedia.org/wiki/MD2_(cryptography) [MD4]: https://en.wikipedia.org/wiki/MD4 [MD5]: https://en.wikipedia.org/wiki/MD5 @@ -9395,7 +9401,7 @@ DEALINGS IN THE SOFTWARE. tar 0.4.40 - MIT/Apache-2.0 https://github.com/alexcrichton/tar-rs -Copyright (c) 2014 Alex Crichton +Copyright (c) The tar-rs Project Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -9519,7 +9525,7 @@ DEALINGS IN THE SOFTWARE. time 0.3.36 - MIT OR Apache-2.0 https://github.com/time-rs/time -Copyright (c) 2024 Jacob Pratt et al. +Copyright (c) Jacob Pratt et al. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9545,7 +9551,7 @@ SOFTWARE. time-core 0.1.2 - MIT OR Apache-2.0 https://github.com/time-rs/time -Copyright (c) 2024 Jacob Pratt et al. +Copyright (c) Jacob Pratt et al. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9621,7 +9627,7 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a --------------------------------------------------------- -tokio 1.37.0 - MIT +tokio 1.38.2 - MIT https://github.com/tokio-rs/tokio MIT License @@ -9649,7 +9655,7 @@ SOFTWARE. --------------------------------------------------------- -tokio-macros 2.2.0 - MIT +tokio-macros 2.3.0 - MIT https://github.com/tokio-rs/tokio MIT License @@ -11542,33 +11548,7 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a zbus 3.15.2 - MIT https://github.com/dbus2/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -11576,33 +11556,7 @@ DEALINGS IN THE SOFTWARE. zbus_macros 3.15.2 - MIT https://github.com/dbus2/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -11610,33 +11564,7 @@ DEALINGS IN THE SOFTWARE. zbus_names 2.6.1 - MIT https://github.com/dbus2/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -11778,6 +11706,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [`collectable`]: ./collectable [`cpufeatures`]: ./cpufeatures [`dbl`]: ./dbl +[`digest-io`]: ./digest-io [`hex-literal`]: ./hex-literal [`inout`]: ./inout [`opaque-debug`]: ./opaque-debug @@ -11931,33 +11860,7 @@ licences; see files named LICENSE.*.txt for details. zvariant 3.15.2 - MIT https://github.com/dbus2/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -11965,33 +11868,7 @@ DEALINGS IN THE SOFTWARE. zvariant_derive 3.15.2 - MIT https://github.com/dbus2/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -11999,31 +11876,5 @@ DEALINGS IN THE SOFTWARE. zvariant_utils 1.0.1 - MIT https://github.com/dbus2/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 2b005721dee..f1b01d02447 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,7 +24,10 @@ const ignores = fs.readFileSync(path.join(__dirname, '.eslint-ignore'), 'utf8') export default tseslint.config( // Global ignores { - ignores, + ignores: [ + ...ignores, + '!**/.eslint-plugin-local/**/*' + ], }, // All files (JS and TS) { @@ -85,6 +88,7 @@ export default tseslint.config( 'local/code-no-unexternalized-strings': 'warn', 'local/code-must-use-super-dispose': 'warn', 'local/code-declare-service-brand': 'warn', + 'local/code-no-deep-import-of-internal': ['error', { '.*Internal': true, 'searchExtTypesInternal': false }], 'local/code-layering': [ 'warn', { @@ -956,7 +960,7 @@ export default tseslint.config( ] }, { - 'target': 'src/vs/editor/editor.worker.ts', + 'target': 'src/vs/editor/editor.worker.start.ts', 'layer': 'worker', 'restrictions': [ 'vs/base/~', @@ -997,7 +1001,6 @@ export default tseslint.config( { 'target': 'src/vs/workbench/api/~', 'restrictions': [ - '@c4312/eventsource-umd', 'vscode', 'vs/base/~', 'vs/base/parts/*/~', diff --git a/extensions/configuration-editing/src/configurationEditingMain.ts b/extensions/configuration-editing/src/configurationEditingMain.ts index f791557a705..2578270c4a0 100644 --- a/extensions/configuration-editing/src/configurationEditingMain.ts +++ b/extensions/configuration-editing/src/configurationEditingMain.ts @@ -62,6 +62,7 @@ function registerVariableCompletions(pattern: string): vscode.Disposable { { label: 'lineNumber', detail: vscode.l10n.t("The current selected line number in the active file") }, { label: 'selectedText', detail: vscode.l10n.t("The current selected text in the active file") }, { label: 'fileDirname', detail: vscode.l10n.t("The current opened file's dirname") }, + { label: 'fileDirnameBasename', detail: vscode.l10n.t("The current opened file's folder name") }, { label: 'fileExtname', detail: vscode.l10n.t("The current opened file's extension") }, { label: 'fileBasename', detail: vscode.l10n.t("The current opened file's basename") }, { label: 'fileBasenameNoExtension', detail: vscode.l10n.t("The current opened file's basename with no file extension") }, diff --git a/extensions/css-language-features/client/src/cssClient.ts b/extensions/css-language-features/client/src/cssClient.ts index f6e8fe3513e..4e90b3482e4 100644 --- a/extensions/css-language-features/client/src/cssClient.ts +++ b/extensions/css-language-features/client/src/cssClient.ts @@ -15,7 +15,7 @@ namespace CustomDataChangedNotification { export type LanguageClientConstructor = (name: string, description: string, clientOptions: LanguageClientOptions) => BaseLanguageClient; export interface Runtime { - TextDecoder: { new(encoding?: string): { decode(buffer: ArrayBuffer): string } }; + TextDecoder: typeof TextDecoder; fs?: RequestService; } diff --git a/extensions/css-language-features/client/src/node/cssClientMain.ts b/extensions/css-language-features/client/src/node/cssClientMain.ts index 96926979b2a..f634188bedf 100644 --- a/extensions/css-language-features/client/src/node/cssClientMain.ts +++ b/extensions/css-language-features/client/src/node/cssClientMain.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TextDecoder } from 'util'; import { ExtensionContext, extensions, l10n } from 'vscode'; import { BaseLanguageClient, LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node'; import { LanguageClientConstructor, startClient } from '../cssClient'; diff --git a/extensions/css-language-features/package-lock.json b/extensions/css-language-features/package-lock.json index a645c2ab11a..7ffe55ba0bc 100644 --- a/extensions/css-language-features/package-lock.json +++ b/extensions/css-language-features/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "vscode-languageclient": "^10.0.0-next.13", - "vscode-uri": "^3.0.8" + "vscode-languageclient": "^10.0.0-next.14", + "vscode-uri": "^3.1.0" }, "devDependencies": { "@types/node": "20.x" @@ -20,59 +20,50 @@ } }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -81,56 +72,56 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/vscode-jsonrpc": { - "version": "9.0.0-next.6", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.6.tgz", - "integrity": "sha512-KCSvUNsFiVciG9iqjJKBZOd66CN3ZKohDlYRmoOi+pd8l15MFLZ8wRG4c+wuzePGba/8WcCG2TM+C/GVlvuaeA==", + "version": "9.0.0-next.7", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.7.tgz", + "integrity": "sha512-7SgnbbbJfYr3off0T2KV/RCMYhVsuLeFPw8l3bkxSiavtoTLsOdu1jyxK3yWbdQuO8QOJC7+no0TXmYjRWSC+g==", + "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageclient": { - "version": "10.0.0-next.13", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.0.0-next.13.tgz", - "integrity": "sha512-KLsOMJoYpkk36PIgcOjyZ4AekOfzp4kdWdRRbVKeVvSIrwrn/4RSZr0NlD6EvUBBJSsJW4WDrYY7Y3znkqa6+w==", + "version": "10.0.0-next.14", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.0.0-next.14.tgz", + "integrity": "sha512-4m/cpNocRgrAkWc8IH4wd3zllAs16NvMmeGcQxFa6xt+mGXJASIeqp0NAFWKZERKg6ClVgBph+SDSZSVvNZ2oA==", "license": "MIT", "dependencies": { - "minimatch": "^9.0.3", - "semver": "^7.6.0", - "vscode-languageserver-protocol": "3.17.6-next.11" + "minimatch": "^10.0.1", + "semver": "^7.6.3", + "vscode-languageserver-protocol": "3.17.6-next.12" }, "engines": { "vscode": "^1.91.0" } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.17.6-next.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.11.tgz", - "integrity": "sha512-GeJxEp1TiLsp79f8WG5n10wLViXfgFKb99hU9K8m7KDWM95/QFEqWkm79f9LVm54tUK74I91a9EeiQLCS/FABQ==", + "version": "3.17.6-next.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.12.tgz", + "integrity": "sha512-EqrbwF0glTWD2HiDpFc32pJOr6/bJvyKSfCpRQrKy3XsfdloH4p3o/rNJYcpujM0OVLmPZgl1i9g57z9g2YRJA==", + "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.0-next.6", - "vscode-languageserver-types": "3.17.6-next.5" + "vscode-jsonrpc": "9.0.0-next.7", + "vscode-languageserver-types": "3.17.6-next.6" } }, "node_modules/vscode-languageserver-types": { - "version": "3.17.6-next.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.5.tgz", - "integrity": "sha512-QFmf3Yl1tCgUQfA77N9Me/LXldJXkIVypQbty2rJ1DNHQkC+iwvm4Z2tXg9czSwlhvv0pD4pbF5mT7WhAglolw==" + "version": "3.17.6-next.6", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.6.tgz", + "integrity": "sha512-aiJY5/yW+xzw7KPNlwi3gQtddq/3EIn5z8X8nCgJfaiAij2R1APKePngv+MUdLdYJBVTLu+Qa0ODsT+pHgYguQ==", + "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" } } } diff --git a/extensions/css-language-features/package.json b/extensions/css-language-features/package.json index 0533881380c..a05f7af687e 100644 --- a/extensions/css-language-features/package.json +++ b/extensions/css-language-features/package.json @@ -994,8 +994,8 @@ ] }, "dependencies": { - "vscode-languageclient": "^10.0.0-next.13", - "vscode-uri": "^3.0.8" + "vscode-languageclient": "^10.0.0-next.14", + "vscode-uri": "^3.1.0" }, "devDependencies": { "@types/node": "20.x" diff --git a/extensions/css-language-features/server/package-lock.json b/extensions/css-language-features/server/package-lock.json index b9c936d84bf..da345e38337 100644 --- a/extensions/css-language-features/server/package-lock.json +++ b/extensions/css-language-features/server/package-lock.json @@ -10,9 +10,9 @@ "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", - "vscode-css-languageservice": "^6.3.2", + "vscode-css-languageservice": "^6.3.5", "vscode-languageserver": "^10.0.0-next.11", - "vscode-uri": "^3.0.8" + "vscode-uri": "^3.1.0" }, "devDependencies": { "@types/mocha": "^9.1.1", @@ -49,15 +49,15 @@ "dev": true }, "node_modules/vscode-css-languageservice": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.2.tgz", - "integrity": "sha512-GEpPxrUTAeXWdZWHev1OJU9lz2Q2/PPBxQ2TIRmLGvQiH3WZbqaNoute0n0ewxlgtjzTW3AKZT+NHySk5Rf4Eg==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.5.tgz", + "integrity": "sha512-ehEIMXYPYEz/5Svi2raL9OKLpBt5dSAdoCFoLpo0TVFKrVpDemyuQwS3c3D552z/qQCg3pMp8oOLMObY6M3ajQ==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", - "vscode-uri": "^3.0.8" + "vscode-uri": "^3.1.0" } }, "node_modules/vscode-jsonrpc": { @@ -105,9 +105,10 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" } } } diff --git a/extensions/css-language-features/server/package.json b/extensions/css-language-features/server/package.json index 9b6a8b47d6b..c63252f6d8d 100644 --- a/extensions/css-language-features/server/package.json +++ b/extensions/css-language-features/server/package.json @@ -11,9 +11,9 @@ "browser": "./dist/browser/cssServerMain", "dependencies": { "@vscode/l10n": "^0.0.18", - "vscode-css-languageservice": "^6.3.2", + "vscode-css-languageservice": "^6.3.5", "vscode-languageserver": "^10.0.0-next.11", - "vscode-uri": "^3.0.8" + "vscode-uri": "^3.1.0" }, "devDependencies": { "@types/mocha": "^9.1.1", @@ -22,7 +22,7 @@ "scripts": { "compile": "gulp compile-extension:css-language-features-server", "watch": "gulp watch-extension:css-language-features-server", - "install-service-next": "npm install vscode-css-languageservice@next", + "install-service-next": "npm install vscode-css-languageservice", "install-service-local": "npm link vscode-css-languageservice", "install-server-next": "npm install vscode-languageserver@next", "install-server-local": "npm install vscode-languageserver", diff --git a/extensions/css-language-features/server/src/cssServer.ts b/extensions/css-language-features/server/src/cssServer.ts index c5db57340fd..8b365f41b6b 100644 --- a/extensions/css-language-features/server/src/cssServer.ts +++ b/extensions/css-language-features/server/src/cssServer.ts @@ -7,7 +7,7 @@ import { Connection, TextDocuments, InitializeParams, InitializeResult, ServerCapabilities, ConfigurationRequest, WorkspaceFolder, TextDocumentSyncKind, NotificationType, Disposable, TextDocumentIdentifier, Range, FormattingOptions, TextEdit, Diagnostic } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; -import { getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService, LanguageSettings, LanguageService, Stylesheet, TextDocument, Position } from 'vscode-css-languageservice'; +import { getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService, LanguageSettings, LanguageService, Stylesheet, TextDocument, Position, CodeActionKind } from 'vscode-css-languageservice'; import { getLanguageModelCache } from './languageModelCache'; import { runSafeAsync } from './utils/runner'; import { DiagnosticsSupport, registerDiagnosticsPullSupport, registerDiagnosticsPushSupport } from './utils/validation'; @@ -119,7 +119,9 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) documentLinkProvider: { resolveProvider: false }, - codeActionProvider: true, + codeActionProvider: { + codeActionKinds: [CodeActionKind.QuickFix] + }, renameProvider: true, colorProvider: {}, foldingRangeProvider: true, @@ -286,7 +288,7 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) if (document) { await dataProvidersReady; const stylesheet = stylesheets.get(document); - return getLanguageService(document).doCodeActions(document, codeActionParams.range, codeActionParams.context, stylesheet); + return getLanguageService(document).doCodeActions2(document, codeActionParams.range, codeActionParams.context, stylesheet); } return []; }, [], `Error while computing code actions for ${codeActionParams.textDocument.uri}`, token); diff --git a/extensions/debug-auto-launch/package-lock.json b/extensions/debug-auto-launch/package-lock.json index 84a1daab83b..d6a69a857f5 100644 --- a/extensions/debug-auto-launch/package-lock.json +++ b/extensions/debug-auto-launch/package-lock.json @@ -16,19 +16,21 @@ } }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" } } } diff --git a/extensions/emmet/package-lock.json b/extensions/emmet/package-lock.json index cadbf5387a8..971528b395b 100644 --- a/extensions/emmet/package-lock.json +++ b/extensions/emmet/package-lock.json @@ -81,12 +81,13 @@ "integrity": "sha1-JEywLHfsLnT3ipvTGCGKvJxQCmE= sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==" }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@vscode/emmet-helper": { @@ -151,10 +152,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", diff --git a/extensions/git/package-lock.json b/extensions/git/package-lock.json index bc150555c70..16b71e40c37 100644 --- a/extensions/git/package-lock.json +++ b/extensions/git/package-lock.json @@ -11,10 +11,8 @@ "dependencies": { "@joaomoreno/unique-names-generator": "^5.2.0", "@vscode/extension-telemetry": "^0.9.8", - "@vscode/iconv-lite-umd": "0.7.0", "byline": "^5.0.0", "file-type": "16.5.4", - "jschardet": "3.1.4", "picomatch": "2.3.1", "vscode-uri": "^2.0.0", "which": "4.0.0" @@ -184,12 +182,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/picomatch": { @@ -218,11 +217,6 @@ "vscode": "^1.75.0" } }, - "node_modules/@vscode/iconv-lite-umd": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.0.tgz", - "integrity": "sha512-bRRFxLfg5dtAyl5XyiVWz/ZBPahpOpPrNYnnHpOpUZvam4tKH35wdhP4Kj6PbM0+KdliOsPzbGWpkxcdpNB/sg==" - }, "node_modules/byline": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", @@ -279,15 +273,6 @@ "node": ">=16" } }, - "node_modules/jschardet": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.1.4.tgz", - "integrity": "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg==", - "license": "LGPL-2.1+", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/peek-readable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", @@ -399,10 +384,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/util-deprecate": { "version": "1.0.2", diff --git a/extensions/git/package.json b/extensions/git/package.json index 7d89571372b..3102a464853 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -251,6 +251,13 @@ "category": "Git", "enablement": "!operationInProgress" }, + { + "command": "git.unstageChange", + "title": "%command.unstageChange%", + "category": "Git", + "icon": "$(remove)", + "enablement": "!operationInProgress" + }, { "command": "git.unstageFile", "title": "%command.unstage%", @@ -970,7 +977,7 @@ "command": "git.unstageSelectedRanges", "key": "ctrl+k ctrl+n", "mac": "cmd+k cmd+n", - "when": "editorTextFocus && isInDiffEditor && isInDiffRightEditor && resourceScheme == git" + "when": "editorTextFocus && isInDiffEditor && isInDiffRightEditor && (resourceScheme == file || resourceScheme == git)" }, { "command": "git.revertSelectedRanges", @@ -1075,7 +1082,11 @@ }, { "command": "git.unstageSelectedRanges", - "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && resourceScheme == git" + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && (resourceScheme == file || resourceScheme == git)" + }, + { + "command": "git.unstageChange", + "when": "false" }, { "command": "git.clean", @@ -2219,11 +2230,15 @@ "scm/change/title": [ { "command": "git.stageChange", - "when": "config.git.enabled && !git.missing && originalResourceScheme == git" + "when": "config.git.enabled && !git.missing && originalResource =~ /^git\\:.*%22ref%22%3A%22%22%7D$/" }, { "command": "git.revertChange", - "when": "config.git.enabled && !git.missing && originalResourceScheme == git" + "when": "config.git.enabled && !git.missing && originalResource =~ /^git\\:.*%22ref%22%3A%22%22%7D$/" + }, + { + "command": "git.unstageChange", + "when": "config.git.enabled && !git.missing && originalResource =~ /^git\\:.*%22ref%22%3A%22HEAD%22%7D$/" } ], "timeline/item/context": [ @@ -3297,13 +3312,13 @@ "markdownDescription": "%config.commitShortHashLength%", "scope": "resource" }, - "git.diagnosticsCommitHook.Enabled": { + "git.diagnosticsCommitHook.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%config.diagnosticsCommitHook.Enabled%", + "markdownDescription": "%config.diagnosticsCommitHook.enabled%", "scope": "resource" }, - "git.diagnosticsCommitHook.Sources": { + "git.diagnosticsCommitHook.sources": { "type": "object", "additionalProperties": { "type": "string", @@ -3318,7 +3333,7 @@ "default": { "*": "error" }, - "markdownDescription": "%config.diagnosticsCommitHook.Sources%", + "markdownDescription": "%config.diagnosticsCommitHook.sources%", "scope": "resource" }, "git.discardUntrackedChangesToTrash": { @@ -3328,7 +3343,7 @@ }, "git.showReferenceDetails": { "type": "boolean", - "default": false, + "default": true, "markdownDescription": "%config.showReferenceDetails%" } } @@ -3567,10 +3582,8 @@ "dependencies": { "@joaomoreno/unique-names-generator": "^5.2.0", "@vscode/extension-telemetry": "^0.9.8", - "@vscode/iconv-lite-umd": "0.7.0", "byline": "^5.0.0", "file-type": "16.5.4", - "jschardet": "3.1.4", "picomatch": "2.3.1", "vscode-uri": "^2.0.0", "which": "4.0.0" diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 52ba3819817..403f704e2f6 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -28,6 +28,7 @@ "command.revertChange": "Revert Change", "command.unstage": "Unstage Changes", "command.unstageAll": "Unstage All Changes", + "command.unstageChange": "Unstage Change", "command.unstageSelectedRanges": "Unstage Selected Ranges", "command.rename": "Rename", "command.clean": "Discard Changes", @@ -286,8 +287,8 @@ "config.blameStatusBarItem.enabled": "Controls whether to show blame information in the status bar.", "config.blameStatusBarItem.template": "Template for the blame information status bar item. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First N characters of the commit hash according to `#git.commitShortHashLength#`\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n", "config.commitShortHashLength": "Controls the length of the commit short hash.", - "config.diagnosticsCommitHook.Enabled": "Controls whether to check for unresolved diagnostics before committing.", - "config.diagnosticsCommitHook.Sources": "Controls the list of sources (**Item**) and the minimum severity (**Value**) to be considered before committing. **Note:** To ignore diagnostics from a particular source, add the source to the list and set the minimum severity to `none`.", + "config.diagnosticsCommitHook.enabled": "Controls whether to check for unresolved diagnostics before committing.", + "config.diagnosticsCommitHook.sources": "Controls the list of sources (**Item**) and the minimum severity (**Value**) to be considered before committing. **Note:** To ignore diagnostics from a particular source, add the source to the list and set the minimum severity to `none`.", "config.discardUntrackedChangesToTrash": "Controls whether discarding untracked changes moves the file(s) to the Recycle Bin (Windows), Trash (macOS, Linux) instead of deleting them permanently. **Note:** This setting has no effect when connected to a remote or when running in Linux as a snap package.", "config.showReferenceDetails": "Controls whether to show the details of the last commit for Git refs in the checkout, branch, and tag pickers.", "submenu.explorer": "Git", diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 9562106f9c6..3e8c92349cc 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -186,7 +186,6 @@ export interface RefQuery { readonly contains?: string; readonly count?: number; readonly pattern?: string | string[]; - readonly includeCommitDetails?: boolean; readonly sort?: 'alphabetically' | 'committerdate'; } diff --git a/extensions/git/src/blame.ts b/extensions/git/src/blame.ts index 12182f1de8d..eb65a8ea0ab 100644 --- a/extensions/git/src/blame.ts +++ b/extensions/git/src/blame.ts @@ -5,7 +5,7 @@ import { DecorationOptions, l10n, Position, Range, TextEditor, TextEditorChange, TextEditorDecorationType, TextEditorChangeKind, ThemeColor, Uri, window, workspace, EventEmitter, ConfigurationChangeEvent, StatusBarItem, StatusBarAlignment, Command, MarkdownString, languages, HoverProvider, CancellationToken, Hover, TextDocument } from 'vscode'; import { Model } from './model'; -import { dispose, fromNow, getCommitShortHash, IDisposable } from './util'; +import { dispose, fromNow, getCommitShortHash, IDisposable, truncate } from './util'; import { Repository } from './repository'; import { throttle } from './decorators'; import { BlameInformation, Commit } from './git'; @@ -186,14 +186,10 @@ export class GitBlameController { } formatBlameInformationMessage(documentUri: Uri, template: string, blameInformation: BlameInformation): string { - const subject = blameInformation.subject && blameInformation.subject.length > this._subjectMaxLength - ? `${blameInformation.subject.substring(0, this._subjectMaxLength)}\u2026` - : blameInformation.subject; - const templateTokens = { hash: blameInformation.hash, hashShort: getCommitShortHash(documentUri, blameInformation.hash), - subject: emojify(subject ?? ''), + subject: emojify(truncate(blameInformation.subject ?? '', this._subjectMaxLength)), authorName: blameInformation.authorName ?? '', authorEmail: blameInformation.authorEmail ?? '', authorDate: new Date(blameInformation.authorDate ?? new Date()).toLocaleString(), @@ -248,7 +244,6 @@ export class GitBlameController { const markdownString = new MarkdownString(); markdownString.isTrusted = true; - markdownString.supportHtml = true; markdownString.supportThemeIcons = true; // Author, date @@ -258,6 +253,7 @@ export class GitBlameController { const authorDate = commitInformation?.authorDate ?? blameInformation.authorDate; const avatar = commitAvatar ? `![${authorName}](${commitAvatar}|width=${AVATAR_SIZE},height=${AVATAR_SIZE})` : '$(account)'; + if (authorName) { if (authorEmail) { const emailTitle = l10n.t('Email'); @@ -277,7 +273,8 @@ export class GitBlameController { } // Subject | Message - markdownString.appendMarkdown(`${emojify(commitMessageWithLinks ?? commitInformation?.message ?? blameInformation.subject ?? '')}\n\n`); + const message = commitMessageWithLinks ?? commitInformation?.message ?? blameInformation.subject ?? ''; + markdownString.appendMarkdown(`${emojify(message.replace(/\r\n|\r|\n/g, '\n\n'))}\n\n`); markdownString.appendMarkdown(`---\n\n`); // Short stats @@ -578,6 +575,7 @@ export class GitBlameController { } class GitBlameEditorDecoration implements HoverProvider { + private _template = ''; private _decoration: TextEditorDecorationType; private _hoverDisposable: IDisposable | undefined; @@ -637,6 +635,10 @@ class GitBlameEditorDecoration implements HoverProvider { return; } + // Cache the decoration template + const config = workspace.getConfiguration('git'); + this._template = config.get('blame.editorDecoration.template', '${subject}, ${authorName} (${authorDateAgo})'); + this._registerHoverProvider(); this._onDidChangeBlameInformation(); } @@ -667,12 +669,9 @@ class GitBlameEditorDecoration implements HoverProvider { } // Set decorations for the editor - const config = workspace.getConfiguration('git'); - const template = config.get('blame.editorDecoration.template', '${subject}, ${authorName} (${authorDateAgo})'); - const decorations = blameInformation.map(blame => { const contentText = typeof blame.blameInformation !== 'string' - ? this._controller.formatBlameInformationMessage(textEditor.document.uri, template, blame.blameInformation) + ? this._controller.formatBlameInformationMessage(textEditor.document.uri, this._template, blame.blameInformation) : blame.blameInformation; return this._createDecoration(blame.lineNumber, contentText); @@ -712,6 +711,7 @@ class GitBlameEditorDecoration implements HoverProvider { } class GitBlameStatusBarItem { + private _template = ''; private _statusBarItem: StatusBarItem; private _disposables: IDisposable[] = []; @@ -722,14 +722,21 @@ class GitBlameStatusBarItem { workspace.onDidChangeConfiguration(this._onDidChangeConfiguration, this, this._disposables); this._controller.onDidChangeBlameInformation(() => this._onDidChangeBlameInformation(), this, this._disposables); + + this._onDidChangeConfiguration(); } - private _onDidChangeConfiguration(e: ConfigurationChangeEvent): void { - if (!e.affectsConfiguration('git.commitShortHashLength') && + private _onDidChangeConfiguration(e?: ConfigurationChangeEvent): void { + if (e && + !e.affectsConfiguration('git.commitShortHashLength') && !e.affectsConfiguration('git.blame.statusBarItem.template')) { return; } + // Cache the decoration template + const config = workspace.getConfiguration('git'); + this._template = config.get('blame.statusBarItem.template', '${authorName} (${authorDateAgo})'); + this._onDidChangeBlameInformation(); } @@ -750,11 +757,8 @@ class GitBlameStatusBarItem { this._statusBarItem.tooltip = l10n.t('Git Blame Information'); this._statusBarItem.command = undefined; } else { - const config = workspace.getConfiguration('git'); - const template = config.get('blame.statusBarItem.template', '${authorName} (${authorDateAgo})'); - this._statusBarItem.text = `$(git-commit) ${this._controller.formatBlameInformationMessage( - window.activeTextEditor.document.uri, template, blameInformation[0].blameInformation)}`; + window.activeTextEditor.document.uri, this._template, blameInformation[0].blameInformation)}`; this._statusBarItem.tooltip2 = (cancellationToken: CancellationToken) => { return this._provideTooltip(window.activeTextEditor!.document.uri, diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 1094a438b07..6aec2846e63 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -8,13 +8,13 @@ import * as path from 'path'; import { Command, commands, Disposable, MessageOptions, Position, ProgressLocation, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook, QuickInputButtonLocation, languages } from 'vscode'; import TelemetryReporter from '@vscode/extension-telemetry'; import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; -import { ForcePushMode, GitErrorCodes, Ref, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote } from './api/git'; +import { ForcePushMode, GitErrorCodes, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git'; import { Git, Stash } from './git'; import { Model } from './model'; import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository'; import { DiffEditorSelectionHunkToolbarContext, LineChange, applyLineChanges, getIndexDiffInformation, getModifiedRange, getWorkingTreeDiffInformation, intersectDiffWithRange, invertLineChange, toLineChanges, toLineRanges } from './staging'; import { fromGitUri, toGitUri, isGitUri, toMergeUris, toMultiFileDiffEditorUris } from './uri'; -import { DiagnosticSeverityConfig, dispose, fromNow, getCommitShortHash, grep, isDefined, isDescendant, isLinuxSnap, isRemote, isWindows, pathEquals, relativePath, toDiagnosticSeverity, truncate } from './util'; +import { DiagnosticSeverityConfig, dispose, fromNow, grep, isDefined, isDescendant, isLinuxSnap, isRemote, isWindows, pathEquals, relativePath, toDiagnosticSeverity, truncate } from './util'; import { GitTimelineItem } from './timelineProvider'; import { ApiRepository } from './api/api1'; import { getRemoteSourceActions, pickRemoteSource } from './remoteSource'; @@ -73,8 +73,8 @@ class RefItem implements QuickPickItem { } get description(): string { - if (this.ref.commitDetails?.authorDate) { - return fromNow(this.ref.commitDetails.authorDate, true, true); + if (this.ref.commitDetails?.commitDate) { + return fromNow(this.ref.commitDetails.commitDate, true, true); } switch (this.ref.type) { @@ -91,7 +91,7 @@ class RefItem implements QuickPickItem { get detail(): string | undefined { if (this.ref.commitDetails?.authorName && this.ref.commitDetails?.message) { - return `${this.ref.commitDetails?.authorName} | ${this.ref.commitDetails?.message}`; + return `${this.ref.commitDetails?.authorName}$(circle-small-filled)${this.ref.commitDetails?.message}`; } return undefined; @@ -108,8 +108,26 @@ class RefItem implements QuickPickItem { constructor(protected readonly ref: Ref) { } } -class CheckoutItem extends RefItem { +class BranchItem extends RefItem { + override get description(): string { + const description: string[] = []; + if (typeof this.ref.behind === 'number' && typeof this.ref.ahead === 'number') { + description.push(`${this.ref.behind}↓ ${this.ref.ahead}↑`); + } + if (this.ref.commitDetails?.commitDate) { + description.push(fromNow(this.ref.commitDetails.commitDate, true, true)); + } + + return description.length > 0 ? description.join('$(circle-small-filled)') : this.shortCommit; + } + + constructor(override readonly ref: Branch) { + super(ref); + } +} + +class CheckoutItem extends BranchItem { async run(repository: Repository, opts?: { detached?: boolean }): Promise { if (!this.ref.name) { return; @@ -128,7 +146,6 @@ class CheckoutProtectedItem extends CheckoutItem { override get label(): string { return `$(lock) ${this.ref.name ?? this.shortCommit}`; } - } class CheckoutRemoteHeadItem extends RefItem { @@ -164,7 +181,7 @@ class CheckoutTagItem extends RefItem { } } -class BranchDeleteItem extends RefItem { +class BranchDeleteItem extends BranchItem { async run(repository: Repository, force?: boolean): Promise { if (this.ref.type === RefType.Head && this.refName) { @@ -198,7 +215,7 @@ class RemoteTagDeleteItem extends RefItem { } } -class MergeItem extends RefItem { +class MergeItem extends BranchItem { async run(repository: Repository): Promise { if (this.ref.name || this.ref.commit) { @@ -207,7 +224,7 @@ class MergeItem extends RefItem { } } -class RebaseItem extends RefItem { +class RebaseItem extends BranchItem { async run(repository: Repository): Promise { if (this.ref?.name) { @@ -360,7 +377,7 @@ async function createCheckoutItems(repository: Repository, detached = false): Pr .filter(p => !!p) as RefProcessor[]; const buttons = await getRemoteRefItemButtons(repository); - const itemsProcessor = new CheckoutItemsProcessor(refProcessors, repository, buttons, detached); + const itemsProcessor = new CheckoutItemsProcessor(repository, refProcessors, buttons, detached); return itemsProcessor.processRefs(refs); } @@ -418,10 +435,22 @@ class RefProcessor { class RefItemsProcessor { - constructor(protected readonly processors: RefProcessor[]) { } + constructor( + protected readonly repository: Repository, + protected readonly processors: RefProcessor[], + protected readonly options: { + skipCurrentBranch?: boolean; + skipCurrentBranchRemote?: boolean; + } = {} + ) { } processRefs(refs: Ref[]): QuickPickItem[] { + const refsToSkip = this.getRefsToSkip(); + for (const ref of refs) { + if (ref.name && refsToSkip.includes(ref.name)) { + continue; + } for (const processor of this.processors) { if (processor.processRef(ref)) { break; @@ -436,48 +465,19 @@ class RefItemsProcessor { return result; } -} -class RebaseItemsProcessors extends RefItemsProcessor { + protected getRefsToSkip(): string[] { + const refsToSkip = ['origin/HEAD']; - private upstreamName: string | undefined; - - constructor(private readonly repository: Repository) { - super([ - new RefProcessor(RefType.Head, RebaseItem), - new RefProcessor(RefType.RemoteHead, RebaseItem) - ]); - - if (this.repository.HEAD?.upstream) { - this.upstreamName = `${this.repository.HEAD?.upstream.remote}/${this.repository.HEAD?.upstream.name}`; - } - } - - override processRefs(refs: Ref[]): QuickPickItem[] { - const result: QuickPickItem[] = []; - - for (const ref of refs) { - if (ref.name === this.repository.HEAD?.name) { - continue; - } - - if (ref.name === this.upstreamName) { - result.push(new RebaseUpstreamItem(ref)); - continue; - } - - for (const processor of this.processors) { - if (processor.processRef(ref)) { - break; - } - } + if (this.options.skipCurrentBranch && this.repository.HEAD?.name) { + refsToSkip.push(this.repository.HEAD.name); } - for (const processor of this.processors) { - result.push(...processor.items); + if (this.options.skipCurrentBranchRemote && this.repository.HEAD?.upstream) { + refsToSkip.push(`${this.repository.HEAD.upstream.remote}/${this.repository.HEAD.upstream.name}`); } - return result; + return refsToSkip; } } @@ -503,11 +503,11 @@ class CheckoutItemsProcessor extends RefItemsProcessor { private defaultButtons: RemoteSourceActionButton[] | undefined; constructor( + repository: Repository, processors: RefProcessor[], - private readonly repository: Repository, private readonly buttons: Map, private readonly detached = false) { - super(processors); + super(repository, processors); // Default button(s) const remote = repository.remotes.find(r => r.pushUrl === repository.HEAD?.remote || r.fetchUrl === repository.HEAD?.remote) ?? repository.remotes[0]; @@ -632,59 +632,59 @@ class CommandErrorOutputTextDocumentContentProvider implements TextDocumentConte async function evaluateDiagnosticsCommitHook(repository: Repository, options: CommitOptions): Promise { const config = workspace.getConfiguration('git', Uri.file(repository.root)); - const enabled = config.get('diagnosticsCommitHook.Enabled', false) === true; - const sourceSeverity = config.get>('diagnosticsCommitHook.Sources', { '*': 'error' }); + const enabled = config.get('diagnosticsCommitHook.enabled', false) === true; + const sourceSeverity = config.get>('diagnosticsCommitHook.sources', { '*': 'error' }); if (!enabled) { return true; } - const changes: Uri[] = []; + const resources: Uri[] = []; if (repository.indexGroup.resourceStates.length > 0) { // Staged files - changes.push(...repository.indexGroup.resourceStates.map(r => r.resourceUri)); + resources.push(...repository.indexGroup.resourceStates.map(r => r.resourceUri)); } else if (options.all === 'tracked') { // Tracked files - changes.push(...repository.workingTreeGroup.resourceStates + resources.push(...repository.workingTreeGroup.resourceStates .filter(r => r.type !== Status.UNTRACKED && r.type !== Status.IGNORED) .map(r => r.resourceUri)); } else { // All files - changes.push(...repository.workingTreeGroup.resourceStates.map(r => r.resourceUri)); - changes.push(...repository.untrackedGroup.resourceStates.map(r => r.resourceUri)); + resources.push(...repository.workingTreeGroup.resourceStates.map(r => r.resourceUri)); + resources.push(...repository.untrackedGroup.resourceStates.map(r => r.resourceUri)); } - const diagnostics = languages.getDiagnostics(); - const changesDiagnostics = diagnostics.filter(([uri, diags]) => { - // File - if (uri.scheme !== 'file' || !changes.find(c => pathEquals(c.fsPath, uri.fsPath))) { - return false; - } + const diagnostics: Map = new Map(); + + for (const resource of resources) { + const unresolvedDiagnostics = languages.getDiagnostics(resource) + .filter(d => { + // No source or ignored source + if (!d.source || (Object.keys(sourceSeverity).includes(d.source) && sourceSeverity[d.source] === 'none')) { + return false; + } + + // Source severity + if (Object.keys(sourceSeverity).includes(d.source) && + d.severity <= toDiagnosticSeverity(sourceSeverity[d.source])) { + return true; + } + + // Wildcard severity + if (Object.keys(sourceSeverity).includes('*') && + d.severity <= toDiagnosticSeverity(sourceSeverity['*'])) { + return true; + } - // Diagnostics - return diags.find(d => { - // No source or ignored source - if (!d.source || (Object.keys(sourceSeverity).includes(d.source) && sourceSeverity[d.source] === 'none')) { return false; - } + }); - // Source severity - if (Object.keys(sourceSeverity).includes(d.source) && - d.severity <= toDiagnosticSeverity(sourceSeverity[d.source])) { - return true; - } + if (unresolvedDiagnostics.length > 0) { + diagnostics.set(resource, unresolvedDiagnostics.length); + } + } - // Wildcard severity - if (Object.keys(sourceSeverity).includes('*') && - d.severity <= toDiagnosticSeverity(sourceSeverity['*'])) { - return true; - } - - return false; - }); - }); - - if (changesDiagnostics.length === 0) { + if (diagnostics.size === 0) { return true; } @@ -692,9 +692,9 @@ async function evaluateDiagnosticsCommitHook(repository: Repository, options: Co const commit = l10n.t('Commit Anyway'); const view = l10n.t('View Problems'); - const message = changesDiagnostics.length === 1 - ? l10n.t('The following file has unresolved diagnostics: \'{0}\'.\n\nHow would you like to proceed?', path.basename(changesDiagnostics[0][0].fsPath)) - : l10n.t('There are {0} files that have unresolved diagnostics.\n\nHow would you like to proceed?', changesDiagnostics.length); + const message = diagnostics.size === 1 + ? l10n.t('The following file has unresolved diagnostics: \'{0}\'.\n\nHow would you like to proceed?', path.basename(diagnostics.keys().next().value!.fsPath)) + : l10n.t('There are {0} files that have unresolved diagnostics.\n\nHow would you like to proceed?', diagnostics.size); const choice = await window.showWarningMessage(message, { modal: true }, commit, view); @@ -1020,18 +1020,37 @@ export class CommandCenter { } } - @command('git.continueInLocalClone') - async continueInLocalClone(): Promise { - if (this.model.repositories.length === 0) { return; } - - // Pick a single repository to continue working on in a local clone if there's more than one - const items = this.model.repositories.reduce<(QuickPickItem & { repository: Repository })[]>((items, repository) => { + private getRepositoriesWithRemote(repositories: Repository[]) { + return repositories.reduce<(QuickPickItem & { repository: Repository })[]>((items, repository) => { const remote = repository.remotes.find((r) => r.name === repository.HEAD?.upstream?.remote); if (remote?.pushUrl) { items.push({ repository: repository, label: remote.pushUrl }); } return items; }, []); + } + + @command('git.continueInLocalClone') + async continueInLocalClone(): Promise { + if (this.model.repositories.length === 0) { return; } + + // Pick a single repository to continue working on in a local clone if there's more than one + let items = this.getRepositoriesWithRemote(this.model.repositories); + + // We have a repository but there is no remote URL (e.g. git init) + if (items.length === 0) { + const pick = this.model.repositories.length === 1 + ? { repository: this.model.repositories[0] } + : await window.showQuickPick(this.model.repositories.map((i) => ({ repository: i, label: i.root })), { canPickMany: false, placeHolder: l10n.t('Choose which repository to publish') }); + if (!pick) { return; } + + await this.publish(pick.repository); + + items = this.getRepositoriesWithRemote([pick.repository]); + if (items.length === 0) { + return; + } + } let selection = items[0]; if (items.length > 1) { @@ -1636,19 +1655,28 @@ export class CommandCenter { } let modifiedUri = changes.modifiedUri; + let modifiedDocument: TextDocument | undefined; + if (!modifiedUri) { const textEditor = window.activeTextEditor; if (!textEditor) { return; } - const modifiedDocument = textEditor.document; + modifiedDocument = textEditor.document; modifiedUri = modifiedDocument.uri; } + if (modifiedUri.scheme !== 'file') { return; } + + if (!modifiedDocument) { + modifiedDocument = await workspace.openTextDocument(modifiedUri); + } + const result = changes.originalWithModifiedChanges; - await this.runByRepository(modifiedUri, async (repository, resource) => await repository.stage(resource, result)); + await this.runByRepository(modifiedUri, async (repository, resource) => + await repository.stage(resource, result, modifiedDocument.encoding)); } @command('git.stageSelectedRanges') @@ -1824,7 +1852,8 @@ export class CommandCenter { const originalDocument = await workspace.openTextDocument(originalUri); const result = applyLineChanges(originalDocument, modifiedDocument, changes); - await this.runByRepository(modifiedUri, async (repository, resource) => await repository.stage(resource, result)); + await this.runByRepository(modifiedUri, async (repository, resource) => + await repository.stage(resource, result, modifiedDocument.encoding)); } @command('git.revertChange') @@ -1945,16 +1974,6 @@ export class CommandCenter { const modifiedDocument = textEditor.document; const modifiedUri = modifiedDocument.uri; - if (!isGitUri(modifiedUri)) { - return; - } - - const { ref } = fromGitUri(modifiedUri); - - if (ref !== '') { - return; - } - const repository = this.model.getRepository(modifiedUri); if (!repository) { return; @@ -1979,6 +1998,7 @@ export class CommandCenter { const originalUri = toGitUri(resource.original, 'HEAD'); const originalDocument = await workspace.openTextDocument(originalUri); const selectedLines = toLineRanges(textEditor.selections, modifiedDocument); + const selectedDiffs = indexLineChanges .map(change => selectedLines.reduce((result, range) => result || intersectDiffWithRange(modifiedDocument, change, range), null)) .filter(c => !!c) as LineChange[]; @@ -1988,13 +2008,25 @@ export class CommandCenter { return; } - const invertedDiffs = selectedDiffs.map(invertLineChange); - this.logger.trace(`[CommandCenter][unstageSelectedRanges] selectedDiffs: ${JSON.stringify(selectedDiffs)}`); - this.logger.trace(`[CommandCenter][unstageSelectedRanges] invertedDiffs: ${JSON.stringify(invertedDiffs)}`); - const result = applyLineChanges(modifiedDocument, originalDocument, invertedDiffs); - await repository.stage(modifiedUri, result); + if (modifiedUri.scheme === 'file') { + // Editor + const changes = indexLineChanges + .filter(c => !selectedDiffs.some(d => + d.originalStartLineNumber === c.originalStartLineNumber && + d.originalEndLineNumber === c.originalEndLineNumber && + d.modifiedStartLineNumber === c.modifiedStartLineNumber && + d.modifiedEndLineNumber === c.modifiedEndLineNumber)); + await this._unstageChanges(textEditor, changes); + return; + } + + const selectedDiffsInverted = selectedDiffs.map(invertLineChange); + this.logger.trace(`[CommandCenter][unstageSelectedRanges] selectedDiffsInverted: ${JSON.stringify(selectedDiffsInverted)}`); + + const result = applyLineChanges(modifiedDocument, originalDocument, selectedDiffsInverted); + await repository.stage(modifiedDocument.uri, result, modifiedDocument.encoding); } @command('git.unstageFile') @@ -2019,6 +2051,38 @@ export class CommandCenter { await repository.revert(resources); } + @command('git.unstageChange') + async unstageChange(uri: Uri, changes: LineChange[], index: number): Promise { + if (!uri) { + return; + } + + const textEditor = window.visibleTextEditors.filter(e => e.document.uri.toString() === uri.toString())[0]; + if (!textEditor) { + return; + } + + changes.splice(index, 1); + await this._unstageChanges(textEditor, changes); + } + + private async _unstageChanges(textEditor: TextEditor, changes: LineChange[]): Promise { + const modifiedDocument = textEditor.document; + const modifiedUri = modifiedDocument.uri; + + if (modifiedUri.scheme !== 'file') { + return; + } + + const originalUri = toGitUri(modifiedUri, 'HEAD'); + const originalDocument = await workspace.openTextDocument(originalUri); + + const invertedChanges = changes.map(invertLineChange); + const result = applyLineChanges(originalDocument, modifiedDocument, invertedChanges); + + await this.runByRepository(modifiedUri, async (repository, resource) => + await repository.stage(resource, result, modifiedDocument.encoding)); + } @command('git.clean') async clean(...resourceStates: SourceControlResourceState[]): Promise { @@ -2710,7 +2774,7 @@ export class CommandCenter { const quickPick = window.createQuickPick(); quickPick.busy = true; quickPick.sortByLabel = false; - quickPick.matchOnDetail = true; + quickPick.matchOnDetail = false; quickPick.placeholder = opts?.detached ? l10n.t('Select a branch to checkout in detached mode') : l10n.t('Select a branch or tag to checkout'); @@ -2857,6 +2921,7 @@ export class CommandCenter { const branchWhitespaceChar = config.get('branchWhitespaceChar')!; const branchValidationRegex = config.get('branchValidationRegex')!; const branchRandomNameEnabled = config.get('branchRandomName.enable', false); + const refs = await repository.getRefs({ pattern: 'refs/heads' }); if (defaultName) { return sanitizeBranchName(defaultName, branchWhitespaceChar); @@ -2874,6 +2939,13 @@ export class CommandCenter { const getValidationMessage = (name: string): string | InputBoxValidationMessage | undefined => { const validateName = new RegExp(branchValidationRegex); const sanitizedName = sanitizeBranchName(name, branchWhitespaceChar); + + // Check if branch name already exists + const existingBranch = refs.find(ref => ref.name === sanitizedName); + if (existingBranch) { + return l10n.t('Branch "{0}" already exists', sanitizedName); + } + if (validateName.test(sanitizedName)) { // If the sanitized name that we will use is different than what is // in the input box, show an info message to the user informing them @@ -2937,7 +3009,7 @@ export class CommandCenter { if (from) { const getRefPicks = async () => { const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); - const refProcessors = new RefItemsProcessor([ + const refProcessors = new RefItemsProcessor(repository, [ new RefProcessor(RefType.Head), new RefProcessor(RefType.RemoteHead), new RefProcessor(RefType.Tag) @@ -3052,33 +3124,25 @@ export class CommandCenter { const getBranchPicks = async () => { const pattern = options.remote ? 'refs/remotes' : 'refs/heads'; const refs = await repository.getRefs({ pattern, includeCommitDetails: showRefDetails }); + const processors = options.remote + ? [new RefProcessor(RefType.RemoteHead, BranchDeleteItem)] + : [new RefProcessor(RefType.Head, BranchDeleteItem)]; - const refsToExclude: string[] = []; - if (options.remote) { - refsToExclude.push('origin/HEAD'); + const itemsProcessor = new RefItemsProcessor(repository, processors, { + skipCurrentBranch: true, + skipCurrentBranchRemote: true + }); - if (repository.HEAD?.upstream) { - // Current branch's upstream - refsToExclude.push(`${repository.HEAD.upstream.remote}/${repository.HEAD.upstream.name}`); - } - } else { - if (repository.HEAD?.name) { - // Current branch - refsToExclude.push(repository.HEAD.name); - } - } - - return refs.filter(ref => ref.name && !refsToExclude.includes(ref.name)) - .map(ref => new BranchDeleteItem(ref)); + return itemsProcessor.processRefs(refs); }; const placeHolder = !options.remote ? l10n.t('Select a branch to delete') : l10n.t('Select a remote branch to delete'); - const choice = await this.pickRef(getBranchPicks(), placeHolder); + const choice = await this.pickRef(getBranchPicks(), placeHolder); - if (!choice || !choice.refName) { + if (!(choice instanceof BranchDeleteItem) || !choice.refName) { return; } name = choice.refName; @@ -3134,11 +3198,14 @@ export class CommandCenter { const getQuickPickItems = async (): Promise => { const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); - const itemsProcessor = new RefItemsProcessor([ + const itemsProcessor = new RefItemsProcessor(repository, [ new RefProcessor(RefType.Head, MergeItem), new RefProcessor(RefType.RemoteHead, MergeItem), new RefProcessor(RefType.Tag, MergeItem) - ]); + ], { + skipCurrentBranch: true, + skipCurrentBranchRemote: true + }); return itemsProcessor.processRefs(refs); }; @@ -3163,9 +3230,26 @@ export class CommandCenter { const getQuickPickItems = async (): Promise => { const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); - const itemsProcessor = new RebaseItemsProcessors(repository); + const itemsProcessor = new RefItemsProcessor(repository, [ + new RefProcessor(RefType.Head, RebaseItem), + new RefProcessor(RefType.RemoteHead, RebaseItem) + ], { + skipCurrentBranch: true, + skipCurrentBranchRemote: true + }); - return itemsProcessor.processRefs(refs); + const quickPickItems = itemsProcessor.processRefs(refs); + + if (repository.HEAD?.upstream) { + const upstreamRef = refs.find(ref => ref.type === RefType.RemoteHead && + ref.name === `${repository.HEAD!.upstream!.remote}/${repository.HEAD!.upstream!.name}`); + + if (upstreamRef) { + quickPickItems.splice(0, 0, new RebaseUpstreamItem(upstreamRef)); + } + } + + return quickPickItems; }; const placeHolder = l10n.t('Select a branch to rebase onto'); @@ -4514,8 +4598,11 @@ export class CommandCenter { } const rootUri = Uri.file(repository.root); + const config = workspace.getConfiguration('git', rootUri); + const commitShortHashLength = config.get('commitShortHashLength', 7); + const commit = await repository.getCommit(historyItemId); - const title = `${getCommitShortHash(rootUri, historyItemId)} - ${truncate(commit.message)}`; + const title = `${truncate(historyItemId, commitShortHashLength, false)} - ${truncate(commit.message)}`; const historyItemParentId = commit.parents.length > 0 ? commit.parents[0] : await repository.getEmptyTree(); const multiDiffSourceUri = Uri.from({ scheme: 'scm-history-item', path: `${repository.root}/${historyItemParentId}..${historyItemId}` }); diff --git a/extensions/git/src/editSessionIdentityProvider.ts b/extensions/git/src/editSessionIdentityProvider.ts index 6a0a31774a1..8380f03ecfd 100644 --- a/extensions/git/src/editSessionIdentityProvider.ts +++ b/extensions/git/src/editSessionIdentityProvider.ts @@ -13,11 +13,18 @@ export class GitEditSessionIdentityProvider implements vscode.EditSessionIdentit private providerRegistration: vscode.Disposable; constructor(private model: Model) { - this.providerRegistration = vscode.workspace.registerEditSessionIdentityProvider('file', this); - - vscode.workspace.onWillCreateEditSessionIdentity((e) => { - e.waitUntil(this._onWillCreateEditSessionIdentity(e.workspaceFolder)); - }); + this.providerRegistration = vscode.Disposable.from( + vscode.workspace.registerEditSessionIdentityProvider('file', this), + vscode.workspace.onWillCreateEditSessionIdentity((e) => { + e.waitUntil( + this._onWillCreateEditSessionIdentity(e.workspaceFolder).catch(err => { + if (err instanceof vscode.CancellationError) { + throw err; + } + }) + ); + }) + ); } dispose() { @@ -81,9 +88,23 @@ export class GitEditSessionIdentityProvider implements vscode.EditSessionIdentit await repository.status(); - // If this branch hasn't been published to the remote yet, - // ensure that it is published before Continue On is invoked - if (!repository.HEAD?.upstream && repository.HEAD?.type === RefType.Head) { + if (!repository.HEAD?.commit) { + // Handle publishing empty repository with no commits + + const yes = vscode.l10n.t('Yes'); + const selection = await vscode.window.showInformationMessage( + vscode.l10n.t('Would you like to publish this repository to continue working on it elsewhere?'), + { modal: true }, + yes + ); + if (selection !== yes) { + throw new vscode.CancellationError(); + } + await repository.commit('Initial commit', { all: true }); + await vscode.commands.executeCommand('git.publish'); + } else if (!repository.HEAD?.upstream && repository.HEAD?.type === RefType.Head) { + // If this branch hasn't been published to the remote yet, + // ensure that it is published before Continue On is invoked const publishBranch = vscode.l10n.t('Publish Branch'); const selection = await vscode.window.showInformationMessage( diff --git a/extensions/git/src/encoding.ts b/extensions/git/src/encoding.ts deleted file mode 100644 index c80fb6ee6d5..00000000000 --- a/extensions/git/src/encoding.ts +++ /dev/null @@ -1,100 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as jschardet from 'jschardet'; - -function detectEncodingByBOM(buffer: Buffer): string | null { - if (!buffer || buffer.length < 2) { - return null; - } - - const b0 = buffer.readUInt8(0); - const b1 = buffer.readUInt8(1); - - // UTF-16 BE - if (b0 === 0xFE && b1 === 0xFF) { - return 'utf16be'; - } - - // UTF-16 LE - if (b0 === 0xFF && b1 === 0xFE) { - return 'utf16le'; - } - - if (buffer.length < 3) { - return null; - } - - const b2 = buffer.readUInt8(2); - - // UTF-8 - if (b0 === 0xEF && b1 === 0xBB && b2 === 0xBF) { - return 'utf8'; - } - - return null; -} - -const IGNORE_ENCODINGS = [ - 'ascii', - 'utf-8', - 'utf-16', - 'utf-32' -]; - -const JSCHARDET_TO_ICONV_ENCODINGS: { [name: string]: string } = { - 'ibm866': 'cp866', - 'big5': 'cp950' -}; - -const MAP_CANDIDATE_GUESS_ENCODING_TO_JSCHARDET: { [key: string]: string } = { - utf8: 'UTF-8', - utf16le: 'UTF-16LE', - utf16be: 'UTF-16BE', - windows1252: 'windows-1252', - windows1250: 'windows-1250', - iso88592: 'ISO-8859-2', - windows1251: 'windows-1251', - cp866: 'IBM866', - iso88595: 'ISO-8859-5', - koi8r: 'KOI8-R', - windows1253: 'windows-1253', - iso88597: 'ISO-8859-7', - windows1255: 'windows-1255', - iso88598: 'ISO-8859-8', - cp950: 'Big5', - shiftjis: 'SHIFT_JIS', - eucjp: 'EUC-JP', - euckr: 'EUC-KR', - gb2312: 'GB2312' -}; - -export function detectEncoding(buffer: Buffer, candidateGuessEncodings: string[]): string | null { - const result = detectEncodingByBOM(buffer); - - if (result) { - return result; - } - - candidateGuessEncodings = candidateGuessEncodings.map(e => MAP_CANDIDATE_GUESS_ENCODING_TO_JSCHARDET[e]).filter(e => !!e); - - const detected = jschardet.detect(buffer, candidateGuessEncodings.length > 0 ? { detectEncodings: candidateGuessEncodings } : undefined); - if (!detected || !detected.encoding) { - return null; - } - - const encoding = detected.encoding; - - // Ignore encodings that cannot guess correctly - // (http://chardet.readthedocs.io/en/latest/supported-encodings.html) - if (0 <= IGNORE_ENCODINGS.indexOf(encoding.toLowerCase())) { - return null; - } - - const normalizedEncodingName = encoding.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); - const mapped = JSCHARDET_TO_ICONV_ENCODINGS[normalizedEncodingName]; - - return mapped || normalizedEncodingName; -} diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 4f64607aa6a..3433770b1f9 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -10,12 +10,10 @@ import * as cp from 'child_process'; import { fileURLToPath } from 'url'; import which from 'which'; import { EventEmitter } from 'events'; -import * as iconv from '@vscode/iconv-lite-umd'; import * as filetype from 'file-type'; -import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter, Versions, isWindows, pathEquals, isMacintosh, isDescendant, relativePath } from './util'; +import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter, Versions, isWindows, pathEquals, isMacintosh, isDescendant, relativePathWithNoFallback } from './util'; import { CancellationError, CancellationToken, ConfigurationChangeEvent, LogOutputChannel, Progress, Uri, workspace } from 'vscode'; -import { detectEncoding } from './encoding'; -import { Commit as ApiCommit, Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery, InitOptions } from './api/git'; +import { Commit as ApiCommit, Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery as ApiRefQuery, InitOptions } from './api/git'; import * as byline from 'byline'; import { StringDecoder } from 'string_decoder'; @@ -194,7 +192,6 @@ function cpErrorHandler(cb: (reason?: any) => void): (reason?: any) => void { export interface SpawnOptions extends cp.SpawnOptions { input?: string; - encoding?: string; log?: boolean; cancellationToken?: CancellationToken; onSpawn?: (childProcess: cp.ChildProcess) => void; @@ -355,8 +352,8 @@ function sanitizePath(path: string): string { return path.replace(/^([a-z]):\\/i, (_, letter) => `${letter.toUpperCase()}:\\`); } -function sanitizeRelativePath(from: string, to: string): string { - return path.isAbsolute(to) ? relativePath(from, to).replace(/\\/g, '/') : to; +function sanitizeRelativePath(path: string): string { + return path.replace(/\\/g, '/'); } const COMMIT_FORMAT = '%H%n%aN%n%aE%n%at%n%ct%n%P%n%D%n%B'; @@ -621,12 +618,9 @@ export class Git { } } - let encoding = options.encoding || 'utf8'; - encoding = iconv.encodingExists(encoding) ? encoding : 'utf8'; - const result: IExecutionResult = { exitCode: bufferResult.exitCode, - stdout: iconv.decode(bufferResult.stdout, encoding), + stdout: bufferResult.stdout.toString('utf8'), stderr: bufferResult.stderr }; @@ -735,6 +729,10 @@ export interface Commit { shortStat?: CommitShortStat; } +export interface RefQuery extends ApiRefQuery { + readonly includeCommitDetails?: boolean; +} + interface GitConfigSection { name: string; subSectionName?: string; @@ -1131,29 +1129,31 @@ function parseGitBlame(data: string): BlameInformation[] { } const REFS_FORMAT = '%(refname)%00%(objectname)%00%(*objectname)'; -const REFS_WITH_DETAILS_FORMAT = `${REFS_FORMAT}%00%(parent)%00%(*parent)%00%(authorname)%00%(*authorname)%00%(authordate:unix)%00%(*authordate:unix)%00%(subject)%00%(*subject)`; +const REFS_WITH_DETAILS_FORMAT = `${REFS_FORMAT}%00%(parent)%00%(*parent)%00%(authorname)%00%(*authorname)%00%(committerdate:unix)%00%(*committerdate:unix)%00%(subject)%00%(*subject)`; -const headRegex = /^refs\/heads\/([^ ]+)$/; -const remoteHeadRegex = /^refs\/remotes\/([^/]+)\/([^ ]+)$/; -const tagRegex = /^refs\/tags\/([^ ]+)$/; +function parseRefs(data: string): (Ref | Branch)[] { + const refRegex = /^(refs\/[^\0]+)\0([0-9a-f]{40})\0([0-9a-f]{40})?(?:\0(.*))?$/gm; -function parseRefs(data: string, includeCommitDetails: boolean): Ref[] { - const refs: Ref[] = []; - const refRegex = !includeCommitDetails - ? /^(.*)\0([0-9a-f]{40})\0([0-9a-f]{40})?$/gm - : /^(.*)\0([0-9a-f]{40})\0([0-9a-f]{40})?\0(.*)\0(.*)\0(.*)\0(.*)\0(.*)\0(.*)\0(.*)\0(.*)$/gm; + const headRegex = /^refs\/heads\/([^ ]+)$/; + const remoteHeadRegex = /^refs\/remotes\/([^/]+)\/([^ ]+)$/; + const tagRegex = /^refs\/tags\/([^ ]+)$/; + const statusRegex = /\[(?:ahead ([0-9]+))?[,\s]*(?:behind ([0-9]+))?]|\[gone]/; let ref: string | undefined; let commitHash: string | undefined; let tagCommitHash: string | undefined; + let details: string | undefined; let commitParents: string | undefined; let tagCommitParents: string | undefined; let commitSubject: string | undefined; let tagCommitSubject: string | undefined; let authorName: string | undefined; let tagAuthorName: string | undefined; - let authorDate: string | undefined; - let tagAuthorDate: string | undefined; + let committerDate: string | undefined; + let tagCommitterDate: string | undefined; + let status: string | undefined; + + const refs: (Ref | Branch)[] = []; let match: RegExpExecArray | null; let refMatch: RegExpExecArray | null; @@ -1164,28 +1164,31 @@ function parseRefs(data: string, includeCommitDetails: boolean): Ref[] { break; } - let commitDetails: ApiCommit | undefined = undefined; - [, ref, commitHash, tagCommitHash, commitParents, tagCommitParents, authorName, tagAuthorName, authorDate, tagAuthorDate, commitSubject, tagCommitSubject] = match; + [, ref, commitHash, tagCommitHash, details] = match; + [commitParents, tagCommitParents, authorName, tagAuthorName, committerDate, tagCommitterDate, commitSubject, tagCommitSubject, status] = details?.split('\0') ?? []; - if (includeCommitDetails) { - const parents = tagCommitParents || commitParents; - const subject = tagCommitSubject || commitSubject; - const author = tagAuthorName || authorName; - const date = tagAuthorDate || authorDate; + const parents = tagCommitParents || commitParents; + const subject = tagCommitSubject || commitSubject; + const author = tagAuthorName || authorName; + const date = tagCommitterDate || committerDate; - commitDetails = { + const commitDetails = parents && subject && author && date + ? { hash: commitHash, message: subject, - parents: parents ? parents.split(' ') : [], + parents: parents.split(' '), authorName: author, - authorDate: date ? new Date(Number(date) * 1000) : undefined - } satisfies ApiCommit; - } + commitDate: date ? new Date(Number(date) * 1000) : undefined, + } satisfies ApiCommit : undefined; if (refMatch = headRegex.exec(ref)) { - refs.push({ name: refMatch[1], commit: commitHash, commitDetails, type: RefType.Head }); + const [, aheadCount, behindCount] = statusRegex.exec(status) ?? []; + const ahead = status ? aheadCount ? Number(aheadCount) : 0 : undefined; + const behind = status ? behindCount ? Number(behindCount) : 0 : undefined; + refs.push({ name: refMatch[1], commit: commitHash, commitDetails, ahead, behind, type: RefType.Head }); } else if (refMatch = remoteHeadRegex.exec(ref)) { - refs.push({ name: `${refMatch[1]}/${refMatch[2]}`, remote: refMatch[1], commit: commitHash, commitDetails, type: RefType.RemoteHead }); + const name = `${refMatch[1]}/${refMatch[2]}`; + refs.push({ name, remote: refMatch[1], commit: commitHash, commitDetails, type: RefType.RemoteHead }); } else if (refMatch = tagRegex.exec(ref)) { refs.push({ name: refMatch[1], commit: tagCommitHash ?? commitHash, commitDetails, type: RefType.Tag }); } @@ -1398,20 +1401,8 @@ export class Repository { .filter(entry => !!entry); } - async bufferString(ref: string, filePath: string, encoding: string = 'utf8', autoGuessEncoding = false, candidateGuessEncodings: string[] = []): Promise { - const stdout = await this.buffer(ref, filePath); - - if (autoGuessEncoding) { - encoding = detectEncoding(stdout, candidateGuessEncodings) || encoding; - } - - encoding = iconv.encodingExists(encoding) ? encoding : 'utf8'; - - return iconv.decode(stdout, encoding); - } - async buffer(ref: string, filePath: string): Promise { - const relativePath = sanitizeRelativePath(this.repositoryRoot, filePath); + const relativePath = this.sanitizeRelativePath(filePath); const child = this.stream(['show', '--textconv', `${ref}:${relativePath}`]); if (!child.stdout) { @@ -1474,7 +1465,7 @@ export class Repository { args.push(treeish); if (path) { - args.push('--', sanitizeRelativePath(this.repositoryRoot, path)); + args.push('--', this.sanitizeRelativePath(path)); } const { stdout } = await this.exec(args); @@ -1483,7 +1474,7 @@ export class Repository { async lsfiles(path: string): Promise { const args = ['ls-files', '--stage']; - const relativePath = sanitizeRelativePath(this.repositoryRoot, path); + const relativePath = this.sanitizeRelativePath(path); if (relativePath) { args.push('--', relativePath); @@ -1498,7 +1489,7 @@ export class Repository { ? await this.lstree(ref, undefined, { recursive: true }) : await this.lsfiles(this.repositoryRoot); - const relativePathLowercase = sanitizeRelativePath(this.repositoryRoot, filePath).toLowerCase(); + const relativePathLowercase = this.sanitizeRelativePath(filePath).toLowerCase(); const element = elements.find(file => file.file.toLowerCase() === relativePathLowercase); if (!element) { @@ -1587,7 +1578,7 @@ export class Repository { return await this.diffFiles(false); } - const args = ['diff', '--', sanitizeRelativePath(this.repositoryRoot, path)]; + const args = ['diff', '--', this.sanitizeRelativePath(path)]; const result = await this.exec(args); return result.stdout; } @@ -1600,7 +1591,7 @@ export class Repository { return await this.diffFiles(false, ref); } - const args = ['diff', ref, '--', sanitizeRelativePath(this.repositoryRoot, path)]; + const args = ['diff', ref, '--', this.sanitizeRelativePath(path)]; const result = await this.exec(args); return result.stdout; } @@ -1613,7 +1604,7 @@ export class Repository { return await this.diffFiles(true); } - const args = ['diff', '--cached', '--', sanitizeRelativePath(this.repositoryRoot, path)]; + const args = ['diff', '--cached', '--', this.sanitizeRelativePath(path)]; const result = await this.exec(args); return result.stdout; } @@ -1626,7 +1617,7 @@ export class Repository { return await this.diffFiles(true, ref); } - const args = ['diff', '--cached', ref, '--', sanitizeRelativePath(this.repositoryRoot, path)]; + const args = ['diff', '--cached', ref, '--', this.sanitizeRelativePath(path)]; const result = await this.exec(args); return result.stdout; } @@ -1646,7 +1637,7 @@ export class Repository { return await this.diffFiles(false, range); } - const args = ['diff', range, '--', sanitizeRelativePath(this.repositoryRoot, path)]; + const args = ['diff', range, '--', this.sanitizeRelativePath(path)]; const result = await this.exec(args); return result.stdout.trim(); @@ -1738,7 +1729,7 @@ export class Repository { } if (paths && paths.length) { - for (const chunk of splitInChunks(paths.map(p => sanitizeRelativePath(this.repositoryRoot, p)), MAX_CLI_LENGTH)) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { await this.exec([...args, '--', ...chunk]); } } else { @@ -1753,15 +1744,15 @@ export class Repository { return; } - args.push(...paths.map(p => sanitizeRelativePath(this.repositoryRoot, p))); + args.push(...paths.map(p => this.sanitizeRelativePath(p))); await this.exec(args); } - async stage(path: string, data: string, encoding: string): Promise { - const relativePath = sanitizeRelativePath(this.repositoryRoot, path); + async stage(path: string, data: Uint8Array): Promise { + const relativePath = this.sanitizeRelativePath(path); const child = this.stream(['hash-object', '--stdin', '-w', '--path', relativePath], { stdio: [null, null, null] }); - child.stdin!.end(iconv.encode(data, encoding)); + child.stdin!.end(data); const { exitCode, stdout } = await exec(child); const hash = stdout.toString('utf8'); @@ -1809,7 +1800,7 @@ export class Repository { try { if (paths && paths.length > 0) { - for (const chunk of splitInChunks(paths.map(p => sanitizeRelativePath(this.repositoryRoot, p)), MAX_CLI_LENGTH)) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { await this.exec([...args, '--', ...chunk]); } } else { @@ -2023,7 +2014,7 @@ export class Repository { const args = ['clean', '-f', '-q']; for (const paths of groups) { - for (const chunk of splitInChunks(paths.map(p => sanitizeRelativePath(this.repositoryRoot, p)), MAX_CLI_LENGTH)) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { promises.push(limiter.queue(() => this.exec([...args, '--', ...chunk]))); } } @@ -2063,7 +2054,7 @@ export class Repository { try { if (paths && paths.length > 0) { - for (const chunk of splitInChunks(paths.map(p => sanitizeRelativePath(this.repositoryRoot, p)), MAX_CLI_LENGTH)) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { await this.exec([...args, '--', ...chunk]); } } else { @@ -2308,7 +2299,7 @@ export class Repository { async blame(path: string): Promise { try { - const args = ['blame', '--', sanitizeRelativePath(this.repositoryRoot, path)]; + const args = ['blame', '--', this.sanitizeRelativePath(path)]; const result = await this.exec(args); return result.stdout.trim(); } catch (err) { @@ -2328,7 +2319,7 @@ export class Repository { args.push(ref); } - args.push('--', sanitizeRelativePath(this.repositoryRoot, path)); + args.push('--', this.sanitizeRelativePath(path)); const result = await this.exec(args); @@ -2634,7 +2625,7 @@ export class Repository { .map(([ref]): Branch => ({ name: ref, type: RefType.Head })); } - async getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise { + async getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise<(Ref | Branch)[]> { if (cancellationToken && cancellationToken.isCancellationRequested) { throw new CancellationError(); } @@ -2649,7 +2640,14 @@ export class Repository { args.push('--sort', `-${query.sort}`); } - args.push('--format', query.includeCommitDetails ? REFS_WITH_DETAILS_FORMAT : REFS_FORMAT); + if (query.includeCommitDetails) { + const format = this._git.compareGitVersionTo('1.9.0') !== -1 + ? `${REFS_WITH_DETAILS_FORMAT}%00%(upstream:track)` + : REFS_WITH_DETAILS_FORMAT; + args.push('--format', format); + } else { + args.push('--format', REFS_FORMAT); + } if (query.pattern) { const patterns = Array.isArray(query.pattern) ? query.pattern : [query.pattern]; @@ -2663,7 +2661,7 @@ export class Repository { } const result = await this.exec(args, { cancellationToken }); - return parseRefs(result.stdout, query.includeCommitDetails === true); + return parseRefs(result.stdout); } async getRemoteRefs(remote: string, opts?: { heads?: boolean; tags?: boolean; cancellationToken?: CancellationToken }): Promise { @@ -2960,7 +2958,7 @@ export class Repository { async updateSubmodules(paths: string[]): Promise { const args = ['submodule', 'update']; - for (const chunk of splitInChunks(paths.map(p => sanitizeRelativePath(this.repositoryRoot, p)), MAX_CLI_LENGTH)) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { await this.exec([...args, '--', ...chunk]); } } @@ -2979,4 +2977,40 @@ export class Repository { throw err; } } + + private sanitizeRelativePath(filePath: string): string { + this.logger.trace(`[Git][sanitizeRelativePath] filePath: ${filePath}`); + + // Relative path + if (!path.isAbsolute(filePath)) { + filePath = sanitizeRelativePath(filePath); + this.logger.trace(`[Git][sanitizeRelativePath] relativePath (noop): ${filePath}`); + return filePath; + } + + let relativePath: string | undefined; + + // Repository root real path + if (this.repositoryRootRealPath) { + relativePath = relativePathWithNoFallback(this.repositoryRootRealPath, filePath); + if (relativePath) { + relativePath = sanitizeRelativePath(relativePath); + this.logger.trace(`[Git][sanitizeRelativePath] relativePath (real path): ${relativePath}`); + return relativePath; + } + } + + // Repository root path + relativePath = relativePathWithNoFallback(this.repositoryRoot, filePath); + if (relativePath) { + relativePath = sanitizeRelativePath(relativePath); + this.logger.trace(`[Git][sanitizeRelativePath] relativePath (path): ${relativePath}`); + return relativePath; + } + + // Fallback to the original path + filePath = sanitizeRelativePath(filePath); + this.logger.trace(`[Git][sanitizeRelativePath] relativePath (fallback): ${filePath}`); + return filePath; + } } diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index 8e5a8f9d2f1..886510b0031 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ -import { Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, SourceControlHistoryItem, SourceControlHistoryItemChange, SourceControlHistoryOptions, SourceControlHistoryProvider, ThemeIcon, Uri, window, LogOutputChannel, SourceControlHistoryItemRef, l10n, SourceControlHistoryItemRefsChangeEvent } from 'vscode'; +import { Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, SourceControlHistoryItem, SourceControlHistoryItemChange, SourceControlHistoryOptions, SourceControlHistoryProvider, ThemeIcon, Uri, window, LogOutputChannel, SourceControlHistoryItemRef, l10n, SourceControlHistoryItemRefsChangeEvent, workspace, ConfigurationChangeEvent } from 'vscode'; import { Repository, Resource } from './repository'; -import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, getCommitShortHash } from './util'; +import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, truncate } from './util'; import { toMultiFileDiffEditorUris } from './uri'; import { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref, RefType } from './api/git'; import { emojify, ensureEmojis } from './emoji'; @@ -14,40 +14,6 @@ import { Commit } from './git'; import { OperationKind, OperationResult } from './operation'; import { ISourceControlHistoryItemDetailsProviderRegistry, provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider'; -function toSourceControlHistoryItemRef(repository: Repository, ref: Ref): SourceControlHistoryItemRef { - const rootUri = Uri.file(repository.root); - - switch (ref.type) { - case RefType.RemoteHead: - return { - id: `refs/remotes/${ref.name}`, - name: ref.name ?? '', - description: ref.commit ? l10n.t('Remote branch at {0}', getCommitShortHash(rootUri, ref.commit)) : undefined, - revision: ref.commit, - icon: new ThemeIcon('cloud'), - category: l10n.t('remote branches') - }; - case RefType.Tag: - return { - id: `refs/tags/${ref.name}`, - name: ref.name ?? '', - description: ref.commit ? l10n.t('Tag at {0}', getCommitShortHash(rootUri, ref.commit)) : undefined, - revision: ref.commit, - icon: new ThemeIcon('tag'), - category: l10n.t('tags') - }; - default: - return { - id: `refs/heads/${ref.name}`, - name: ref.name ?? '', - description: ref.commit ? getCommitShortHash(rootUri, ref.commit) : undefined, - revision: ref.commit, - icon: new ThemeIcon('git-branch'), - category: l10n.t('branches') - }; - } -} - function compareSourceControlHistoryItemRef(ref1: SourceControlHistoryItemRef, ref2: SourceControlHistoryItemRef): number { const getOrder = (ref: SourceControlHistoryItemRef): number => { if (ref.id.startsWith('refs/heads/')) { @@ -93,6 +59,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec private _HEAD: Branch | undefined; private _historyItemRefs: SourceControlHistoryItemRef[] = []; + private commitShortHashLength = 7; private historyItemDecorations = new Map(); private disposables: Disposable[] = []; @@ -102,12 +69,24 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec private readonly repository: Repository, private readonly logger: LogOutputChannel ) { + this.disposables.push(workspace.onDidChangeConfiguration(this.onDidChangeConfiguration)); + this.onDidChangeConfiguration(); + const onDidRunWriteOperation = filterEvent(repository.onDidRunOperation, e => !e.operation.readOnly); this.disposables.push(onDidRunWriteOperation(this.onDidRunWriteOperation, this)); this.disposables.push(window.registerFileDecorationProvider(this)); } + private onDidChangeConfiguration(e?: ConfigurationChangeEvent): void { + if (e && !e.affectsConfiguration('git.commitShortHashLength')) { + return; + } + + const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); + this.commitShortHashLength = config.get('commitShortHashLength', 7); + } + private async onDidRunWriteOperation(result: OperationResult): Promise { if (!this.repository.HEAD) { this.logger.trace('[GitHistoryProvider][onDidRunWriteOperation] repository.HEAD is undefined'); @@ -119,7 +98,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec // Refs (alphabetically) const historyItemRefs = this.repository.refs - .map(ref => toSourceControlHistoryItemRef(this.repository, ref)) + .map(ref => this.toSourceControlHistoryItemRef(ref)) .sort((a, b) => a.id.localeCompare(b.id)); const delta = deltaHistoryItemRefs(this._historyItemRefs, historyItemRefs); @@ -241,13 +220,13 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec for (const ref of refs) { switch (ref.type) { case RefType.RemoteHead: - remoteBranches.push(toSourceControlHistoryItemRef(this.repository, ref)); + remoteBranches.push(this.toSourceControlHistoryItemRef(ref)); break; case RefType.Tag: - tags.push(toSourceControlHistoryItemRef(this.repository, ref)); + tags.push(this.toSourceControlHistoryItemRef(ref)); break; default: - branches.push(toSourceControlHistoryItemRef(this.repository, ref)); + branches.push(this.toSourceControlHistoryItemRef(ref)); break; } } @@ -305,7 +284,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec const newLineIndex = message.indexOf('\n'); const subject = newLineIndex !== -1 - ? `${message.substring(0, newLineIndex)}\u2026` + ? `${truncate(message, newLineIndex)}` : message; const avatarUrl = commitAvatars?.get(commit.hash); @@ -319,7 +298,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec author: commit.authorName, authorEmail: commit.authorEmail, authorIcon: avatarUrl ? Uri.parse(avatarUrl) : new ThemeIcon('account'), - displayId: getCommitShortHash(Uri.file(this.repository.root), commit.hash), + displayId: truncate(commit.hash, this.commitShortHashLength, false), timestamp: commit.authorDate?.getTime(), statistics: commit.shortStat ?? { files: 0, insertions: 0, deletions: 0 }, references: references.length !== 0 ? references : undefined @@ -469,6 +448,38 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec } } + private toSourceControlHistoryItemRef(ref: Ref): SourceControlHistoryItemRef { + switch (ref.type) { + case RefType.RemoteHead: + return { + id: `refs/remotes/${ref.name}`, + name: ref.name ?? '', + description: ref.commit ? l10n.t('Remote branch at {0}', truncate(ref.commit, this.commitShortHashLength, false)) : undefined, + revision: ref.commit, + icon: new ThemeIcon('cloud'), + category: l10n.t('remote branches') + }; + case RefType.Tag: + return { + id: `refs/tags/${ref.name}`, + name: ref.name ?? '', + description: ref.commit ? l10n.t('Tag at {0}', truncate(ref.commit, this.commitShortHashLength, false)) : undefined, + revision: ref.commit, + icon: new ThemeIcon('tag'), + category: l10n.t('tags') + }; + default: + return { + id: `refs/heads/${ref.name}`, + name: ref.name ?? '', + description: ref.commit ? truncate(ref.commit, this.commitShortHashLength, false) : undefined, + revision: ref.commit, + icon: new ThemeIcon('git-branch'), + category: l10n.t('branches') + }; + } + } + dispose(): void { dispose(this.disposables); } diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index 8205d8de69f..a55c103c9ef 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -27,7 +27,6 @@ import { GitPostCommitCommandsProvider } from './postCommitCommands'; import { GitEditSessionIdentityProvider } from './editSessionIdentityProvider'; import { GitCommitInputBoxCodeActionsProvider, GitCommitInputBoxDiagnosticsManager } from './diagnostics'; import { GitBlameController } from './blame'; -import { StagedResourceQuickDiffProvider } from './repository'; const deactivateTasks: { (): Promise }[] = []; @@ -117,7 +116,6 @@ async function createModel(context: ExtensionContext, logger: LogOutputChannel, new GitBlameController(model), new GitTimelineProvider(model, cc), new GitEditSessionIdentityProvider(model), - new StagedResourceQuickDiffProvider(model), new TerminalShellExecutionManager(model, logger) ); diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index d17d118de1c..74486e6a090 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -640,7 +640,8 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi this.open(repository); this._closedRepositoriesManager.deleteRepository(repository.root); - this.logger.info(`[Model][openRepository] Opened repository: ${repository.root}`); + this.logger.info(`[Model][openRepository] Opened repository (path): ${repository.root}`); + this.logger.info(`[Model][openRepository] Opened repository (real path): ${repository.rootRealPath ?? repository.root}`); // Do not await this, we want SCM // to know about the repo asap diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index f32d915d715..c2b24e460ea 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -7,15 +7,14 @@ import TelemetryReporter from '@vscode/extension-telemetry'; import * as fs from 'fs'; import * as path from 'path'; import picomatch from 'picomatch'; -import * as iconv from '@vscode/iconv-lite-umd'; import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, Disposable, Event, EventEmitter, FileDecoration, l10n, LogLevel, LogOutputChannel, Memento, ProgressLocation, ProgressOptions, QuickDiffProvider, RelativePattern, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, ThemeColor, Uri, window, workspace, WorkspaceEdit } from 'vscode'; import { ActionButton } from './actionButton'; import { ApiRepository } from './api/api1'; -import { Branch, BranchQuery, Change, CommitOptions, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefQuery, RefType, Remote, Status } from './api/git'; +import { Branch, BranchQuery, Change, CommitOptions, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status } from './api/git'; import { AutoFetcher } from './autofetch'; import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection'; import { debounce, memoize, throttle } from './decorators'; -import { Repository as BaseRepository, BlameInformation, Commit, GitError, LogFileOptions, LsTreeElement, PullOptions, Stash, Submodule } from './git'; +import { Repository as BaseRepository, BlameInformation, Commit, GitError, LogFileOptions, LsTreeElement, PullOptions, RefQuery, Stash, Submodule } from './git'; import { GitHistoryProvider } from './historyProvider'; import { Operation, OperationKind, OperationManager, OperationResult } from './operation'; import { CommitCommandsCenter, IPostCommitCommandsProviderRegistry } from './postCommitCommands'; @@ -25,7 +24,6 @@ import { StatusBarCommands } from './statusbar'; import { toGitUri } from './uri'; import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isDescendant, isLinuxSnap, isRemote, Limiter, onceEvent, pathEquals, relativePath } from './util'; import { IFileWatcher, watch } from './watch'; -import { detectEncoding } from './encoding'; import { ISourceControlHistoryItemDetailsProviderRegistry } from './historyItemDetailsProvider'; const timeout = (millis: number) => new Promise(c => setTimeout(c, millis)); @@ -70,15 +68,13 @@ export class Resource implements SourceControlResourceState { return 'U'; case Status.IGNORED: return 'I'; - case Status.DELETED_BY_THEM: - return 'D'; - case Status.DELETED_BY_US: - return 'D'; case Status.INDEX_COPIED: return 'C'; case Status.BOTH_DELETED: case Status.ADDED_BY_US: + case Status.DELETED_BY_THEM: case Status.ADDED_BY_THEM: + case Status.DELETED_BY_US: case Status.BOTH_ADDED: case Status.BOTH_MODIFIED: return '!'; // Using ! instead of ⚠, because the latter looks really bad on windows @@ -894,6 +890,7 @@ export class Repository implements Disposable { this._sourceControl = scm.createSourceControl('git', 'Git', root); this._sourceControl.quickDiffProvider = this; + this._sourceControl.secondaryQuickDiffProvider = new StagedResourceQuickDiffProvider(this, logger); this._historyProvider = new GitHistoryProvider(historyItemDetailProviderRegistry, this, logger); this._sourceControl.historyProvider = this._historyProvider; @@ -1028,6 +1025,8 @@ export class Repository implements Disposable { } provideOriginalResource(uri: Uri): Uri | undefined { + this.logger.trace(`[Repository][provideOriginalResource] Resource: ${uri.toString()}`); + if (uri.scheme !== 'file') { return; } @@ -1065,7 +1064,10 @@ export class Repository implements Disposable { return undefined; } - return toGitUri(uri, '', { replaceFileExtension: true }); + const originalResource = toGitUri(uri, '', { replaceFileExtension: true }); + this.logger.trace(`[Repository][provideOriginalResource] Original resource: ${originalResource.toString()}`); + + return originalResource; } async getInputTemplate(): Promise { @@ -1222,19 +1224,10 @@ export class Repository implements Disposable { await this.run(Operation.Remove, () => this.repository.rm(resources.map(r => r.fsPath))); } - async stage(resource: Uri, contents: string): Promise { + async stage(resource: Uri, contents: string, encoding: string): Promise { await this.run(Operation.Stage, async () => { - const configFiles = workspace.getConfiguration('files', Uri.file(resource.fsPath)); - let encoding = configFiles.get('encoding') ?? 'utf8'; - const autoGuessEncoding = configFiles.get('autoGuessEncoding') === true; - const candidateGuessEncodings = configFiles.get('candidateGuessEncodings') ?? []; - - if (autoGuessEncoding) { - encoding = detectEncoding(Buffer.from(contents), candidateGuessEncodings) ?? encoding; - } - - encoding = iconv.encodingExists(encoding) ? encoding : 'utf8'; - await this.repository.stage(resource.fsPath, contents, encoding); + const data = await workspace.encode(contents, { encoding }); + await this.repository.stage(resource.fsPath, data); this._onDidChangeOriginalResource.fire(resource); this.closeDiffEditors([], [...resource.fsPath]); @@ -1246,6 +1239,9 @@ export class Repository implements Disposable { Operation.RevertFiles(!this.optimisticUpdateEnabled()), async () => { await this.repository.revert('HEAD', resources.map(r => r.fsPath)); + for (const resource of resources) { + this._onDidChangeOriginalResource.fire(resource); + } this.closeDiffEditors([...resources.length !== 0 ? resources.map(r => r.fsPath) : this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)], []); @@ -1399,22 +1395,29 @@ export class Repository implements Disposable { } }); - if (discardUntrackedChangesToTrash) { - const limiter = new Limiter(5); - await Promise.all(toClean.map(fsPath => limiter.queue( - async () => await workspace.fs.delete(Uri.file(fsPath), { useTrash: true })))); - } else { - await this.repository.clean(toClean); - } - - try { - await this.repository.checkout('', toCheckout); - } catch (err) { - if (err.gitErrorCode !== GitErrorCodes.BranchNotYetBorn) { - throw err; + if (toClean.length > 0) { + if (discardUntrackedChangesToTrash) { + const limiter = new Limiter(5); + await Promise.all(toClean.map(fsPath => limiter.queue( + async () => await workspace.fs.delete(Uri.file(fsPath), { useTrash: true })))); + } else { + await this.repository.clean(toClean); } } - await this.repository.updateSubmodules(submodulesToUpdate); + + if (toCheckout.length > 0) { + try { + await this.repository.checkout('', toCheckout); + } catch (err) { + if (err.gitErrorCode !== GitErrorCodes.BranchNotYetBorn) { + throw err; + } + } + } + + if (submodulesToUpdate.length > 0) { + await this.repository.updateSubmodules(submodulesToUpdate); + } this.closeDiffEditors([], [...toClean, ...toCheckout]); }, @@ -1628,7 +1631,7 @@ export class Repository implements Disposable { } } - async getRefs(query: RefQuery = {}, cancellationToken?: CancellationToken): Promise { + async getRefs(query: RefQuery = {}, cancellationToken?: CancellationToken): Promise<(Ref | Branch)[]> { const config = workspace.getConfiguration('git'); let defaultSort = config.get<'alphabetically' | 'committerdate'>('branchSortOrder'); if (defaultSort !== 'alphabetically' && defaultSort !== 'committerdate') { @@ -1976,17 +1979,14 @@ export class Repository implements Disposable { async show(ref: string, filePath: string): Promise { return await this.run(Operation.Show, async () => { - const configFiles = workspace.getConfiguration('files', Uri.file(filePath)); - const defaultEncoding = configFiles.get('encoding'); - const autoGuessEncoding = configFiles.get('autoGuessEncoding'); - const candidateGuessEncodings = configFiles.get('candidateGuessEncodings'); - try { - return await this.repository.bufferString(ref, filePath, defaultEncoding, autoGuessEncoding, candidateGuessEncodings); + const content = await this.repository.buffer(ref, filePath); + return await workspace.decode(content, { uri: Uri.file(filePath) }); } catch (err) { if (err.gitErrorCode === GitErrorCodes.WrongCase) { const gitFilePath = await this.repository.getGitFilePath(ref, filePath); - return await this.repository.bufferString(ref, gitFilePath, defaultEncoding, autoGuessEncoding, candidateGuessEncodings); + const content = await this.repository.buffer(ref, gitFilePath); + return await workspace.decode(content, { uri: Uri.file(filePath) }); } throw err; @@ -2805,30 +2805,25 @@ export class Repository implements Disposable { } export class StagedResourceQuickDiffProvider implements QuickDiffProvider { - readonly visible: boolean = false; + readonly visible: boolean = true; + readonly label = l10n.t('Git local changes (index)'); - private _disposables: IDisposable[] = []; - - constructor(private readonly _repositoryResolver: IRepositoryResolver) { - this._disposables.push(window.registerQuickDiffProvider({ scheme: 'file' }, this, l10n.t('Git local changes (working tree + index)'))); - } + constructor( + private readonly _repository: Repository, + private readonly logger: LogOutputChannel + ) { } provideOriginalResource(uri: Uri): Uri | undefined { - // Ignore resources outside a repository - const repository = this._repositoryResolver.getRepository(uri); - if (!repository) { - return undefined; - } + this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource: ${uri.toString()}`); // Ignore resources that are not in the index group - if (!repository.indexGroup.resourceStates.some(r => pathEquals(r.resourceUri.fsPath, uri.fsPath))) { + if (!this._repository.indexGroup.resourceStates.some(r => pathEquals(r.resourceUri.fsPath, uri.fsPath))) { + this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource is not part of a index group: ${uri.toString()}`); return undefined; } - return toGitUri(uri, 'HEAD', { replaceFileExtension: true }); - } - - dispose() { - this._disposables = dispose(this._disposables); + const originalResource = toGitUri(uri, 'HEAD', { replaceFileExtension: true }); + this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Original resource: ${originalResource.toString()}`); + return originalResource; } } diff --git a/extensions/git/src/staging.ts b/extensions/git/src/staging.ts index ec7232bec44..208f1e99f57 100644 --- a/extensions/git/src/staging.ts +++ b/extensions/git/src/staging.ts @@ -186,10 +186,9 @@ export function toLineChanges(diffInformation: TextEditorDiffInformation): LineC } export function getIndexDiffInformation(textEditor: TextEditor): TextEditorDiffInformation | undefined { - // Diff Editor (Index) + // Diff Editor (Index) | Text Editor return textEditor.diffInformation?.find(diff => - diff.original && isGitUri(diff.original) && fromGitUri(diff.original).ref === 'HEAD' && - diff.modified && isGitUri(diff.modified) && fromGitUri(diff.modified).ref === ''); + diff.original && isGitUri(diff.original) && fromGitUri(diff.original).ref === 'HEAD'); } export function getWorkingTreeDiffInformation(textEditor: TextEditor): TextEditorDiffInformation | undefined { diff --git a/extensions/git/src/test/smoke.test.ts b/extensions/git/src/test/smoke.test.ts index b9a3ddfd063..d9a5776824b 100644 --- a/extensions/git/src/test/smoke.test.ts +++ b/extensions/git/src/test/smoke.test.ts @@ -13,7 +13,7 @@ import { GitExtension, API, Repository, Status } from '../api/git'; import { eventToPromise } from '../util'; suite('git smoke test', function () { - const cwd = fs.realpathSync(workspace.workspaceFolders![0].uri.fsPath); + const cwd = workspace.workspaceFolders![0].uri.fsPath; function file(relativePath: string) { return path.join(cwd, relativePath); @@ -61,7 +61,7 @@ suite('git smoke test', function () { } assert.strictEqual(git.repositories.length, 1); - assert.strictEqual(fs.realpathSync(git.repositories[0].rootUri.fsPath), cwd); + assert.strictEqual(git.repositories[0].rootUri.fsPath, cwd); repository = git.repositories[0]; }); diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts index 90d8d28c396..76a3f92dde1 100644 --- a/extensions/git/src/timelineProvider.ts +++ b/extensions/git/src/timelineProvider.ts @@ -10,7 +10,7 @@ import { debounce } from './decorators'; import { emojify, ensureEmojis } from './emoji'; import { CommandCenter } from './commands'; import { OperationKind, OperationResult } from './operation'; -import { getCommitShortHash } from './util'; +import { truncate } from './util'; import { CommitShortStat } from './git'; import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider'; import { AvatarQuery, AvatarQueryCommit } from './api/git'; @@ -35,7 +35,7 @@ export class GitTimelineItem extends TimelineItem { contextValue: string ) { const index = message.indexOf('\n'); - const label = index !== -1 ? `${message.substring(0, index)} \u2026` : message; + const label = index !== -1 ? `${truncate(message, index)}` : message; super(label, timestamp); @@ -54,10 +54,9 @@ export class GitTimelineItem extends TimelineItem { return this.shortenRef(this.previousRef); } - setItemDetails(uri: Uri, hash: string | undefined, avatar: string | undefined, author: string, email: string | undefined, date: string, message: string, shortStat?: CommitShortStat, remoteSourceCommands: Command[] = []): void { + setItemDetails(uri: Uri, hash: string | undefined, shortHash: string | undefined, avatar: string | undefined, author: string, email: string | undefined, date: string, message: string, shortStat?: CommitShortStat, remoteSourceCommands: Command[] = []): void { this.tooltip = new MarkdownString('', true); this.tooltip.isTrusted = true; - this.tooltip.supportHtml = true; const avatarMarkdown = avatar ? `![${author}](${avatar}|width=${AVATAR_SIZE},height=${AVATAR_SIZE})` @@ -92,10 +91,10 @@ export class GitTimelineItem extends TimelineItem { this.tooltip.appendMarkdown(`${labels.join(', ')}\n\n`); } - if (hash) { + if (hash && shortHash) { this.tooltip.appendMarkdown(`---\n\n`); - this.tooltip.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(uri, hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([uri, hash]))} "${l10n.t('Open Commit')}")`); + this.tooltip.appendMarkdown(`[\`$(git-commit) ${shortHash} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([uri, hash]))} "${l10n.t('Open Commit')}")`); this.tooltip.appendMarkdown(' '); this.tooltip.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`); @@ -174,8 +173,6 @@ export class GitTimelineProvider implements TimelineProvider { ); } - const config = workspace.getConfiguration('git.timeline'); - // TODO@eamodio: Ensure that the uri is a file -- if not we could get the history of the repo? let limit: number | undefined; @@ -216,9 +213,11 @@ export class GitTimelineProvider implements TimelineProvider { const dateFormatter = new Intl.DateTimeFormat(env.language, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }); - const dateType = config.get<'committed' | 'authored'>('date'); - const showAuthor = config.get('showAuthor'); - const showUncommitted = config.get('showUncommitted'); + const config = workspace.getConfiguration('git', Uri.file(repo.root)); + const dateType = config.get<'committed' | 'authored'>('timeline.date'); + const showAuthor = config.get('timeline.showAuthor'); + const showUncommitted = config.get('timeline.showUncommitted'); + const commitShortHashLength = config.get('commitShortHashLength') ?? 7; const openComparison = l10n.t('Open Comparison'); @@ -254,7 +253,7 @@ export class GitTimelineProvider implements TimelineProvider { const commitRemoteSourceCommands = !unpublishedCommits.has(c.hash) ? remoteHoverCommands : []; const messageWithLinks = await provideSourceControlHistoryItemMessageLinks(this.model, repo, message) ?? message; - item.setItemDetails(uri, c.hash, avatars?.get(c.hash), c.authorName!, c.authorEmail, dateFormatter.format(date), messageWithLinks, c.shortStat, commitRemoteSourceCommands); + item.setItemDetails(uri, c.hash, truncate(c.hash, commitShortHashLength, false), avatars?.get(c.hash), c.authorName!, c.authorEmail, dateFormatter.format(date), messageWithLinks, c.shortStat, commitRemoteSourceCommands); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { @@ -279,7 +278,7 @@ export class GitTimelineProvider implements TimelineProvider { // TODO@eamodio: Replace with a better icon -- reflecting its status maybe? item.iconPath = new ThemeIcon('git-commit'); item.description = ''; - item.setItemDetails(uri, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(index.type)); + item.setItemDetails(uri, undefined, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(index.type)); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { @@ -301,7 +300,7 @@ export class GitTimelineProvider implements TimelineProvider { const item = new GitTimelineItem('', index ? '~' : 'HEAD', l10n.t('Uncommitted Changes'), date.getTime(), 'working', 'git:file:working'); item.iconPath = new ThemeIcon('circle-outline'); item.description = ''; - item.setItemDetails(uri, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(working.type)); + item.setItemDetails(uri, undefined, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(working.type)); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { diff --git a/extensions/git/src/util.ts b/extensions/git/src/util.ts index 7dd1cbafbdf..28d8c27fbc5 100644 --- a/extensions/git/src/util.ts +++ b/extensions/git/src/util.ts @@ -291,8 +291,8 @@ export function detectUnicodeEncoding(buffer: Buffer): Encoding | null { return null; } -export function truncate(value: string, maxLength = 20): string { - return value.length <= maxLength ? value : `${value.substring(0, maxLength)}\u2026`; +export function truncate(value: string, maxLength = 20, ellipsis = true): string { + return value.length <= maxLength ? value : `${value.substring(0, maxLength)}${ellipsis ? '\u2026' : ''}`; } function normalizePath(path: string): string { @@ -328,6 +328,10 @@ export function pathEquals(a: string, b: string): boolean { * casing which is why we attempt to use substring() before relative(). */ export function relativePath(from: string, to: string): string { + return relativePathWithNoFallback(from, to) ?? relative(from, to); +} + +export function relativePathWithNoFallback(from: string, to: string): string | undefined { // There are cases in which the `from` path may contain a trailing separator at // the end (ex: "C:\", "\\server\folder\" (Windows) or "/" (Linux/macOS)) which // is by design as documented in https://github.com/nodejs/node/issues/1765. If @@ -340,8 +344,7 @@ export function relativePath(from: string, to: string): string { return to.substring(from.length); } - // Fallback to `path.relative` - return relative(from, to); + return undefined; } export function* splitInChunks(array: string[], maxChunkLength: number): IterableIterator { diff --git a/extensions/github/package-lock.json b/extensions/github/package-lock.json index cf7317a40a4..1b7dc727a92 100644 --- a/extensions/github/package-lock.json +++ b/extensions/github/package-lock.json @@ -9,9 +9,9 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@octokit/graphql": "8.2.0", + "@octokit/graphql": "5.0.5", "@octokit/graphql-schema": "14.4.0", - "@octokit/rest": "21.1.0", + "@octokit/rest": "19.0.4", "@vscode/extension-telemetry": "^0.9.8", "tunnel": "^0.0.6" }, @@ -147,57 +147,96 @@ "license": "MIT" }, "node_modules/@octokit/auth-token": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", - "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", - "license": "MIT", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-3.0.1.tgz", + "integrity": "sha512-/USkK4cioY209wXRpund6HZzHo9GmjakpV9ycOkpMcMxMk7QVcVFVyCMtzvXYiHsB2crgDgrtNYSELYFBXhhaA==", + "dependencies": { + "@octokit/types": "^7.0.0" + }, "engines": { - "node": ">= 18" + "node": ">= 14" + } + }, + "node_modules/@octokit/auth-token/node_modules/@octokit/openapi-types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-13.6.0.tgz", + "integrity": "sha512-bxftLwoZ2J6zsU1rzRvk0O32j7lVB0NWWn+P5CDHn9zPzytasR3hdAeXlTngRDkqv1LyEeuy5psVnDkmOSwrcQ==" + }, + "node_modules/@octokit/auth-token/node_modules/@octokit/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-7.2.0.tgz", + "integrity": "sha512-pYQ/a1U6mHptwhGyp6SvsiM4bWP2s3V95olUeTxas85D/2kN78yN5C8cGN+P4LwJSWUqIEyvq0Qn2WUn6NQRjw==", + "dependencies": { + "@octokit/openapi-types": "^13.6.0" } }, "node_modules/@octokit/core": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.4.tgz", - "integrity": "sha512-lAS9k7d6I0MPN+gb9bKDt7X8SdxknYqAMh44S5L+lNqIN2NuV8nvv3g8rPp7MuRxcOpxpUIATWprO0C34a8Qmg==", - "license": "MIT", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.0.5.tgz", + "integrity": "sha512-4R3HeHTYVHCfzSAi0C6pbGXV8UDI5Rk+k3G7kLVNckswN9mvpOzW9oENfjfH3nEmzg8y3AmKmzs8Sg6pLCeOCA==", "dependencies": { - "@octokit/auth-token": "^5.0.0", - "@octokit/graphql": "^8.1.2", - "@octokit/request": "^9.2.1", - "@octokit/request-error": "^6.1.7", - "@octokit/types": "^13.6.2", - "before-after-hook": "^3.0.2", - "universal-user-agent": "^7.0.0" + "@octokit/auth-token": "^3.0.0", + "@octokit/graphql": "^5.0.0", + "@octokit/request": "^6.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^7.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 14" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-13.6.0.tgz", + "integrity": "sha512-bxftLwoZ2J6zsU1rzRvk0O32j7lVB0NWWn+P5CDHn9zPzytasR3hdAeXlTngRDkqv1LyEeuy5psVnDkmOSwrcQ==" + }, + "node_modules/@octokit/core/node_modules/@octokit/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-7.2.0.tgz", + "integrity": "sha512-pYQ/a1U6mHptwhGyp6SvsiM4bWP2s3V95olUeTxas85D/2kN78yN5C8cGN+P4LwJSWUqIEyvq0Qn2WUn6NQRjw==", + "dependencies": { + "@octokit/openapi-types": "^13.6.0" } }, "node_modules/@octokit/endpoint": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.3.tgz", - "integrity": "sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA==", - "license": "MIT", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.1.tgz", + "integrity": "sha512-/wTXAJwt0HzJ2IeE4kQXO+mBScfzyCkI0hMtkIaqyXd9zg76OpOfNQfHL9FlaxAV2RsNiOXZibVWloy8EexENg==", "dependencies": { - "@octokit/types": "^13.6.2", - "universal-user-agent": "^7.0.2" + "@octokit/types": "^7.0.0", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 14" + } + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-13.6.0.tgz", + "integrity": "sha512-bxftLwoZ2J6zsU1rzRvk0O32j7lVB0NWWn+P5CDHn9zPzytasR3hdAeXlTngRDkqv1LyEeuy5psVnDkmOSwrcQ==" + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-7.2.0.tgz", + "integrity": "sha512-pYQ/a1U6mHptwhGyp6SvsiM4bWP2s3V95olUeTxas85D/2kN78yN5C8cGN+P4LwJSWUqIEyvq0Qn2WUn6NQRjw==", + "dependencies": { + "@octokit/openapi-types": "^13.6.0" } }, "node_modules/@octokit/graphql": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.0.tgz", - "integrity": "sha512-gejfDywEml/45SqbWTWrhfwvLBrcGYhOn50sPOjIeVvH6i7D16/9xcFA8dAJNp2HMcd+g4vru41g4E2RBiZvfQ==", - "license": "MIT", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-5.0.5.tgz", + "integrity": "sha512-Qwfvh3xdqKtIznjX9lz2D458r7dJPP8l6r4GQkIdWQouZwHQK0mVT88uwiU2bdTU2OtT1uOlKpRciUWldpG0yQ==", "dependencies": { - "@octokit/request": "^9.1.4", - "@octokit/types": "^13.8.0", - "universal-user-agent": "^7.0.0" + "@octokit/request": "^6.0.0", + "@octokit/types": "^9.0.0", + "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 14" } }, "node_modules/@octokit/graphql-schema": { @@ -210,103 +249,148 @@ } }, "node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", - "license": "MIT" + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-17.1.0.tgz", + "integrity": "sha512-rnI26BAITDZTo5vqFOmA7oX4xRd18rO+gcK4MiTpJmsRMxAw0JmevNjPsjpry1bb9SVNo56P/0kbiyXXa4QluA==" }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.4.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.2.tgz", - "integrity": "sha512-BXJ7XPCTDXFF+wxcg/zscfgw2O/iDPtNSkwwR1W1W5c4Mb3zav/M2XvxQ23nVmKj7jpweB4g8viMeCQdm7LMVA==", - "license": "MIT", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-4.2.0.tgz", + "integrity": "sha512-8otLCIK9esfmOCY14CBnG/xPqv0paf14rc+s9tHpbOpeFwrv5CnECKW1qdqMAT60ngAa9eB1bKQ+l2YCpi0HPQ==", "dependencies": { - "@octokit/types": "^13.7.0" + "@octokit/types": "^7.2.0" }, "engines": { - "node": ">= 18" + "node": ">= 14" }, "peerDependencies": { - "@octokit/core": ">=6" + "@octokit/core": ">=4" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-13.6.0.tgz", + "integrity": "sha512-bxftLwoZ2J6zsU1rzRvk0O32j7lVB0NWWn+P5CDHn9zPzytasR3hdAeXlTngRDkqv1LyEeuy5psVnDkmOSwrcQ==" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-7.2.0.tgz", + "integrity": "sha512-pYQ/a1U6mHptwhGyp6SvsiM4bWP2s3V95olUeTxas85D/2kN78yN5C8cGN+P4LwJSWUqIEyvq0Qn2WUn6NQRjw==", + "dependencies": { + "@octokit/openapi-types": "^13.6.0" } }, "node_modules/@octokit/plugin-request-log": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz", - "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", "peerDependencies": { - "@octokit/core": ">=6" + "@octokit/core": ">=3" } }, "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "13.3.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.1.tgz", - "integrity": "sha512-o8uOBdsyR+WR8MK9Cco8dCgvG13H1RlM1nWnK/W7TEACQBFux/vPREgKucxUfuDQ5yi1T3hGf4C5ZmZXAERgwQ==", - "license": "MIT", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-6.4.0.tgz", + "integrity": "sha512-YP4eUqZ6vORy/eZOTdil1ZSrMt0kv7i/CVw+HhC2C0yJN+IqTc/rot957JQ7JfyeJD6HZOjLg6Jp1o9cPhI9KA==", "dependencies": { - "@octokit/types": "^13.8.0" + "@octokit/types": "^7.2.0", + "deprecation": "^2.3.1" }, "engines": { - "node": ">= 18" + "node": ">= 14" }, "peerDependencies": { - "@octokit/core": ">=6" + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-13.6.0.tgz", + "integrity": "sha512-bxftLwoZ2J6zsU1rzRvk0O32j7lVB0NWWn+P5CDHn9zPzytasR3hdAeXlTngRDkqv1LyEeuy5psVnDkmOSwrcQ==" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-7.2.0.tgz", + "integrity": "sha512-pYQ/a1U6mHptwhGyp6SvsiM4bWP2s3V95olUeTxas85D/2kN78yN5C8cGN+P4LwJSWUqIEyvq0Qn2WUn6NQRjw==", + "dependencies": { + "@octokit/openapi-types": "^13.6.0" } }, "node_modules/@octokit/request": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.1.tgz", - "integrity": "sha512-TqHLIdw1KFvx8WvLc7Jv94r3C3+AzKY2FWq7c20zvrxmCIa6MCVkLCE/826NCXnml3LFJjLsidDh1BhMaGEDQw==", - "license": "MIT", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.1.tgz", + "integrity": "sha512-gYKRCia3cpajRzDSU+3pt1q2OcuC6PK8PmFIyxZDWCzRXRSIBH8jXjFJ8ZceoygBIm0KsEUg4x1+XcYBz7dHPQ==", "dependencies": { - "@octokit/endpoint": "^10.1.3", - "@octokit/request-error": "^6.1.6", - "@octokit/types": "^13.6.2", - "fast-content-type-parse": "^2.0.0", - "universal-user-agent": "^7.0.2" + "@octokit/endpoint": "^7.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^7.0.0", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 14" } }, "node_modules/@octokit/request-error": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.7.tgz", - "integrity": "sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g==", - "license": "MIT", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.1.tgz", + "integrity": "sha512-ym4Bp0HTP7F3VFssV88WD1ZyCIRoE8H35pXSKwLeMizcdZAYc/t6N9X9Yr9n6t3aG9IH75XDnZ6UeZph0vHMWQ==", "dependencies": { - "@octokit/types": "^13.6.2" + "@octokit/types": "^7.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" }, "engines": { - "node": ">= 18" + "node": ">= 14" + } + }, + "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-13.6.0.tgz", + "integrity": "sha512-bxftLwoZ2J6zsU1rzRvk0O32j7lVB0NWWn+P5CDHn9zPzytasR3hdAeXlTngRDkqv1LyEeuy5psVnDkmOSwrcQ==" + }, + "node_modules/@octokit/request-error/node_modules/@octokit/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-7.2.0.tgz", + "integrity": "sha512-pYQ/a1U6mHptwhGyp6SvsiM4bWP2s3V95olUeTxas85D/2kN78yN5C8cGN+P4LwJSWUqIEyvq0Qn2WUn6NQRjw==", + "dependencies": { + "@octokit/openapi-types": "^13.6.0" + } + }, + "node_modules/@octokit/request/node_modules/@octokit/openapi-types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-13.6.0.tgz", + "integrity": "sha512-bxftLwoZ2J6zsU1rzRvk0O32j7lVB0NWWn+P5CDHn9zPzytasR3hdAeXlTngRDkqv1LyEeuy5psVnDkmOSwrcQ==" + }, + "node_modules/@octokit/request/node_modules/@octokit/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-7.2.0.tgz", + "integrity": "sha512-pYQ/a1U6mHptwhGyp6SvsiM4bWP2s3V95olUeTxas85D/2kN78yN5C8cGN+P4LwJSWUqIEyvq0Qn2WUn6NQRjw==", + "dependencies": { + "@octokit/openapi-types": "^13.6.0" } }, "node_modules/@octokit/rest": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.0.tgz", - "integrity": "sha512-93iLxcKDJboUpmnUyeJ6cRIi7z7cqTZT1K7kRK4LobGxwTwpsa+2tQQbRQNGy7IFDEAmrtkf4F4wBj3D5rVlJQ==", - "license": "MIT", + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-19.0.4.tgz", + "integrity": "sha512-LwG668+6lE8zlSYOfwPj4FxWdv/qFXYBpv79TWIQEpBLKA9D/IMcWsF/U9RGpA3YqMVDiTxpgVpEW3zTFfPFTA==", "dependencies": { - "@octokit/core": "^6.1.3", - "@octokit/plugin-paginate-rest": "^11.4.0", - "@octokit/plugin-request-log": "^5.3.1", - "@octokit/plugin-rest-endpoint-methods": "^13.3.0" + "@octokit/core": "^4.0.0", + "@octokit/plugin-paginate-rest": "^4.0.0", + "@octokit/plugin-request-log": "^1.0.4", + "@octokit/plugin-rest-endpoint-methods": "^6.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 14" } }, "node_modules/@octokit/types": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", - "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", - "license": "MIT", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.2.0.tgz", + "integrity": "sha512-xySzJG4noWrIBFyMu4lg4tu9vAgNg9S0aoLRONhAEz6ueyi1evBzb40HitIosaYS4XOexphG305IVcLrIX/30g==", "dependencies": { - "@octokit/openapi-types": "^23.0.1" + "@octokit/openapi-types": "^17.1.0" } }, "node_modules/@types/node": { @@ -333,26 +417,14 @@ } }, "node_modules/before-after-hook": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", - "license": "Apache-2.0" + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", + "integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==" }, - "node_modules/fast-content-type-parse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", - "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" }, "node_modules/graphql": { "version": "16.8.1", @@ -376,6 +448,46 @@ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E= sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", @@ -396,10 +508,28 @@ "dev": true }, "node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", - "license": "ISC" + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", + "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0= sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" } } } diff --git a/extensions/github/package.json b/extensions/github/package.json index 86adc2ddc4e..524cee5bbea 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -227,9 +227,9 @@ "watch": "gulp watch-extension:github" }, "dependencies": { - "@octokit/graphql": "8.2.0", + "@octokit/graphql": "5.0.5", "@octokit/graphql-schema": "14.4.0", - "@octokit/rest": "21.1.0", + "@octokit/rest": "19.0.4", "tunnel": "^0.0.6", "@vscode/extension-telemetry": "^0.9.8" }, diff --git a/extensions/go/cgmanifest.json b/extensions/go/cgmanifest.json index a6dbd5d1bf0..7d5ee20f828 100644 --- a/extensions/go/cgmanifest.json +++ b/extensions/go/cgmanifest.json @@ -6,12 +6,12 @@ "git": { "name": "go-syntax", "repositoryUrl": "https://github.com/worlpaker/go-syntax", - "commitHash": "fbdaec061157e98dda185c0ce771ce6a2c793045" + "commitHash": "415b7167f2e5396284b65692ef8fd08a3475362a" } }, "license": "MIT", "description": "The file syntaxes/go.tmLanguage.json is from https://github.com/worlpaker/go-syntax, which in turn was derived from https://github.com/jeff-hykin/better-go-syntax.", - "version": "0.7.9" + "version": "0.8.0" } ], "version": 1 diff --git a/extensions/go/syntaxes/go.tmLanguage.json b/extensions/go/syntaxes/go.tmLanguage.json index db17cad3f91..48aa1d7264b 100644 --- a/extensions/go/syntaxes/go.tmLanguage.json +++ b/extensions/go/syntaxes/go.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/worlpaker/go-syntax/commit/fbdaec061157e98dda185c0ce771ce6a2c793045", + "version": "https://github.com/worlpaker/go-syntax/commit/415b7167f2e5396284b65692ef8fd08a3475362a", "name": "Go", "scopeName": "source.go", "patterns": [ @@ -618,6 +618,10 @@ { "match": "\\bany\\b", "name": "entity.name.type.any.go" + }, + { + "match": "\\bcomparable\\b", + "name": "entity.name.type.comparable.go" } ] }, @@ -1757,7 +1761,7 @@ "include": "#after_control_variables" }, { - "match": "(\\b[\\w\\.]+)(\\[(?:[^\\]]+)?\\])?(?=\\{)(?=10" - } - }, "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -220,56 +211,56 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/vscode-jsonrpc": { - "version": "9.0.0-next.6", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.6.tgz", - "integrity": "sha512-KCSvUNsFiVciG9iqjJKBZOd66CN3ZKohDlYRmoOi+pd8l15MFLZ8wRG4c+wuzePGba/8WcCG2TM+C/GVlvuaeA==", + "version": "9.0.0-next.7", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.7.tgz", + "integrity": "sha512-7SgnbbbJfYr3off0T2KV/RCMYhVsuLeFPw8l3bkxSiavtoTLsOdu1jyxK3yWbdQuO8QOJC7+no0TXmYjRWSC+g==", + "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageclient": { - "version": "10.0.0-next.13", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.0.0-next.13.tgz", - "integrity": "sha512-KLsOMJoYpkk36PIgcOjyZ4AekOfzp4kdWdRRbVKeVvSIrwrn/4RSZr0NlD6EvUBBJSsJW4WDrYY7Y3znkqa6+w==", + "version": "10.0.0-next.14", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.0.0-next.14.tgz", + "integrity": "sha512-4m/cpNocRgrAkWc8IH4wd3zllAs16NvMmeGcQxFa6xt+mGXJASIeqp0NAFWKZERKg6ClVgBph+SDSZSVvNZ2oA==", "license": "MIT", "dependencies": { - "minimatch": "^9.0.3", - "semver": "^7.6.0", - "vscode-languageserver-protocol": "3.17.6-next.11" + "minimatch": "^10.0.1", + "semver": "^7.6.3", + "vscode-languageserver-protocol": "3.17.6-next.12" }, "engines": { "vscode": "^1.91.0" } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.17.6-next.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.11.tgz", - "integrity": "sha512-GeJxEp1TiLsp79f8WG5n10wLViXfgFKb99hU9K8m7KDWM95/QFEqWkm79f9LVm54tUK74I91a9EeiQLCS/FABQ==", + "version": "3.17.6-next.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.12.tgz", + "integrity": "sha512-EqrbwF0glTWD2HiDpFc32pJOr6/bJvyKSfCpRQrKy3XsfdloH4p3o/rNJYcpujM0OVLmPZgl1i9g57z9g2YRJA==", + "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.0-next.6", - "vscode-languageserver-types": "3.17.6-next.5" + "vscode-jsonrpc": "9.0.0-next.7", + "vscode-languageserver-types": "3.17.6-next.6" } }, "node_modules/vscode-languageserver-types": { - "version": "3.17.6-next.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.5.tgz", - "integrity": "sha512-QFmf3Yl1tCgUQfA77N9Me/LXldJXkIVypQbty2rJ1DNHQkC+iwvm4Z2tXg9czSwlhvv0pD4pbF5mT7WhAglolw==" + "version": "3.17.6-next.6", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.6.tgz", + "integrity": "sha512-aiJY5/yW+xzw7KPNlwi3gQtddq/3EIn5z8X8nCgJfaiAij2R1APKePngv+MUdLdYJBVTLu+Qa0ODsT+pHgYguQ==", + "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" } } } diff --git a/extensions/html-language-features/package.json b/extensions/html-language-features/package.json index be411fe63a2..1202f1d41c0 100644 --- a/extensions/html-language-features/package.json +++ b/extensions/html-language-features/package.json @@ -259,8 +259,8 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "vscode-languageclient": "^10.0.0-next.13", - "vscode-uri": "^3.0.8" + "vscode-languageclient": "^10.0.0-next.14", + "vscode-uri": "^3.1.0" }, "devDependencies": { "@types/node": "20.x" diff --git a/extensions/html-language-features/server/package-lock.json b/extensions/html-language-features/server/package-lock.json index 5795436cf94..0d0999f0382 100644 --- a/extensions/html-language-features/server/package-lock.json +++ b/extensions/html-language-features/server/package-lock.json @@ -10,11 +10,11 @@ "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", - "vscode-css-languageservice": "^6.3.2", - "vscode-html-languageservice": "^5.3.2", - "vscode-languageserver": "^10.0.0-next.11", + "vscode-css-languageservice": "^6.3.5", + "vscode-html-languageservice": "^5.4.0", + "vscode-languageserver": "^10.0.0-next.12", "vscode-languageserver-textdocument": "^1.0.12", - "vscode-uri": "^3.0.8" + "vscode-uri": "^3.1.0" }, "devDependencies": { "@types/mocha": "^9.1.1", @@ -51,62 +51,65 @@ "dev": true }, "node_modules/vscode-css-languageservice": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.2.tgz", - "integrity": "sha512-GEpPxrUTAeXWdZWHev1OJU9lz2Q2/PPBxQ2TIRmLGvQiH3WZbqaNoute0n0ewxlgtjzTW3AKZT+NHySk5Rf4Eg==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.5.tgz", + "integrity": "sha512-ehEIMXYPYEz/5Svi2raL9OKLpBt5dSAdoCFoLpo0TVFKrVpDemyuQwS3c3D552z/qQCg3pMp8oOLMObY6M3ajQ==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", - "vscode-uri": "^3.0.8" + "vscode-uri": "^3.1.0" } }, "node_modules/vscode-html-languageservice": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.3.2.tgz", - "integrity": "sha512-3MgFQqVG+iQVNG7QI/slaoL7lJpne0nssX082kjUF1yn/YJa8BWCLeCJjM0YpTlp8A7JT1+J22mk4qSPx3NjSQ==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.4.0.tgz", + "integrity": "sha512-9/cbc90BSYCghmHI7/VbWettHZdC7WYpz2g5gBK6UDUI1MkZbM773Q12uAYJx9jzAiNHPpyo6KzcwmcnugncAQ==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", - "vscode-uri": "^3.0.8" + "vscode-uri": "^3.1.0" } }, "node_modules/vscode-jsonrpc": { - "version": "9.0.0-next.6", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.6.tgz", - "integrity": "sha512-KCSvUNsFiVciG9iqjJKBZOd66CN3ZKohDlYRmoOi+pd8l15MFLZ8wRG4c+wuzePGba/8WcCG2TM+C/GVlvuaeA==", + "version": "9.0.0-next.7", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.7.tgz", + "integrity": "sha512-7SgnbbbJfYr3off0T2KV/RCMYhVsuLeFPw8l3bkxSiavtoTLsOdu1jyxK3yWbdQuO8QOJC7+no0TXmYjRWSC+g==", + "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageserver": { - "version": "10.0.0-next.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.0.0-next.11.tgz", - "integrity": "sha512-cmobSrVDYhlh/t02vz/bV8nNpds8mus5HnILULae2iAvOjoaJPnTAp0jJWoYdUqTpIVzT9JV6JMKqLEvdqpeqg==", + "version": "10.0.0-next.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.0.0-next.12.tgz", + "integrity": "sha512-6lT2CJhH93YFmdDrFTwWvuG0/yzEN2Zbw/DfPaRF91sylZ3TSD0NkJU5jug6t/3NLoDh9VjfJZkgkKr6e3UmRw==", "license": "MIT", "dependencies": { - "vscode-languageserver-protocol": "3.17.6-next.11" + "vscode-languageserver-protocol": "3.17.6-next.12" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.17.6-next.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.11.tgz", - "integrity": "sha512-GeJxEp1TiLsp79f8WG5n10wLViXfgFKb99hU9K8m7KDWM95/QFEqWkm79f9LVm54tUK74I91a9EeiQLCS/FABQ==", + "version": "3.17.6-next.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.12.tgz", + "integrity": "sha512-EqrbwF0glTWD2HiDpFc32pJOr6/bJvyKSfCpRQrKy3XsfdloH4p3o/rNJYcpujM0OVLmPZgl1i9g57z9g2YRJA==", + "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.0-next.6", - "vscode-languageserver-types": "3.17.6-next.5" + "vscode-jsonrpc": "9.0.0-next.7", + "vscode-languageserver-types": "3.17.6-next.6" } }, "node_modules/vscode-languageserver-protocol/node_modules/vscode-languageserver-types": { - "version": "3.17.6-next.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.5.tgz", - "integrity": "sha512-QFmf3Yl1tCgUQfA77N9Me/LXldJXkIVypQbty2rJ1DNHQkC+iwvm4Z2tXg9czSwlhvv0pD4pbF5mT7WhAglolw==" + "version": "3.17.6-next.6", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.6.tgz", + "integrity": "sha512-aiJY5/yW+xzw7KPNlwi3gQtddq/3EIn5z8X8nCgJfaiAij2R1APKePngv+MUdLdYJBVTLu+Qa0ODsT+pHgYguQ==", + "license": "MIT" }, "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", @@ -119,9 +122,10 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" } } } diff --git a/extensions/html-language-features/server/package.json b/extensions/html-language-features/server/package.json index 7017ee85721..883a17760b2 100644 --- a/extensions/html-language-features/server/package.json +++ b/extensions/html-language-features/server/package.json @@ -10,11 +10,11 @@ "main": "./out/node/htmlServerMain", "dependencies": { "@vscode/l10n": "^0.0.18", - "vscode-css-languageservice": "^6.3.2", - "vscode-html-languageservice": "^5.3.2", - "vscode-languageserver": "^10.0.0-next.11", + "vscode-css-languageservice": "^6.3.5", + "vscode-html-languageservice": "^5.4.0", + "vscode-languageserver": "^10.0.0-next.12", "vscode-languageserver-textdocument": "^1.0.12", - "vscode-uri": "^3.0.8" + "vscode-uri": "^3.1.0" }, "devDependencies": { "@types/mocha": "^9.1.1", @@ -23,7 +23,7 @@ "scripts": { "compile": "npx gulp compile-extension:html-language-features-server", "watch": "npx gulp watch-extension:html-language-features-server", - "install-service-next": "npm install vscode-css-languageservice@next && npm install vscode-html-languageservice@next", + "install-service-next": "npm install vscode-css-languageservice && npm install vscode-html-languageservice", "install-service-local": "npm install vscode-css-languageservice && npm install vscode-html-languageservice", "install-server-next": "npm install vscode-languageserver@next", "install-server-local": "npm install vscode-languageserver", diff --git a/extensions/html-language-features/server/src/htmlServer.ts b/extensions/html-language-features/server/src/htmlServer.ts index 29aa041746c..22ab2e18076 100644 --- a/extensions/html-language-features/server/src/htmlServer.ts +++ b/extensions/html-language-features/server/src/htmlServer.ts @@ -7,11 +7,12 @@ import { Connection, TextDocuments, InitializeParams, InitializeResult, RequestType, DocumentRangeFormattingRequest, Disposable, ServerCapabilities, ConfigurationRequest, ConfigurationParams, DidChangeWorkspaceFoldersNotification, - DocumentColorRequest, ColorPresentationRequest, TextDocumentSyncKind, NotificationType, RequestType0, DocumentFormattingRequest, FormattingOptions, TextEdit + DocumentColorRequest, ColorPresentationRequest, TextDocumentSyncKind, NotificationType, RequestType0, DocumentFormattingRequest, FormattingOptions, TextEdit, + TextDocumentContentRequest } from 'vscode-languageserver'; import { getLanguageModes, LanguageModes, Settings, TextDocument, Position, Diagnostic, WorkspaceFolder, ColorInformation, - Range, DocumentLink, SymbolInformation, TextDocumentIdentifier, isCompletionItemData + Range, DocumentLink, SymbolInformation, TextDocumentIdentifier, isCompletionItemData, FILE_PROTOCOL } from './modes/languageModes'; import { format } from './modes/formatting'; @@ -213,6 +214,9 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) documentSelector: null, interFileDependencies: false, workspaceDiagnostics: false + }, + workspace: { + textDocumentContent: { schemes: [FILE_PROTOCOL] } } }; return { capabilities }; @@ -584,6 +588,18 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) }); }); + connection.onRequest(TextDocumentContentRequest.type, (params, token) => { + return runSafe(runtime, async () => { + for (const languageMode of languageModes.getAllModes()) { + const content = await languageMode.getTextDocumentContent?.(params.uri); + if (content) { + return { text: content }; + } + } + return null; + }, null, `Error while computing text document content for ${params.uri}`, token); + }); + // Listen on the connection connection.listen(); } diff --git a/extensions/html-language-features/server/src/modes/javascriptMode.ts b/extensions/html-language-features/server/src/modes/javascriptMode.ts index d820810c920..aafb54a64b5 100644 --- a/extensions/html-language-features/server/src/modes/javascriptMode.ts +++ b/extensions/html-language-features/server/src/modes/javascriptMode.ts @@ -8,7 +8,7 @@ import { SymbolInformation, SymbolKind, CompletionItem, Location, SignatureHelp, SignatureInformation, ParameterInformation, Definition, TextEdit, TextDocument, Diagnostic, DiagnosticSeverity, Range, CompletionItemKind, Hover, DocumentHighlight, DocumentHighlightKind, CompletionList, Position, FormattingOptions, FoldingRange, FoldingRangeKind, SelectionRange, - LanguageMode, Settings, SemanticTokenData, Workspace, DocumentContext, CompletionItemData, isCompletionItemData + LanguageMode, Settings, SemanticTokenData, Workspace, DocumentContext, CompletionItemData, isCompletionItemData, FILE_PROTOCOL, DocumentUri } from './languageModes'; import { getWordAtText, isWhitespaceOnly, repeat } from '../utils/strings'; import { HTMLDocumentRegions } from './embeddedSupport'; @@ -77,18 +77,24 @@ function getLanguageServiceHost(scriptKind: ts.ScriptKind) { } }; - return ts.createLanguageService(host); + return { + service: ts.createLanguageService(host), + loadLibrary: libs.loadLibrary, + }; }); return { async getLanguageService(jsDocument: TextDocument): Promise { currentTextDocument = jsDocument; - return jsLanguageService; + return (await jsLanguageService).service; }, getCompilationSettings() { return compilerOptions; }, + async loadLibrary(fileName: string) { + return (await jsLanguageService).loadLibrary(fileName); + }, dispose() { - jsLanguageService.then(s => s.dispose()); + jsLanguageService.then(s => s.service.dispose()); } }; } @@ -104,6 +110,8 @@ export function getJavaScriptMode(documentRegions: LanguageModelCache d.fileName === jsDocument.uri).map(d => { - return { - uri: document.uri, - range: convertRange(jsDocument, d.textSpan) - }; - }); + return (await Promise.all(definition.map(async d => { + if (d.fileName === jsDocument.uri) { + return { + uri: document.uri, + range: convertRange(jsDocument, d.textSpan) + }; + } else { + const libUri = libParentUri + d.fileName; + const content = await host.loadLibrary(d.fileName); + if (!content) { + return undefined; + } + const libDocument = TextDocument.create(libUri, languageId, 1, content); + return { + uri: libUri, + range: convertRange(libDocument, d.textSpan) + }; + } + }))).filter(d => !!d); } return null; }, @@ -402,6 +423,12 @@ export function getJavaScriptMode(documentRegions: LanguageModelCache { + if (documentUri.startsWith(libParentUri)) { + return host.loadLibrary(documentUri.substring(libParentUri.length)); + } + return undefined; + }, dispose() { host.dispose(); jsDocuments.dispose(); diff --git a/extensions/html-language-features/server/src/modes/languageModes.ts b/extensions/html-language-features/server/src/modes/languageModes.ts index 803fa6c1c87..45d0b8fabe7 100644 --- a/extensions/html-language-features/server/src/modes/languageModes.ts +++ b/extensions/html-language-features/server/src/modes/languageModes.ts @@ -14,7 +14,7 @@ import { Color, ColorInformation, ColorPresentation, WorkspaceEdit, WorkspaceFolder } from 'vscode-languageserver'; -import { TextDocument } from 'vscode-languageserver-textdocument'; +import { DocumentUri, TextDocument } from 'vscode-languageserver-textdocument'; import { getLanguageModelCache, LanguageModelCache } from '../languageModelCache'; import { getCSSMode } from './cssMode'; @@ -34,7 +34,7 @@ export { export { ClientCapabilities, DocumentContext, LanguageService, HTMLDocument, HTMLFormatConfiguration, TokenType } from 'vscode-html-languageservice'; -export { TextDocument } from 'vscode-languageserver-textdocument'; +export { TextDocument, DocumentUri } from 'vscode-languageserver-textdocument'; export interface Settings { readonly css?: any; @@ -89,6 +89,7 @@ export interface LanguageMode { onDocumentRemoved(document: TextDocument): void; getSemanticTokens?(document: TextDocument): Promise; getSemanticTokenLegend?(): { types: string[]; modifiers: string[] }; + getTextDocumentContent?(uri: DocumentUri): Promise; dispose(): void; } @@ -108,6 +109,8 @@ export interface LanguageModeRange extends Range { attributeValue?: boolean; } +export const FILE_PROTOCOL = 'html-server'; + export function getLanguageModes(supportedLanguages: { [languageId: string]: boolean }, workspace: Workspace, clientCapabilities: ClientCapabilities, requestService: FileSystemProvider): LanguageModes { const htmlLanguageService = getHTMLLanguageService({ clientCapabilities, fileSystemProvider: requestService }); const cssLanguageService = getCSSLanguageService({ clientCapabilities, fileSystemProvider: requestService }); diff --git a/extensions/html/build/update-grammar.mjs b/extensions/html/build/update-grammar.mjs index 64bfe548faa..29934012ac4 100644 --- a/extensions/html/build/update-grammar.mjs +++ b/extensions/html/build/update-grammar.mjs @@ -32,6 +32,22 @@ function patchGrammar(grammar) { console.warn(`Expected to patch 2 occurrences of source.js & source.css: Was ${patchCount}`); } + return grammar; +} + +function patchGrammarDerivative(grammar) { + let patchCount = 0; + + let patterns = grammar.patterns; + for (let key in patterns) { + if (patterns[key]?.name === 'meta.tag.other.unrecognized.html.derivative' && patterns[key]?.begin === '(]*)(?]*)(? patchGrammar(grammar)); +const grammarDerivativePath = 'Syntaxes/HTML%20%28Derivative%29.tmLanguage'; +vscodeGrammarUpdater.update(tsGrammarRepo, grammarDerivativePath, './syntaxes/html-derivative.tmLanguage.json', grammar => patchGrammarDerivative(grammar)); diff --git a/extensions/html/syntaxes/html-derivative.tmLanguage.json b/extensions/html/syntaxes/html-derivative.tmLanguage.json index dc73025b9dd..f06c4d0ec44 100644 --- a/extensions/html/syntaxes/html-derivative.tmLanguage.json +++ b/extensions/html/syntaxes/html-derivative.tmLanguage.json @@ -23,7 +23,7 @@ "include": "text.html.basic#core-minus-invalid" }, { - "begin": "(]*)(?]*)(? = new RequestType('json/languageStatus'); } +namespace ValidateContentRequest { + export const type: RequestType<{ schemaUri: string; content: string }, LSPDiagnostic[], any> = new RequestType('json/validateContent'); +} interface SortOptions extends LSPFormattingOptions { } @@ -182,7 +186,7 @@ export async function startClient(context: ExtensionContext, newLanguageClient: }; } -async function startClientWithParticipants(context: ExtensionContext, languageParticipants: LanguageParticipants, newLanguageClient: LanguageClientConstructor, runtime: Runtime): Promise { +async function startClientWithParticipants(_context: ExtensionContext, languageParticipants: LanguageParticipants, newLanguageClient: LanguageClientConstructor, runtime: Runtime): Promise { const toDispose: Disposable[] = []; @@ -211,6 +215,10 @@ async function startClientWithParticipants(context: ExtensionContext, languagePa window.showInformationMessage(l10n.t('JSON schema cache cleared.')); })); + toDispose.push(commands.registerCommand('json.validate', async (schemaUri: Uri, content: string) => { + const diagnostics: LSPDiagnostic[] = await client.sendRequest(ValidateContentRequest.type, { schemaUri: schemaUri.toString(), content }); + return diagnostics.map(client.protocol2CodeConverter.asDiagnostic); + })); toDispose.push(commands.registerCommand('json.sort', async () => { @@ -363,7 +371,7 @@ async function startClientWithParticipants(context: ExtensionContext, languagePa // handle content request client.onRequest(VSCodeContentRequest.type, async (uriPath: string) => { const uri = Uri.parse(uriPath); - const uriString = uri.toString(); + const uriString = uri.toString(true); if (uri.scheme === 'untitled') { throw new ResponseError(3, l10n.t('Unable to load {0}', uriString)); } @@ -495,10 +503,19 @@ async function startClientWithParticipants(context: ExtensionContext, languagePa toDispose.push(commands.registerCommand('_json.retryResolveSchema', handleRetryResolveSchemaCommand)); - client.sendNotification(SchemaAssociationNotification.type, getSchemaAssociations(context)); + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); - toDispose.push(extensions.onDidChange(_ => { - client.sendNotification(SchemaAssociationNotification.type, getSchemaAssociations(context)); + toDispose.push(extensions.onDidChange(async _ => { + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); + })); + + const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern( + Uri.parse(`vscode://schemas-associations/`), + '**/schemas-associations.json') + ); + toDispose.push(associationWatcher); + toDispose.push(associationWatcher.onDidChange(async _e => { + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); })); // manually register / deregister format provider based on the `json.format.enable` setting avoiding issues with late registration. See #71652. @@ -595,7 +612,12 @@ async function startClientWithParticipants(context: ExtensionContext, languagePa }; } -function getSchemaAssociations(_context: ExtensionContext): ISchemaAssociation[] { +async function getSchemaAssociations(): Promise { + return getSchemaExtensionAssociations() + .concat(await getDynamicSchemaAssociations()); +} + +function getSchemaExtensionAssociations(): ISchemaAssociation[] { const associations: ISchemaAssociation[] = []; extensions.allAcrossExtensionHosts.forEach(extension => { const packageJSON = extension.packageJSON; @@ -631,6 +653,24 @@ function getSchemaAssociations(_context: ExtensionContext): ISchemaAssociation[] return associations; } +async function getDynamicSchemaAssociations(): Promise { + const result: ISchemaAssociation[] = []; + try { + const data = await workspace.fs.readFile(Uri.parse(`vscode://schemas-associations/schemas-associations.json`)); + const rawStr = new TextDecoder().decode(data); + const obj = >JSON.parse(rawStr); + for (const item of Object.keys(obj)) { + result.push({ + fileMatch: obj[item], + uri: item + }); + } + } catch { + // ignore + } + return result; +} + function getSettings(): Settings { const configuration = workspace.getConfiguration(); const httpSettings = workspace.getConfiguration('http'); @@ -735,3 +775,5 @@ function updateMarkdownString(h: MarkdownString): MarkdownString { function isSchemaResolveError(d: Diagnostic) { return d.code === /* SchemaResolveError */ 0x300; } + + diff --git a/extensions/json-language-features/package-lock.json b/extensions/json-language-features/package-lock.json index bde80cbfbe4..85f31440359 100644 --- a/extensions/json-language-features/package-lock.json +++ b/extensions/json-language-features/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@vscode/extension-telemetry": "^0.9.8", "request-light": "^0.8.0", - "vscode-languageclient": "^10.0.0-next.13" + "vscode-languageclient": "^10.0.0-next.14" }, "devDependencies": { "@types/node": "20.x" @@ -168,38 +168,30 @@ } }, "node_modules/balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c= sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -211,12 +203,10 @@ "integrity": "sha512-bH6E4PMmsEXYrLX6Kr1vu+xI3HproB1vECAwaPSJeroLE1kpWE3HR27uB4icx+6YORu1ajqBJXxuedv8ZQg5Lw==" }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -231,45 +221,43 @@ "dev": true }, "node_modules/vscode-jsonrpc": { - "version": "9.0.0-next.6", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.6.tgz", - "integrity": "sha512-KCSvUNsFiVciG9iqjJKBZOd66CN3ZKohDlYRmoOi+pd8l15MFLZ8wRG4c+wuzePGba/8WcCG2TM+C/GVlvuaeA==", + "version": "9.0.0-next.7", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.7.tgz", + "integrity": "sha512-7SgnbbbJfYr3off0T2KV/RCMYhVsuLeFPw8l3bkxSiavtoTLsOdu1jyxK3yWbdQuO8QOJC7+no0TXmYjRWSC+g==", + "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageclient": { - "version": "10.0.0-next.13", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.0.0-next.13.tgz", - "integrity": "sha512-KLsOMJoYpkk36PIgcOjyZ4AekOfzp4kdWdRRbVKeVvSIrwrn/4RSZr0NlD6EvUBBJSsJW4WDrYY7Y3znkqa6+w==", + "version": "10.0.0-next.14", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.0.0-next.14.tgz", + "integrity": "sha512-4m/cpNocRgrAkWc8IH4wd3zllAs16NvMmeGcQxFa6xt+mGXJASIeqp0NAFWKZERKg6ClVgBph+SDSZSVvNZ2oA==", "license": "MIT", "dependencies": { - "minimatch": "^9.0.3", - "semver": "^7.6.0", - "vscode-languageserver-protocol": "3.17.6-next.11" + "minimatch": "^10.0.1", + "semver": "^7.6.3", + "vscode-languageserver-protocol": "3.17.6-next.12" }, "engines": { "vscode": "^1.91.0" } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.17.6-next.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.11.tgz", - "integrity": "sha512-GeJxEp1TiLsp79f8WG5n10wLViXfgFKb99hU9K8m7KDWM95/QFEqWkm79f9LVm54tUK74I91a9EeiQLCS/FABQ==", + "version": "3.17.6-next.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.12.tgz", + "integrity": "sha512-EqrbwF0glTWD2HiDpFc32pJOr6/bJvyKSfCpRQrKy3XsfdloH4p3o/rNJYcpujM0OVLmPZgl1i9g57z9g2YRJA==", + "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.0-next.6", - "vscode-languageserver-types": "3.17.6-next.5" + "vscode-jsonrpc": "9.0.0-next.7", + "vscode-languageserver-types": "3.17.6-next.6" } }, "node_modules/vscode-languageserver-types": { - "version": "3.17.6-next.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.5.tgz", - "integrity": "sha512-QFmf3Yl1tCgUQfA77N9Me/LXldJXkIVypQbty2rJ1DNHQkC+iwvm4Z2tXg9czSwlhvv0pD4pbF5mT7WhAglolw==" - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "version": "3.17.6-next.6", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.6.tgz", + "integrity": "sha512-aiJY5/yW+xzw7KPNlwi3gQtddq/3EIn5z8X8nCgJfaiAij2R1APKePngv+MUdLdYJBVTLu+Qa0ODsT+pHgYguQ==", + "license": "MIT" } } } diff --git a/extensions/json-language-features/package.json b/extensions/json-language-features/package.json index cf4a7f162da..375afed0ce6 100644 --- a/extensions/json-language-features/package.json +++ b/extensions/json-language-features/package.json @@ -16,7 +16,8 @@ "activationEvents": [ "onLanguage:json", "onLanguage:jsonc", - "onLanguage:snippets" + "onLanguage:snippets", + "onCommand:json.validate" ], "main": "./client/out/node/jsonClientMain", "browser": "./client/dist/browser/jsonClientMain", @@ -169,7 +170,7 @@ "dependencies": { "@vscode/extension-telemetry": "^0.9.8", "request-light": "^0.8.0", - "vscode-languageclient": "^10.0.0-next.13" + "vscode-languageclient": "^10.0.0-next.14" }, "devDependencies": { "@types/node": "20.x" diff --git a/extensions/json-language-features/server/package-lock.json b/extensions/json-language-features/server/package-lock.json index 384ce045c9c..a0b95ddd48f 100644 --- a/extensions/json-language-features/server/package-lock.json +++ b/extensions/json-language-features/server/package-lock.json @@ -12,9 +12,9 @@ "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^5.4.3", - "vscode-languageserver": "^10.0.0-next.11", - "vscode-uri": "^3.0.8" + "vscode-json-languageservice": "^5.5.0", + "vscode-languageserver": "^10.0.0-next.12", + "vscode-uri": "^3.1.0" }, "bin": { "vscode-json-languageserver": "bin/vscode-json-languageserver" @@ -64,51 +64,54 @@ "dev": true }, "node_modules/vscode-json-languageservice": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.4.3.tgz", - "integrity": "sha512-NVSEQDloP9NYccuqKg4eI46kutZpwucBY4csBB6FCxbM7AZVoBt0oxTItPVA+ZwhnG1bg/fmiBRAwcGJyNQoPA==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.5.0.tgz", + "integrity": "sha512-JchBzp8ArzhCVpRS/LT4wzEEvwHXIUEdZD064cGTI4RVs34rNCZXPUguIYSfGBcHH1GV79ufPcfy3Pd8+ukbKw==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", - "vscode-uri": "^3.0.8" + "vscode-uri": "^3.1.0" } }, "node_modules/vscode-jsonrpc": { - "version": "9.0.0-next.6", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.6.tgz", - "integrity": "sha512-KCSvUNsFiVciG9iqjJKBZOd66CN3ZKohDlYRmoOi+pd8l15MFLZ8wRG4c+wuzePGba/8WcCG2TM+C/GVlvuaeA==", + "version": "9.0.0-next.7", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.7.tgz", + "integrity": "sha512-7SgnbbbJfYr3off0T2KV/RCMYhVsuLeFPw8l3bkxSiavtoTLsOdu1jyxK3yWbdQuO8QOJC7+no0TXmYjRWSC+g==", + "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageserver": { - "version": "10.0.0-next.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.0.0-next.11.tgz", - "integrity": "sha512-cmobSrVDYhlh/t02vz/bV8nNpds8mus5HnILULae2iAvOjoaJPnTAp0jJWoYdUqTpIVzT9JV6JMKqLEvdqpeqg==", + "version": "10.0.0-next.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.0.0-next.12.tgz", + "integrity": "sha512-6lT2CJhH93YFmdDrFTwWvuG0/yzEN2Zbw/DfPaRF91sylZ3TSD0NkJU5jug6t/3NLoDh9VjfJZkgkKr6e3UmRw==", "license": "MIT", "dependencies": { - "vscode-languageserver-protocol": "3.17.6-next.11" + "vscode-languageserver-protocol": "3.17.6-next.12" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.17.6-next.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.11.tgz", - "integrity": "sha512-GeJxEp1TiLsp79f8WG5n10wLViXfgFKb99hU9K8m7KDWM95/QFEqWkm79f9LVm54tUK74I91a9EeiQLCS/FABQ==", + "version": "3.17.6-next.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.12.tgz", + "integrity": "sha512-EqrbwF0glTWD2HiDpFc32pJOr6/bJvyKSfCpRQrKy3XsfdloH4p3o/rNJYcpujM0OVLmPZgl1i9g57z9g2YRJA==", + "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.0-next.6", - "vscode-languageserver-types": "3.17.6-next.5" + "vscode-jsonrpc": "9.0.0-next.7", + "vscode-languageserver-types": "3.17.6-next.6" } }, "node_modules/vscode-languageserver-protocol/node_modules/vscode-languageserver-types": { - "version": "3.17.6-next.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.5.tgz", - "integrity": "sha512-QFmf3Yl1tCgUQfA77N9Me/LXldJXkIVypQbty2rJ1DNHQkC+iwvm4Z2tXg9czSwlhvv0pD4pbF5mT7WhAglolw==" + "version": "3.17.6-next.6", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.6.tgz", + "integrity": "sha512-aiJY5/yW+xzw7KPNlwi3gQtddq/3EIn5z8X8nCgJfaiAij2R1APKePngv+MUdLdYJBVTLu+Qa0ODsT+pHgYguQ==", + "license": "MIT" }, "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", @@ -121,9 +124,10 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" } } } diff --git a/extensions/json-language-features/server/package.json b/extensions/json-language-features/server/package.json index 6dcd82930d2..55deaff19e5 100644 --- a/extensions/json-language-features/server/package.json +++ b/extensions/json-language-features/server/package.json @@ -15,9 +15,9 @@ "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^5.4.3", - "vscode-languageserver": "^10.0.0-next.11", - "vscode-uri": "^3.0.8" + "vscode-json-languageservice": "^5.5.0", + "vscode-languageserver": "^10.0.0-next.12", + "vscode-uri": "^3.1.0" }, "devDependencies": { "@types/mocha": "^9.1.1", @@ -28,7 +28,7 @@ "compile": "npx gulp compile-extension:json-language-features-server", "watch": "npx gulp watch-extension:json-language-features-server", "clean": "../../../node_modules/.bin/rimraf out", - "install-service-next": "npm install vscode-json-languageservice@next", + "install-service-next": "npm install vscode-json-languageservice", "install-service-latest": "npm install vscode-json-languageservice", "install-service-local": "npm link vscode-json-languageservice", "install-server-next": "npm install vscode-languageserver@next", diff --git a/extensions/json-language-features/server/src/jsonServer.ts b/extensions/json-language-features/server/src/jsonServer.ts index 36ca0dc591d..830ee8c4393 100644 --- a/extensions/json-language-features/server/src/jsonServer.ts +++ b/extensions/json-language-features/server/src/jsonServer.ts @@ -40,6 +40,10 @@ namespace LanguageStatusRequest { export const type: RequestType = new RequestType('json/languageStatus'); } +namespace ValidateContentRequest { + export const type: RequestType<{ schemaUri: string; content: string }, Diagnostic[], any> = new RequestType('json/validateContent'); +} + export interface DocumentSortingParams { /** * The uri of the document to sort. @@ -76,6 +80,8 @@ export interface RuntimeEnvironment { }; } +const sortCodeActionKind = CodeActionKind.Source.concat('.sort', '.json'); + export function startServer(connection: Connection, runtime: RuntimeEnvironment) { function getSchemaRequestService(handledSchemas: string[] = ['https', 'http', 'file']) { @@ -190,7 +196,9 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) interFileDependencies: false, workspaceDiagnostics: false }, - codeActionProvider: true + codeActionProvider: { + codeActionKinds: [sortCodeActionKind] + } }; return { capabilities }; @@ -299,6 +307,14 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) return []; }); + connection.onRequest(ValidateContentRequest.type, async ({ schemaUri, content }) => { + const docURI = 'vscode://schemas/temp/' + new Date().getTime(); + const document = TextDocument.create(docURI, 'json', 1, content); + updateConfiguration([{ uri: schemaUri, fileMatch: [docURI] }]); + return await validateTextDocument(document); + }); + + connection.onRequest(LanguageStatusRequest.type, async uri => { const document = documents.get(uri); if (document) { @@ -319,7 +335,7 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) return []; }); - function updateConfiguration() { + function updateConfiguration(extraSchemas?: SchemaConfiguration[]) { const languageSettings = { validate: validateEnabled, allowComments: true, @@ -350,6 +366,10 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) } }); } + if (extraSchemas) { + languageSettings.schemas.push(...extraSchemas); + } + languageService.configure(languageSettings); diagnosticsSupport?.requestRefresh(); @@ -430,7 +450,7 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) return runSafeAsync(runtime, async () => { const document = documents.get(codeActionParams.textDocument.uri); if (document) { - const sortCodeAction = CodeAction.create('Sort JSON', CodeActionKind.Source.concat('.sort', '.json')); + const sortCodeAction = CodeAction.create('Sort JSON', sortCodeActionKind); sortCodeAction.command = { command: 'json.sort', title: l10n.t('Sort JSON') @@ -529,3 +549,7 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) function getFullRange(document: TextDocument): Range { return Range.create(Position.create(0, 0), document.positionAt(document.getText().length)); } + + + + diff --git a/extensions/latex/cgmanifest.json b/extensions/latex/cgmanifest.json index d937ba4f430..eb4e0384157 100644 --- a/extensions/latex/cgmanifest.json +++ b/extensions/latex/cgmanifest.json @@ -6,11 +6,11 @@ "git": { "name": "jlelong/vscode-latex-basics", "repositoryUrl": "https://github.com/jlelong/vscode-latex-basics", - "commitHash": "df6ef817c932d24da5cc72927344a547e463cc65" + "commitHash": "b46aaf9bf4d265e63e262ded4bf9beffe19d35b2" } }, "license": "MIT", - "version": "1.9.0", + "version": "1.13.0", "description": "The files in syntaxes/ were originally part of https://github.com/James-Yu/LaTeX-Workshop. They have been extracted in the hope that they can useful outside of the LaTeX-Workshop extension.", "licenseDetail": [ "Copyright (c) vscode-latex-basics authors", diff --git a/extensions/latex/syntaxes/Bibtex.tmLanguage.json b/extensions/latex/syntaxes/Bibtex.tmLanguage.json index 0f3a3a408a5..ed523fcbdca 100644 --- a/extensions/latex/syntaxes/Bibtex.tmLanguage.json +++ b/extensions/latex/syntaxes/Bibtex.tmLanguage.json @@ -4,217 +4,28 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/jlelong/vscode-latex-basics/commit/c787db94a56bd93131ce0938046063320a02cc73", + "version": "https://github.com/jlelong/vscode-latex-basics/commit/0fcf9283828cab2aa611072f54feb1e7d501c2b4", "name": "BibTeX", "scopeName": "text.bibtex", - "comment": "Grammar based on description from https://github.com/aclements/biblib\n", + "comment": "Grammar based on description from https://github.com/aclements/biblib", "patterns": [ { + "match": "@(?i:comment)(?=[\\s{(])", "captures": { "0": { "name": "punctuation.definition.comment.bibtex" } }, - "match": "@(?i:comment)(?=[\\s{(])", "name": "comment.block.at-sign.bibtex" }, { - "begin": "((@)(?i:preamble))\\s*(\\{)\\s*", - "beginCaptures": { - "1": { - "name": "keyword.other.preamble.bibtex" - }, - "2": { - "name": "punctuation.definition.keyword.bibtex" - }, - "3": { - "name": "punctuation.section.preamble.begin.bibtex" - } - }, - "end": "\\}", - "endCaptures": { - "0": { - "name": "punctuation.section.preamble.end.bibtex" - } - }, - "name": "meta.preamble.braces.bibtex", - "patterns": [ - { - "include": "#field_value" - } - ] + "include": "#preamble" }, { - "begin": "((@)(?i:preamble))\\s*(\\()\\s*", - "beginCaptures": { - "1": { - "name": "keyword.other.preamble.bibtex" - }, - "2": { - "name": "punctuation.definition.keyword.bibtex" - }, - "3": { - "name": "punctuation.section.preamble.begin.bibtex" - } - }, - "end": "\\)", - "endCaptures": { - "0": { - "name": "punctuation.section.preamble.end.bibtex" - } - }, - "name": "meta.preamble.parenthesis.bibtex", - "patterns": [ - { - "include": "#field_value" - } - ] + "include": "#string" }, { - "begin": "((@)(?i:string))\\s*(\\{)\\s*([a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)", - "beginCaptures": { - "1": { - "name": "keyword.other.string-constant.bibtex" - }, - "2": { - "name": "punctuation.definition.keyword.bibtex" - }, - "3": { - "name": "punctuation.section.string-constant.begin.bibtex" - }, - "4": { - "name": "variable.other.bibtex" - } - }, - "end": "\\}", - "endCaptures": { - "0": { - "name": "punctuation.section.string-constant.end.bibtex" - } - }, - "name": "meta.string-constant.braces.bibtex", - "patterns": [ - { - "include": "#field_value" - } - ] - }, - { - "begin": "((@)(?i:string))\\s*(\\()\\s*([a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)", - "beginCaptures": { - "1": { - "name": "keyword.other.string-constant.bibtex" - }, - "2": { - "name": "punctuation.definition.keyword.bibtex" - }, - "3": { - "name": "punctuation.section.string-constant.begin.bibtex" - }, - "4": { - "name": "variable.other.bibtex" - } - }, - "end": "\\)", - "endCaptures": { - "0": { - "name": "punctuation.section.string-constant.end.bibtex" - } - }, - "name": "meta.string-constant.parenthesis.bibtex", - "patterns": [ - { - "include": "#field_value" - } - ] - }, - { - "begin": "((@)[a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)\\s*(\\{)\\s*([^\\s,}]*)", - "beginCaptures": { - "1": { - "name": "keyword.other.entry-type.bibtex" - }, - "2": { - "name": "punctuation.definition.keyword.bibtex" - }, - "3": { - "name": "punctuation.section.entry.begin.bibtex" - }, - "4": { - "name": "entity.name.type.entry-key.bibtex" - } - }, - "end": "\\}", - "endCaptures": { - "0": { - "name": "punctuation.section.entry.end.bibtex" - } - }, - "name": "meta.entry.braces.bibtex", - "patterns": [ - { - "begin": "([a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)\\s*(\\=)", - "beginCaptures": { - "1": { - "name": "support.function.key.bibtex" - }, - "2": { - "name": "punctuation.separator.key-value.bibtex" - } - }, - "end": "(?=[,}])", - "name": "meta.key-assignment.bibtex", - "patterns": [ - { - "include": "#field_value" - } - ] - } - ] - }, - { - "begin": "((@)[a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)\\s*(\\()\\s*([^\\s,]*)", - "beginCaptures": { - "1": { - "name": "keyword.other.entry-type.bibtex" - }, - "2": { - "name": "punctuation.definition.keyword.bibtex" - }, - "3": { - "name": "punctuation.section.entry.begin.bibtex" - }, - "4": { - "name": "entity.name.type.entry-key.bibtex" - } - }, - "end": "\\)", - "endCaptures": { - "0": { - "name": "punctuation.section.entry.end.bibtex" - } - }, - "name": "meta.entry.parenthesis.bibtex", - "patterns": [ - { - "begin": "([a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)\\s*(\\=)", - "beginCaptures": { - "1": { - "name": "support.function.key.bibtex" - }, - "2": { - "name": "punctuation.separator.key-value.bibtex" - } - }, - "end": "(?=[,)])", - "name": "meta.key-assignment.bibtex", - "patterns": [ - { - "include": "#field_value" - } - ] - } - ] + "include": "#entry" }, { "begin": "[^@\\n]", @@ -223,6 +34,216 @@ } ], "repository": { + "preamble": { + "patterns": [ + { + "begin": "((@)(?i:preamble))\\s*(\\{)\\s*", + "beginCaptures": { + "1": { + "name": "keyword.other.preamble.bibtex" + }, + "2": { + "name": "punctuation.definition.keyword.bibtex" + }, + "3": { + "name": "punctuation.section.preamble.begin.bibtex" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.section.preamble.end.bibtex" + } + }, + "name": "meta.preamble.braces.bibtex", + "patterns": [ + { + "include": "#field_value" + } + ] + }, + { + "begin": "((@)(?i:preamble))\\s*(\\()\\s*", + "beginCaptures": { + "1": { + "name": "keyword.other.preamble.bibtex" + }, + "2": { + "name": "punctuation.definition.keyword.bibtex" + }, + "3": { + "name": "punctuation.section.preamble.begin.bibtex" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.section.preamble.end.bibtex" + } + }, + "name": "meta.preamble.parenthesis.bibtex", + "patterns": [ + { + "include": "#field_value" + } + ] + } + ] + }, + "string": { + "patterns": [ + { + "begin": "((@)(?i:string))\\s*(\\{)\\s*([a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)", + "beginCaptures": { + "1": { + "name": "keyword.other.string-constant.bibtex" + }, + "2": { + "name": "punctuation.definition.keyword.bibtex" + }, + "3": { + "name": "punctuation.section.string-constant.begin.bibtex" + }, + "4": { + "name": "variable.other.bibtex" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.section.string-constant.end.bibtex" + } + }, + "name": "meta.string-constant.braces.bibtex", + "patterns": [ + { + "include": "#field_value" + } + ] + }, + { + "begin": "((@)(?i:string))\\s*(\\()\\s*([a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)", + "beginCaptures": { + "1": { + "name": "keyword.other.string-constant.bibtex" + }, + "2": { + "name": "punctuation.definition.keyword.bibtex" + }, + "3": { + "name": "punctuation.section.string-constant.begin.bibtex" + }, + "4": { + "name": "variable.other.bibtex" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.section.string-constant.end.bibtex" + } + }, + "name": "meta.string-constant.parenthesis.bibtex", + "patterns": [ + { + "include": "#field_value" + } + ] + } + ] + }, + "entry": { + "patterns": [ + { + "begin": "((@)[a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)\\s*(\\{)\\s*([^\\s,}]*)", + "beginCaptures": { + "1": { + "name": "keyword.other.entry-type.bibtex" + }, + "2": { + "name": "punctuation.definition.keyword.bibtex" + }, + "3": { + "name": "punctuation.section.entry.begin.bibtex" + }, + "4": { + "name": "entity.name.type.entry-key.bibtex" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.section.entry.end.bibtex" + } + }, + "name": "meta.entry.braces.bibtex", + "patterns": [ + { + "begin": "([a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)\\s*(\\=)", + "beginCaptures": { + "1": { + "name": "support.function.key.bibtex" + }, + "2": { + "name": "punctuation.separator.key-value.bibtex" + } + }, + "end": "(?=[,}])", + "name": "meta.key-assignment.bibtex", + "patterns": [ + { + "include": "#field_value" + } + ] + } + ] + }, + { + "begin": "((@)[a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)\\s*(\\()\\s*([^\\s,]*)", + "beginCaptures": { + "1": { + "name": "keyword.other.entry-type.bibtex" + }, + "2": { + "name": "punctuation.definition.keyword.bibtex" + }, + "3": { + "name": "punctuation.section.entry.begin.bibtex" + }, + "4": { + "name": "entity.name.type.entry-key.bibtex" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.section.entry.end.bibtex" + } + }, + "name": "meta.entry.parenthesis.bibtex", + "patterns": [ + { + "begin": "([a-zA-Z!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~][a-zA-Z0-9!$&*+\\-./:;<>?@\\[\\\\\\]^_`|~]*)\\s*(\\=)", + "beginCaptures": { + "1": { + "name": "support.function.key.bibtex" + }, + "2": { + "name": "punctuation.separator.key-value.bibtex" + } + }, + "end": "(?=[,)])", + "name": "meta.key-assignment.bibtex", + "patterns": [ + { + "include": "#field_value" + } + ] + } + ] + } + ] + }, "field_value": { "patterns": [ { diff --git a/extensions/latex/syntaxes/LaTeX.tmLanguage.json b/extensions/latex/syntaxes/LaTeX.tmLanguage.json index 06c4c59c60e..5a15e0eb15f 100644 --- a/extensions/latex/syntaxes/LaTeX.tmLanguage.json +++ b/extensions/latex/syntaxes/LaTeX.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/jlelong/vscode-latex-basics/commit/7b75bae583f3f9802c533e021f882428872c572c", + "version": "https://github.com/jlelong/vscode-latex-basics/commit/a39a1f5ec1dee1c7e6e564ea86ab2c8d8779aa07", "name": "LaTeX", "scopeName": "text.tex.latex", "patterns": [ @@ -121,7 +121,7 @@ ] }, { - "begin": "((?:\\s*)\\\\begin\\{songs\\}\\{.*\\})", + "begin": "(\\s*\\\\begin\\{songs\\}\\{.*\\})", "captures": { "1": { "patterns": [ @@ -136,21 +136,45 @@ "name": "meta.function.environment.songs.latex", "patterns": [ { - "begin": "\\\\\\[", - "end": "\\]", - "name": "meta.chord.block.latex support.class.chord.block.environment.latex", + "include": "text.tex.latex#songs-chords" + } + ] + }, + { + "comment": "This scope applies songs-environment coloring between \\\\beginsong and \\\\endsong. Useful in separate files without \\\\begin{songs}.", + "begin": "\\s*((\\\\)beginsong)(?=\\{)", + "captures": { + "1": { + "name": "support.function.be.latex" + }, + "2": { + "name": "punctuation.definition.function.latex" + }, + "3": { + "name": "punctuation.definition.arguments.begin.latex" + }, + "4": { + "name": "punctuation.definition.arguments.end.latex" + } + }, + "end": "((\\\\)endsong)(?:\\s*\\n)?", + "name": "meta.function.environment.song.latex", + "patterns": [ + { + "include": "#multiline-arg-no-highlight" + }, + { + "include": "#multiline-optional-arg-no-highlight" + }, + { + "begin": "(?:\\G|(?<=\\]|\\}))\\s*", + "end": "\\s*(?=\\\\endsong)", + "contentName": "meta.data.environment.song.latex", "patterns": [ { - "include": "$self" + "include": "text.tex.latex#songs-chords" } ] - }, - { - "match": "\\^", - "name": "meta.chord.block.latex support.class.chord.block.environment.latex" - }, - { - "include": "$self" } ] }, @@ -2198,7 +2222,7 @@ "include": "#definition-label" }, { - "include": "text.tex#math" + "include": "text.tex#math-content" }, { "include": "$self" @@ -2232,7 +2256,7 @@ "include": "#definition-label" }, { - "include": "text.tex#math" + "include": "text.tex#math-content" }, { "include": "$self" @@ -2349,11 +2373,11 @@ ] } }, - "contentName": "meta.embedded.internal_only_markdown_latex_combined", + "contentName": "meta.embedded.markdown_latex_combined", "end": "(\\\\end\\{markdown\\})", "patterns": [ { - "include": "text.tex.internal_only_markdown_latex_combined" + "include": "text.tex.markdown_latex_combined" } ] }, @@ -2949,7 +2973,7 @@ "name": "meta.function.verb.latex" }, { - "begin": "((\\\\)(?:directlua|luadirect))(\\{)", + "begin": "((\\\\)(?:directlua|luadirect|luaexec))(\\{)", "beginCaptures": { "1": { "name": "support.function.verb.latex" @@ -2994,7 +3018,7 @@ "name": "meta.math.block.latex support.class.math.block.environment.latex", "patterns": [ { - "include": "text.tex#math" + "include": "text.tex#math-content" }, { "include": "$self" @@ -3021,7 +3045,7 @@ "name": "constant.character.escape.latex" }, { - "include": "text.tex#math" + "include": "text.tex#math-content" }, { "include": "$self" @@ -3048,7 +3072,7 @@ "name": "constant.character.escape.latex" }, { - "include": "text.tex#math" + "include": "text.tex#math-content" }, { "include": "$self" @@ -3071,7 +3095,7 @@ "name": "meta.math.block.latex support.class.math.block.environment.latex", "patterns": [ { - "include": "text.tex#math" + "include": "text.tex#math-content" }, { "include": "$self" @@ -3250,7 +3274,7 @@ ] }, "multiline-optional-arg-no-highlight": { - "begin": "\\G\\[", + "begin": "(?:\\G|(?<=\\}))\\s*\\[", "beginCaptures": { "0": { "name": "punctuation.definition.arguments.optional.begin.latex" @@ -3269,6 +3293,26 @@ } ] }, + "multiline-arg-no-highlight": { + "begin": "\\G\\{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.arguments.begin.latex" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.arguments.end.latex" + } + }, + "name": "meta.parameter.latex", + "patterns": [ + { + "include": "$self" + } + ] + }, "optional-arg-bracket": { "patterns": [ { @@ -3354,6 +3398,27 @@ "name": "meta.parameter.optional.latex" } ] + }, + "songs-chords": { + "patterns": [ + { + "begin": "\\\\\\[", + "end": "\\]", + "name": "meta.chord.block.latex support.class.chord.block.environment.latex", + "patterns": [ + { + "include": "$self" + } + ] + }, + { + "match": "\\^", + "name": "meta.chord.block.latex support.class.chord.block.environment.latex" + }, + { + "include": "$self" + } + ] } } } \ No newline at end of file diff --git a/extensions/latex/syntaxes/TeX.tmLanguage.json b/extensions/latex/syntaxes/TeX.tmLanguage.json index b3a32817482..db2a62a2267 100644 --- a/extensions/latex/syntaxes/TeX.tmLanguage.json +++ b/extensions/latex/syntaxes/TeX.tmLanguage.json @@ -4,11 +4,57 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/jlelong/vscode-latex-basics/commit/df6ef817c932d24da5cc72927344a547e463cc65", + "version": "https://github.com/jlelong/vscode-latex-basics/commit/b46aaf9bf4d265e63e262ded4bf9beffe19d35b2", "name": "TeX", "scopeName": "text.tex", "patterns": [ { + "include": "#iffalse-block" + }, + { + "include": "#macro-control" + }, + { + "include": "#catcode" + }, + { + "include": "#comment" + }, + { + "match": "[\\[\\]]", + "name": "punctuation.definition.brackets.tex" + }, + { + "include": "#dollar-math" + }, + { + "match": "\\\\\\\\", + "name": "keyword.control.newline.tex" + }, + { + "include": "#macro-general" + } + ], + "repository": { + "catcode": { + "match": "((\\\\)catcode)`(?:\\\\)?.(=)(\\d+)", + "captures": { + "1": { + "name": "keyword.control.catcode.tex" + }, + "2": { + "name": "punctuation.definition.keyword.tex" + }, + "3": { + "name": "punctuation.separator.key-value.tex" + }, + "4": { + "name": "constant.numeric.category.tex" + } + }, + "name": "meta.catcode.tex" + }, + "iffalse-block": { "begin": "(?<=^\\s*)((\\\\)iffalse)(?!\\s*[{}]\\s*\\\\fi)", "beginCaptures": { "1": { @@ -40,109 +86,15 @@ } ] }, - { + "macro-control": { + "match": "(\\\\)(backmatter|csname|else|endcsname|fi|frontmatter|mainmatter|unless|if(case|cat|csname|defined|dim|eof|false|fontchar|hbox|hmode|inner|mmode|num|odd|true|vbox|vmode|void|x)?)(?![a-zA-Z@])", "captures": { "1": { "name": "punctuation.definition.keyword.tex" } }, - "match": "(\\\\)(backmatter|csname|else|endcsname|fi|frontmatter|mainmatter|unless|if(case|cat|csname|defined|dim|eof|false|fontchar|hbox|hmode|inner|mmode|num|odd|true|vbox|vmode|void|x)?)(?![a-zA-Z@])", "name": "keyword.control.tex" }, - { - "captures": { - "1": { - "name": "keyword.control.catcode.tex" - }, - "2": { - "name": "punctuation.definition.keyword.tex" - }, - "3": { - "name": "punctuation.separator.key-value.tex" - }, - "4": { - "name": "constant.numeric.category.tex" - } - }, - "match": "((\\\\)catcode)`(?:\\\\)?.(=)(\\d+)", - "name": "meta.catcode.tex" - }, - { - "include": "#comment" - }, - { - "match": "[\\[\\]]", - "name": "punctuation.definition.brackets.tex" - }, - { - "begin": "(\\$\\$|\\$)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.string.begin.tex" - } - }, - "end": "(\\1)", - "endCaptures": { - "1": { - "name": "punctuation.definition.string.end.tex" - } - }, - "name": "meta.math.block.tex support.class.math.block.tex", - "patterns": [ - { - "match": "\\\\\\$", - "name": "constant.character.escape.tex" - }, - { - "include": "#math" - }, - { - "include": "$self" - } - ] - }, - { - "match": "\\\\\\\\", - "name": "keyword.control.newline.tex" - }, - { - "captures": { - "1": { - "name": "punctuation.definition.function.tex" - } - }, - "match": "(\\\\)_*[\\p{Alphabetic}@]+(?:_[\\p{Alphabetic}@]+)*:[NncVvoxefTFpwD]*", - "name": "support.class.general.latex3.tex" - }, - { - "captures": { - "1": { - "name": "punctuation.definition.function.tex" - } - }, - "match": "(\\.)[\\p{Alphabetic}@]+(?:_[\\p{Alphabetic}@]+)*:[NncVvoxefTFpwD]*", - "name": "support.class.general.latex3.tex" - }, - { - "captures": { - "1": { - "name": "punctuation.definition.function.tex" - } - }, - "match": "(\\\\)(?:[,;]|(?:[\\p{Alphabetic}@]+))", - "name": "support.function.general.tex" - }, - { - "captures": { - "1": { - "name": "punctuation.definition.keyword.tex" - } - }, - "match": "(\\\\)[^a-zA-Z@]", - "name": "constant.character.escape.tex" - } - ], - "repository": { "braces": { "begin": "(? ${1:${TM_SELECTED_TEXT}}", + "body": "${1:${TM_SELECTED_TEXT/^/> /gm}}", "description": "Insert quoted text" }, "Insert inline code": { diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index c1026aca966..2b8430bbbf2 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -19,7 +19,7 @@ "punycode": "^2.3.1", "vscode-languageclient": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.11", - "vscode-markdown-languageserver": "^0.5.0-alpha.9", + "vscode-markdown-languageserver": "^0.5.0-alpha.10", "vscode-uri": "^3.0.3" }, "devDependencies": { @@ -275,7 +275,8 @@ "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" }, "node_modules/brace-expansion": { "version": "1.1.11", @@ -305,6 +306,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -320,6 +322,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "license": "BSD-2-Clause", "engines": { "node": ">= 6" }, @@ -331,6 +334,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -349,12 +353,14 @@ "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -375,9 +381,10 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -391,6 +398,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -402,6 +410,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", "bin": { "he": "bin/he" } @@ -510,6 +519,7 @@ "version": "6.1.13", "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "license": "MIT", "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" @@ -519,6 +529,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" }, @@ -638,15 +649,16 @@ "integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==" }, "node_modules/vscode-markdown-languageserver": { - "version": "0.5.0-alpha.9", - "resolved": "https://registry.npmjs.org/vscode-markdown-languageserver/-/vscode-markdown-languageserver-0.5.0-alpha.9.tgz", - "integrity": "sha512-60jiPHgkstgkyODCN8qCJ4xAOrP/EoKyISmEAcJ7ILT5k2kAJF9JFEl3LvVZ+11HGGMJ2lm1L+lT2/JHvu5Pgg==", + "version": "0.5.0-alpha.10", + "resolved": "https://registry.npmjs.org/vscode-markdown-languageserver/-/vscode-markdown-languageserver-0.5.0-alpha.10.tgz", + "integrity": "sha512-e/Vzd2M34CvmkFYAF+MVh/mF2jmZQ8lQoxMB9V14JUs5UXbyJO2avTv6XF3GEB0EfHlQQ7qVOlCRghzPyskB8A==", + "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.11", "vscode-languageserver": "^8.1.0", "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3", - "vscode-markdown-languageservice": "^0.5.0-alpha.8", + "vscode-markdown-languageservice": "^0.5.0-alpha.9", "vscode-uri": "^3.0.7" }, "engines": { @@ -659,9 +671,10 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "node_modules/vscode-markdown-languageserver/node_modules/vscode-markdown-languageservice": { - "version": "0.5.0-alpha.8", - "resolved": "https://registry.npmjs.org/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.5.0-alpha.8.tgz", - "integrity": "sha512-b2NgVMZvzI/7hRL32Kcu9neAAPFQzkcf/Fqwlxbz9p1/Q7aIorGACOGGo00s72AJtwjkCJ29eVJwUlFMFbPKqA==", + "version": "0.5.0-alpha.9", + "resolved": "https://registry.npmjs.org/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.5.0-alpha.9.tgz", + "integrity": "sha512-OrE8homBOuXX9FOUhqRXgx/Iw0qA94yj3FBRSMztn8VveeO1Y0Eqej/9HBb5ga4sYdlFtQRIZ19lie37TsI+cQ==", + "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.10", "node-html-parser": "^6.1.5", @@ -677,7 +690,8 @@ "node_modules/vscode-markdown-languageserver/node_modules/vscode-markdown-languageservice/node_modules/@vscode/l10n": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.10.tgz", - "integrity": "sha512-E1OCmDcDWa0Ya7vtSjp/XfHFGqYJfh+YPC1RkATU71fTac+j1JjCcB3qwSzmlKAighx2WxhLlfhS0RwAN++PFQ==" + "integrity": "sha512-E1OCmDcDWa0Ya7vtSjp/XfHFGqYJfh+YPC1RkATU71fTac+j1JjCcB3qwSzmlKAighx2WxhLlfhS0RwAN++PFQ==", + "license": "MIT" }, "node_modules/vscode-markdown-languageservice": { "version": "0.3.0-alpha.3", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 6abce928b05..5d76a322788 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -773,7 +773,7 @@ "punycode": "^2.3.1", "vscode-languageclient": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.11", - "vscode-markdown-languageserver": "^0.5.0-alpha.9", + "vscode-markdown-languageserver": "^0.5.0-alpha.10", "vscode-uri": "^3.0.3" }, "devDependencies": { diff --git a/extensions/markdown-language-features/preview-src/index.ts b/extensions/markdown-language-features/preview-src/index.ts index 380223ccdaf..6c70e58ee7e 100644 --- a/extensions/markdown-language-features/preview-src/index.ts +++ b/extensions/markdown-language-features/preview-src/index.ts @@ -184,6 +184,17 @@ async function copyImage(image: HTMLImageElement, retries = 5) { })]); } catch (e) { console.error(e); + const selection = window.getSelection(); + if (!selection) { + await navigator.clipboard.writeText(image.getAttribute('data-src') ?? image.src); + return; + } + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNode(image); + selection.addRange(range); + document.execCommand('copy'); + selection.removeAllRanges(); } } diff --git a/extensions/media-preview/package.json b/extensions/media-preview/package.json index 7e2b70293fc..02b0134e4cf 100644 --- a/extensions/media-preview/package.json +++ b/extensions/media-preview/package.json @@ -90,6 +90,18 @@ "command": "imagePreview.copyImage", "title": "%command.copyImage%", "category": "Image Preview" + }, + { + "command": "imagePreview.reopenAsPreview", + "title": "%command.reopenAsPreview%", + "category": "Image Preview", + "icon": "$(preview)" + }, + { + "command": "imagePreview.reopenAsText", + "title": "%command.reopenAsText%", + "category": "Image Preview", + "icon": "$(go-to-file)" } ], "menus": { @@ -107,6 +119,16 @@ { "command": "imagePreview.copyImage", "when": "false" + }, + { + "command": "imagePreview.reopenAsPreview", + "when": "activeEditor == workbench.editors.files.textFileEditor && resourceExtname == '.svg'", + "group": "navigation" + }, + { + "command": "imagePreview.reopenAsText", + "when": "activeCustomEditorId == 'imagePreview.previewEditor' && resourceExtname == '.svg'", + "group": "navigation" } ], "webview/context": [ @@ -114,6 +136,18 @@ "command": "imagePreview.copyImage", "when": "webviewId == 'imagePreview.previewEditor'" } + ], + "editor/title": [ + { + "command": "imagePreview.reopenAsPreview", + "when": "editorFocus && resourceExtname == '.svg'", + "group": "navigation" + }, + { + "command": "imagePreview.reopenAsText", + "when": "activeCustomEditorId == 'imagePreview.previewEditor' && resourceExtname == '.svg'", + "group": "navigation" + } ] } }, diff --git a/extensions/media-preview/package.nls.json b/extensions/media-preview/package.nls.json index c45e1e2613b..920ced76435 100644 --- a/extensions/media-preview/package.nls.json +++ b/extensions/media-preview/package.nls.json @@ -8,5 +8,7 @@ "videoPreviewerLoop": "Loop videos over again automatically.", "command.zoomIn": "Zoom in", "command.zoomOut": "Zoom out", - "command.copyImage": "Copy" + "command.copyImage": "Copy", + "command.reopenAsPreview": "Reopen as image preview", + "command.reopenAsText": "Reopen as source text" } diff --git a/extensions/media-preview/src/audioPreview.ts b/extensions/media-preview/src/audioPreview.ts index e21a4189d7b..5058f7e978e 100644 --- a/extensions/media-preview/src/audioPreview.ts +++ b/extensions/media-preview/src/audioPreview.ts @@ -54,12 +54,12 @@ class AudioPreview extends MediaPreview { protected async getWebviewContents(): Promise { const version = Date.now().toString(); const settings = { - src: await this.getResourcePath(this.webviewEditor, this.resource, version), + src: await this.getResourcePath(this._webviewEditor, this._resource, version), }; const nonce = getNonce(); - const cspSource = this.webviewEditor.webview.cspSource; + const cspSource = this._webviewEditor.webview.cspSource; return /* html */` @@ -104,7 +104,7 @@ class AudioPreview extends MediaPreview { } private extensionResource(...parts: string[]) { - return this.webviewEditor.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionRoot, ...parts)); + return this._webviewEditor.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionRoot, ...parts)); } } diff --git a/extensions/media-preview/src/imagePreview/index.ts b/extensions/media-preview/src/imagePreview/index.ts index e0c605c2a6e..b405cd652c4 100644 --- a/extensions/media-preview/src/imagePreview/index.ts +++ b/extensions/media-preview/src/imagePreview/index.ts @@ -11,7 +11,7 @@ import { SizeStatusBarEntry } from './sizeStatusBarEntry'; import { Scale, ZoomStatusBarEntry } from './zoomStatusBarEntry'; -export class PreviewManager implements vscode.CustomReadonlyEditorProvider { +export class ImagePreviewManager implements vscode.CustomReadonlyEditorProvider { public static readonly viewType = 'imagePreview.previewEditor'; @@ -48,7 +48,20 @@ export class PreviewManager implements vscode.CustomReadonlyEditorProvider { }); } - public get activePreview() { return this._activePreview; } + public get activePreview() { + return this._activePreview; + } + + public getPreviewFor(resource: vscode.Uri, viewColumn?: vscode.ViewColumn): ImagePreview | undefined { + for (const preview of this._previews) { + if (preview.resource.toString() === resource.toString()) { + if (!viewColumn || preview.viewColumn === viewColumn) { + return preview; + } + } + } + return undefined; + } private setActivePreview(value: ImagePreview | undefined): void { this._activePreview = value; @@ -94,12 +107,12 @@ class ImagePreview extends MediaPreview { this._register(zoomStatusBarEntry.onDidChangeScale(e => { if (this.previewState === PreviewState.Active) { - this.webviewEditor.webview.postMessage({ type: 'setScale', scale: e.scale }); + this._webviewEditor.webview.postMessage({ type: 'setScale', scale: e.scale }); } })); this._register(webviewEditor.onDidChangeViewState(() => { - this.webviewEditor.webview.postMessage({ type: 'setActive', value: this.webviewEditor.active }); + this._webviewEditor.webview.postMessage({ type: 'setActive', value: this._webviewEditor.active }); })); this._register(webviewEditor.onDidDispose(() => { @@ -121,22 +134,26 @@ class ImagePreview extends MediaPreview { this.zoomStatusBarEntry.hide(this); } + public get viewColumn() { + return this._webviewEditor.viewColumn; + } + public zoomIn() { if (this.previewState === PreviewState.Active) { - this.webviewEditor.webview.postMessage({ type: 'zoomIn' }); + this._webviewEditor.webview.postMessage({ type: 'zoomIn' }); } } public zoomOut() { if (this.previewState === PreviewState.Active) { - this.webviewEditor.webview.postMessage({ type: 'zoomOut' }); + this._webviewEditor.webview.postMessage({ type: 'zoomOut' }); } } public copyImage() { if (this.previewState === PreviewState.Active) { - this.webviewEditor.reveal(); - this.webviewEditor.webview.postMessage({ type: 'copyImage' }); + this._webviewEditor.reveal(); + this._webviewEditor.webview.postMessage({ type: 'copyImage' }); } } @@ -147,7 +164,7 @@ class ImagePreview extends MediaPreview { return; } - if (this.webviewEditor.active) { + if (this._webviewEditor.active) { this.sizeStatusBarEntry.show(this, this._imageSize || ''); this.zoomStatusBarEntry.show(this, this._imageZoom || 'fit'); } else { @@ -155,20 +172,21 @@ class ImagePreview extends MediaPreview { this.zoomStatusBarEntry.hide(this); } } + protected override async render(): Promise { await super.render(); - this.webviewEditor.webview.postMessage({ type: 'setActive', value: this.webviewEditor.active }); + this._webviewEditor.webview.postMessage({ type: 'setActive', value: this._webviewEditor.active }); } protected override async getWebviewContents(): Promise { const version = Date.now().toString(); const settings = { - src: await this.getResourcePath(this.webviewEditor, this.resource, version), + src: await this.getResourcePath(this._webviewEditor, this._resource, version), }; const nonce = getNonce(); - const cspSource = this.webviewEditor.webview.cspSource; + const cspSource = this._webviewEditor.webview.cspSource; return /* html */` @@ -212,7 +230,12 @@ class ImagePreview extends MediaPreview { } private extensionResource(...parts: string[]) { - return this.webviewEditor.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionRoot, ...parts)); + return this._webviewEditor.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionRoot, ...parts)); + } + + public async reopenAsText() { + await vscode.commands.executeCommand('reopenActiveEditorWith', 'default'); + this._webviewEditor.dispose(); } } @@ -226,9 +249,9 @@ export function registerImagePreviewSupport(context: vscode.ExtensionContext, bi const zoomStatusBarEntry = new ZoomStatusBarEntry(); disposables.push(zoomStatusBarEntry); - const previewManager = new PreviewManager(context.extensionUri, sizeStatusBarEntry, binarySizeStatusBarEntry, zoomStatusBarEntry); + const previewManager = new ImagePreviewManager(context.extensionUri, sizeStatusBarEntry, binarySizeStatusBarEntry, zoomStatusBarEntry); - disposables.push(vscode.window.registerCustomEditorProvider(PreviewManager.viewType, previewManager, { + disposables.push(vscode.window.registerCustomEditorProvider(ImagePreviewManager.viewType, previewManager, { supportsMultipleEditorsPerDocument: true, })); @@ -244,5 +267,14 @@ export function registerImagePreviewSupport(context: vscode.ExtensionContext, bi previewManager.activePreview?.copyImage(); })); + disposables.push(vscode.commands.registerCommand('imagePreview.reopenAsText', async () => { + return previewManager.activePreview?.reopenAsText(); + })); + + disposables.push(vscode.commands.registerCommand('imagePreview.reopenAsPreview', async () => { + + await vscode.commands.executeCommand('reopenActiveEditorWith', ImagePreviewManager.viewType); + })); + return vscode.Disposable.from(...disposables); } diff --git a/extensions/media-preview/src/mediaPreview.ts b/extensions/media-preview/src/mediaPreview.ts index 26d1e25dbaa..ccf83166e29 100644 --- a/extensions/media-preview/src/mediaPreview.ts +++ b/extensions/media-preview/src/mediaPreview.ts @@ -8,8 +8,8 @@ import { Utils } from 'vscode-uri'; import { BinarySizeStatusBarEntry } from './binarySizeStatusBarEntry'; import { Disposable } from './util/dispose'; -export function reopenAsText(resource: vscode.Uri, viewColumn: vscode.ViewColumn | undefined) { - vscode.commands.executeCommand('vscode.openWith', resource, 'default', viewColumn); +export async function reopenAsText(resource: vscode.Uri, viewColumn: vscode.ViewColumn | undefined): Promise { + await vscode.commands.executeCommand('vscode.openWith', resource, 'default', viewColumn); } export const enum PreviewState { @@ -25,52 +25,56 @@ export abstract class MediaPreview extends Disposable { constructor( extensionRoot: vscode.Uri, - protected readonly resource: vscode.Uri, - protected readonly webviewEditor: vscode.WebviewPanel, - private readonly binarySizeStatusBarEntry: BinarySizeStatusBarEntry, + protected readonly _resource: vscode.Uri, + protected readonly _webviewEditor: vscode.WebviewPanel, + private readonly _binarySizeStatusBarEntry: BinarySizeStatusBarEntry, ) { super(); - webviewEditor.webview.options = { + _webviewEditor.webview.options = { enableScripts: true, enableForms: false, localResourceRoots: [ - Utils.dirname(resource), + Utils.dirname(_resource), extensionRoot, ] }; - this._register(webviewEditor.onDidChangeViewState(() => { + this._register(_webviewEditor.onDidChangeViewState(() => { this.updateState(); })); - this._register(webviewEditor.onDidDispose(() => { + this._register(_webviewEditor.onDidDispose(() => { this.previewState = PreviewState.Disposed; this.dispose(); })); - const watcher = this._register(vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(resource, '*'))); + const watcher = this._register(vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(_resource, '*'))); this._register(watcher.onDidChange(e => { - if (e.toString() === this.resource.toString()) { + if (e.toString() === this._resource.toString()) { this.updateBinarySize(); this.render(); } })); this._register(watcher.onDidDelete(e => { - if (e.toString() === this.resource.toString()) { - this.webviewEditor.dispose(); + if (e.toString() === this._resource.toString()) { + this._webviewEditor.dispose(); } })); } public override dispose() { super.dispose(); - this.binarySizeStatusBarEntry.hide(this); + this._binarySizeStatusBarEntry.hide(this); + } + + public get resource() { + return this._resource; } protected updateBinarySize() { - vscode.workspace.fs.stat(this.resource).then(({ size }) => { + vscode.workspace.fs.stat(this._resource).then(({ size }) => { this._binarySize = size; this.updateState(); }); @@ -86,7 +90,7 @@ export abstract class MediaPreview extends Disposable { return; } - this.webviewEditor.webview.html = content; + this._webviewEditor.webview.html = content; } protected abstract getWebviewContents(): Promise; @@ -96,11 +100,11 @@ export abstract class MediaPreview extends Disposable { return; } - if (this.webviewEditor.active) { + if (this._webviewEditor.active) { this.previewState = PreviewState.Active; - this.binarySizeStatusBarEntry.show(this, this._binarySize); + this._binarySizeStatusBarEntry.show(this, this._binarySize); } else { - this.binarySizeStatusBarEntry.hide(this); + this._binarySizeStatusBarEntry.hide(this); this.previewState = PreviewState.Visible; } } diff --git a/extensions/media-preview/src/videoPreview.ts b/extensions/media-preview/src/videoPreview.ts index efc6be76a4f..67012128cf7 100644 --- a/extensions/media-preview/src/videoPreview.ts +++ b/extensions/media-preview/src/videoPreview.ts @@ -56,14 +56,14 @@ class VideoPreview extends MediaPreview { const version = Date.now().toString(); const configurations = vscode.workspace.getConfiguration('mediaPreview.video'); const settings = { - src: await this.getResourcePath(this.webviewEditor, this.resource, version), + src: await this.getResourcePath(this._webviewEditor, this._resource, version), autoplay: configurations.get('autoPlay'), loop: configurations.get('loop'), }; const nonce = getNonce(); - const cspSource = this.webviewEditor.webview.cspSource; + const cspSource = this._webviewEditor.webview.cspSource; return /* html */` @@ -108,7 +108,7 @@ class VideoPreview extends MediaPreview { } private extensionResource(...parts: string[]) { - return this.webviewEditor.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionRoot, ...parts)); + return this._webviewEditor.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionRoot, ...parts)); } } diff --git a/extensions/merge-conflict/package-lock.json b/extensions/merge-conflict/package-lock.json index 5ee68d290f0..94caad8f57e 100644 --- a/extensions/merge-conflict/package-lock.json +++ b/extensions/merge-conflict/package-lock.json @@ -143,12 +143,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@vscode/extension-telemetry": { @@ -166,10 +167,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" } } } diff --git a/extensions/microsoft-authentication/package-lock.json b/extensions/microsoft-authentication/package-lock.json index 4aae40c5f17..c500a8c3d3e 100644 --- a/extensions/microsoft-authentication/package-lock.json +++ b/extensions/microsoft-authentication/package-lock.json @@ -71,9 +71,9 @@ } }, "node_modules/@azure/msal-node-runtime": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@azure/msal-node-runtime/-/msal-node-runtime-0.17.1.tgz", - "integrity": "sha512-qAfTg+iGJsg+XvD9nmknI63+XuoX32oT+SX4wJdFz7CS6ETVpSHoroHVaUmsTU1H7H0+q1/ZkP988gzPRMYRsg==", + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node-runtime/-/msal-node-runtime-0.18.2.tgz", + "integrity": "sha512-v45fyBQp80BrjZAeGJXl+qggHcbylQiFBihr0ijO2eniDCW9tz5TZBKYsqzH06VuiRaVG/Sa0Hcn4pjhJqFSTw==", "hasInstallScript": true, "license": "MIT" }, @@ -202,12 +202,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/node-fetch": { @@ -456,10 +457,11 @@ "integrity": "sha512-V+uqV66BOQnWxvI6HjDnE4VkInmYZUQ4dgB7gzaDyFyFSK1i1nF/j7DpS9UbQAgV9NaF1XpcyuavnM1qOeiEIg==" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/uuid": { "version": "8.3.2", diff --git a/extensions/microsoft-authentication/package.json b/extensions/microsoft-authentication/package.json index 928ffa68889..1356583844e 100644 --- a/extensions/microsoft-authentication/package.json +++ b/extensions/microsoft-authentication/package.json @@ -145,6 +145,9 @@ "keytar": "file:./packageMocks/keytar", "vscode-tas-client": "^0.1.84" }, + "overrides": { + "@azure/msal-node-runtime": "^0.18.2" + }, "repository": { "type": "git", "url": "https://github.com/microsoft/vscode.git" diff --git a/extensions/microsoft-authentication/src/AADHelper.ts b/extensions/microsoft-authentication/src/AADHelper.ts index 9722145dd03..ad8dabe7533 100644 --- a/extensions/microsoft-authentication/src/AADHelper.ts +++ b/extensions/microsoft-authentication/src/AADHelper.ts @@ -551,7 +551,7 @@ export class AzureActiveDirectoryService { throw e; } - const id = `${claims.tid}/${(claims.oid ?? (claims.altsecid ?? '' + claims.ipd ?? ''))}`; + const id = `${claims.tid}/${(claims.oid ?? (claims.altsecid ?? '' + claims.ipd))}`; const sessionId = existingId || `${id}/${randomUUID()}`; this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Token response parsed successfully.`); return { diff --git a/extensions/microsoft-authentication/src/common/loggerOptions.ts b/extensions/microsoft-authentication/src/common/loggerOptions.ts index d572f655f92..af5c1644a27 100644 --- a/extensions/microsoft-authentication/src/common/loggerOptions.ts +++ b/extensions/microsoft-authentication/src/common/loggerOptions.ts @@ -5,11 +5,15 @@ import { LogLevel as MsalLogLevel } from '@azure/msal-node'; import { env, LogLevel, LogOutputChannel } from 'vscode'; +import { MicrosoftAuthenticationTelemetryReporter } from './telemetryReporter'; export class MsalLoggerOptions { piiLoggingEnabled = false; - constructor(private readonly _output: LogOutputChannel) { } + constructor( + private readonly _output: LogOutputChannel, + private readonly _telemtryReporter: MicrosoftAuthenticationTelemetryReporter + ) { } get logLevel(): MsalLogLevel { return this._toMsalLogLevel(env.logLevel); @@ -27,6 +31,7 @@ export class MsalLoggerOptions { switch (level) { case MsalLogLevel.Error: this._output.error(message); + this._telemtryReporter.sendTelemetryErrorEvent(message); return; case MsalLogLevel.Warning: this._output.warn(message); diff --git a/extensions/microsoft-authentication/src/common/telemetryReporter.ts b/extensions/microsoft-authentication/src/common/telemetryReporter.ts index 25ac2623282..c28aa887e0c 100644 --- a/extensions/microsoft-authentication/src/common/telemetryReporter.ts +++ b/extensions/microsoft-authentication/src/common/telemetryReporter.ts @@ -66,6 +66,24 @@ export class MicrosoftAuthenticationTelemetryReporter implements IExperimentatio */ this._telemetryReporter.sendTelemetryEvent('logoutFailed'); } + + sendTelemetryErrorEvent(error: unknown): void { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + const errorName = error instanceof Error ? error.name : undefined; + + /* __GDPR__ + "msalError" : { + "owner": "TylerLeonhardt", + "comment": "Used to determine how often users run into issues with the login flow.", + "errorMessage": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The error message from the exception." }, + "errorStack": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The stack trace from the exception." }, + "errorName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The name of the error." } + } + */ + this._telemetryReporter.sendTelemetryErrorEvent('msalError', { errorMessage, errorStack, errorName }); + } + /** * Sends an event for an account type available at startup. * @param scopes The scopes for the session diff --git a/extensions/microsoft-authentication/src/node/authProvider.ts b/extensions/microsoft-authentication/src/node/authProvider.ts index a26008fb780..5ce9acd3e6a 100644 --- a/extensions/microsoft-authentication/src/node/authProvider.ts +++ b/extensions/microsoft-authentication/src/node/authProvider.ts @@ -86,7 +86,7 @@ export class MsalAuthProvider implements AuthenticationProvider { uriHandler: UriEventHandler, env: Environment = Environment.AzureCloud ): Promise { - const publicClientManager = await CachedPublicClientApplicationManager.create(context.secrets, logger, env.name); + const publicClientManager = await CachedPublicClientApplicationManager.create(context.secrets, logger, telemetryReporter, env.name); context.subscriptions.push(publicClientManager); const authProvider = new MsalAuthProvider(context, telemetryReporter, logger, uriHandler, publicClientManager, env); await authProvider.initialize(); @@ -354,6 +354,7 @@ export class MsalAuthProvider implements AuthenticationProvider { } catch (e) { // If we can't get a token silently, the account is probably in a bad state so we should skip it // MSAL will log this already, so we don't need to log it again + this._telemetryReporter.sendTelemetryErrorEvent(e); continue; } } @@ -368,7 +369,7 @@ export class MsalAuthProvider implements AuthenticationProvider { id: result.account?.homeAccountId ?? result.uniqueId, account: { id: result.account?.homeAccountId ?? result.uniqueId, - label: result.account?.username ?? 'Unknown', + label: result.account?.username.toLowerCase() ?? 'Unknown', }, scopes }; @@ -381,7 +382,7 @@ export class MsalAuthProvider implements AuthenticationProvider { scopes: [], account: { id: account.homeAccountId, - label: account.username + label: account.username.toLowerCase(), }, idToken: account.idToken, }; diff --git a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts index f0f4eb7b9bc..c1b4fbac4c1 100644 --- a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts +++ b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts @@ -11,6 +11,7 @@ import { SecretStorageCachePlugin } from '../common/cachePlugin'; import { MsalLoggerOptions } from '../common/loggerOptions'; import { ICachedPublicClientApplication } from '../common/publicClientCache'; import { IAccountAccess } from '../common/accountAccess'; +import { MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter'; export class CachedPublicClientApplication implements ICachedPublicClientApplication { // Core properties @@ -44,8 +45,9 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica private readonly _secretStorage: SecretStorage, private readonly _accountAccess: IAccountAccess, private readonly _logger: LogOutputChannel, + telemetryReporter: MicrosoftAuthenticationTelemetryReporter ) { - const loggerOptions = new MsalLoggerOptions(_logger); + const loggerOptions = new MsalLoggerOptions(_logger, telemetryReporter); const nativeBrokerPlugin = new NativeBrokerPlugin(); this._isBrokerAvailable = nativeBrokerPlugin.isBrokerAvailable; this._pca = new PublicClientApplication({ @@ -75,9 +77,10 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica clientId: string, secretStorage: SecretStorage, accountAccess: IAccountAccess, - logger: LogOutputChannel + logger: LogOutputChannel, + telemetryReporter: MicrosoftAuthenticationTelemetryReporter ): Promise { - const app = new CachedPublicClientApplication(clientId, secretStorage, accountAccess, logger); + const app = new CachedPublicClientApplication(clientId, secretStorage, accountAccess, logger, telemetryReporter); await app.initialize(); return app; } diff --git a/extensions/microsoft-authentication/src/node/publicClientCache.ts b/extensions/microsoft-authentication/src/node/publicClientCache.ts index 16ccb80321f..777c3c5f272 100644 --- a/extensions/microsoft-authentication/src/node/publicClientCache.ts +++ b/extensions/microsoft-authentication/src/node/publicClientCache.ts @@ -8,6 +8,7 @@ import { SecretStorage, LogOutputChannel, Disposable, EventEmitter, Memento, Eve import { ICachedPublicClientApplication, ICachedPublicClientApplicationManager } from '../common/publicClientCache'; import { CachedPublicClientApplication } from './cachedPublicClientApplication'; import { IAccountAccess, ScopedAccountAccess } from '../common/accountAccess'; +import { MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter'; export interface IPublicClientApplicationInfo { clientId: string; @@ -29,6 +30,7 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient private readonly _accountAccess: IAccountAccess, private readonly _secretStorage: SecretStorage, private readonly _logger: LogOutputChannel, + private readonly _telemetryReporter: MicrosoftAuthenticationTelemetryReporter, disposables: Disposable[] ) { this._disposable = Disposable.from( @@ -41,13 +43,14 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient static async create( secretStorage: SecretStorage, logger: LogOutputChannel, + telemetryReporter: MicrosoftAuthenticationTelemetryReporter, cloudName: string ): Promise { const pcasSecretStorage = await PublicClientApplicationsSecretStorage.create(secretStorage, cloudName); // TODO: Remove the migrations in a version const migrations = await pcasSecretStorage.getOldValue(); const accountAccess = await ScopedAccountAccess.create(secretStorage, cloudName, logger, migrations); - const manager = new CachedPublicClientApplicationManager(pcasSecretStorage, accountAccess, secretStorage, logger, [pcasSecretStorage, accountAccess]); + const manager = new CachedPublicClientApplicationManager(pcasSecretStorage, accountAccess, secretStorage, logger, telemetryReporter, [pcasSecretStorage, accountAccess]); await manager.initialize(); return manager; } @@ -138,7 +141,7 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient } private async _doCreatePublicClientApplication(clientId: string): Promise { - const pca = await CachedPublicClientApplication.create(clientId, this._secretStorage, this._accountAccess, this._logger); + const pca = await CachedPublicClientApplication.create(clientId, this._secretStorage, this._accountAccess, this._logger, this._telemetryReporter); this._pcas.set(clientId, pca); const disposable = Disposable.from( pca, diff --git a/extensions/notebook-renderers/src/stackTraceHelper.ts b/extensions/notebook-renderers/src/stackTraceHelper.ts index ecf0eddb40e..9570ab8762d 100644 --- a/extensions/notebook-renderers/src/stackTraceHelper.ts +++ b/extensions/notebook-renderers/src/stackTraceHelper.ts @@ -11,6 +11,7 @@ export function formatStackTrace(stack: string): { formattedStack: string; error // Remove background colors. The ones from IPython don't work well with // themes 40-49 sets background color cleaned = stack.replace(/\u001b\[4\dm/g, ''); + cleaned = cleaned.replace(/(?<=\u001b\[[\d;]*?);4\d(?=m)/g, ''); // Also remove specific foreground colors (38 is the ascii code for picking one) (they don't translate either) // Turn them into default foreground diff --git a/extensions/notebook-renderers/src/test/stackTraceHelper.test.ts b/extensions/notebook-renderers/src/test/stackTraceHelper.test.ts index 54ec15b428c..faae56894f8 100644 --- a/extensions/notebook-renderers/src/test/stackTraceHelper.test.ts +++ b/extensions/notebook-renderers/src/test/stackTraceHelper.test.ts @@ -105,4 +105,13 @@ suite('StackTraceHelper', () => { formattedLines.slice(1).forEach(line => assert.ok(!//.test(line), 'line should not contain a link: ' + line)); }); + test('background (40-49) ANSI colors are removed', () => { + const stack = + 'open\u001b[39;49m\u001b[43m(\u001b[49m\u001b[33;43m\'\u001b[39;49m\u001b[33;43minput.txt\u001b[39;49m\u001b[33;43m\'\u001b[39;49m\u001b[43m)\u001b[49m;'; + + const formattedLines = formatStackTrace(stack).formattedStack.split('\n'); + assert.ok(!/4\d/.test(formattedLines[0]), 'should not contain background colors ' + formattedLines[0]); + formattedLines.slice(1).forEach(line => assert.ok(!//.test(line), 'line should not contain a link: ' + line)); + }); + }); diff --git a/extensions/package-lock.json b/extensions/package-lock.json index e3e87e1f52d..6694419fa1b 100644 --- a/extensions/package-lock.json +++ b/extensions/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "typescript": "^5.8.2" + "typescript": "^5.8.3" }, "devDependencies": { "@parcel/watcher": "2.5.1", @@ -940,9 +940,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/extensions/package.json b/extensions/package.json index d24575b17f5..eb206022d50 100644 --- a/extensions/package.json +++ b/extensions/package.json @@ -4,7 +4,7 @@ "license": "MIT", "description": "Dependencies shared by all extensions", "dependencies": { - "typescript": "^5.8.2" + "typescript": "^5.8.3" }, "scripts": { "postinstall": "node ./postinstall.mjs" diff --git a/extensions/php-language-features/src/features/phpGlobalFunctions.ts b/extensions/php-language-features/src/features/phpGlobalFunctions.ts index ab1c5487ae8..e8f10a29db5 100644 --- a/extensions/php-language-features/src/features/phpGlobalFunctions.ts +++ b/extensions/php-language-features/src/features/phpGlobalFunctions.ts @@ -1664,31 +1664,35 @@ export const globalfunctions: IEntries = { }, fclose: { description: 'Closes an open file pointer', - signature: '( resource $handle ): bool' + signature: '( resource $stream ): bool' + }, + fdatasync: { + description: 'Synchronizes data (but not meta-data) to the file', + signature: '( resource $stream ): bool' }, feof: { description: 'Tests for end-of-file on a file pointer', - signature: '( resource $handle ): bool' + signature: '( resource $stream ): bool' }, fflush: { description: 'Flushes the output to a file', - signature: '( resource $handle ): bool' + signature: '( resource $stream ): bool' }, fgetc: { description: 'Gets character from file pointer', - signature: '( resource $handle ): string' + signature: '( resource $string ): string|false' }, fgetcsv: { description: 'Gets line from file pointer and parse for CSV fields', - signature: '( resource $handle [, int $length = 0 [, string $delimiter = "," [, string $enclosure = \'"\' [, string $escape = "\\" ]]]]): array' + signature: '( resource $stream [, ?int $length = null [, string $separator = "," [, string $enclosure = \'"\' [, string $escape = "\\" ]]]]): array|false' }, fgets: { description: 'Gets line from file pointer', - signature: '( resource $handle [, int $length ]): string' + signature: '( resource $stream [, ?int $length = null ]): string|false' }, fgetss: { description: 'Gets line from file pointer and strip HTML tags', - signature: '( resource $handle [, int $length [, string $allowable_tags ]]): string' + signature: '( resource $handle [, int $length = ? [, string $allowable_tags = ? ]]): string' }, file_exists: { description: 'Checks whether a file or directory exists', @@ -1696,102 +1700,106 @@ export const globalfunctions: IEntries = { }, file_get_contents: { description: 'Reads entire file into a string', - signature: '( string $filename [, bool $use_include_path [, resource $context [, int $offset = 0 [, int $maxlen ]]]]): string' + signature: '( string $filename [, bool $use_include_path = false [, ?resource $context = null [, int $offset = 0 [, ?int $maxlen = null ]]]]): string|false' }, file_put_contents: { description: 'Write data to a file', - signature: '( string $filename , mixed $data [, int $flags = 0 [, resource $context ]]): int' + signature: '( string $filename , mixed $data [, int $flags = 0 [, ?resource $context = null ]]): int|false' }, file: { description: 'Reads entire file into an array', - signature: '( string $filename [, int $flags = 0 [, resource $context ]]): array' + signature: '( string $filename [, int $flags = 0 [, ?resource $context = null ]]): array|false' }, fileatime: { description: 'Gets last access time of file', - signature: '( string $filename ): int' + signature: '( string $filename ): int|false' }, filectime: { description: 'Gets inode change time of file', - signature: '( string $filename ): int' + signature: '( string $filename ): int|false' }, filegroup: { description: 'Gets file group', - signature: '( string $filename ): int' + signature: '( string $filename ): int|false' }, fileinode: { description: 'Gets file inode', - signature: '( string $filename ): int' + signature: '( string $filename ): int|false' }, filemtime: { description: 'Gets file modification time', - signature: '( string $filename ): int' + signature: '( string $filename ): int|false' }, fileowner: { description: 'Gets file owner', - signature: '( string $filename ): int' + signature: '( string $filename ): int|false' }, fileperms: { description: 'Gets file permissions', - signature: '( string $filename ): int' + signature: '( string $filename ): int|false' }, filesize: { description: 'Gets file size', - signature: '( string $filename ): int' + signature: '( string $filename ): int|false' }, filetype: { description: 'Gets file type', - signature: '( string $filename ): string' + signature: '( string $filename ): string|false' }, flock: { description: 'Portable advisory file locking', - signature: '( resource $handle , int $operation [, int $wouldblock ]): bool' + signature: '( resource $stream , int $operation [, int &$would_block = null ]): bool' }, fnmatch: { description: 'Match filename against a pattern', - signature: '( string $pattern , string $string [, int $flags = 0 ]): bool' + signature: '( string $pattern , string $filename [, int $flags = 0 ]): bool' }, fopen: { description: 'Opens file or URL', - signature: '( string $filename , string $mode [, bool $use_include_path [, resource $context ]]): resource' + signature: '( string $filename , string $mode [, bool $use_include_path = false [, ?resource $context = null ]]): resource|false' }, fpassthru: { description: 'Output all remaining data on a file pointer', - signature: '( resource $handle ): int' + signature: '( resource $stream ): int' }, fputcsv: { description: 'Format line as CSV and write to file pointer', - signature: '( resource $handle , array $fields [, string $delimiter = "," [, string $enclosure = \'"\' [, string $escape_char = "\\" ]]]): int' + signature: '( resource $stream , array $fields [, string $separator = "," [, string $enclosure = \'"\' [, string $escape = "\\" [, string $eol = "\n" ]]]]): int|false' }, fputs: { description: 'Alias of fwrite', }, fread: { description: 'Binary-safe file read', - signature: '( resource $handle , int $length ): string' + signature: '( resource $stream , int $length ): string|false' }, fscanf: { description: 'Parses input from a file according to a format', - signature: '( resource $handle , string $format [, mixed $... ]): mixed' + signature: '( resource $stream , string $format [, mixed &...$vars ]): array|int|false|null' }, fseek: { description: 'Seeks on a file pointer', - signature: '( resource $handle , int $offset [, int $whence = SEEK_SET ]): int' + signature: '( resource $stream , int $offset [, int $whence = SEEK_SET ]): int' }, fstat: { description: 'Gets information about a file using an open file pointer', - signature: '( resource $handle ): array' + signature: '( resource $stream ): array|false' + }, + fsync: { + description: 'Synchronizes changes to the file (including meta-data)', + signature: '( resource $stream ): bool' }, ftell: { description: 'Returns the current position of the file read/write pointer', - signature: '( resource $handle ): int' + signature: '( resource $stream ): int|false' }, ftruncate: { description: 'Truncates a file to a given length', - signature: '( resource $handle , int $size ): bool' + signature: '( resource $stream , int $size ): bool' }, fwrite: { description: 'Binary-safe file write', - signature: '( resource $handle , string $string [, int $length ]): int' + signature: '( resource $stream , string $data [, ?int $length = null ]): int|false' }, glob: { description: 'Find pathnames matching a pattern', diff --git a/extensions/php/cgmanifest.json b/extensions/php/cgmanifest.json index 02faac2b0c0..7dd44bc830d 100644 --- a/extensions/php/cgmanifest.json +++ b/extensions/php/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "language-php", "repositoryUrl": "https://github.com/KapitanOczywisty/language-php", - "commitHash": "5e8f000cb5a20f44f7a7a89d07ad0774031c53f3" + "commitHash": "26cf1ebee89d4b55bf5823eb47eaa6a6dfda9336" } }, "license": "MIT", diff --git a/extensions/php/syntaxes/php.tmLanguage.json b/extensions/php/syntaxes/php.tmLanguage.json index 96821c6770c..63900f4c23a 100644 --- a/extensions/php/syntaxes/php.tmLanguage.json +++ b/extensions/php/syntaxes/php.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/KapitanOczywisty/language-php/commit/5e8f000cb5a20f44f7a7a89d07ad0774031c53f3", + "version": "https://github.com/KapitanOczywisty/language-php/commit/26cf1ebee89d4b55bf5823eb47eaa6a6dfda9336", "scopeName": "source.php", "patterns": [ { @@ -2402,23 +2402,68 @@ ] }, "instantiation": { - "begin": "(?i)(new)\\s+(?!class\\b)", - "beginCaptures": { - "1": { - "name": "keyword.other.new.php" - } - }, - "end": "(?i)(?=[^a-z0-9_\\x{7f}-\\x{10ffff}\\\\])", "patterns": [ { - "match": "(?i)(parent|static|self)(?![a-z0-9_\\x{7f}-\\x{10ffff}])", - "name": "storage.type.php" + "match": "(?i)(new)\\s+(?!class\\b)([$a-z0-9_\\x{7f}-\\x{10ffff}\\\\]+)(?![a-z0-9_\\x{7f}-\\x{10ffff}\\\\(])", + "captures": { + "1": { + "name": "keyword.other.new.php" + }, + "2": { + "patterns": [ + { + "match": "(?i)(parent|static|self)(?![a-z0-9_\\x{7f}-\\x{10ffff}])", + "name": "storage.type.php" + }, + { + "include": "#class-name" + }, + { + "include": "#variable-name" + } + ] + } + } }, { - "include": "#class-name" - }, - { - "include": "#variable-name" + "begin": "(?i)(new)\\s+(?!class\\b)([$a-z0-9_\\x{7f}-\\x{10ffff}\\\\]+)\\s*(\\()", + "beginCaptures": { + "1": { + "name": "keyword.other.new.php" + }, + "2": { + "patterns": [ + { + "match": "(?i)(parent|static|self)(?![a-z0-9_\\x{7f}-\\x{10ffff}])", + "name": "storage.type.php" + }, + { + "include": "#class-name" + }, + { + "include": "#variable-name" + } + ] + }, + "3": { + "name": "punctuation.definition.arguments.begin.bracket.round.php" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.arguments.end.bracket.round.php" + } + }, + "contentName": "meta.function-call.php", + "patterns": [ + { + "include": "#named-arguments" + }, + { + "include": "$self" + } + ] } ] }, @@ -2784,6 +2829,10 @@ }, { "include": "#php_doc_types" + }, + { + "match": "[|&]", + "name": "punctuation.separator.delimiter.php" } ] }, @@ -2803,7 +2852,7 @@ ] }, "php_doc_types": { - "match": "(?i)\\??[a-z_\\x{7f}-\\x{10ffff}\\\\][a-z0-9_\\x{7f}-\\x{10ffff}\\\\]*([|&]\\??[a-z_\\x{7f}-\\x{10ffff}\\\\][a-z0-9_\\x{7f}-\\x{10ffff}\\\\]*)*", + "match": "(?i)\\??[a-z0-9_\\x{7f}-\\x{10ffff}\\\\]+([|&]\\??[a-z0-9_\\x{7f}-\\x{10ffff}\\\\]+)*", "captures": { "0": { "patterns": [ @@ -2821,14 +2870,6 @@ { "match": "[|&]", "name": "punctuation.separator.delimiter.php" - }, - { - "match": "\\(", - "name": "punctuation.definition.type.begin.bracket.round.php" - }, - { - "match": "\\)", - "name": "punctuation.definition.type.end.bracket.round.php" } ] } @@ -2841,7 +2882,7 @@ "name": "punctuation.definition.type.begin.bracket.round.phpdoc.php" } }, - "end": "(\\))(\\[\\])|(?=\\*/)", + "end": "(\\))(\\[\\])?|(?=\\*/)", "endCaptures": { "1": { "name": "punctuation.definition.type.end.bracket.round.phpdoc.php" @@ -2867,7 +2908,7 @@ ] }, "php_doc_types_array_single": { - "match": "(?i)([a-z_\\x{7f}-\\x{10ffff}\\\\][a-z0-9_\\x{7f}-\\x{10ffff}\\\\]*)(\\[\\])", + "match": "(?i)([a-z0-9_\\x{7f}-\\x{10ffff}\\\\]+)(\\[\\])", "captures": { "1": { "patterns": [ diff --git a/extensions/prompt-basics/.vscodeignore b/extensions/prompt-basics/.vscodeignore new file mode 100644 index 00000000000..89fb2149dcb --- /dev/null +++ b/extensions/prompt-basics/.vscodeignore @@ -0,0 +1,4 @@ +test/** +src/** +tsconfig.json +cgmanifest.json diff --git a/extensions/prompt-basics/cgmanifest.json b/extensions/prompt-basics/cgmanifest.json new file mode 100644 index 00000000000..0c39c97297b --- /dev/null +++ b/extensions/prompt-basics/cgmanifest.json @@ -0,0 +1,4 @@ +{ + "registrations": [], + "version": 1 +} diff --git a/extensions/prompt-basics/language-configuration.json b/extensions/prompt-basics/language-configuration.json new file mode 100644 index 00000000000..935b1c66250 --- /dev/null +++ b/extensions/prompt-basics/language-configuration.json @@ -0,0 +1,103 @@ +{ + "comments": { + // symbols used for start and end a block comment. Remove this entry if your language does not support block comments + "blockComment": [ + "" + ] + }, + // symbols used as brackets + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ] + ], + "colorizedBracketPairs": [], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "<", + "close": ">", + "notIn": [ + "string" + ] + }, + ], + "surroundingPairs": [ + [ + "(", + ")" + ], + [ + "[", + "]" + ], + [ + "`", + "`" + ], + [ + "_", + "_" + ], + [ + "*", + "*" + ], + [ + "{", + "}" + ], + [ + "'", + "'" + ], + [ + "\"", + "\"" + ], + [ + "<", + ">" + ], + [ + "~", + "~" + ], + [ + "$", + "$" + ] + ], + "folding": { + "offSide": true, + "markers": { + "start": "^\\s*", + "end": "^\\s*" + } + }, + "wordPattern": { + "pattern": "(\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark})(((\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark})|[_])?(\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark}))*", + "flags": "ug" + }, +} diff --git a/extensions/prompt-basics/package.json b/extensions/prompt-basics/package.json new file mode 100644 index 00000000000..7957cdd4d0e --- /dev/null +++ b/extensions/prompt-basics/package.json @@ -0,0 +1,87 @@ +{ + "name": "prompt", + "displayName": "%displayName%", + "description": "%description%", + "version": "1.0.0", + "publisher": "vscode", + "license": "MIT", + "engines": { + "vscode": "^1.20.0" + }, + "categories": ["Programming Languages"], + "contributes": { + "languages": [ + { + "id": "prompt", + "aliases": [ + "Prompt", + "prompt" + ], + "extensions": [ + ".prompt.md", + "copilot-instructions.md" + ], + "configuration": "./language-configuration.json" + }, + { + "id": "instructions", + "aliases": [ + "Instructions", + "instructions" + ], + "extensions": [ + ".instructions.md", + "copilot-instructions.md" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "prompt", + "path": "./syntaxes/prompt.tmLanguage.json", + "scopeName": "text.html.markdown.prompt", + "unbalancedBracketScopes": [ + "markup.underline.link.markdown", + "punctuation.definition.list.begin.markdown" + ] + }, + { + "language": "instructions", + "path": "./syntaxes/prompt.tmLanguage.json", + "scopeName": "text.html.markdown.prompt", + "unbalancedBracketScopes": [ + "markup.underline.link.markdown", + "punctuation.definition.list.begin.markdown" + ] + } + ], + "configurationDefaults": { + "[prompt]": { + "editor.unicodeHighlight.ambiguousCharacters": false, + "editor.unicodeHighlight.invisibleCharacters": false, + "diffEditor.ignoreTrimWhitespace": false + }, + "[instructions]": { + "editor.unicodeHighlight.ambiguousCharacters": false, + "editor.unicodeHighlight.invisibleCharacters": false, + "diffEditor.ignoreTrimWhitespace": false + } + }, + "snippets": [ + { + "language": "prompt", + "path": "./snippets/prompt.code-snippets" + }, + { + "language": "instructions", + "path": "./snippets/instructions.code-snippets" + } + ] + }, + "scripts": {}, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/vscode.git" + } +} diff --git a/extensions/prompt-basics/package.nls.json b/extensions/prompt-basics/package.nls.json new file mode 100644 index 00000000000..207593c43c8 --- /dev/null +++ b/extensions/prompt-basics/package.nls.json @@ -0,0 +1,4 @@ +{ + "displayName": "Prompt Language Basics", + "description": "Syntax highlighting for Prompt and Instructions documents." +} diff --git a/extensions/prompt-basics/snippets/instructions.code-snippets b/extensions/prompt-basics/snippets/instructions.code-snippets new file mode 100644 index 00000000000..2f58714740c --- /dev/null +++ b/extensions/prompt-basics/snippets/instructions.code-snippets @@ -0,0 +1,13 @@ +{ + "fileTemplate": { + "prefix": "New Chat Instructions", + "body": [ + "---", + "applyTo: '${1|**,**/*.ts|}'", + "---", + "${2:Instructions here...}", + ], + "description": "Instructions", + "isFileTemplate": true, + } +} diff --git a/extensions/prompt-basics/snippets/prompt.code-snippets b/extensions/prompt-basics/snippets/prompt.code-snippets new file mode 100644 index 00000000000..99115b3dba8 --- /dev/null +++ b/extensions/prompt-basics/snippets/prompt.code-snippets @@ -0,0 +1,13 @@ +{ + "fileTemplate": { + "prefix": "New Chat Prompt", + "body": [ + "---", + "mode: '${1|ask,edit,agent|}'", + "---", + "${2:Prompt description}", + ], + "description": "Template for chat prompt", + "isFileTemplate": true, + } +} diff --git a/extensions/prompt-basics/syntaxes/prompt.tmLanguage.json b/extensions/prompt-basics/syntaxes/prompt.tmLanguage.json new file mode 100644 index 00000000000..314dc26aaed --- /dev/null +++ b/extensions/prompt-basics/syntaxes/prompt.tmLanguage.json @@ -0,0 +1,15 @@ +{ + "information_for_contributors": [ + "This file has been converted from https://github.com/microsoft/vscode-markdown-tm-grammar/blob/master/syntaxes/markdown.tmLanguage", + "If you want to provide a fix or improvement, please create a pull request against the original repository.", + "Once accepted there, we are happy to receive an update request." + ], + "version": "0.1.0", + "name": "Prompt", + "scopeName": "text.html.markdown.prompt", + "patterns": [ + { + "include": "text.html.markdown" + } + ] +} diff --git a/extensions/rust/cgmanifest.json b/extensions/rust/cgmanifest.json index a0eb585e052..73be467648b 100644 --- a/extensions/rust/cgmanifest.json +++ b/extensions/rust/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "rust-syntax", "repositoryUrl": "https://github.com/dustypomerleau/rust-syntax", - "commitHash": "e90d3dbdb61b96e4afdce6f7a3572426b1a86d9d" + "commitHash": "268fd42cfd4aa96a6ed9024a2850d17d6cd2dc7b" } }, "license": "MIT", diff --git a/extensions/rust/syntaxes/rust.tmLanguage.json b/extensions/rust/syntaxes/rust.tmLanguage.json index 16307e72a6a..5f871ae8895 100644 --- a/extensions/rust/syntaxes/rust.tmLanguage.json +++ b/extensions/rust/syntaxes/rust.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/dustypomerleau/rust-syntax/commit/e90d3dbdb61b96e4afdce6f7a3572426b1a86d9d", + "version": "https://github.com/dustypomerleau/rust-syntax/commit/268fd42cfd4aa96a6ed9024a2850d17d6cd2dc7b", "name": "Rust", "scopeName": "source.rust", "patterns": [ @@ -52,7 +52,7 @@ { "comment": "macro type metavariables", "name": "meta.macro.metavariable.type.rust", - "match": "(\\$)((crate)|([A-Z][A-Za-z0-9_]*))((:)(block|expr|ident|item|lifetime|literal|meta|path?|stmt|tt|ty|vis))?", + "match": "(\\$)((crate)|([A-Z]\\w*))(\\s*(:)\\s*(block|expr(?:_2021)?|ident|item|lifetime|literal|meta|pat(?:_param)?|path|stmt|tt|ty|vis)\\b)?", "captures": { "1": { "name": "keyword.operator.macro.dollar.rust" @@ -79,7 +79,7 @@ { "comment": "macro metavariables", "name": "meta.macro.metavariable.rust", - "match": "(\\$)([a-z][A-Za-z0-9_]*)((:)(block|expr|ident|item|lifetime|literal|meta|path?|stmt|tt|ty|vis))?", + "match": "(\\$)([a-z]\\w*)(\\s*(:)\\s*(block|expr(?:_2021)?|ident|item|lifetime|literal|meta|pat(?:_param)?|path|stmt|tt|ty|vis)\\b)?", "captures": { "1": { "name": "keyword.operator.macro.dollar.rust" diff --git a/extensions/simple-browser/preview-src/index.ts b/extensions/simple-browser/preview-src/index.ts index 3d804aa60fa..d2b0b7549e9 100644 --- a/extensions/simple-browser/preview-src/index.ts +++ b/extensions/simple-browser/preview-src/index.ts @@ -95,6 +95,8 @@ onceDocumentLoaded(() => { // Try to bust the cache for the iframe // There does not appear to be any way to reliably do this except modifying the url + const existing = new URLSearchParams(location.search); + url.searchParams.append('id', existing.get('id')!); url.searchParams.append('vscodeBrowserReqId', Date.now().toString()); iframe.src = url.toString(); diff --git a/extensions/terminal-suggest/.vscodeignore b/extensions/terminal-suggest/.vscodeignore index f05a79416be..d9b5dc0447c 100644 --- a/extensions/terminal-suggest/.vscodeignore +++ b/extensions/terminal-suggest/.vscodeignore @@ -5,3 +5,7 @@ tsconfig.json extension.webpack.config.js extension-browser.webpack.config.js package-lock.json +fixtures/** +scripts/** +testWorkspace/** +cgmanifest.json diff --git a/extensions/terminal-suggest/package.json b/extensions/terminal-suggest/package.json index f2e4104823c..5ce429f4184 100644 --- a/extensions/terminal-suggest/package.json +++ b/extensions/terminal-suggest/package.json @@ -15,8 +15,7 @@ ], "enabledApiProposals": [ "terminalCompletionProvider", - "terminalShellEnv", - "terminalShellType" + "terminalShellEnv" ], "scripts": { "compile": "npx gulp compile-extension:terminal-suggest", diff --git a/extensions/terminal-suggest/src/completions/code-tunnel-insiders.ts b/extensions/terminal-suggest/src/completions/code-tunnel-insiders.ts new file mode 100644 index 00000000000..c0a3abc9000 --- /dev/null +++ b/extensions/terminal-suggest/src/completions/code-tunnel-insiders.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { commonOptions, extensionManagementOptions, troubleshootingOptions, globalTunnelOptions, codeTunnelSubcommands, extTunnelSubcommand, codeTunnelOptions } from './code'; +import codeTunnelCompletionSpec from './code-tunnel'; + +const codeTunnelInsidersCompletionSpec: Fig.Spec = { + ...codeTunnelCompletionSpec, + name: 'code-tunnel-insiders', + description: 'Visual Studio Code Insiders', + subcommands: [...codeTunnelSubcommands, extTunnelSubcommand], + options: [ + ...commonOptions, + ...extensionManagementOptions('code-tunnel-insiders'), + ...troubleshootingOptions('code-tunnel-insiders'), + ...globalTunnelOptions, + ...codeTunnelOptions, + ] +}; + +export default codeTunnelInsidersCompletionSpec; diff --git a/extensions/terminal-suggest/src/completions/code-tunnel.ts b/extensions/terminal-suggest/src/completions/code-tunnel.ts new file mode 100644 index 00000000000..6abeabb0db7 --- /dev/null +++ b/extensions/terminal-suggest/src/completions/code-tunnel.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import code, { codeTunnelSubcommands, commonOptions, extensionManagementOptions, troubleshootingOptions, globalTunnelOptions, extTunnelSubcommand, codeTunnelOptions } from './code'; + + +export const codeTunnelSpecOptions: Fig.Option[] = [ + { + name: '--cli-data-dir', + description: 'Directory where CLI metadata should be stored', + isRepeatable: true, + args: { + name: 'cli_data_dir', + isOptional: true, + }, + }, + { + name: '--log-to-file', + description: 'Log to a file in addition to stdout. Used when running as a service', + hidden: true, + isRepeatable: true, + args: { + name: 'log_to_file', + isOptional: true, + template: 'filepaths', + }, + }, + { + name: '--log', + description: 'Log level to use', + isRepeatable: true, + args: { + name: 'log', + isOptional: true, + suggestions: [ + 'trace', + 'debug', + 'info', + 'warn', + 'error', + 'critical', + 'off', + ], + }, + }, + { + name: '--telemetry-level', + description: 'Sets the initial telemetry level', + hidden: true, + isRepeatable: true, + args: { + name: 'telemetry_level', + isOptional: true, + suggestions: [ + 'off', + 'crash', + 'error', + 'all', + ], + }, + }, + { + name: '--verbose', + description: 'Print verbose output (implies --wait)', + }, + { + name: '--disable-telemetry', + description: 'Disable telemetry for the current command, even if it was previously accepted as part of the license prompt or specified in \'--telemetry-level\'', + }, + { + name: ['-h', '--help'], + description: 'Print help', + }, +]; + +const codeTunnelCompletionSpec: Fig.Spec = { + ...code, + name: 'code-tunnel', + subcommands: [ + ...codeTunnelSubcommands, + extTunnelSubcommand + ], + options: [ + ...commonOptions, + ...extensionManagementOptions('code-tunnel'), + ...troubleshootingOptions('code-tunnel'), + ...globalTunnelOptions, + ...codeTunnelOptions, + ] +}; + +export default codeTunnelCompletionSpec; diff --git a/extensions/terminal-suggest/src/completions/code.ts b/extensions/terminal-suggest/src/completions/code.ts index 919253c387f..a6dfd47738e 100644 --- a/extensions/terminal-suggest/src/completions/code.ts +++ b/extensions/terminal-suggest/src/completions/code.ts @@ -342,6 +342,620 @@ export function parseInstalledExtensions(out: string): Fig.Suggestion[] | undefi return extensions; } +export const commonAuthOptions: Fig.Option[] = [ + { + name: '--access-token', + description: 'An access token to store for authentication', + isRepeatable: true, + args: { + name: 'access_token', + isOptional: true, + }, + }, + { + name: '--refresh-token', + description: 'An access token to store for authentication', + isRepeatable: true, + args: { + name: 'refresh_token', + isOptional: true, + }, + }, + { + name: '--provider', + description: 'The auth provider to use. If not provided, a prompt will be shown', + isRepeatable: true, + args: { + name: 'provider', + isOptional: true, + suggestions: [ + 'microsoft', + 'github', + ], + }, + } +]; + +export const tunnelHelpOptions: Fig.Option[] = [ + { + name: ['-h', '--help'], + description: 'Print help', + }, +]; + +export const globalTunnelOptions: Fig.Option[] = [ + { + name: '--cli-data-dir', + description: 'Directory where CLI metadata should be stored', + args: { + name: 'cli_data_dir', + }, + }, + { + name: '--verbose', + description: 'Print verbose output (implies --wait)', + }, + { + name: '--log', + description: 'Log level to use', + isRepeatable: true, + args: { + name: 'log', + isOptional: true, + suggestions: [ + 'trace', + 'debug', + 'info', + 'warn', + 'error', + 'critical', + 'off', + ], + }, + }, +]; + + +export const codeTunnelOptions = [ + { + name: '--extensions-dir', + description: 'Set the root path for extensions', + isRepeatable: true, + args: { + name: 'extensions_dir', + isOptional: true, + }, + }, + { + name: '--user-data-dir', + description: 'Specifies the directory that user data is kept in. Can be used to open multiple distinct instances of the editor', + isRepeatable: true, + args: { + name: 'user_data_dir', + isOptional: true, + }, + }, + { + name: '--use-version', + description: 'Sets the editor version to use for this command. The preferred version can be persisted with `code version use `. Can be \'stable\', \'insiders\', a version number, or an absolute path to an existing install', + isRepeatable: true, + args: { + name: 'use_version', + isOptional: true, + }, + }, +]; + +export const extTunnelSubcommand = { + name: 'ext', + description: 'Manage editor extensions', + subcommands: [ + { + name: 'list', + description: 'List installed extensions', + options: [...globalTunnelOptions, ...tunnelHelpOptions, + { + name: '--category', + description: 'Filters installed extensions by provided category, when using --list-extensions', + isRepeatable: true, + args: { + name: 'category', + isOptional: true, + }, + }, + { + name: '--show-versions', + description: 'Show versions of installed extensions, when using --list-extensions', + }, + ] + }, + { + name: 'install', + description: 'Install an extension', + options: [...globalTunnelOptions, ...tunnelHelpOptions, + { + name: '--pre-release', + description: 'Installs the pre-release version of the extension', + }, + { + name: '--donot-include-pack-and-dependencies', + description: `Don't include installing pack and dependencies of the extension`, + }, + { + name: '--force', + description: `Update to the latest version of the extension if it's already installed`, + }, + ], + args: { + name: 'ext-id | id', + isVariadic: true, + isOptional: true, + }, + }, + { + name: 'uninstall', + description: 'Uninstall an extension', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + args: { + name: 'ext-id | id', + isVariadic: true, + isOptional: true, + }, + }, + { + name: 'update', + description: 'Update the installed extensions', + options: [...globalTunnelOptions, ...tunnelHelpOptions] + }, + ], + ...globalTunnelOptions, + ...codeTunnelOptions +}; + + +export const codeTunnelSubcommands = [ + { + name: 'tunnel', + description: 'Create a tunnel that\'s accessible on vscode.dev from anywhere. Run`code tunnel --help` for more usage info', + subcommands: [ + { + name: 'prune', + description: 'Delete all servers which are currently not running', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'kill', + description: 'Stops any running tunnel on the system', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'restart', + description: 'Restarts any running tunnel on the system', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'status', + description: 'Gets whether there is a tunnel running on the current machine', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'rename', + description: 'Rename the name of this machine associated with port forwarding service', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + args: { + name: 'name', + }, + }, + { + name: 'unregister', + description: 'Remove this machine\'s association with the port forwarding service', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'user', + subcommands: [ + { + name: 'login', + description: 'Log in to port forwarding service', + options: [...globalTunnelOptions, ...tunnelHelpOptions, ...commonAuthOptions], + }, + { + name: 'logout', + description: 'Log out of port forwarding service', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'show', + description: 'Show the account that\'s logged into port forwarding service', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'help', + description: 'Print this message or the help of the given subcommand(s)', + subcommands: [ + { name: 'login', description: 'Log in to port forwarding service' }, + { name: 'logout', description: 'Log out of port forwarding service' }, + { name: 'show', description: 'Show the account that\'s logged into port forwarding service' }, + { name: 'help', description: 'Print this message or the help of the given subcommand(s)' }, + ], + }, + ], + }, + { + name: 'service', + description: '(Preview) Manages the tunnel when installed as a system service,', + subcommands: [ + { + name: 'install', + description: 'Installs or re-installs the tunnel service on the machine', + options: [ + { + name: '--name', + description: 'Sets the machine name for port forwarding service', + + args: { + name: 'name', + + }, + }, + { + name: '--accept-server-license-terms', + description: 'If set, the user accepts the server license terms and the server will be started without a user prompt', + }, + ...globalTunnelOptions, ...tunnelHelpOptions + ], + }, + { + name: 'uninstall', + description: 'Uninstalls and stops the tunnel service', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'log', + description: 'Shows logs for the running service', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'help', + description: 'Print this message or the help of the given subcommand(s)', + subcommands: [ + { name: 'install', description: 'Installs or re-installs the tunnel service on the machine' }, + { name: 'uninstall', description: 'Uninstalls and stops the tunnel service' }, + { name: 'log', description: 'Shows logs for the running service' }, + { name: 'help', description: 'Print this message or the help of the given subcommand(s)' }, + ], + }, + ], + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'help', + description: 'Print this message or the help of the given subcommand(s)', + subcommands: [ + { name: 'prune', description: 'Delete all servers which are currently not running' }, + { name: 'kill', description: 'Stops any running tunnel on the system' }, + { name: 'restart', description: 'Restarts any running tunnel on the system' }, + { name: 'status', description: 'Gets whether there is a tunnel running on the current machine' }, + { name: 'rename', description: 'Rename the name of this machine associated with port forwarding service' }, + { name: 'unregister', description: 'Remove this machine\'s association with the port forwarding service' }, + { + name: 'user', + subcommands: [ + { name: 'login', description: 'Log in to port forwarding service' }, + { name: 'logout', description: 'Log out of port forwarding service' }, + { name: 'show', description: 'Show the account that\'s logged into port forwarding service' }, + ], + }, + { + name: 'service', + description: '(Preview) Manages the tunnel when installed as a system service,', + subcommands: [ + { name: 'install', description: 'Installs or re-installs the tunnel service on the machine' }, + { name: 'uninstall', description: 'Uninstalls and stops the tunnel service' }, + { name: 'log', description: 'Shows logs for the running service' }, + ], + }, + { name: 'help', description: 'Print this message or the help of the given subcommand(s)' }, + ], + }, + ], + options: [ + { + name: '--install-extension', + description: 'Requests that extensions be preloaded and installed on connecting servers', + isRepeatable: true, + args: { + name: 'install_extension', + isOptional: true, + }, + }, + { + name: '--server-data-dir', + description: 'Specifies the directory that server data is kept in', + isRepeatable: true, + args: { + name: 'server_data_dir', + isOptional: true, + }, + }, + { + name: '--extensions-dir', + description: 'Set the root path for extensions', + isRepeatable: true, + args: { + name: 'extensions_dir', + isOptional: true, + }, + }, + { + name: '--user-data-dir', + description: 'Specifies the directory that user data is kept in. Can be used to open multiple distinct instances of the editor', + isRepeatable: true, + args: { + name: 'user_data_dir', + isOptional: true, + }, + }, + { + name: '--use-version', + description: 'Sets the editor version to use for this command. The preferred version can be persisted with `code version use `. Can be \'stable\', \'insiders\', a version number, or an absolute path to an existing install', + isRepeatable: true, + args: { + name: 'use_version', + isOptional: true, + }, + }, + { + name: '--random-name', + description: 'Randomly name machine for port forwarding service', + }, + { + name: '--no-sleep', + description: 'Prevents the machine going to sleep while this command runs', + }, + { + name: '--accept-server-license-terms', + description: 'If set, the user accepts the server license terms and the server will be started without a user prompt', + }, + { + name: '--name', + description: 'Sets the machine name for port forwarding service', + isRepeatable: true, + args: { + name: 'name', + isOptional: true, + }, + }, + { + name: ['-h', '--help'], + description: 'Print help', + }, + { + name: '--log', + description: 'Log level to use', + isRepeatable: true, + args: { + name: 'log', + isOptional: true, + suggestions: [ + 'trace', + 'debug', + 'info', + 'warn', + 'error', + 'critical', + 'off', + ], + }, + }, + { + name: '--verbose', + description: 'Print verbose output (implies --wait)', + }, + { + name: '--cli-data-dir', + description: 'Directory where CLI metadata should be stored', + args: { + name: 'cli_data_dir', + }, + }, + ], + }, + { + name: 'status', + description: 'Print process usage and diagnostics information', + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'version', + description: `Changes the version of the editor you're using`, + options: [...globalTunnelOptions, ...tunnelHelpOptions], + }, + { + name: 'serve-web', + description: 'Runs a local web version of Code - OSS', + options: [ + { + name: '--host', + description: 'Host to listen on, defaults to \'localhost\'', + isRepeatable: true, + args: { + name: 'host', + isOptional: true, + }, + }, + { + name: '--socket-path', + isRepeatable: true, + args: { + name: 'socket_path', + isOptional: true, + }, + }, + { + name: '--port', + description: 'Port to listen on. If 0 is passed a random free port is picked', + isRepeatable: true, + args: { + name: 'port', + isOptional: true, + }, + }, + { + name: '--connection-token', + description: 'A secret that must be included with all requests', + isRepeatable: true, + args: { + name: 'connection_token', + isOptional: true, + }, + }, + { + name: '--connection-token-file', + description: 'A file containing a secret that must be included with all requests', + isRepeatable: true, + args: { + name: 'connection_token_file', + isOptional: true, + }, + }, + { + name: '--server-base-path', + description: 'Specifies the path under which the web UI and the code server is provided', + isRepeatable: true, + args: { + name: 'server_base_path', + isOptional: true, + }, + }, + { + name: '--server-data-dir', + description: 'Specifies the directory that server data is kept in', + isRepeatable: true, + args: { + name: 'server_data_dir', + isOptional: true, + }, + }, + { + name: '--without-connection-token', + description: 'Run without a connection token. Only use this if the connection is secured by other means', + }, + { + name: '--accept-server-license-terms', + description: 'If set, the user accepts the server license terms and the server will be started without a user prompt', + }, + ...globalTunnelOptions, ...tunnelHelpOptions, + ] + }, + { + name: 'help', + description: 'Print this message or the help of the given subcommand(s)', + subcommands: [ + { + name: 'tunnel', + description: 'Create a tunnel that\'s accessible on vscode.dev from anywhere. Run`code tunnel --help` for more usage info', + subcommands: [ + { + name: 'prune', + description: 'Delete all servers which are currently not running', + }, + { + name: 'kill', + description: 'Stops any running tunnel on the system', + }, + { + name: 'restart', + description: 'Restarts any running tunnel on the system', + }, + { + name: 'status', + description: 'Gets whether there is a tunnel running on the current machine', + }, + { + name: 'rename', + description: 'Rename the name of this machine associated with port forwarding service', + }, + { + name: 'unregister', + description: `Remove this machine's association with the port forwarding service`, + }, + { + name: 'user', + subcommands: [ + { + name: 'login', + description: 'Log in to port forwarding service', + }, + { + name: 'logout', + description: 'Log out of port forwarding service', + }, + { + name: 'show', + description: 'Show the account that\'s logged into port forwarding service', + }, + ], + }, + { + name: 'service', + description: '(Preview) Manages the tunnel when installed as a system service,', + subcommands: [ + { + name: 'install', + description: 'Installs or re-installs the tunnel service on the machine', + }, + { + name: 'uninstall', + description: 'Uninstalls and stops the tunnel service', + }, + { + name: 'log', + description: 'Shows logs for the running service', + }, + ], + } + ], + }, + extTunnelSubcommand, + { + name: 'status', + description: 'Print process usage and diagnostics information', + }, + { + name: 'version', + description: `Changes the version of the editor you're using`, + subcommands: [ + { + name: 'use', + description: 'Switches the version of the editor in use', + }, + { + name: 'show', + description: 'Shows the currently configured editor version', + }, + ], + }, + { + name: 'serve-web', + description: 'Runs a local web version of Code - OSS', + }, + { + name: 'command-shell', + description: 'Runs the control server on process stdin/stdout', + hidden: true, + }, + { + name: 'update', + description: 'Updates the CLI', + }, + { + name: 'help', + description: 'Print this message or the help of the given subcommand(s)', + }, + ], + }, +]; const codeCompletionSpec: Fig.Spec = { name: 'code', @@ -350,6 +964,7 @@ const codeCompletionSpec: Fig.Spec = { template: ['filepaths', 'folders'], isVariadic: true, }, + subcommands: codeTunnelSubcommands, options: [ ...commonOptions, ...extensionManagementOptions('code'), diff --git a/extensions/terminal-suggest/src/env/pathExecutableCache.ts b/extensions/terminal-suggest/src/env/pathExecutableCache.ts index 4ce8090f088..9932bc88933 100644 --- a/extensions/terminal-suggest/src/env/pathExecutableCache.ts +++ b/extensions/terminal-suggest/src/env/pathExecutableCache.ts @@ -10,6 +10,8 @@ import { osIsWindows } from '../helpers/os'; import type { ICompletionResource } from '../types'; import { getFriendlyResourcePath } from '../helpers/uri'; import { SettingsIds } from '../constants'; +import * as filesystem from 'fs'; +import * as path from 'path'; const isWindows = osIsWindows(); @@ -38,7 +40,12 @@ export class PathExecutableCache implements vscode.Disposable { } } - async getExecutablesInPath(env: { [key: string]: string | undefined } = process.env): Promise<{ completionResources: Set | undefined; labels: Set | undefined } | undefined> { + refresh(): void { + this._cachedExes = undefined; + this._cachedPathValue = undefined; + } + + async getExecutablesInPath(env: ITerminalEnvironment = process.env): Promise<{ completionResources: Set | undefined; labels: Set | undefined } | undefined> { // Create cache key let pathValue: string | undefined; if (isWindows) { @@ -96,7 +103,7 @@ export class PathExecutableCache implements vscode.Disposable { for (const [file, fileType] of files) { const formattedPath = getFriendlyResourcePath(vscode.Uri.joinPath(fileResource, file), pathSeparator); if (!labels.has(file) && fileType !== vscode.FileType.Unknown && fileType !== vscode.FileType.Directory && await isExecutable(formattedPath, this._cachedWindowsExeExtensions)) { - result.add({ label: file, detail: formattedPath }); + result.add({ label: file, documentation: formattedPath, kind: vscode.TerminalCompletionItemKind.Method }); labels.add(file); } } @@ -107,3 +114,47 @@ export class PathExecutableCache implements vscode.Disposable { } } } + +export async function watchPathDirectories(context: vscode.ExtensionContext, env: ITerminalEnvironment, pathExecutableCache: PathExecutableCache | undefined): Promise { + const pathDirectories = new Set(); + + const envPath = env.PATH; + if (envPath) { + envPath.split(path.delimiter).forEach(p => pathDirectories.add(p)); + } + + const activeWatchers = new Set(); + + // Watch each directory + for (const dir of pathDirectories) { + try { + if (activeWatchers.has(dir)) { + // Skip if already watching or directory doesn't exist + continue; + } + + const stat = await fs.stat(dir); + if (!stat.isDirectory()) { + continue; + } + + const watcher = filesystem.watch(dir, { persistent: false }, () => { + if (pathExecutableCache) { + // Refresh cache when directory contents change + pathExecutableCache.refresh(); + } + }); + + activeWatchers.add(dir); + + context.subscriptions.push(new vscode.Disposable(() => { + try { + watcher.close(); + activeWatchers.delete(dir); + } catch { } { } + })); + } catch { } + } +} + +export type ITerminalEnvironment = { [key: string]: string | undefined }; diff --git a/extensions/terminal-suggest/src/terminalSuggestMain.ts b/extensions/terminal-suggest/src/terminalSuggestMain.ts index 3abd2e8efd1..3e923dde3a9 100644 --- a/extensions/terminal-suggest/src/terminalSuggestMain.ts +++ b/extensions/terminal-suggest/src/terminalSuggestMain.ts @@ -13,7 +13,7 @@ import codeInsidersCompletionSpec from './completions/code-insiders'; import npxCompletionSpec from './completions/npx'; import setLocationSpec from './completions/set-location'; import { upstreamSpecs } from './constants'; -import { PathExecutableCache } from './env/pathExecutableCache'; +import { ITerminalEnvironment, PathExecutableCache, watchPathDirectories } from './env/pathExecutableCache'; import { osIsWindows } from './helpers/os'; import { getFriendlyResourcePath } from './helpers/uri'; import { getBashGlobals } from './shell/bash'; @@ -26,13 +26,16 @@ import { createCompletionItem } from './helpers/completionItem'; import { getFigSuggestions } from './fig/figInterface'; import { executeCommand, executeCommandTimeout, IFigExecuteExternals } from './fig/execute'; import { createTimeoutPromise } from './helpers/promise'; +import codeTunnelCompletionSpec from './completions/code-tunnel'; +import codeTunnelInsidersCompletionSpec from './completions/code-tunnel-insiders'; export const enum TerminalShellType { Bash = 'bash', Fish = 'fish', Zsh = 'zsh', PowerShell = 'pwsh', - Python = 'python' + Python = 'python', + GitBash = 'gitbash', } const isWindows = osIsWindows(); @@ -43,6 +46,8 @@ export const availableSpecs: Fig.Spec[] = [ cdSpec, codeInsidersCompletionSpec, codeCompletionSpec, + codeTunnelCompletionSpec, + codeTunnelInsidersCompletionSpec, npxCompletionSpec, setLocationSpec, ]; @@ -79,13 +84,15 @@ async function getShellGlobals(shellType: TerminalShellType, existingCommands?: } } + export async function activate(context: vscode.ExtensionContext) { pathExecutableCache = new PathExecutableCache(); context.subscriptions.push(pathExecutableCache); - + let currentTerminalEnv: ITerminalEnvironment = process.env; context.subscriptions.push(vscode.window.registerTerminalCompletionProvider({ id: 'terminal-suggest', async provideTerminalCompletions(terminal: vscode.Terminal, terminalContext: vscode.TerminalCompletionContext, token: vscode.CancellationToken): Promise { + currentTerminalEnv = terminal.shellIntegration?.env?.value ?? process.env; if (token.isCancellationRequested) { console.debug('#terminalCompletions token cancellation requested'); return; @@ -117,7 +124,7 @@ export async function activate(context: vscode.ExtensionContext) { prefix, tokenType, terminal.shellIntegration?.cwd, - getEnvAsRecord(terminal.shellIntegration?.env?.value), + getEnvAsRecord(currentTerminalEnv), terminal.name, token ), @@ -141,6 +148,7 @@ export async function activate(context: vscode.ExtensionContext) { return result.items; } }, '/', '\\')); + await watchPathDirectories(context, currentTerminalEnv, pathExecutableCache); } /** @@ -264,18 +272,19 @@ export async function getCompletionItemsFromSpecs( prefix, command, command.detail, - command.documentation + command.documentation, + vscode.TerminalCompletionItemKind.Method )); labels.add(commandTextLabel); - } else { + } + else { const existingItem = items.find(i => (typeof i.label === 'string' ? i.label : i.label.label) === commandTextLabel); if (!existingItem) { continue; } - const preferredItem = compareItems(existingItem, command); - if (preferredItem) { - items.splice(items.indexOf(existingItem), 1, preferredItem); - } + + existingItem.documentation ??= command.documentation; + existingItem.detail ??= command.detail; } } filesRequested = true; @@ -297,21 +306,7 @@ export async function getCompletionItemsFromSpecs( return { items, filesRequested, foldersRequested, fileExtensions, cwd }; } -function compareItems(existingItem: vscode.TerminalCompletionItem, command: ICompletionResource): vscode.TerminalCompletionItem | undefined { - let score = typeof command.label === 'object' ? (command.label.detail !== undefined ? 1 : 0) : 0; - score += typeof command.label === 'object' ? (command.label.description !== undefined ? 2 : 0) : 0; - score += command.documentation ? typeof command.documentation === 'string' ? 2 : 3 : 0; - if (score > 0) { - score -= typeof existingItem.label === 'object' ? (existingItem.label.detail !== undefined ? 1 : 0) : 0; - score -= typeof existingItem.label === 'object' ? (existingItem.label.description !== undefined ? 2 : 0) : 0; - score -= existingItem.documentation ? typeof existingItem.documentation === 'string' ? 2 : 3 : 0; - if (score >= 0) { - return { ...command, replacementIndex: existingItem.replacementIndex, replacementLength: existingItem.replacementLength }; - } - } -} - -function getEnvAsRecord(shellIntegrationEnv: { [key: string]: string | undefined } | undefined): Record { +function getEnvAsRecord(shellIntegrationEnv: ITerminalEnvironment): Record { const env: Record = {}; for (const [key, value] of Object.entries(shellIntegrationEnv ?? process.env)) { if (typeof value === 'string') { @@ -328,6 +323,8 @@ function getTerminalShellType(shellType: string | undefined): TerminalShellType switch (shellType) { case 'bash': return TerminalShellType.Bash; + case 'gitbash': + return TerminalShellType.GitBash; case 'zsh': return TerminalShellType.Zsh; case 'pwsh': diff --git a/extensions/terminal-suggest/src/test/completions/code-insiders.test.ts b/extensions/terminal-suggest/src/test/completions/code-insiders.test.ts index 447772ad8b7..f64688066e3 100644 --- a/extensions/terminal-suggest/src/test/completions/code-insiders.test.ts +++ b/extensions/terminal-suggest/src/test/completions/code-insiders.test.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import codeInsidersCompletionSpec from '../../completions/code-insiders'; +import codeTunnelInsidersCompletionSpec from '../../completions/code-tunnel-insiders'; import type { ISuiteSpec } from '../helpers'; -import { createCodeTestSpecs } from './code.test'; +import { createCodeTestSpecs, createCodeTunnelTestSpecs } from './code.test'; export const codeInsidersTestSuite: ISuiteSpec = { name: 'code-insiders', @@ -13,3 +14,11 @@ export const codeInsidersTestSuite: ISuiteSpec = { availableCommands: 'code-insiders', testSpecs: createCodeTestSpecs('code-insiders') }; + +export const codeTunnelInsidersTestSuite: ISuiteSpec = { + name: 'code-tunnel-insiders', + completionSpecs: codeTunnelInsidersCompletionSpec, + availableCommands: 'code-tunnel-insiders', + testSpecs: createCodeTunnelTestSpecs('code-tunnel-insiders') +}; + diff --git a/extensions/terminal-suggest/src/test/completions/code.test.ts b/extensions/terminal-suggest/src/test/completions/code.test.ts index 30410825904..d00dcdbf947 100644 --- a/extensions/terminal-suggest/src/test/completions/code.test.ts +++ b/extensions/terminal-suggest/src/test/completions/code.test.ts @@ -7,8 +7,9 @@ import 'mocha'; import codeCompletionSpec from '../../completions/code'; import { testPaths, type ISuiteSpec, type ITestSpec } from '../helpers'; import codeInsidersCompletionSpec from '../../completions/code-insiders'; +import codeTunnelCompletionSpec from '../../completions/code-tunnel'; -export const codeSpecOptions = [ +export const codeSpecOptionsAndSubcommands = [ '-a ', '-d ', '-g ', @@ -53,6 +54,11 @@ export const codeSpecOptions = [ '--verbose', '--version', '--wait', + 'tunnel', + 'serve-web', + 'help', + 'status', + 'version' ]; export function createCodeTestSpecs(executable: string): ITestSpec[] { @@ -73,7 +79,7 @@ export function createCodeTestSpecs(executable: string): ITestSpec[] { ...typingTests, // Basic arguments - { input: `${executable} |`, expectedCompletions: codeSpecOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: `${executable} |`, expectedCompletions: codeSpecOptionsAndSubcommands, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, { input: `${executable} --locale |`, expectedCompletions: localeOptions }, { input: `${executable} --diff |`, expectedResourceRequests: { type: 'files', cwd: testPaths.cwd } }, { input: `${executable} --diff ./file1 |`, expectedResourceRequests: { type: 'files', cwd: testPaths.cwd } }, @@ -89,13 +95,168 @@ export function createCodeTestSpecs(executable: string): ITestSpec[] { { input: `${executable} --log |`, expectedCompletions: logOptions }, { input: `${executable} --sync |`, expectedCompletions: syncOptions }, { input: `${executable} --extensions-dir |`, expectedResourceRequests: { type: 'folders', cwd: testPaths.cwd } }, - { input: `${executable} --list-extensions |`, expectedCompletions: codeSpecOptions.filter(c => c !== '--list-extensions'), expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, - { input: `${executable} --show-versions |`, expectedCompletions: codeSpecOptions.filter(c => c !== '--show-versions'), expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: `${executable} --list-extensions |`, expectedCompletions: codeSpecOptionsAndSubcommands.filter(c => c !== '--list-extensions'), expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: `${executable} --show-versions |`, expectedCompletions: codeSpecOptionsAndSubcommands.filter(c => c !== '--show-versions'), expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, { input: `${executable} --category |`, expectedCompletions: categoryOptions }, { input: `${executable} --category a|`, expectedCompletions: categoryOptions }, // Middle of command - { input: `${executable} | --locale`, expectedCompletions: codeSpecOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: `${executable} | --locale`, expectedCompletions: codeSpecOptionsAndSubcommands, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + ]; +} + +export function createCodeTunnelTestSpecs(executable: string): ITestSpec[] { + const subcommandAndFlags: string[] = [ + '-', + '--add ', + '--category ', + '--cli-data-dir ', + '--diff ', + '--disable-extension ', + '--disable-extensions', + '--disable-gpu', + '--enable-proposed-api', + '--extensions-dir []', + '--goto ', + '--help', + '--inspect-brk-extensions ', + '--inspect-extensions ', + '--install-extension ', + '--list-extensions', + '--locale ', + '--locate-shell-integration-path ', + '--log []', + '--max-memory ', + '--merge ', + '--new-window', + '--pre-release', + '--prof-startup', + '--profile ', + '--reuse-window', + '--show-versions', + '--status', + '--sync ', + '--telemetry', + '--uninstall-extension ', + '--use-version []', + '--user-data-dir []', + '--verbose', + '--version', + '--wait', + '-a ', + '-d ', + '-g ', + '-h', + '-m ', + '-n', + '-r', + '-s', + '-v', + '-w', + 'ext', + 'help', + 'serve-web', + 'status', + 'tunnel', + 'version' + ]; + const tunnelSubcommandsAndFlags: string[] = [ + '--accept-server-license-terms', + '--cli-data-dir ', + '--extensions-dir []', + '--help', + '--install-extension []', + '--log []', + '--name []', + '--no-sleep', + '--random-name', + '--server-data-dir []', + '--use-version []', + '--user-data-dir []', + '--verbose', + '-h', + 'help', + 'kill', + 'prune', + 'rename ', + 'restart', + 'service', + 'status', + 'unregister', + 'user', + ]; + + const helpSubcommands: string[] = [ + 'help', + 'kill', + 'prune', + 'rename', + 'restart', + 'service', + 'status', + 'unregister', + 'user' + ]; + const serveWebSubcommandsAndFlags: string[] = [ + '--accept-server-license-terms', + '--cli-data-dir ', + '--connection-token []', + '--connection-token-file []', + '--help', + '--host []', + '--log []', + '--port []', + '--server-base-path []', + '--server-data-dir []', + '--socket-path []', + '--verbose', + '--without-connection-token', + '-h' + ]; + + const extSubcommands: string[] = [ + 'install []', + 'list', + 'uninstall []', + 'update' + ]; + + const commonFlags: string[] = [ + '--cli-data-dir ', + '--log []', + '--verbose', + '--help', + '-h' + ]; + + const typingTests: ITestSpec[] = []; + for (let i = 1; i < executable.length; i++) { + const expectedCompletions = [{ label: executable, description: executable === codeCompletionSpec.name || executable === codeTunnelCompletionSpec.name ? (codeCompletionSpec as any).description : (codeInsidersCompletionSpec as any).description }]; + const input = `${executable.slice(0, i)}|`; + typingTests.push({ input, expectedCompletions, expectedResourceRequests: input.endsWith(' ') ? undefined : { type: 'both', cwd: testPaths.cwd } }); + } + + return [ + ...typingTests, + { input: `${executable} |`, expectedCompletions: subcommandAndFlags, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: `${executable} tunnel |`, expectedCompletions: tunnelSubcommandsAndFlags }, + { input: `${executable} tunnel user |`, expectedCompletions: ['help', 'login', 'logout', 'show'] }, + { input: `${executable} tunnel prune |`, expectedCompletions: [...commonFlags] }, + { input: `${executable} tunnel kill |`, expectedCompletions: [...commonFlags] }, + { input: `${executable} tunnel restart |`, expectedCompletions: [...commonFlags] }, + { input: `${executable} tunnel status |`, expectedCompletions: [...commonFlags] }, + { input: `${executable} tunnel rename |`, expectedCompletions: [...commonFlags] }, + { input: `${executable} tunnel unregister |`, expectedCompletions: [...commonFlags] }, + { input: `${executable} tunnel service |`, expectedCompletions: [...commonFlags, 'help', 'install', 'log', 'uninstall'] }, + { input: `${executable} tunnel help |`, expectedCompletions: helpSubcommands }, + { input: `${executable} serve-web |`, expectedCompletions: serveWebSubcommandsAndFlags }, + { input: `${executable} ext |`, expectedCompletions: extSubcommands }, + { input: `${executable} ext list |`, expectedCompletions: [...commonFlags, '--category []', '--show-versions'] }, + { input: `${executable} ext install |`, expectedCompletions: [...commonFlags, '--pre-release', '--donot-include-pack-and-dependencies', '--force'] }, + { input: `${executable} ext update |`, expectedCompletions: [...commonFlags] }, + { input: `${executable} status |`, expectedCompletions: commonFlags }, + { input: `${executable} version |`, expectedCompletions: commonFlags }, + ]; } @@ -105,3 +266,10 @@ export const codeTestSuite: ISuiteSpec = { availableCommands: 'code', testSpecs: createCodeTestSpecs('code') }; + +export const codeTunnelTestSuite: ISuiteSpec = { + name: 'code-tunnel', + completionSpecs: codeTunnelCompletionSpec, + availableCommands: 'code-tunnel', + testSpecs: createCodeTunnelTestSpecs('code-tunnel') +}; diff --git a/extensions/terminal-suggest/src/test/env/pathExecutableCache.test.ts b/extensions/terminal-suggest/src/test/env/pathExecutableCache.test.ts index 890aa101033..149f75ad5d4 100644 --- a/extensions/terminal-suggest/src/test/env/pathExecutableCache.test.ts +++ b/extensions/terminal-suggest/src/test/env/pathExecutableCache.test.ts @@ -22,4 +22,13 @@ suite('PathExecutableCache', () => { const result2 = await cache.getExecutablesInPath(env); strictEqual(result, result2); }); + + test('refresh clears the cache', async () => { + const cache = new PathExecutableCache(); + const env = { PATH: process.env.PATH }; + const result = await cache.getExecutablesInPath(env); + cache.refresh(); + const result2 = await cache.getExecutablesInPath(env); + strictEqual(result !== result2, true); + }); }); diff --git a/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts b/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts index 2baabd83886..85f9cb6ca98 100644 --- a/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts +++ b/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts @@ -9,9 +9,9 @@ import { basename } from 'path'; import { asArray, getCompletionItemsFromSpecs } from '../terminalSuggestMain'; import { getTokenType } from '../tokens'; import { cdTestSuiteSpec as cdTestSuite } from './completions/cd.test'; -import { codeSpecOptions, codeTestSuite } from './completions/code.test'; +import { codeSpecOptionsAndSubcommands, codeTestSuite, codeTunnelTestSuite } from './completions/code.test'; import { testPaths, type ISuiteSpec } from './helpers'; -import { codeInsidersTestSuite } from './completions/code-insiders.test'; +import { codeInsidersTestSuite, codeTunnelInsidersTestSuite } from './completions/code-insiders.test'; import { lsTestSuiteSpec } from './completions/upstream/ls.test'; import { echoTestSuiteSpec } from './completions/upstream/echo.test'; import { mkdirTestSuiteSpec } from './completions/upstream/mkdir.test'; @@ -43,6 +43,8 @@ const testSpecs2: ISuiteSpec[] = [ cdTestSuite, codeTestSuite, codeInsidersTestSuite, + codeTunnelTestSuite, + codeTunnelInsidersTestSuite, // completions/upstream/ echoTestSuiteSpec, @@ -65,11 +67,11 @@ if (osIsWindows()) { 'code.anything', ], testSpecs: [ - { input: 'code |', expectedCompletions: codeSpecOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, - { input: 'code.bat |', expectedCompletions: codeSpecOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, - { input: 'code.cmd |', expectedCompletions: codeSpecOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, - { input: 'code.exe |', expectedCompletions: codeSpecOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, - { input: 'code.anything |', expectedCompletions: codeSpecOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: 'code |', expectedCompletions: codeSpecOptionsAndSubcommands, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: 'code.bat |', expectedCompletions: codeSpecOptionsAndSubcommands, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: 'code.cmd |', expectedCompletions: codeSpecOptionsAndSubcommands, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: 'code.exe |', expectedCompletions: codeSpecOptionsAndSubcommands, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, + { input: 'code.anything |', expectedCompletions: codeSpecOptionsAndSubcommands, expectedResourceRequests: { type: 'both', cwd: testPaths.cwd } }, ] }); } diff --git a/extensions/tunnel-forwarding/package-lock.json b/extensions/tunnel-forwarding/package-lock.json index 57accdc3b8d..307cef66071 100644 --- a/extensions/tunnel-forwarding/package-lock.json +++ b/extensions/tunnel-forwarding/package-lock.json @@ -16,19 +16,21 @@ } }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" } } } diff --git a/extensions/typescript-basics/package.json b/extensions/typescript-basics/package.json index d765f6116f8..d64e6df2147 100644 --- a/extensions/typescript-basics/package.json +++ b/extensions/typescript-basics/package.json @@ -75,9 +75,7 @@ "keyword.operator.assignment.compound.bitwise.ts" ], "tokenTypes": { - "meta.template.expression": "other", - "meta.template.expression string": "string", - "meta.template.expression comment": "comment", + "punctuation.definition.template-expression": "other", "entity.name.type.instance.jsdoc": "other", "entity.name.function.tagged-template": "other", "meta.import string.quoted": "other", @@ -102,9 +100,7 @@ "meta.embedded.expression.tsx": "typescriptreact" }, "tokenTypes": { - "meta.template.expression": "other", - "meta.template.expression string": "string", - "meta.template.expression comment": "comment", + "punctuation.definition.template-expression": "other", "entity.name.type.instance.jsdoc": "other", "entity.name.function.tagged-template": "other", "meta.import string.quoted": "other", diff --git a/extensions/typescript-basics/syntaxes/Readme.md b/extensions/typescript-basics/syntaxes/Readme.md index fa05c28d970..e8c3cd9bf08 100644 --- a/extensions/typescript-basics/syntaxes/Readme.md +++ b/extensions/typescript-basics/syntaxes/Readme.md @@ -2,7 +2,7 @@ The file `TypeScript.tmLanguage.json` and `TypeScriptReact.tmLanguage.json` are To update to the latest version: -- `cd extensions/typescript` and run `npm run update-grammars` +- `cd extensions/typescript-basics` and run `npm run update-grammars` - don't forget to run the integration tests at `./scripts/test-integration.sh` Migration notes and todos: diff --git a/extensions/typescript-language-features/package-lock.json b/extensions/typescript-language-features/package-lock.json index 4f97eb1048c..2078b4845e8 100644 --- a/extensions/typescript-language-features/package-lock.json +++ b/extensions/typescript-language-features/package-lock.json @@ -152,12 +152,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/semver": { @@ -257,10 +258,11 @@ "integrity": "sha512-V+uqV66BOQnWxvI6HjDnE4VkInmYZUQ4dgB7gzaDyFyFSK1i1nF/j7DpS9UbQAgV9NaF1XpcyuavnM1qOeiEIg==" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/vscode-tas-client": { "version": "0.1.84", diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 322bab13b1c..2365a6e3267 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -11,7 +11,8 @@ "workspaceTrust", "multiDocumentHighlightProvider", "codeActionAI", - "codeActionRanges" + "codeActionRanges", + "editorHoverVerbosityLevel" ], "capabilities": { "virtualWorkspaces": { @@ -147,1376 +148,1429 @@ "url": "https://typedoc.org/schema.json" } ], - "configuration": { - "type": "object", - "title": "%configuration.typescript%", - "order": 20, - "properties": { - "typescript.tsdk": { - "type": "string", - "markdownDescription": "%typescript.tsdk.desc%", - "scope": "window" - }, - "typescript.disableAutomaticTypeAcquisition": { - "type": "boolean", - "default": false, - "markdownDescription": "%typescript.disableAutomaticTypeAcquisition%", - "scope": "window", - "tags": [ - "usesOnlineServices" - ] - }, - "typescript.enablePromptUseWorkspaceTsdk": { - "type": "boolean", - "default": false, - "description": "%typescript.enablePromptUseWorkspaceTsdk%", - "scope": "window" - }, - "typescript.npm": { - "type": "string", - "markdownDescription": "%typescript.npm%", - "scope": "machine" - }, - "typescript.check.npmIsInstalled": { - "type": "boolean", - "default": true, - "markdownDescription": "%typescript.check.npmIsInstalled%", - "scope": "window" - }, - "javascript.referencesCodeLens.enabled": { - "type": "boolean", - "default": false, - "description": "%javascript.referencesCodeLens.enabled%", - "scope": "window" - }, - "javascript.referencesCodeLens.showOnAllFunctions": { - "type": "boolean", - "default": false, - "description": "%javascript.referencesCodeLens.showOnAllFunctions%", - "scope": "window" - }, - "typescript.referencesCodeLens.enabled": { - "type": "boolean", - "default": false, - "description": "%typescript.referencesCodeLens.enabled%", - "scope": "window" - }, - "typescript.referencesCodeLens.showOnAllFunctions": { - "type": "boolean", - "default": false, - "description": "%typescript.referencesCodeLens.showOnAllFunctions%", - "scope": "window" - }, - "typescript.implementationsCodeLens.enabled": { - "type": "boolean", - "default": false, - "description": "%typescript.implementationsCodeLens.enabled%", - "scope": "window" - }, - "typescript.implementationsCodeLens.showOnInterfaceMethods": { - "type": "boolean", - "default": false, - "description": "%typescript.implementationsCodeLens.showOnInterfaceMethods%", - "scope": "window" - }, - "typescript.tsserver.enableTracing": { - "type": "boolean", - "default": false, - "description": "%typescript.tsserver.enableTracing%", - "scope": "window" - }, - "typescript.tsserver.log": { - "type": "string", - "enum": [ - "off", - "terse", - "normal", - "verbose" - ], - "default": "off", - "description": "%typescript.tsserver.log%", - "scope": "window" - }, - "typescript.tsserver.pluginPaths": { - "type": "array", - "items": { + "configuration": [ + { + "type": "object", + "order": 20, + "properties": { + "typescript.tsdk": { "type": "string", - "description": "%typescript.tsserver.pluginPaths.item%" + "markdownDescription": "%typescript.tsdk.desc%", + "scope": "window" }, - "default": [], - "description": "%typescript.tsserver.pluginPaths%", - "scope": "machine" - }, - "javascript.suggest.completeFunctionCalls": { - "type": "boolean", - "default": false, - "description": "%configuration.suggest.completeFunctionCalls%", - "scope": "resource" - }, - "typescript.suggest.completeFunctionCalls": { - "type": "boolean", - "default": false, - "description": "%configuration.suggest.completeFunctionCalls%", - "scope": "resource" - }, - "javascript.suggest.includeAutomaticOptionalChainCompletions": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.includeAutomaticOptionalChainCompletions%", - "scope": "resource" - }, - "typescript.suggest.includeAutomaticOptionalChainCompletions": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.includeAutomaticOptionalChainCompletions%", - "scope": "resource" - }, - "typescript.inlayHints.parameterNames.enabled": { - "type": "string", - "enum": [ - "none", - "literals", - "all" - ], - "enumDescriptions": [ - "%inlayHints.parameterNames.none%", - "%inlayHints.parameterNames.literals%", - "%inlayHints.parameterNames.all%" - ], - "default": "none", - "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", - "scope": "resource" - }, - "typescript.inlayHints.parameterTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.variableTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", - "scope": "resource" - }, - "typescript.inlayHints.propertyDeclarationTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.functionLikeReturnTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", - "scope": "resource" - }, - "typescript.inlayHints.enumMemberValues.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.enumMemberValues.enabled%", - "scope": "resource" - }, - "javascript.inlayHints.parameterNames.enabled": { - "type": "string", - "enum": [ - "none", - "literals", - "all" - ], - "enumDescriptions": [ - "%inlayHints.parameterNames.none%", - "%inlayHints.parameterNames.literals%", - "%inlayHints.parameterNames.all%" - ], - "default": "none", - "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", - "scope": "resource" - }, - "javascript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", - "scope": "resource" - }, - "javascript.inlayHints.parameterTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", - "scope": "resource" - }, - "javascript.inlayHints.variableTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", - "scope": "resource" - }, - "javascript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", - "scope": "resource" - }, - "javascript.inlayHints.propertyDeclarationTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", - "scope": "resource" - }, - "javascript.inlayHints.functionLikeReturnTypes.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", - "scope": "resource" - }, - "javascript.suggest.includeCompletionsForImportStatements": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.includeCompletionsForImportStatements%", - "scope": "resource" - }, - "typescript.suggest.includeCompletionsForImportStatements": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.includeCompletionsForImportStatements%", - "scope": "resource" - }, - "typescript.reportStyleChecksAsWarnings": { - "type": "boolean", - "default": true, - "description": "%typescript.reportStyleChecksAsWarnings%", - "scope": "window" - }, - "typescript.validate.enable": { - "type": "boolean", - "default": true, - "description": "%typescript.validate.enable%", - "scope": "window" - }, - "typescript.format.enable": { - "type": "boolean", - "default": true, - "description": "%typescript.format.enable%", - "scope": "window" - }, - "typescript.format.insertSpaceAfterCommaDelimiter": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterCommaDelimiter%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterConstructor": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterConstructor%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterSemicolonInForStatements": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterSemicolonInForStatements%", - "scope": "resource" - }, - "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", - "scope": "resource" - }, - "typescript.format.insertSpaceBeforeFunctionParenthesis": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceBeforeFunctionParenthesis%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", - "scope": "resource" - }, - "typescript.format.insertSpaceAfterTypeAssertion": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterTypeAssertion%", - "scope": "resource" - }, - "typescript.format.placeOpenBraceOnNewLineForFunctions": { - "type": "boolean", - "default": false, - "description": "%format.placeOpenBraceOnNewLineForFunctions%", - "scope": "resource" - }, - "typescript.format.placeOpenBraceOnNewLineForControlBlocks": { - "type": "boolean", - "default": false, - "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", - "scope": "resource" - }, - "typescript.format.semicolons": { - "type": "string", - "default": "ignore", - "description": "%format.semicolons%", - "scope": "resource", - "enum": [ - "ignore", - "insert", - "remove" - ], - "enumDescriptions": [ - "%format.semicolons.ignore%", - "%format.semicolons.insert%", - "%format.semicolons.remove%" - ] - }, - "typescript.format.indentSwitchCase": { - "type": "boolean", - "default": true, - "description": "%format.indentSwitchCase%", - "scope": "resource" - }, - "javascript.format.indentSwitchCase": { - "type": "boolean", - "default": true, - "description": "%format.indentSwitchCase%", - "scope": "resource" - }, - "javascript.validate.enable": { - "type": "boolean", - "default": true, - "description": "%javascript.validate.enable%", - "scope": "window" - }, - "javascript.format.enable": { - "type": "boolean", - "default": true, - "description": "%javascript.format.enable%", - "scope": "window" - }, - "javascript.format.insertSpaceAfterCommaDelimiter": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterCommaDelimiter%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterConstructor": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterConstructor%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterSemicolonInForStatements": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterSemicolonInForStatements%", - "scope": "resource" - }, - "javascript.format.insertSpaceBeforeAndAfterBinaryOperators": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterKeywordsInControlFlowStatements": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", - "scope": "resource" - }, - "javascript.format.insertSpaceBeforeFunctionParenthesis": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceBeforeFunctionParenthesis%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { - "type": "boolean", - "default": true, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", - "scope": "resource" - }, - "javascript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { - "type": "boolean", - "default": false, - "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", - "scope": "resource" - }, - "javascript.format.placeOpenBraceOnNewLineForFunctions": { - "type": "boolean", - "default": false, - "description": "%format.placeOpenBraceOnNewLineForFunctions%", - "scope": "resource" - }, - "javascript.format.placeOpenBraceOnNewLineForControlBlocks": { - "type": "boolean", - "default": false, - "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", - "scope": "resource" - }, - "javascript.format.semicolons": { - "type": "string", - "default": "ignore", - "description": "%format.semicolons%", - "scope": "resource", - "enum": [ - "ignore", - "insert", - "remove" - ], - "enumDescriptions": [ - "%format.semicolons.ignore%", - "%format.semicolons.insert%", - "%format.semicolons.remove%" - ] - }, - "js/ts.implicitProjectConfig.module": { - "type": "string", - "markdownDescription": "%configuration.implicitProjectConfig.module%", - "default": "ESNext", - "enum": [ - "CommonJS", - "AMD", - "System", - "UMD", - "ES6", - "ES2015", - "ES2020", - "ESNext", - "None", - "ES2022", - "Node12", - "NodeNext" - ], - "scope": "window" - }, - "js/ts.implicitProjectConfig.target": { - "type": "string", - "default": "ES2022", - "markdownDescription": "%configuration.implicitProjectConfig.target%", - "enum": [ - "ES3", - "ES5", - "ES6", - "ES2015", - "ES2016", - "ES2017", - "ES2018", - "ES2019", - "ES2020", - "ES2021", - "ES2022", - "ES2023", - "ES2024", - "ESNext" - ], - "scope": "window" - }, - "javascript.implicitProjectConfig.checkJs": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.implicitProjectConfig.checkJs%", - "markdownDeprecationMessage": "%configuration.javascript.checkJs.checkJs.deprecation%", - "scope": "window" - }, - "js/ts.implicitProjectConfig.checkJs": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.implicitProjectConfig.checkJs%", - "scope": "window" - }, - "javascript.implicitProjectConfig.experimentalDecorators": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.implicitProjectConfig.experimentalDecorators%", - "markdownDeprecationMessage": "%configuration.javascript.checkJs.experimentalDecorators.deprecation%", - "scope": "window" - }, - "js/ts.implicitProjectConfig.experimentalDecorators": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.implicitProjectConfig.experimentalDecorators%", - "scope": "window" - }, - "js/ts.implicitProjectConfig.strictNullChecks": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.implicitProjectConfig.strictNullChecks%", - "scope": "window" - }, - "js/ts.implicitProjectConfig.strictFunctionTypes": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.implicitProjectConfig.strictFunctionTypes%", - "scope": "window" - }, - "javascript.suggest.names": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.suggest.names%", - "scope": "resource" - }, - "typescript.tsc.autoDetect": { - "type": "string", - "default": "on", - "enum": [ - "on", - "off", - "build", - "watch" - ], - "markdownEnumDescriptions": [ - "%typescript.tsc.autoDetect.on%", - "%typescript.tsc.autoDetect.off%", - "%typescript.tsc.autoDetect.build%", - "%typescript.tsc.autoDetect.watch%" - ], - "description": "%typescript.tsc.autoDetect%", - "scope": "window" - }, - "javascript.suggest.paths": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.paths%", - "scope": "resource" - }, - "typescript.suggest.paths": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.paths%", - "scope": "resource" - }, - "javascript.suggest.autoImports": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.autoImports%", - "scope": "resource" - }, - "typescript.suggest.autoImports": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.autoImports%", - "scope": "resource" - }, - "javascript.suggest.completeJSDocs": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.completeJSDocs%", - "scope": "language-overridable" - }, - "typescript.suggest.completeJSDocs": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.completeJSDocs%", - "scope": "language-overridable" - }, - "javascript.suggest.jsdoc.generateReturns": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.suggest.jsdoc.generateReturns%", - "scope": "language-overridable" - }, - "typescript.suggest.jsdoc.generateReturns": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.suggest.jsdoc.generateReturns%", - "scope": "language-overridable" - }, - "typescript.locale": { - "type": "string", - "default": "auto", - "enum": [ - "auto", - "de", - "es", - "en", - "fr", - "it", - "ja", - "ko", - "ru", - "zh-CN", - "zh-TW" - ], - "enumDescriptions": [ - "%typescript.locale.auto%", - "Deutsch", - "español", - "English", - "français", - "italiano", - "日本語", - "한국어", - "русский", - "中文(简体)", - "中文(繁體)" - ], - "markdownDescription": "%typescript.locale%", - "scope": "window" - }, - "javascript.suggestionActions.enabled": { - "type": "boolean", - "default": true, - "description": "%javascript.suggestionActions.enabled%", - "scope": "resource" - }, - "typescript.suggestionActions.enabled": { - "type": "boolean", - "default": true, - "description": "%typescript.suggestionActions.enabled%", - "scope": "resource" - }, - "javascript.preferences.quoteStyle": { - "type": "string", - "enum": [ - "auto", - "single", - "double" - ], - "default": "auto", - "markdownDescription": "%typescript.preferences.quoteStyle%", - "markdownEnumDescriptions": [ - "%typescript.preferences.quoteStyle.auto%", - "%typescript.preferences.quoteStyle.single%", - "%typescript.preferences.quoteStyle.double%" - ], - "scope": "language-overridable" - }, - "typescript.preferences.quoteStyle": { - "type": "string", - "enum": [ - "auto", - "single", - "double" - ], - "default": "auto", - "markdownDescription": "%typescript.preferences.quoteStyle%", - "markdownEnumDescriptions": [ - "%typescript.preferences.quoteStyle.auto%", - "%typescript.preferences.quoteStyle.single%", - "%typescript.preferences.quoteStyle.double%" - ], - "scope": "language-overridable" - }, - "javascript.preferences.importModuleSpecifier": { - "type": "string", - "enum": [ - "shortest", - "relative", - "non-relative", - "project-relative" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.importModuleSpecifier.shortest%", - "%typescript.preferences.importModuleSpecifier.relative%", - "%typescript.preferences.importModuleSpecifier.nonRelative%", - "%typescript.preferences.importModuleSpecifier.projectRelative%" - ], - "default": "shortest", - "description": "%typescript.preferences.importModuleSpecifier%", - "scope": "language-overridable" - }, - "typescript.preferences.importModuleSpecifier": { - "type": "string", - "enum": [ - "shortest", - "relative", - "non-relative", - "project-relative" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.importModuleSpecifier.shortest%", - "%typescript.preferences.importModuleSpecifier.relative%", - "%typescript.preferences.importModuleSpecifier.nonRelative%", - "%typescript.preferences.importModuleSpecifier.projectRelative%" - ], - "default": "shortest", - "description": "%typescript.preferences.importModuleSpecifier%", - "scope": "language-overridable" - }, - "javascript.preferences.importModuleSpecifierEnding": { - "type": "string", - "enum": [ - "auto", - "minimal", - "index", - "js" - ], - "enumItemLabels": [ - null, - null, - null, - "%typescript.preferences.importModuleSpecifierEnding.label.js%" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.importModuleSpecifierEnding.auto%", - "%typescript.preferences.importModuleSpecifierEnding.minimal%", - "%typescript.preferences.importModuleSpecifierEnding.index%", - "%typescript.preferences.importModuleSpecifierEnding.js%" - ], - "default": "auto", - "description": "%typescript.preferences.importModuleSpecifierEnding%", - "scope": "language-overridable" - }, - "typescript.preferences.importModuleSpecifierEnding": { - "type": "string", - "enum": [ - "auto", - "minimal", - "index", - "js" - ], - "enumItemLabels": [ - null, - null, - null, - "%typescript.preferences.importModuleSpecifierEnding.label.js%" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.importModuleSpecifierEnding.auto%", - "%typescript.preferences.importModuleSpecifierEnding.minimal%", - "%typescript.preferences.importModuleSpecifierEnding.index%", - "%typescript.preferences.importModuleSpecifierEnding.js%" - ], - "default": "auto", - "description": "%typescript.preferences.importModuleSpecifierEnding%", - "scope": "language-overridable" - }, - "javascript.preferences.jsxAttributeCompletionStyle": { - "type": "string", - "enum": [ - "auto", - "braces", - "none" - ], - "markdownEnumDescriptions": [ - "%javascript.preferences.jsxAttributeCompletionStyle.auto%", - "%typescript.preferences.jsxAttributeCompletionStyle.braces%", - "%typescript.preferences.jsxAttributeCompletionStyle.none%" - ], - "default": "auto", - "description": "%typescript.preferences.jsxAttributeCompletionStyle%", - "scope": "language-overridable" - }, - "typescript.preferences.jsxAttributeCompletionStyle": { - "type": "string", - "enum": [ - "auto", - "braces", - "none" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.jsxAttributeCompletionStyle.auto%", - "%typescript.preferences.jsxAttributeCompletionStyle.braces%", - "%typescript.preferences.jsxAttributeCompletionStyle.none%" - ], - "default": "auto", - "description": "%typescript.preferences.jsxAttributeCompletionStyle%", - "scope": "language-overridable" - }, - "typescript.preferences.includePackageJsonAutoImports": { - "type": "string", - "enum": [ - "auto", - "on", - "off" - ], - "enumDescriptions": [ - "%typescript.preferences.includePackageJsonAutoImports.auto%", - "%typescript.preferences.includePackageJsonAutoImports.on%", - "%typescript.preferences.includePackageJsonAutoImports.off%" - ], - "default": "auto", - "markdownDescription": "%typescript.preferences.includePackageJsonAutoImports%", - "scope": "window" - }, - "typescript.preferences.autoImportFileExcludePatterns": { - "type": "array", - "items": { - "type": "string" + "typescript.disableAutomaticTypeAcquisition": { + "type": "boolean", + "default": false, + "markdownDescription": "%typescript.disableAutomaticTypeAcquisition%", + "scope": "window", + "tags": [ + "usesOnlineServices" + ] }, - "markdownDescription": "%typescript.preferences.autoImportFileExcludePatterns%", - "scope": "resource" - }, - "javascript.preferences.autoImportFileExcludePatterns": { - "type": "array", - "items": { - "type": "string" + "typescript.enablePromptUseWorkspaceTsdk": { + "type": "boolean", + "default": false, + "description": "%typescript.enablePromptUseWorkspaceTsdk%", + "scope": "window" }, - "markdownDescription": "%typescript.preferences.autoImportFileExcludePatterns%", - "scope": "resource" - }, - "typescript.preferences.autoImportSpecifierExcludeRegexes": { - "type": "array", - "items": { - "type": "string" + "javascript.referencesCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%javascript.referencesCodeLens.enabled%", + "scope": "window" }, - "markdownDescription": "%typescript.preferences.autoImportSpecifierExcludeRegexes%", - "scope": "resource" - }, - "javascript.preferences.autoImportSpecifierExcludeRegexes": { - "type": "array", - "items": { - "type": "string" + "javascript.referencesCodeLens.showOnAllFunctions": { + "type": "boolean", + "default": false, + "description": "%javascript.referencesCodeLens.showOnAllFunctions%", + "scope": "window" }, - "markdownDescription": "%typescript.preferences.autoImportSpecifierExcludeRegexes%", - "scope": "resource" - }, - "typescript.preferences.preferTypeOnlyAutoImports": { - "type": "boolean", - "default": false, - "markdownDescription": "%typescript.preferences.preferTypeOnlyAutoImports%", - "scope": "resource" - }, - "javascript.preferences.renameShorthandProperties": { - "type": "boolean", - "default": true, - "description": "%typescript.preferences.useAliasesForRenames%", - "deprecationMessage": "%typescript.preferences.renameShorthandProperties.deprecationMessage%", - "scope": "language-overridable" - }, - "typescript.preferences.renameShorthandProperties": { - "type": "boolean", - "default": true, - "description": "%typescript.preferences.useAliasesForRenames%", - "deprecationMessage": "%typescript.preferences.renameShorthandProperties.deprecationMessage%", - "scope": "language-overridable" - }, - "javascript.preferences.useAliasesForRenames": { - "type": "boolean", - "default": true, - "description": "%typescript.preferences.useAliasesForRenames%", - "scope": "language-overridable" - }, - "typescript.preferences.useAliasesForRenames": { - "type": "boolean", - "default": true, - "description": "%typescript.preferences.useAliasesForRenames%", - "scope": "language-overridable" - }, - "javascript.preferences.renameMatchingJsxTags": { - "type": "boolean", - "default": true, - "description": "%typescript.preferences.renameMatchingJsxTags%", - "scope": "language-overridable" - }, - "typescript.preferences.renameMatchingJsxTags": { - "type": "boolean", - "default": true, - "description": "%typescript.preferences.renameMatchingJsxTags%", - "scope": "language-overridable" - }, - "typescript.preferences.organizeImports": { - "type": "object", - "markdownDescription": "%typescript.preferences.organizeImports%", - "properties": { - "caseSensitivity": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.caseSensitivity%", - "enum": [ - "auto", - "caseInsensitive", - "caseSensitive" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.organizeImports.caseSensitivity.auto%", - "%typescript.preferences.organizeImports.caseSensitivity.insensitive", - "%typescript.preferences.organizeImports.caseSensitivity.sensitive%" - ], - "default": "auto" - }, - "typeOrder": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.typeOrder%", - "enum": [ - "auto", - "last", - "inline", - "first" - ], - "default": "auto", - "markdownEnumDescriptions": [ - "%typescript.preferences.organizeImports.typeOrder.auto%", - "%typescript.preferences.organizeImports.typeOrder.last%", - "%typescript.preferences.organizeImports.typeOrder.inline%", - "%typescript.preferences.organizeImports.typeOrder.first%" - ] - }, - "unicodeCollation": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.unicodeCollation%", - "enum": [ - "ordinal", - "unicode" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.organizeImports.unicodeCollation.ordinal%", - "%typescript.preferences.organizeImports.unicodeCollation.unicode%" - ], - "default": "ordinal" - }, - "locale": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.locale%" - }, - "numericCollation": { - "type": "boolean", - "markdownDescription": "%typescript.preferences.organizeImports.numericCollation%" - }, - "accentCollation": { - "type": "boolean", - "markdownDescription": "%typescript.preferences.organizeImports.accentCollation%" - }, - "caseFirst": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.caseFirst%", - "enum": [ - "default", - "upper", - "lower" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.organizeImports.caseFirst.default%", - "%typescript.preferences.organizeImports.caseFirst.upper%", - "%typescript.preferences.organizeImports.caseFirst.lower%" - ], - "default": "default" - } + "typescript.referencesCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%typescript.referencesCodeLens.enabled%", + "scope": "window" + }, + "typescript.referencesCodeLens.showOnAllFunctions": { + "type": "boolean", + "default": false, + "description": "%typescript.referencesCodeLens.showOnAllFunctions%", + "scope": "window" + }, + "typescript.implementationsCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%typescript.implementationsCodeLens.enabled%", + "scope": "window" + }, + "typescript.implementationsCodeLens.showOnInterfaceMethods": { + "type": "boolean", + "default": false, + "description": "%typescript.implementationsCodeLens.showOnInterfaceMethods%", + "scope": "window" + }, + "typescript.inlayHints.parameterNames.enabled": { + "type": "string", + "enum": [ + "none", + "literals", + "all" + ], + "enumDescriptions": [ + "%inlayHints.parameterNames.none%", + "%inlayHints.parameterNames.literals%", + "%inlayHints.parameterNames.all%" + ], + "default": "none", + "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", + "scope": "resource" + }, + "typescript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", + "scope": "resource" + }, + "typescript.inlayHints.parameterTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", + "scope": "resource" + }, + "typescript.inlayHints.variableTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", + "scope": "resource" + }, + "typescript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "scope": "resource" + }, + "typescript.inlayHints.propertyDeclarationTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", + "scope": "resource" + }, + "typescript.inlayHints.functionLikeReturnTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", + "scope": "resource" + }, + "typescript.inlayHints.enumMemberValues.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.enumMemberValues.enabled%", + "scope": "resource" + }, + "javascript.inlayHints.parameterNames.enabled": { + "type": "string", + "enum": [ + "none", + "literals", + "all" + ], + "enumDescriptions": [ + "%inlayHints.parameterNames.none%", + "%inlayHints.parameterNames.literals%", + "%inlayHints.parameterNames.all%" + ], + "default": "none", + "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", + "scope": "resource" + }, + "javascript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", + "scope": "resource" + }, + "javascript.inlayHints.parameterTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", + "scope": "resource" + }, + "javascript.inlayHints.variableTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", + "scope": "resource" + }, + "javascript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "scope": "resource" + }, + "javascript.inlayHints.propertyDeclarationTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", + "scope": "resource" + }, + "javascript.inlayHints.functionLikeReturnTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", + "scope": "resource" + }, + "typescript.reportStyleChecksAsWarnings": { + "type": "boolean", + "default": true, + "description": "%typescript.reportStyleChecksAsWarnings%", + "scope": "window" + }, + "typescript.validate.enable": { + "type": "boolean", + "default": true, + "description": "%typescript.validate.enable%", + "scope": "window" + }, + "javascript.validate.enable": { + "type": "boolean", + "default": true, + "description": "%javascript.validate.enable%", + "scope": "window" + }, + "js/ts.implicitProjectConfig.module": { + "type": "string", + "markdownDescription": "%configuration.implicitProjectConfig.module%", + "default": "ESNext", + "enum": [ + "CommonJS", + "AMD", + "System", + "UMD", + "ES6", + "ES2015", + "ES2020", + "ESNext", + "None", + "ES2022", + "Node12", + "NodeNext" + ], + "scope": "window" + }, + "js/ts.implicitProjectConfig.target": { + "type": "string", + "default": "ES2022", + "markdownDescription": "%configuration.implicitProjectConfig.target%", + "enum": [ + "ES3", + "ES5", + "ES6", + "ES2015", + "ES2016", + "ES2017", + "ES2018", + "ES2019", + "ES2020", + "ES2021", + "ES2022", + "ES2023", + "ES2024", + "ESNext" + ], + "scope": "window" + }, + "javascript.implicitProjectConfig.checkJs": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.implicitProjectConfig.checkJs%", + "markdownDeprecationMessage": "%configuration.javascript.checkJs.checkJs.deprecation%", + "scope": "window" + }, + "js/ts.implicitProjectConfig.checkJs": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.implicitProjectConfig.checkJs%", + "scope": "window" + }, + "javascript.implicitProjectConfig.experimentalDecorators": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.implicitProjectConfig.experimentalDecorators%", + "markdownDeprecationMessage": "%configuration.javascript.checkJs.experimentalDecorators.deprecation%", + "scope": "window" + }, + "js/ts.implicitProjectConfig.experimentalDecorators": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.implicitProjectConfig.experimentalDecorators%", + "scope": "window" + }, + "js/ts.implicitProjectConfig.strictNullChecks": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.implicitProjectConfig.strictNullChecks%", + "scope": "window" + }, + "js/ts.implicitProjectConfig.strictFunctionTypes": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.implicitProjectConfig.strictFunctionTypes%", + "scope": "window" + }, + "typescript.tsc.autoDetect": { + "type": "string", + "default": "on", + "enum": [ + "on", + "off", + "build", + "watch" + ], + "markdownEnumDescriptions": [ + "%typescript.tsc.autoDetect.on%", + "%typescript.tsc.autoDetect.off%", + "%typescript.tsc.autoDetect.build%", + "%typescript.tsc.autoDetect.watch%" + ], + "description": "%typescript.tsc.autoDetect%", + "scope": "window" + }, + "typescript.locale": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "de", + "es", + "en", + "fr", + "it", + "ja", + "ko", + "ru", + "zh-CN", + "zh-TW" + ], + "enumDescriptions": [ + "%typescript.locale.auto%", + "Deutsch", + "español", + "English", + "français", + "italiano", + "日本語", + "한국어", + "русский", + "中文(简体)", + "中文(繁體)" + ], + "markdownDescription": "%typescript.locale%", + "scope": "window" + }, + "javascript.suggestionActions.enabled": { + "type": "boolean", + "default": true, + "description": "%javascript.suggestionActions.enabled%", + "scope": "resource" + }, + "typescript.suggestionActions.enabled": { + "type": "boolean", + "default": true, + "description": "%typescript.suggestionActions.enabled%", + "scope": "resource" + }, + "typescript.updateImportsOnFileMove.enabled": { + "type": "string", + "enum": [ + "prompt", + "always", + "never" + ], + "markdownEnumDescriptions": [ + "%typescript.updateImportsOnFileMove.enabled.prompt%", + "%typescript.updateImportsOnFileMove.enabled.always%", + "%typescript.updateImportsOnFileMove.enabled.never%" + ], + "default": "prompt", + "description": "%typescript.updateImportsOnFileMove.enabled%", + "scope": "resource" + }, + "javascript.updateImportsOnFileMove.enabled": { + "type": "string", + "enum": [ + "prompt", + "always", + "never" + ], + "markdownEnumDescriptions": [ + "%typescript.updateImportsOnFileMove.enabled.prompt%", + "%typescript.updateImportsOnFileMove.enabled.always%", + "%typescript.updateImportsOnFileMove.enabled.never%" + ], + "default": "prompt", + "description": "%typescript.updateImportsOnFileMove.enabled%", + "scope": "resource" + }, + "typescript.autoClosingTags": { + "type": "boolean", + "default": true, + "description": "%typescript.autoClosingTags%", + "scope": "language-overridable" + }, + "javascript.autoClosingTags": { + "type": "boolean", + "default": true, + "description": "%typescript.autoClosingTags%", + "scope": "language-overridable" + }, + "typescript.workspaceSymbols.scope": { + "type": "string", + "enum": [ + "allOpenProjects", + "currentProject" + ], + "enumDescriptions": [ + "%typescript.workspaceSymbols.scope.allOpenProjects%", + "%typescript.workspaceSymbols.scope.currentProject%" + ], + "default": "allOpenProjects", + "markdownDescription": "%typescript.workspaceSymbols.scope%", + "scope": "window" + }, + "typescript.preferGoToSourceDefinition": { + "type": "boolean", + "default": false, + "description": "%configuration.preferGoToSourceDefinition%", + "scope": "window" + }, + "javascript.preferGoToSourceDefinition": { + "type": "boolean", + "default": false, + "description": "%configuration.preferGoToSourceDefinition%", + "scope": "window" + }, + "typescript.workspaceSymbols.excludeLibrarySymbols": { + "type": "boolean", + "default": true, + "markdownDescription": "%typescript.workspaceSymbols.excludeLibrarySymbols%", + "scope": "window" + }, + "typescript.tsserver.enableRegionDiagnostics": { + "type": "boolean", + "default": true, + "description": "%typescript.tsserver.enableRegionDiagnostics%", + "scope": "window" + }, + "javascript.updateImportsOnPaste.enabled": { + "scope": "window", + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.updateImportsOnPaste%" + }, + "typescript.updateImportsOnPaste.enabled": { + "scope": "window", + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.updateImportsOnPaste%" + }, + "typescript.experimental.expandableHover": { + "type": "boolean", + "default": true, + "description": "%configuration.expandableHover%", + "scope": "window", + "tags": [ + "experimental" + ] } - }, - "javascript.preferences.organizeImports": { - "type": "object", - "markdownDescription": "%typescript.preferences.organizeImports%", - "properties": { - "caseSensitivity": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.caseSensitivity%", - "enum": [ - "auto", - "caseInsensitive", - "caseSensitive" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.organizeImports.caseSensitivity.auto%", - "%typescript.preferences.organizeImports.caseSensitivity.insensitive", - "%typescript.preferences.organizeImports.caseSensitivity.sensitive%" - ], - "default": "auto" - }, - "typeOrder": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.typeOrder%", - "enum": [ - "auto", - "last", - "inline", - "first" - ], - "default": "auto", - "markdownEnumDescriptions": [ - "%typescript.preferences.organizeImports.typeOrder.auto%", - "%typescript.preferences.organizeImports.typeOrder.last%", - "%typescript.preferences.organizeImports.typeOrder.inline%", - "%typescript.preferences.organizeImports.typeOrder.first%" - ] - }, - "unicodeCollation": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.unicodeCollation%", - "enum": [ - "ordinal", - "unicode" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.organizeImports.unicodeCollation.ordinal%", - "%typescript.preferences.organizeImports.unicodeCollation.unicode%" - ], - "default": "ordinal" - }, - "locale": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.locale%" - }, - "numericCollation": { - "type": "boolean", - "markdownDescription": "%typescript.preferences.organizeImports.numericCollation%" - }, - "accentCollation": { - "type": "boolean", - "markdownDescription": "%typescript.preferences.organizeImports.accentCollation%" - }, - "caseFirst": { - "type": "string", - "markdownDescription": "%typescript.preferences.organizeImports.caseFirst%", - "enum": [ - "default", - "upper", - "lower" - ], - "markdownEnumDescriptions": [ - "%typescript.preferences.organizeImports.caseFirst.default%", - "%typescript.preferences.organizeImports.caseFirst.upper%", - "%typescript.preferences.organizeImports.caseFirst.lower%" - ], - "default": "default" - } + } + }, + { + "type": "object", + "title": "%configuration.typescript.suggest%", + "order": 21, + "properties": { + "typescript.suggest.enabled": { + "type": "boolean", + "default": true, + "description": "%typescript.suggest.enabled%", + "scope": "language-overridable" + }, + "typescript.suggest.autoImports": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.autoImports%", + "scope": "resource" + }, + "typescript.suggest.completeFunctionCalls": { + "type": "boolean", + "default": false, + "description": "%configuration.suggest.completeFunctionCalls%", + "scope": "resource" + }, + "typescript.suggest.paths": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.paths%", + "scope": "resource" + }, + "typescript.suggest.completeJSDocs": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.completeJSDocs%", + "scope": "language-overridable" + }, + "typescript.suggest.jsdoc.generateReturns": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.suggest.jsdoc.generateReturns%", + "scope": "language-overridable" + }, + "typescript.suggest.includeAutomaticOptionalChainCompletions": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.includeAutomaticOptionalChainCompletions%", + "scope": "resource" + }, + "typescript.suggest.includeCompletionsForImportStatements": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.includeCompletionsForImportStatements%", + "scope": "resource" + }, + "typescript.suggest.classMemberSnippets.enabled": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.classMemberSnippets.enabled%", + "scope": "resource" + }, + "typescript.suggest.objectLiteralMethodSnippets.enabled": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.objectLiteralMethodSnippets.enabled%", + "scope": "resource" } - }, - "typescript.updateImportsOnFileMove.enabled": { - "type": "string", - "enum": [ - "prompt", - "always", - "never" - ], - "markdownEnumDescriptions": [ - "%typescript.updateImportsOnFileMove.enabled.prompt%", - "%typescript.updateImportsOnFileMove.enabled.always%", - "%typescript.updateImportsOnFileMove.enabled.never%" - ], - "default": "prompt", - "description": "%typescript.updateImportsOnFileMove.enabled%", - "scope": "resource" - }, - "javascript.updateImportsOnFileMove.enabled": { - "type": "string", - "enum": [ - "prompt", - "always", - "never" - ], - "markdownEnumDescriptions": [ - "%typescript.updateImportsOnFileMove.enabled.prompt%", - "%typescript.updateImportsOnFileMove.enabled.always%", - "%typescript.updateImportsOnFileMove.enabled.never%" - ], - "default": "prompt", - "description": "%typescript.updateImportsOnFileMove.enabled%", - "scope": "resource" - }, - "typescript.autoClosingTags": { - "type": "boolean", - "default": true, - "description": "%typescript.autoClosingTags%", - "scope": "language-overridable" - }, - "javascript.autoClosingTags": { - "type": "boolean", - "default": true, - "description": "%typescript.autoClosingTags%", - "scope": "language-overridable" - }, - "javascript.suggest.enabled": { - "type": "boolean", - "default": true, - "description": "%typescript.suggest.enabled%", - "scope": "language-overridable" - }, - "typescript.suggest.enabled": { - "type": "boolean", - "default": true, - "description": "%typescript.suggest.enabled%", - "scope": "language-overridable" - }, - "typescript.surveys.enabled": { - "type": "boolean", - "default": true, - "description": "%configuration.surveys.enabled%", - "scope": "window" - }, - "typescript.tsserver.useSeparateSyntaxServer": { - "type": "boolean", - "default": true, - "description": "%configuration.tsserver.useSeparateSyntaxServer%", - "markdownDeprecationMessage": "%configuration.tsserver.useSeparateSyntaxServer.deprecation%", - "scope": "window" - }, - "typescript.tsserver.useSyntaxServer": { - "type": "string", - "scope": "window", - "description": "%configuration.tsserver.useSyntaxServer%", - "default": "auto", - "enum": [ - "always", - "never", - "auto" - ], - "enumDescriptions": [ - "%configuration.tsserver.useSyntaxServer.always%", - "%configuration.tsserver.useSyntaxServer.never%", - "%configuration.tsserver.useSyntaxServer.auto%" - ] - }, - "typescript.tsserver.maxTsServerMemory": { - "type": "number", - "default": 3072, - "markdownDescription": "%configuration.tsserver.maxTsServerMemory%", - "scope": "window" - }, - "typescript.tsserver.experimental.enableProjectDiagnostics": { - "type": "boolean", - "default": false, - "description": "%configuration.tsserver.experimental.enableProjectDiagnostics%", - "scope": "window", - "tags": [ - "experimental" - ] - }, - "typescript.tsserver.experimental.useVsCodeWatcher": { - "type": "boolean", - "description": "%configuration.tsserver.useVsCodeWatcher%", - "deprecationMessage": "%configuration.tsserver.useVsCodeWatcher.deprecation%", - "default": true - }, - "typescript.tsserver.watchOptions": { - "description": "%configuration.tsserver.watchOptions%", - "scope": "window", - "default": "vscode", - "oneOf": [ - { - "type": "string", - "const": "vscode", - "description": "%configuration.tsserver.watchOptions.vscode%" + } + }, + { + "type": "object", + "title": "%configuration.javascript.suggest%", + "order": 21, + "properties": { + "javascript.suggest.enabled": { + "type": "boolean", + "default": true, + "description": "%typescript.suggest.enabled%", + "scope": "language-overridable" + }, + "javascript.suggest.autoImports": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.autoImports%", + "scope": "resource" + }, + "javascript.suggest.names": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.suggest.names%", + "scope": "resource" + }, + "javascript.suggest.paths": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.paths%", + "scope": "resource" + }, + "javascript.suggest.completeJSDocs": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.completeJSDocs%", + "scope": "language-overridable" + }, + "javascript.suggest.jsdoc.generateReturns": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.suggest.jsdoc.generateReturns%", + "scope": "language-overridable" + }, + "javascript.suggest.completeFunctionCalls": { + "type": "boolean", + "default": false, + "description": "%configuration.suggest.completeFunctionCalls%", + "scope": "resource" + }, + "javascript.suggest.includeAutomaticOptionalChainCompletions": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.includeAutomaticOptionalChainCompletions%", + "scope": "resource" + }, + "javascript.suggest.includeCompletionsForImportStatements": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.includeCompletionsForImportStatements%", + "scope": "resource" + }, + "javascript.suggest.classMemberSnippets.enabled": { + "type": "boolean", + "default": true, + "description": "%configuration.suggest.classMemberSnippets.enabled%", + "scope": "resource" + } + } + }, + { + "type": "object", + "title": "%configuration.typescript.preferences%", + "order": 21, + "properties": { + "typescript.preferences.quoteStyle": { + "type": "string", + "enum": [ + "auto", + "single", + "double" + ], + "default": "auto", + "markdownDescription": "%typescript.preferences.quoteStyle%", + "markdownEnumDescriptions": [ + "%typescript.preferences.quoteStyle.auto%", + "%typescript.preferences.quoteStyle.single%", + "%typescript.preferences.quoteStyle.double%" + ], + "scope": "language-overridable" + }, + "typescript.preferences.importModuleSpecifier": { + "type": "string", + "enum": [ + "shortest", + "relative", + "non-relative", + "project-relative" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.importModuleSpecifier.shortest%", + "%typescript.preferences.importModuleSpecifier.relative%", + "%typescript.preferences.importModuleSpecifier.nonRelative%", + "%typescript.preferences.importModuleSpecifier.projectRelative%" + ], + "default": "shortest", + "description": "%typescript.preferences.importModuleSpecifier%", + "scope": "language-overridable" + }, + "typescript.preferences.importModuleSpecifierEnding": { + "type": "string", + "enum": [ + "auto", + "minimal", + "index", + "js" + ], + "enumItemLabels": [ + null, + null, + null, + "%typescript.preferences.importModuleSpecifierEnding.label.js%" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.importModuleSpecifierEnding.auto%", + "%typescript.preferences.importModuleSpecifierEnding.minimal%", + "%typescript.preferences.importModuleSpecifierEnding.index%", + "%typescript.preferences.importModuleSpecifierEnding.js%" + ], + "default": "auto", + "description": "%typescript.preferences.importModuleSpecifierEnding%", + "scope": "language-overridable" + }, + "typescript.preferences.jsxAttributeCompletionStyle": { + "type": "string", + "enum": [ + "auto", + "braces", + "none" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.jsxAttributeCompletionStyle.auto%", + "%typescript.preferences.jsxAttributeCompletionStyle.braces%", + "%typescript.preferences.jsxAttributeCompletionStyle.none%" + ], + "default": "auto", + "description": "%typescript.preferences.jsxAttributeCompletionStyle%", + "scope": "language-overridable" + }, + "typescript.preferences.includePackageJsonAutoImports": { + "type": "string", + "enum": [ + "auto", + "on", + "off" + ], + "enumDescriptions": [ + "%typescript.preferences.includePackageJsonAutoImports.auto%", + "%typescript.preferences.includePackageJsonAutoImports.on%", + "%typescript.preferences.includePackageJsonAutoImports.off%" + ], + "default": "auto", + "markdownDescription": "%typescript.preferences.includePackageJsonAutoImports%", + "scope": "window" + }, + "typescript.preferences.autoImportFileExcludePatterns": { + "type": "array", + "items": { + "type": "string" }, - { - "type": "object", - "properties": { - "watchFile": { - "type": "string", - "description": "%configuration.tsserver.watchOptions.watchFile%", - "enum": [ - "fixedChunkSizePolling", - "fixedPollingInterval", - "priorityPollingInterval", - "dynamicPriorityPolling", - "useFsEvents", - "useFsEventsOnParentDirectory" - ], - "enumDescriptions": [ - "%configuration.tsserver.watchOptions.watchFile.fixedChunkSizePolling%", - "%configuration.tsserver.watchOptions.watchFile.fixedPollingInterval%", - "%configuration.tsserver.watchOptions.watchFile.priorityPollingInterval%", - "%configuration.tsserver.watchOptions.watchFile.dynamicPriorityPolling%", - "%configuration.tsserver.watchOptions.watchFile.useFsEvents%", - "%configuration.tsserver.watchOptions.watchFile.useFsEventsOnParentDirectory%" - ], - "default": "useFsEvents" - }, - "watchDirectory": { - "type": "string", - "description": "%configuration.tsserver.watchOptions.watchDirectory%", - "enum": [ - "fixedChunkSizePolling", - "fixedPollingInterval", - "dynamicPriorityPolling", - "useFsEvents" - ], - "enumDescriptions": [ - "%configuration.tsserver.watchOptions.watchDirectory.fixedChunkSizePolling%", - "%configuration.tsserver.watchOptions.watchDirectory.fixedPollingInterval%", - "%configuration.tsserver.watchOptions.watchDirectory.dynamicPriorityPolling%", - "%configuration.tsserver.watchOptions.watchDirectory.useFsEvents%" - ], - "default": "useFsEvents" - }, - "fallbackPolling": { - "type": "string", - "description": "%configuration.tsserver.watchOptions.fallbackPolling%", - "enum": [ - "fixedPollingInterval", - "priorityPollingInterval", - "dynamicPriorityPolling" - ], - "enumDescriptions": [ - "configuration.tsserver.watchOptions.fallbackPolling.fixedPollingInterval", - "configuration.tsserver.watchOptions.fallbackPolling.priorityPollingInterval", - "configuration.tsserver.watchOptions.fallbackPolling.dynamicPriorityPolling" - ] - }, - "synchronousWatchDirectory": { - "type": "boolean", - "description": "%configuration.tsserver.watchOptions.synchronousWatchDirectory%" - } + "markdownDescription": "%typescript.preferences.autoImportFileExcludePatterns%", + "scope": "resource" + }, + "typescript.preferences.autoImportSpecifierExcludeRegexes": { + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "%typescript.preferences.autoImportSpecifierExcludeRegexes%", + "scope": "resource" + }, + "typescript.preferences.preferTypeOnlyAutoImports": { + "type": "boolean", + "default": false, + "markdownDescription": "%typescript.preferences.preferTypeOnlyAutoImports%", + "scope": "resource" + }, + "typescript.preferences.renameShorthandProperties": { + "type": "boolean", + "default": true, + "description": "%typescript.preferences.useAliasesForRenames%", + "deprecationMessage": "%typescript.preferences.renameShorthandProperties.deprecationMessage%", + "scope": "language-overridable" + }, + "typescript.preferences.useAliasesForRenames": { + "type": "boolean", + "default": true, + "description": "%typescript.preferences.useAliasesForRenames%", + "scope": "language-overridable" + }, + "typescript.preferences.renameMatchingJsxTags": { + "type": "boolean", + "default": true, + "description": "%typescript.preferences.renameMatchingJsxTags%", + "scope": "language-overridable" + }, + "typescript.preferences.organizeImports": { + "type": "object", + "markdownDescription": "%typescript.preferences.organizeImports%", + "properties": { + "caseSensitivity": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.caseSensitivity%", + "enum": [ + "auto", + "caseInsensitive", + "caseSensitive" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.caseSensitivity.auto%", + "%typescript.preferences.organizeImports.caseSensitivity.insensitive", + "%typescript.preferences.organizeImports.caseSensitivity.sensitive%" + ], + "default": "auto" + }, + "typeOrder": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.typeOrder%", + "enum": [ + "auto", + "last", + "inline", + "first" + ], + "default": "auto", + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.typeOrder.auto%", + "%typescript.preferences.organizeImports.typeOrder.last%", + "%typescript.preferences.organizeImports.typeOrder.inline%", + "%typescript.preferences.organizeImports.typeOrder.first%" + ] + }, + "unicodeCollation": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.unicodeCollation%", + "enum": [ + "ordinal", + "unicode" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.unicodeCollation.ordinal%", + "%typescript.preferences.organizeImports.unicodeCollation.unicode%" + ], + "default": "ordinal" + }, + "locale": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.locale%" + }, + "numericCollation": { + "type": "boolean", + "markdownDescription": "%typescript.preferences.organizeImports.numericCollation%" + }, + "accentCollation": { + "type": "boolean", + "markdownDescription": "%typescript.preferences.organizeImports.accentCollation%" + }, + "caseFirst": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.caseFirst%", + "enum": [ + "default", + "upper", + "lower" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.caseFirst.default%", + "%typescript.preferences.organizeImports.caseFirst.upper%", + "%typescript.preferences.organizeImports.caseFirst.lower%" + ], + "default": "default" } } - ] - }, - "typescript.workspaceSymbols.scope": { - "type": "string", - "enum": [ - "allOpenProjects", - "currentProject" - ], - "enumDescriptions": [ - "%typescript.workspaceSymbols.scope.allOpenProjects%", - "%typescript.workspaceSymbols.scope.currentProject%" - ], - "default": "allOpenProjects", - "markdownDescription": "%typescript.workspaceSymbols.scope%", - "scope": "window" - }, - "javascript.suggest.classMemberSnippets.enabled": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.classMemberSnippets.enabled%", - "scope": "resource" - }, - "typescript.suggest.classMemberSnippets.enabled": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.classMemberSnippets.enabled%", - "scope": "resource" - }, - "typescript.suggest.objectLiteralMethodSnippets.enabled": { - "type": "boolean", - "default": true, - "description": "%configuration.suggest.objectLiteralMethodSnippets.enabled%", - "scope": "resource" - }, - "typescript.tsserver.web.projectWideIntellisense.enabled": { - "type": "boolean", - "default": true, - "description": "%configuration.tsserver.web.projectWideIntellisense.enabled%", - "scope": "window" - }, - "typescript.tsserver.web.projectWideIntellisense.suppressSemanticErrors": { - "type": "boolean", - "default": false, - "description": "%configuration.tsserver.web.projectWideIntellisense.suppressSemanticErrors%", - "scope": "window" - }, - "typescript.tsserver.web.typeAcquisition.enabled": { - "type": "boolean", - "default": true, - "description": "%configuration.tsserver.web.typeAcquisition.enabled%", - "scope": "window" - }, - "typescript.tsserver.nodePath": { - "type": "string", - "description": "%configuration.tsserver.nodePath%", - "scope": "window" - }, - "typescript.preferGoToSourceDefinition": { - "type": "boolean", - "default": false, - "description": "%configuration.preferGoToSourceDefinition%", - "scope": "window" - }, - "javascript.preferGoToSourceDefinition": { - "type": "boolean", - "default": false, - "description": "%configuration.preferGoToSourceDefinition%", - "scope": "window" - }, - "typescript.workspaceSymbols.excludeLibrarySymbols": { - "type": "boolean", - "default": true, - "markdownDescription": "%typescript.workspaceSymbols.excludeLibrarySymbols%", - "scope": "window" - }, - "typescript.tsserver.enableRegionDiagnostics": { - "type": "boolean", - "default": true, - "description": "%typescript.tsserver.enableRegionDiagnostics%", - "scope": "window" - }, - "javascript.updateImportsOnPaste.enabled": { - "scope": "window", - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.updateImportsOnPaste%" - }, - "typescript.updateImportsOnPaste.enabled": { - "scope": "window", - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.updateImportsOnPaste%" + } + } + }, + { + "type": "object", + "title": "%configuration.javascript.preferences%", + "order": 22, + "properties": { + "javascript.preferences.quoteStyle": { + "type": "string", + "enum": [ + "auto", + "single", + "double" + ], + "default": "auto", + "markdownDescription": "%typescript.preferences.quoteStyle%", + "markdownEnumDescriptions": [ + "%typescript.preferences.quoteStyle.auto%", + "%typescript.preferences.quoteStyle.single%", + "%typescript.preferences.quoteStyle.double%" + ], + "scope": "language-overridable" + }, + "javascript.preferences.importModuleSpecifier": { + "type": "string", + "enum": [ + "shortest", + "relative", + "non-relative", + "project-relative" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.importModuleSpecifier.shortest%", + "%typescript.preferences.importModuleSpecifier.relative%", + "%typescript.preferences.importModuleSpecifier.nonRelative%", + "%typescript.preferences.importModuleSpecifier.projectRelative%" + ], + "default": "shortest", + "description": "%typescript.preferences.importModuleSpecifier%", + "scope": "language-overridable" + }, + "javascript.preferences.importModuleSpecifierEnding": { + "type": "string", + "enum": [ + "auto", + "minimal", + "index", + "js" + ], + "enumItemLabels": [ + null, + null, + null, + "%typescript.preferences.importModuleSpecifierEnding.label.js%" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.importModuleSpecifierEnding.auto%", + "%typescript.preferences.importModuleSpecifierEnding.minimal%", + "%typescript.preferences.importModuleSpecifierEnding.index%", + "%typescript.preferences.importModuleSpecifierEnding.js%" + ], + "default": "auto", + "description": "%typescript.preferences.importModuleSpecifierEnding%", + "scope": "language-overridable" + }, + "javascript.preferences.jsxAttributeCompletionStyle": { + "type": "string", + "enum": [ + "auto", + "braces", + "none" + ], + "markdownEnumDescriptions": [ + "%javascript.preferences.jsxAttributeCompletionStyle.auto%", + "%typescript.preferences.jsxAttributeCompletionStyle.braces%", + "%typescript.preferences.jsxAttributeCompletionStyle.none%" + ], + "default": "auto", + "description": "%typescript.preferences.jsxAttributeCompletionStyle%", + "scope": "language-overridable" + }, + "javascript.preferences.autoImportFileExcludePatterns": { + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "%typescript.preferences.autoImportFileExcludePatterns%", + "scope": "resource" + }, + "javascript.preferences.autoImportSpecifierExcludeRegexes": { + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "%typescript.preferences.autoImportSpecifierExcludeRegexes%", + "scope": "resource" + }, + "javascript.preferences.renameShorthandProperties": { + "type": "boolean", + "default": true, + "description": "%typescript.preferences.useAliasesForRenames%", + "deprecationMessage": "%typescript.preferences.renameShorthandProperties.deprecationMessage%", + "scope": "language-overridable" + }, + "javascript.preferences.useAliasesForRenames": { + "type": "boolean", + "default": true, + "description": "%typescript.preferences.useAliasesForRenames%", + "scope": "language-overridable" + }, + "javascript.preferences.renameMatchingJsxTags": { + "type": "boolean", + "default": true, + "description": "%typescript.preferences.renameMatchingJsxTags%", + "scope": "language-overridable" + }, + "javascript.preferences.organizeImports": { + "type": "object", + "markdownDescription": "%typescript.preferences.organizeImports%", + "properties": { + "caseSensitivity": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.caseSensitivity%", + "enum": [ + "auto", + "caseInsensitive", + "caseSensitive" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.caseSensitivity.auto%", + "%typescript.preferences.organizeImports.caseSensitivity.insensitive", + "%typescript.preferences.organizeImports.caseSensitivity.sensitive%" + ], + "default": "auto" + }, + "typeOrder": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.typeOrder%", + "enum": [ + "auto", + "last", + "inline", + "first" + ], + "default": "auto", + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.typeOrder.auto%", + "%typescript.preferences.organizeImports.typeOrder.last%", + "%typescript.preferences.organizeImports.typeOrder.inline%", + "%typescript.preferences.organizeImports.typeOrder.first%" + ] + }, + "unicodeCollation": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.unicodeCollation%", + "enum": [ + "ordinal", + "unicode" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.unicodeCollation.ordinal%", + "%typescript.preferences.organizeImports.unicodeCollation.unicode%" + ], + "default": "ordinal" + }, + "locale": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.locale%" + }, + "numericCollation": { + "type": "boolean", + "markdownDescription": "%typescript.preferences.organizeImports.numericCollation%" + }, + "accentCollation": { + "type": "boolean", + "markdownDescription": "%typescript.preferences.organizeImports.accentCollation%" + }, + "caseFirst": { + "type": "string", + "markdownDescription": "%typescript.preferences.organizeImports.caseFirst%", + "enum": [ + "default", + "upper", + "lower" + ], + "markdownEnumDescriptions": [ + "%typescript.preferences.organizeImports.caseFirst.default%", + "%typescript.preferences.organizeImports.caseFirst.upper%", + "%typescript.preferences.organizeImports.caseFirst.lower%" + ], + "default": "default" + } + } + } + } + }, + { + "type": "object", + "title": "%configuration.typescript.format%", + "order": 23, + "properties": { + "typescript.format.enable": { + "type": "boolean", + "default": true, + "description": "%typescript.format.enable%", + "scope": "window" + }, + "typescript.format.insertSpaceAfterCommaDelimiter": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterCommaDelimiter%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterConstructor": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterConstructor%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterSemicolonInForStatements": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterSemicolonInForStatements%", + "scope": "resource" + }, + "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", + "scope": "resource" + }, + "typescript.format.insertSpaceBeforeFunctionParenthesis": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceBeforeFunctionParenthesis%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", + "scope": "resource" + }, + "typescript.format.insertSpaceAfterTypeAssertion": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterTypeAssertion%", + "scope": "resource" + }, + "typescript.format.placeOpenBraceOnNewLineForFunctions": { + "type": "boolean", + "default": false, + "description": "%format.placeOpenBraceOnNewLineForFunctions%", + "scope": "resource" + }, + "typescript.format.placeOpenBraceOnNewLineForControlBlocks": { + "type": "boolean", + "default": false, + "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", + "scope": "resource" + }, + "typescript.format.semicolons": { + "type": "string", + "default": "ignore", + "description": "%format.semicolons%", + "scope": "resource", + "enum": [ + "ignore", + "insert", + "remove" + ], + "enumDescriptions": [ + "%format.semicolons.ignore%", + "%format.semicolons.insert%", + "%format.semicolons.remove%" + ] + }, + "typescript.format.indentSwitchCase": { + "type": "boolean", + "default": true, + "description": "%format.indentSwitchCase%", + "scope": "resource" + } + } + }, + { + "type": "object", + "title": "%configuration.javascript.format%", + "order": 24, + "properties": { + "javascript.format.enable": { + "type": "boolean", + "default": true, + "description": "%javascript.format.enable%", + "scope": "window" + }, + "javascript.format.insertSpaceAfterCommaDelimiter": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterCommaDelimiter%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterConstructor": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterConstructor%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterSemicolonInForStatements": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterSemicolonInForStatements%", + "scope": "resource" + }, + "javascript.format.insertSpaceBeforeAndAfterBinaryOperators": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterKeywordsInControlFlowStatements": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", + "scope": "resource" + }, + "javascript.format.insertSpaceBeforeFunctionParenthesis": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceBeforeFunctionParenthesis%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", + "scope": "resource" + }, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", + "scope": "resource" + }, + "javascript.format.placeOpenBraceOnNewLineForFunctions": { + "type": "boolean", + "default": false, + "description": "%format.placeOpenBraceOnNewLineForFunctions%", + "scope": "resource" + }, + "javascript.format.placeOpenBraceOnNewLineForControlBlocks": { + "type": "boolean", + "default": false, + "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", + "scope": "resource" + }, + "javascript.format.semicolons": { + "type": "string", + "default": "ignore", + "description": "%format.semicolons%", + "scope": "resource", + "enum": [ + "ignore", + "insert", + "remove" + ], + "enumDescriptions": [ + "%format.semicolons.ignore%", + "%format.semicolons.insert%", + "%format.semicolons.remove%" + ] + }, + "javascript.format.indentSwitchCase": { + "type": "boolean", + "default": true, + "description": "%format.indentSwitchCase%", + "scope": "resource" + } + } + }, + { + "type": "object", + "title": "%configuration.server%", + "order": 25, + "properties": { + "typescript.tsserver.nodePath": { + "type": "string", + "description": "%configuration.tsserver.nodePath%", + "scope": "window" + }, + "typescript.npm": { + "type": "string", + "markdownDescription": "%typescript.npm%", + "scope": "machine" + }, + "typescript.check.npmIsInstalled": { + "type": "boolean", + "default": true, + "markdownDescription": "%typescript.check.npmIsInstalled%", + "scope": "window" + }, + "typescript.tsserver.web.projectWideIntellisense.enabled": { + "type": "boolean", + "default": true, + "description": "%configuration.tsserver.web.projectWideIntellisense.enabled%", + "scope": "window" + }, + "typescript.tsserver.web.projectWideIntellisense.suppressSemanticErrors": { + "type": "boolean", + "default": false, + "description": "%configuration.tsserver.web.projectWideIntellisense.suppressSemanticErrors%", + "scope": "window" + }, + "typescript.tsserver.web.typeAcquisition.enabled": { + "type": "boolean", + "default": true, + "description": "%configuration.tsserver.web.typeAcquisition.enabled%", + "scope": "window" + }, + "typescript.tsserver.useSeparateSyntaxServer": { + "type": "boolean", + "default": true, + "description": "%configuration.tsserver.useSeparateSyntaxServer%", + "markdownDeprecationMessage": "%configuration.tsserver.useSeparateSyntaxServer.deprecation%", + "scope": "window" + }, + "typescript.tsserver.useSyntaxServer": { + "type": "string", + "scope": "window", + "description": "%configuration.tsserver.useSyntaxServer%", + "default": "auto", + "enum": [ + "always", + "never", + "auto" + ], + "enumDescriptions": [ + "%configuration.tsserver.useSyntaxServer.always%", + "%configuration.tsserver.useSyntaxServer.never%", + "%configuration.tsserver.useSyntaxServer.auto%" + ] + }, + "typescript.tsserver.maxTsServerMemory": { + "type": "number", + "default": 3072, + "markdownDescription": "%configuration.tsserver.maxTsServerMemory%", + "scope": "window" + }, + "typescript.tsserver.experimental.enableProjectDiagnostics": { + "type": "boolean", + "default": false, + "description": "%configuration.tsserver.experimental.enableProjectDiagnostics%", + "scope": "window", + "tags": [ + "experimental" + ] + }, + "typescript.tsserver.experimental.useVsCodeWatcher": { + "type": "boolean", + "description": "%configuration.tsserver.useVsCodeWatcher%", + "deprecationMessage": "%configuration.tsserver.useVsCodeWatcher.deprecation%", + "default": true + }, + "typescript.tsserver.watchOptions": { + "description": "%configuration.tsserver.watchOptions%", + "scope": "window", + "default": "vscode", + "oneOf": [ + { + "type": "string", + "const": "vscode", + "description": "%configuration.tsserver.watchOptions.vscode%" + }, + { + "type": "object", + "properties": { + "watchFile": { + "type": "string", + "description": "%configuration.tsserver.watchOptions.watchFile%", + "enum": [ + "fixedChunkSizePolling", + "fixedPollingInterval", + "priorityPollingInterval", + "dynamicPriorityPolling", + "useFsEvents", + "useFsEventsOnParentDirectory" + ], + "enumDescriptions": [ + "%configuration.tsserver.watchOptions.watchFile.fixedChunkSizePolling%", + "%configuration.tsserver.watchOptions.watchFile.fixedPollingInterval%", + "%configuration.tsserver.watchOptions.watchFile.priorityPollingInterval%", + "%configuration.tsserver.watchOptions.watchFile.dynamicPriorityPolling%", + "%configuration.tsserver.watchOptions.watchFile.useFsEvents%", + "%configuration.tsserver.watchOptions.watchFile.useFsEventsOnParentDirectory%" + ], + "default": "useFsEvents" + }, + "watchDirectory": { + "type": "string", + "description": "%configuration.tsserver.watchOptions.watchDirectory%", + "enum": [ + "fixedChunkSizePolling", + "fixedPollingInterval", + "dynamicPriorityPolling", + "useFsEvents" + ], + "enumDescriptions": [ + "%configuration.tsserver.watchOptions.watchDirectory.fixedChunkSizePolling%", + "%configuration.tsserver.watchOptions.watchDirectory.fixedPollingInterval%", + "%configuration.tsserver.watchOptions.watchDirectory.dynamicPriorityPolling%", + "%configuration.tsserver.watchOptions.watchDirectory.useFsEvents%" + ], + "default": "useFsEvents" + }, + "fallbackPolling": { + "type": "string", + "description": "%configuration.tsserver.watchOptions.fallbackPolling%", + "enum": [ + "fixedPollingInterval", + "priorityPollingInterval", + "dynamicPriorityPolling" + ], + "enumDescriptions": [ + "configuration.tsserver.watchOptions.fallbackPolling.fixedPollingInterval", + "configuration.tsserver.watchOptions.fallbackPolling.priorityPollingInterval", + "configuration.tsserver.watchOptions.fallbackPolling.dynamicPriorityPolling" + ] + }, + "synchronousWatchDirectory": { + "type": "boolean", + "description": "%configuration.tsserver.watchOptions.synchronousWatchDirectory%" + } + } + } + ] + }, + "typescript.tsserver.enableTracing": { + "type": "boolean", + "default": false, + "description": "%typescript.tsserver.enableTracing%", + "scope": "window" + }, + "typescript.tsserver.log": { + "type": "string", + "enum": [ + "off", + "terse", + "normal", + "verbose" + ], + "default": "off", + "description": "%typescript.tsserver.log%", + "scope": "window" + }, + "typescript.tsserver.pluginPaths": { + "type": "array", + "items": { + "type": "string", + "description": "%typescript.tsserver.pluginPaths.item%" + }, + "default": [], + "description": "%typescript.tsserver.pluginPaths%", + "scope": "machine" + } } } - }, + ], "commands": [ { "command": "typescript.reloadProjects", diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index f88b9c72969..2547b30e88c 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -5,6 +5,13 @@ "virtualWorkspaces": "In virtual workspaces, resolving and finding references across files is not supported.", "reloadProjects.title": "Reload Project", "configuration.typescript": "TypeScript", + "configuration.javascript.preferences": "JavaScript Preferences", + "configuration.typescript.preferences": "TypeScript Preferences", + "configuration.javascript.format": "JavaScript Formatting", + "configuration.typescript.format": "TypeScript Formatting", + "configuration.javascript.suggest": "JavaScript Suggestions", + "configuration.typescript.suggest": "TypeScript Suggestions", + "configuration.server": "TS Server", "configuration.suggest.completeFunctionCalls": "Complete functions with their parameter signature.", "configuration.suggest.includeAutomaticOptionalChainCompletions": "Enable/disable showing completions on potentially undefined values that insert an optional chain call. Requires strict null checks to be enabled.", "configuration.suggest.includeCompletionsForImportStatements": "Enable/disable auto-import-style completions on partially-typed import statements.", @@ -164,7 +171,6 @@ "typescript.updateImportsOnFileMove.enabled.never": "Never rename paths and don't prompt.", "typescript.autoClosingTags": "Enable/disable automatic closing of JSX tags.", "typescript.suggest.enabled": "Enabled/disable autocomplete suggestions.", - "configuration.surveys.enabled": "Enabled/disable occasional surveys that help us improve VS Code's JavaScript and TypeScript support.", "configuration.suggest.completeJSDocs": "Enable/disable suggestion to complete JSDoc comments.", "configuration.tsserver.useVsCodeWatcher": "Use VS Code's file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", "configuration.tsserver.useVsCodeWatcher.deprecation": "Please use the `#typescript.tsserver.watchOptions#` setting instead.", @@ -225,6 +231,7 @@ "configuration.tsserver.web.typeAcquisition.enabled": "Enable/disable package acquisition on the web. This enables IntelliSense for imported packages. Requires `#typescript.tsserver.web.projectWideIntellisense.enabled#`. Currently not supported for Safari.", "configuration.tsserver.nodePath": "Run TS Server on a custom Node installation. This can be a path to a Node executable, or 'node' if you want VS Code to detect a Node installation.", "configuration.updateImportsOnPaste": "Automatically update imports when pasting code. Requires TypeScript 5.6+.", + "configuration.expandableHover": "Enable expanding/contracting the hover to reveal more/less information from the TS server. Requires TypeScript 5.9+.", "walkthroughs.nodejsWelcome.title": "Get started with JavaScript and Node.js", "walkthroughs.nodejsWelcome.description": "Make the most of Visual Studio Code's first-class JavaScript experience.", "walkthroughs.nodejsWelcome.downloadNode.forMacOrWindows.title": "Install Node.js", diff --git a/extensions/typescript-language-features/src/commands/tsserverRequests.ts b/extensions/typescript-language-features/src/commands/tsserverRequests.ts index 0fc70b33bf5..7c925024b96 100644 --- a/extensions/typescript-language-features/src/commands/tsserverRequests.ts +++ b/extensions/typescript-language-features/src/commands/tsserverRequests.ts @@ -16,6 +16,7 @@ function isCancellationToken(value: any): value is vscode.CancellationToken { interface RequestArgs { readonly file?: unknown; + readonly $traceId?: unknown; } export class TSServerRequestCommand implements Command { @@ -31,11 +32,18 @@ export class TSServerRequestCommand implements Command { } if (args && typeof args === 'object' && !Array.isArray(args)) { const requestArgs = args as RequestArgs; - let newArgs: any = undefined; - if (requestArgs.file instanceof vscode.Uri) { - newArgs = { ...args }; - const client = this.lazyClientHost.value.serviceClient; - newArgs.file = client.toOpenTsFilePath(requestArgs.file); + const hasFile = requestArgs.file instanceof vscode.Uri; + const hasTraceId = typeof requestArgs.$traceId === 'string'; + if (hasFile || hasTraceId) { + const newArgs = { ...args }; + if (hasFile) { + const client = this.lazyClientHost.value.serviceClient; + newArgs.file = client.toOpenTsFilePath(requestArgs.file); + } + if (hasTraceId) { + const telemetryReporter = this.lazyClientHost.value.serviceClient.telemetryReporter; + telemetryReporter.logTraceEvent('TSServerRequestCommand.execute', requestArgs.$traceId, JSON.stringify({ command })); + } args = newArgs; } } diff --git a/extensions/typescript-language-features/src/extension.browser.ts b/extensions/typescript-language-features/src/extension.browser.ts index b87a41901bb..f39740bab23 100644 --- a/extensions/typescript-language-features/src/extension.browser.ts +++ b/extensions/typescript-language-features/src/extension.browser.ts @@ -61,7 +61,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { new TypeScriptVersion( TypeScriptVersionSource.Bundled, vscode.Uri.joinPath(context.extensionUri, 'dist/browser/typescript/tsserver.web.js').toString(), - API.fromSimpleString('5.6.2'))); + API.fromSimpleString('5.8.3'))); let experimentTelemetryReporter: IExperimentationTelemetryReporter | undefined; const packageInfo = getPackageInfo(context); diff --git a/extensions/typescript-language-features/src/languageFeatures/hover.ts b/extensions/typescript-language-features/src/languageFeatures/hover.ts index 3012658036f..ff7719c0986 100644 --- a/extensions/typescript-language-features/src/languageFeatures/hover.ts +++ b/extensions/typescript-language-features/src/languageFeatures/hover.ts @@ -11,10 +11,11 @@ import { DocumentSelector } from '../configuration/documentSelector'; import { documentationToMarkdown } from './util/textRendering'; import * as typeConverters from '../typeConverters'; import FileConfigurationManager from './fileConfigurationManager'; - +import { API } from '../tsServer/api'; class TypeScriptHoverProvider implements vscode.HoverProvider { + private lastHoverAndLevel: [vscode.Hover, number] | undefined; public constructor( private readonly client: ITypeScriptServiceClient, @@ -24,17 +25,24 @@ class TypeScriptHoverProvider implements vscode.HoverProvider { public async provideHover( document: vscode.TextDocument, position: vscode.Position, - token: vscode.CancellationToken - ): Promise { + token: vscode.CancellationToken, + context?: vscode.HoverContext, + ): Promise { const filepath = this.client.toOpenTsFilePath(document); if (!filepath) { return undefined; } + const enableExpandableHover = vscode.workspace.getConfiguration('typescript').get('experimental.expandableHover', true); + let verbosityLevel: number | undefined; + if (enableExpandableHover && this.client.apiVersion.gte(API.v590)) { + verbosityLevel = Math.max(0, this.getPreviousLevel(context?.previousHover) + (context?.verbosityDelta ?? 0)); + } + const args = { ...typeConverters.Position.toFileLocationRequestArgs(filepath, position), verbosityLevel }; + const response = await this.client.interruptGetErr(async () => { await this.fileConfigurationManager.ensureConfigurationForDocument(document, token); - const args = typeConverters.Position.toFileLocationRequestArgs(filepath, position); return this.client.execute('quickinfo', args, token); }); @@ -42,9 +50,24 @@ class TypeScriptHoverProvider implements vscode.HoverProvider { return undefined; } - return new vscode.Hover( - this.getContents(document.uri, response.body, response._serverType), - typeConverters.Range.fromTextSpan(response.body)); + const contents = this.getContents(document.uri, response.body, response._serverType); + const range = typeConverters.Range.fromTextSpan(response.body); + const hover = verbosityLevel !== undefined ? + new vscode.VerboseHover( + contents, + range, + // @ts-expect-error + /*canIncreaseVerbosity*/ response.body.canIncreaseVerbosityLevel, + /*canDecreaseVerbosity*/ verbosityLevel !== 0 + ) : new vscode.Hover( + contents, + range + ); + + if (verbosityLevel !== undefined) { + this.lastHoverAndLevel = [hover, verbosityLevel]; + } + return hover; } private getContents( @@ -72,6 +95,13 @@ class TypeScriptHoverProvider implements vscode.HoverProvider { parts.push(md); return parts; } + + private getPreviousLevel(previousHover: vscode.Hover | undefined): number { + if (previousHover && this.lastHoverAndLevel && this.lastHoverAndLevel[0] === previousHover) { + return this.lastHoverAndLevel[1]; + } + return 0; + } } export function register( diff --git a/extensions/typescript-language-features/src/logging/telemetry.ts b/extensions/typescript-language-features/src/logging/telemetry.ts index 4b2d3867f6d..b96487b2419 100644 --- a/extensions/typescript-language-features/src/logging/telemetry.ts +++ b/extensions/typescript-language-features/src/logging/telemetry.ts @@ -11,6 +11,7 @@ export interface TelemetryProperties { export interface TelemetryReporter { logTelemetry(eventName: string, properties?: TelemetryProperties): void; + logTraceEvent(tracePoint: string, correlationId: string, command?: string): void; } export class VSCodeTelemetryReporter implements TelemetryReporter { @@ -34,4 +35,27 @@ export class VSCodeTelemetryReporter implements TelemetryReporter { reporter.postEventObj(eventName, properties); } + + public logTraceEvent(point: string, id: string, data?: string): void { + const event: { point: string; id: string; data?: string | undefined } = { + point, + id + }; + if (data) { + event.data = data; + } + + /* __GDPR__ + "typeScriptExtension.trace" : { + "owner": "dirkb", + "${include}": [ + "${TypeScriptCommonProperties}" + ], + "point" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The trace point." }, + "id" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The traceId is used to correlate the request with other trace points." }, + "data": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Additional data" } + } + */ + this.logTelemetry('typeScriptExtension.trace', event); + } } diff --git a/extensions/typescript-language-features/src/test/unit/server.test.ts b/extensions/typescript-language-features/src/test/unit/server.test.ts index 4d086be5b88..ae4a05a6555 100644 --- a/extensions/typescript-language-features/src/test/unit/server.test.ts +++ b/extensions/typescript-language-features/src/test/unit/server.test.ts @@ -18,6 +18,7 @@ import { nulToken } from '../../utils/cancellation'; const NoopTelemetryReporter = new class implements TelemetryReporter { logTelemetry(): void { /* noop */ } + logTraceEvent(): void { /* noop */ } dispose(): void { /* noop */ } }; diff --git a/extensions/typescript-language-features/src/tsServer/api.ts b/extensions/typescript-language-features/src/tsServer/api.ts index 4ddc29944f0..ef3ce4c933d 100644 --- a/extensions/typescript-language-features/src/tsServer/api.ts +++ b/extensions/typescript-language-features/src/tsServer/api.ts @@ -30,6 +30,7 @@ export class API { public static readonly v540 = API.fromSimpleString('5.4.0'); public static readonly v560 = API.fromSimpleString('5.6.0'); public static readonly v570 = API.fromSimpleString('5.7.0'); + public static readonly v590 = API.fromSimpleString('5.9.0'); public static fromVersionString(versionString: string): API { let version = semver.valid(versionString); diff --git a/extensions/typescript-language-features/src/tsServer/callbackMap.ts b/extensions/typescript-language-features/src/tsServer/callbackMap.ts index 57a80051e6d..1484b0ff654 100644 --- a/extensions/typescript-language-features/src/tsServer/callbackMap.ts +++ b/extensions/typescript-language-features/src/tsServer/callbackMap.ts @@ -11,6 +11,7 @@ export interface CallbackItem { readonly onError: (err: Error) => void; readonly queuingStartTime: number; readonly isAsync: boolean; + readonly traceId?: string | undefined; } export class CallbackMap { @@ -43,6 +44,10 @@ export class CallbackMap { return callback; } + public peek(seq: number): CallbackItem | undefined> | undefined { + return this._callbacks.get(seq) ?? this._asyncCallbacks.get(seq); + } + private delete(seq: number) { if (!this._callbacks.delete(seq)) { this._asyncCallbacks.delete(seq); diff --git a/extensions/typescript-language-features/src/tsServer/server.ts b/extensions/typescript-language-features/src/tsServer/server.ts index 4e41f7aa79a..dbb867f8bb3 100644 --- a/extensions/typescript-language-features/src/tsServer/server.ts +++ b/extensions/typescript-language-features/src/tsServer/server.ts @@ -185,6 +185,10 @@ export class SingleTsServer extends Disposable implements ITypeScriptServer { private tryCancelRequest(request: Proto.Request, command: string): boolean { const seq = request.seq; + const callback = this._callbacks.peek(seq); + if (callback?.traceId !== undefined) { + this._telemetryReporter.logTraceEvent('TSServer.tryCancelRequest', callback.traceId, JSON.stringify({ command, cancelled: true })); + } try { if (this._requestQueue.tryDeletePendingRequest(seq)) { this.logTrace(`Canceled request with sequence number ${seq}`); @@ -206,7 +210,9 @@ export class SingleTsServer extends Disposable implements ITypeScriptServer { if (!callback) { return; } - + if (callback.traceId !== undefined) { + this._telemetryReporter.logTraceEvent('TSServerRequest.dispatchResponse', callback.traceId, JSON.stringify({ command: response.command, success: response.success, performanceData: response.performanceData })); + } this._tracer.traceResponse(this._serverId, response, callback); if (response.success) { callback.onSuccess(response); @@ -218,7 +224,7 @@ export class SingleTsServer extends Disposable implements ITypeScriptServer { } } - public executeImpl(command: keyof TypeScriptRequests, args: any, executeInfo: { isAsync: boolean; token?: vscode.CancellationToken; expectsResult: boolean; lowPriority?: boolean; executionTarget?: ExecutionTarget }): Array> | undefined> { + public executeImpl(command: keyof TypeScriptRequests, args: unknown, executeInfo: { isAsync: boolean; token?: vscode.CancellationToken; expectsResult: boolean; lowPriority?: boolean; executionTarget?: ExecutionTarget }): Array> | undefined> { const request = this._requestQueue.createRequest(command, args); const requestInfo: RequestItem = { request, @@ -229,7 +235,7 @@ export class SingleTsServer extends Disposable implements ITypeScriptServer { let result: Promise> | undefined; if (executeInfo.expectsResult) { result = new Promise>((resolve, reject) => { - this._callbacks.add(request.seq, { onSuccess: resolve as () => ServerResponse.Response | undefined, onError: reject, queuingStartTime: Date.now(), isAsync: executeInfo.isAsync }, executeInfo.isAsync); + this._callbacks.add(request.seq, { onSuccess: resolve as () => ServerResponse.Response | undefined, onError: reject, queuingStartTime: Date.now(), isAsync: executeInfo.isAsync, traceId: request.arguments?.$traceId }, executeInfo.isAsync); if (executeInfo.token) { @@ -263,6 +269,10 @@ export class SingleTsServer extends Disposable implements ITypeScriptServer { } this._requestQueue.enqueue(requestInfo); + if (args && typeof (args as any).$traceId === 'string') { + const queueLength = this._requestQueue.length - 1; + this._telemetryReporter.logTraceEvent('TSServer.enqueueRequest', (args as any).$traceId, JSON.stringify({ command, queueLength })); + } this.sendNextRequests(); return [result]; @@ -287,6 +297,9 @@ export class SingleTsServer extends Disposable implements ITypeScriptServer { try { this.write(serverRequest); + if (typeof serverRequest.arguments?.$traceId === 'string') { + this._telemetryReporter.logTraceEvent('TSServer.sendRequest', serverRequest.arguments.$traceId, JSON.stringify({ command: serverRequest.command })); + } } catch (err) { const callback = this.fetchCallback(serverRequest.seq); callback?.onError(err); diff --git a/extensions/typescript-language-features/tsconfig.json b/extensions/typescript-language-features/tsconfig.json index f604952abd2..776a71efaf8 100644 --- a/extensions/typescript-language-features/tsconfig.json +++ b/extensions/typescript-language-features/tsconfig.json @@ -14,6 +14,7 @@ "../../src/vscode-dts/vscode.proposed.codeActionAI.d.ts", "../../src/vscode-dts/vscode.proposed.codeActionRanges.d.ts", "../../src/vscode-dts/vscode.proposed.multiDocumentHighlightProvider.d.ts", - "../../src/vscode-dts/vscode.proposed.workspaceTrust.d.ts" + "../../src/vscode-dts/vscode.proposed.workspaceTrust.d.ts", + "../../src/vscode-dts/vscode.proposed.editorHoverVerbosityLevel.d.ts", ] } diff --git a/extensions/vscode-api-tests/package-lock.json b/extensions/vscode-api-tests/package-lock.json index cd90b33ca49..9a80cf4f19b 100644 --- a/extensions/vscode-api-tests/package-lock.json +++ b/extensions/vscode-api-tests/package-lock.json @@ -26,12 +26,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/node-forge": { @@ -216,10 +217,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/wrap-ansi": { "version": "7.0.0", diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 0998d2fff63..0d7f87a9be4 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -17,7 +17,6 @@ "documentFiltersExclusive", "editorInsets", "embeddings", - "envExtractUri", "extensionRuntime", "extensionsAny", "externalUriOpener", @@ -45,7 +44,6 @@ "terminalDataWriteEvent", "terminalDimensions", "testObserver", - "textDocumentEncoding", "textSearchProvider", "timeline", "tokenInformation", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts index 289f0a6a1c0..df90a035401 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import 'mocha'; -import { ChatContext, ChatRequest, ChatResult, Disposable, Event, EventEmitter, chat, commands, lm } from 'vscode'; +import { ChatContext, ChatRequest, ChatRequestTurn, ChatRequestTurn2, ChatResult, Disposable, Event, EventEmitter, chat, commands, lm } from 'vscode'; import { DeferredPromise, asPromise, assertNoRpc, closeAllEditors, delay, disposeAll } from '../utils'; suite('chat', () => { @@ -71,6 +71,7 @@ suite('chat', () => { assert.strictEqual(request.context.history.length, 2); assert.strictEqual(request.context.history[0].participant, 'api-test.participant'); assert.strictEqual(request.context.history[0].command, 'hello'); + assert.ok(request.context.history[0] instanceof ChatRequestTurn && request.context.history[0] instanceof ChatRequestTurn2); deferred.complete(); } } catch (e) { diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/interactiveWindow.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/interactiveWindow.test.ts index 69810825b1b..55a17cd3ce0 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/interactiveWindow.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/interactiveWindow.test.ts @@ -73,7 +73,7 @@ async function addCellAndRun(code: string, notebook: vscode.NotebookDocument) { await saveAllFilesAndCloseAll(); }); - test('Can open an interactive window and execute from input box', async () => { + test.skip('Can open an interactive window and execute from input box', async () => { assert.ok(vscode.workspace.workspaceFolders); const { notebookEditor, inputUri } = await createInteractiveWindow(defaultKernel); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts index ceb3aea00d5..7ee205256bb 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts @@ -1373,13 +1373,20 @@ suite('vscode API - workspace', () => { let err; try { - await vscode.workspace.decode(new Uint8Array([0, 0, 0, 0]), doc1.uri); + await vscode.workspace.decode(new Uint8Array([0, 0, 0, 0]), { uri: doc1.uri }); } catch (e) { err = e; } assert.ok(err); }); + test('encoding: openTextDocument - invalid encoding falls back to default', async () => { + const uri1 = await createRandomFile(); + + const doc1 = await vscode.workspace.openTextDocument(uri1, { encoding: 'foobar123' }); + assert.strictEqual(doc1.encoding, 'utf8'); + }); + test('encoding: openTextDocument - multiple requests with different encoding work', async () => { const uri1 = await createRandomFile(); @@ -1396,18 +1403,18 @@ suite('vscode API - workspace', () => { const uri = root.with({ path: posix.join(root.path, 'file.txt') }); // without setting - assert.strictEqual(await vscode.workspace.decode(Buffer.from('Hello World'), uri), 'Hello World'); - assert.strictEqual(await vscode.workspace.decode(Buffer.from('Hellö Wörld'), uri), 'Hellö Wörld'); - assert.strictEqual(await vscode.workspace.decode(new Uint8Array([0xEF, 0xBB, 0xBF, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]), uri), 'Hello World'); // UTF-8 with BOM - assert.strictEqual(await vscode.workspace.decode(new Uint8Array([0xFE, 0xFF, 0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100]), uri), 'Hello World'); // UTF-16 BE with BOM - assert.strictEqual(await vscode.workspace.decode(new Uint8Array([0xFF, 0xFE, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0]), uri), 'Hello World'); // UTF-16 LE with BOM - assert.strictEqual(await vscode.workspace.decode(new Uint8Array([0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100]), uri), 'Hello World'); - assert.strictEqual(await vscode.workspace.decode(new Uint8Array([72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0]), uri), 'Hello World'); + assert.strictEqual(await vscode.workspace.decode(Buffer.from('Hello World'), { uri }), 'Hello World'); + assert.strictEqual(await vscode.workspace.decode(Buffer.from('Hellö Wörld'), { uri }), 'Hellö Wörld'); + assert.strictEqual(await vscode.workspace.decode(new Uint8Array([0xEF, 0xBB, 0xBF, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]), { uri }), 'Hello World'); // UTF-8 with BOM + assert.strictEqual(await vscode.workspace.decode(new Uint8Array([0xFE, 0xFF, 0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100]), { uri }), 'Hello World'); // UTF-16 BE with BOM + assert.strictEqual(await vscode.workspace.decode(new Uint8Array([0xFF, 0xFE, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0]), { uri }), 'Hello World'); // UTF-16 LE with BOM + assert.strictEqual(await vscode.workspace.decode(new Uint8Array([0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100]), { uri }), 'Hello World'); + assert.strictEqual(await vscode.workspace.decode(new Uint8Array([72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0]), { uri }), 'Hello World'); // with auto-guess encoding try { await vscode.workspace.getConfiguration('files', uri).update('autoGuessEncoding', true, vscode.ConfigurationTarget.Global); - assert.strictEqual(await vscode.workspace.decode(new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]), uri), 'Hellö Wörld'); + assert.strictEqual(await vscode.workspace.decode(new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]), { uri }), 'Hellö Wörld'); } finally { await vscode.workspace.getConfiguration('files', uri).update('autoGuessEncoding', false, vscode.ConfigurationTarget.Global); } @@ -1415,19 +1422,19 @@ suite('vscode API - workspace', () => { // with encoding setting try { await vscode.workspace.getConfiguration('files', uri).update('encoding', 'windows1252', vscode.ConfigurationTarget.Global); - assert.strictEqual(await vscode.workspace.decode(new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]), uri), 'Hellö Wörld'); + assert.strictEqual(await vscode.workspace.decode(new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]), { uri }), 'Hellö Wörld'); } finally { await vscode.workspace.getConfiguration('files', uri).update('encoding', 'utf8', vscode.ConfigurationTarget.Global); } // with encoding provided - assert.strictEqual(await vscode.workspace.decode(new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]), uri, { encoding: 'windows1252' }), 'Hellö Wörld'); - assert.strictEqual(await vscode.workspace.decode(Buffer.from('Hello World'), uri, { encoding: 'foobar123' }), 'Hello World'); + assert.strictEqual(await vscode.workspace.decode(new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]), { encoding: 'windows1252' }), 'Hellö Wörld'); + assert.strictEqual(await vscode.workspace.decode(Buffer.from('Hello World'), { encoding: 'foobar123' }), 'Hello World'); // binary let err; try { - await vscode.workspace.decode(new Uint8Array([0, 0, 0, 0]), uri); + await vscode.workspace.decode(new Uint8Array([0, 0, 0, 0]), { uri }); } catch (e) { err = e; } @@ -1438,32 +1445,32 @@ suite('vscode API - workspace', () => { const uri = root.with({ path: posix.join(root.path, 'file.txt') }); // without setting - assert.strictEqual((await vscode.workspace.encode('Hello World', uri)).toString(), 'Hello World'); + assert.strictEqual((await vscode.workspace.encode('Hello World', { uri })).toString(), 'Hello World'); // with encoding setting try { await vscode.workspace.getConfiguration('files', uri).update('encoding', 'utf8bom', vscode.ConfigurationTarget.Global); - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', uri), new Uint8Array([0xEF, 0xBB, 0xBF, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', { uri }), new Uint8Array([0xEF, 0xBB, 0xBF, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]))); await vscode.workspace.getConfiguration('files', uri).update('encoding', 'utf16le', vscode.ConfigurationTarget.Global); - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', uri), new Uint8Array([0xFF, 0xFE, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', { uri }), new Uint8Array([0xFF, 0xFE, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0]))); await vscode.workspace.getConfiguration('files', uri).update('encoding', 'utf16be', vscode.ConfigurationTarget.Global); - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', uri), new Uint8Array([0xFE, 0xFF, 0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', { uri }), new Uint8Array([0xFE, 0xFF, 0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100]))); await vscode.workspace.getConfiguration('files', uri).update('encoding', 'cp1252', vscode.ConfigurationTarget.Global); - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hellö Wörld', uri), new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hellö Wörld', { uri }), new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]))); } finally { await vscode.workspace.getConfiguration('files', uri).update('encoding', 'utf8', vscode.ConfigurationTarget.Global); } // with encoding provided - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', uri, { encoding: 'utf8' }), new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]))); - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', uri, { encoding: 'utf8bom' }), new Uint8Array([0xEF, 0xBB, 0xBF, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]))); - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', uri, { encoding: 'utf16le' }), new Uint8Array([0xFF, 0xFE, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0]))); - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', uri, { encoding: 'utf16be' }), new Uint8Array([0xFE, 0xFF, 0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100]))); - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hellö Wörld', uri, { encoding: 'cp1252' }), new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]))); - assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', uri, { encoding: 'foobar123' }), new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', { encoding: 'utf8' }), new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', { encoding: 'utf8bom' }), new Uint8Array([0xEF, 0xBB, 0xBF, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', { encoding: 'utf16le' }), new Uint8Array([0xFF, 0xFE, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', { encoding: 'utf16be' }), new Uint8Array([0xFE, 0xFF, 0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hellö Wörld', { encoding: 'cp1252' }), new Uint8Array([72, 101, 108, 108, 0xF6, 32, 87, 0xF6, 114, 108, 100]))); + assert.ok(equalsUint8Array(await vscode.workspace.encode('Hello World', { encoding: 'foobar123' }), new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]))); }); function equalsUint8Array(a: Uint8Array, b: Uint8Array): boolean { @@ -1490,7 +1497,7 @@ suite('vscode API - workspace', () => { const text = doc.getText(); assert.strictEqual(text, originalText); - const buf = await vscode.workspace.encode(text, uri, { encoding: 'windows1252' }); + const buf = await vscode.workspace.encode(text, { encoding: 'windows1252' }); await vscode.workspace.fs.writeFile(uri, buf); doc = await vscode.workspace.openTextDocument(uri, { encoding: 'windows1252' }); @@ -1509,10 +1516,10 @@ suite('vscode API - workspace', () => { doc = await vscode.workspace.openTextDocument(uri, { encoding: 'utf8bom' }); assert.strictEqual(doc.encoding, 'utf8bom'); - const decoded = await vscode.workspace.decode(new Uint8Array(buffer), uri, { encoding: 'utf8bom' }); + const decoded = await vscode.workspace.decode(new Uint8Array(buffer), { encoding: 'utf8bom' }); assert.strictEqual(decoded, 'Hello World'); - const encoded = await vscode.workspace.encode('Hello World', uri, { encoding: 'utf8bom' }); + const encoded = await vscode.workspace.encode('Hello World', { encoding: 'utf8bom' }); assert.ok(equalsUint8Array(encoded, new Uint8Array(buffer))); }); }); diff --git a/extensions/vscode-api-tests/src/utils.ts b/extensions/vscode-api-tests/src/utils.ts index 7223fa8a6a1..4bf02420715 100644 --- a/extensions/vscode-api-tests/src/utils.ts +++ b/extensions/vscode-api-tests/src/utils.ts @@ -24,7 +24,7 @@ export async function createRandomFile(contents: string | Uint8Array = '', dir: } else { fakeFile = vscode.Uri.parse(`${testFs.scheme}:/${rndName() + ext}`); } - testFs.writeFile(fakeFile, Buffer.from(contents), { create: true, overwrite: true }); + testFs.writeFile(fakeFile, typeof contents === 'string' ? Buffer.from(contents) : Buffer.from(contents), { create: true, overwrite: true }); return fakeFile; } diff --git a/extensions/vscode-colorize-tests/src/colorizer.test.ts b/extensions/vscode-colorize-tests/src/colorizer.test.ts index 07f4dd33de6..0d76231eaa0 100644 --- a/extensions/vscode-colorize-tests/src/colorizer.test.ts +++ b/extensions/vscode-colorize-tests/src/colorizer.test.ts @@ -70,14 +70,20 @@ suite('colorization', () => { suiteSetup(async function () { originalSettingValues = [ workspace.getConfiguration('editor.experimental').get('preferTreeSitter.typescript'), - workspace.getConfiguration('editor.experimental').get('preferTreeSitter.ini') + workspace.getConfiguration('editor.experimental').get('preferTreeSitter.ini'), + workspace.getConfiguration('editor.experimental').get('preferTreeSitter.regex'), + workspace.getConfiguration('editor.experimental').get('preferTreeSitter.css') ]; await workspace.getConfiguration('editor.experimental').update('preferTreeSitter.typescript', true, ConfigurationTarget.Global); await workspace.getConfiguration('editor.experimental').update('preferTreeSitter.ini', true, ConfigurationTarget.Global); + await workspace.getConfiguration('editor.experimental').update('preferTreeSitter.regex', true, ConfigurationTarget.Global); + await workspace.getConfiguration('editor.experimental').update('preferTreeSitter.css', true, ConfigurationTarget.Global); }); suiteTeardown(async function () { await workspace.getConfiguration('editor.experimental').update('preferTreeSitter.typescript', originalSettingValues[0], ConfigurationTarget.Global); await workspace.getConfiguration('editor.experimental').update('preferTreeSitter.ini', originalSettingValues[1], ConfigurationTarget.Global); + await workspace.getConfiguration('editor.experimental').update('preferTreeSitter.regex', originalSettingValues[2], ConfigurationTarget.Global); + await workspace.getConfiguration('editor.experimental').update('preferTreeSitter.css', originalSettingValues[3], ConfigurationTarget.Global); }); for (const fixture of fs.readdirSync(fixturesPath)) { diff --git a/extensions/vscode-colorize-tests/test/colorize-fixtures/test-issue241715.ts b/extensions/vscode-colorize-tests/test/colorize-fixtures/test-issue241715.ts index ff95456b856..ebfddb5daa1 100644 --- a/extensions/vscode-colorize-tests/test/colorize-fixtures/test-issue241715.ts +++ b/extensions/vscode-colorize-tests/test/colorize-fixtures/test-issue241715.ts @@ -45,3 +45,6 @@ function makeDate(mOrTimestamp: number, d?: number, y?: number): Date { type StringNumberBooleans = [string, number, ...boolean[]]; type StringBooleansNumber = [string, ...boolean[], number]; type BooleansStringNumber = [...boolean[], string, number]; + +let s = '2'; ++s; diff --git a/extensions/vscode-colorize-tests/test/colorize-fixtures/test.css b/extensions/vscode-colorize-tests/test/colorize-fixtures/test.css index 4a8bd2395f8..88b01799a1e 100644 --- a/extensions/vscode-colorize-tests/test/colorize-fixtures/test.css +++ b/extensions/vscode-colorize-tests/test/colorize-fixtures/test.css @@ -16,7 +16,7 @@ } body { font: 75% georgia, sans-serif; - line-height: 1.88889; + line-height: 1.88889 !important; color: #555753; background: #fff url(blossoms.jpg) no-repeat bottom right; margin: 0; @@ -63,6 +63,11 @@ abbr { border-bottom: none; } +@property --gradient-angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} /* specific divs */ @@ -73,7 +78,7 @@ abbr { position: relative; } -.intro { +.intro span::after{ min-width: 470px; width: 100%; } @@ -149,6 +154,20 @@ footer a:visited { color: '#B3AE94'; } +.parent { + color: tomato; + .child { + color: blue; + } +} + +.parent { + color: tomato; + & .child { + color: blue; + } +} + .extra1 { background: transparent url(cr2.gif) top left no-repeat; position: absolute; @@ -156,4 +175,26 @@ footer a:visited { right: 0; width: 148px; height: 110px; -} \ No newline at end of file +} + +.chat-feature-container .codicon[class*='codicon-'] { + font-size: 16px; +} + +figma-help-bubble { + position: absolute; + right: 16px; + bottom: 16px; +} + +figma-select::part(listbox) { + max-height: 250px; +} + +div > * + * { + margin-top: 4rem; +} + +* { + box-sizing: border-box; +} diff --git a/extensions/vscode-colorize-tests/test/colorize-fixtures/test.regexp.ts b/extensions/vscode-colorize-tests/test/colorize-fixtures/test.regexp.ts new file mode 100644 index 00000000000..734dac8d9b2 --- /dev/null +++ b/extensions/vscode-colorize-tests/test/colorize-fixtures/test.regexp.ts @@ -0,0 +1,7 @@ +const a = /\\\xFF/; +const b = /[.*+\-?^${}()|[\]\\]/; +const c = /\r\n|\r|\n/; +const d = /\/\/# sourceMappingURL=[^ ]+$/; +const e = /<%=\s*([^\s]+)\s*%>/; +const f = /```suggestion(\u0020*(\r\n|\n))((?[\s\S]*?)(\r\n|\n))?```/; +const g = /(?<=^|\s)(?=[a-z])([a-z])(?=.*\1$)\(([^()]*0+)(?", + "t": "source.css meta.at-rule.body.css meta.selector.css keyword.operator.combinator.css", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "';", + "t": "source.css meta.at-rule.body.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "\t", + "t": "source.css meta.at-rule.body.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "initial-value", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.custom.css", + "r": { + "dark_plus": "entity.name.tag: #569CD6", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag: #569CD6", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag: #569CD6", + "dark_modern": "entity.name.tag: #569CD6", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": ": 0deg;", + "t": "source.css meta.at-rule.body.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "\tinherits: false;", + "t": "source.css meta.at-rule.body.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "source.css meta.at-rule.body.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, { "c": "/*", - "t": "source.css comment.block.css punctuation.definition.comment.begin.css", + "t": "source.css meta.at-rule.body.css comment.block.css punctuation.definition.comment.begin.css", "r": { "dark_plus": "comment: #6A9955", "light_plus": "comment: #008000", @@ -4901,7 +5125,7 @@ }, { "c": " specific divs ", - "t": "source.css comment.block.css", + "t": "source.css meta.at-rule.body.css comment.block.css", "r": { "dark_plus": "comment: #6A9955", "light_plus": "comment: #008000", @@ -4915,7 +5139,7 @@ }, { "c": "*/", - "t": "source.css comment.block.css punctuation.definition.comment.end.css", + "t": "source.css meta.at-rule.body.css comment.block.css punctuation.definition.comment.end.css", "r": { "dark_plus": "comment: #6A9955", "light_plus": "comment: #008000", @@ -4929,7 +5153,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -4943,7 +5167,7 @@ }, { "c": "page-wrapper", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -4957,7 +5181,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -4971,7 +5195,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -4985,7 +5209,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -4999,7 +5223,7 @@ }, { "c": "background", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -5013,7 +5237,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5027,7 +5251,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5041,7 +5265,7 @@ }, { "c": "url", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css support.function.url.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css support.function.url.css", "r": { "dark_plus": "support.function: #DCDCAA", "light_plus": "support.function: #795E26", @@ -5055,7 +5279,7 @@ }, { "c": "(", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.begin.bracket.round.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.begin.bracket.round.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5069,7 +5293,7 @@ }, { "c": "zen-bg.jpg", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css variable.parameter.url.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css variable.parameter.url.css", "r": { "dark_plus": "source.css variable: #9CDCFE", "light_plus": "source.css variable: #E50000", @@ -5083,7 +5307,7 @@ }, { "c": ")", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.end.bracket.round.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.end.bracket.round.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5097,7 +5321,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5111,7 +5335,7 @@ }, { "c": "no-repeat", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -5125,7 +5349,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5139,7 +5363,7 @@ }, { "c": "top", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -5153,7 +5377,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5167,7 +5391,7 @@ }, { "c": "left", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -5181,7 +5405,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5195,7 +5419,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5209,7 +5433,7 @@ }, { "c": "padding", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -5223,7 +5447,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5237,7 +5461,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5251,7 +5475,7 @@ }, { "c": "0", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -5265,7 +5489,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5279,7 +5503,7 @@ }, { "c": "175", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -5293,7 +5517,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -5307,7 +5531,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5321,7 +5545,7 @@ }, { "c": "0", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -5335,7 +5559,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5349,7 +5573,7 @@ }, { "c": "110", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -5363,7 +5587,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -5377,7 +5601,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5391,7 +5615,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5405,7 +5629,7 @@ }, { "c": "margin", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -5419,7 +5643,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5433,7 +5657,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5447,7 +5671,7 @@ }, { "c": "0", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -5461,7 +5685,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5475,7 +5699,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5489,7 +5713,7 @@ }, { "c": "position", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -5503,7 +5727,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5517,7 +5741,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5531,7 +5755,7 @@ }, { "c": "relative", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -5545,7 +5769,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5559,7 +5783,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5573,7 +5797,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -5587,7 +5811,7 @@ }, { "c": "intro", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -5601,7 +5825,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css meta.selector.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5613,9 +5837,51 @@ "light_modern": "default: #3B3B3B" } }, + { + "c": "span", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", + "r": { + "dark_plus": "entity.name.tag.css: #D7BA7D", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag.css: #D7BA7D", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag.css: #D7BA7D", + "dark_modern": "entity.name.tag.css: #D7BA7D", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": "::", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-element.css punctuation.definition.entity.css", + "r": { + "dark_plus": "entity.other.attribute-name.pseudo-element.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.pseudo-element.css: #800000", + "dark_vs": "entity.other.attribute-name.pseudo-element.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.pseudo-element.css: #800000", + "hc_black": "entity.other.attribute-name.pseudo-element.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.pseudo-element.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.pseudo-element.css: #0F4A85", + "light_modern": "entity.other.attribute-name.pseudo-element.css: #800000" + } + }, + { + "c": "after", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-element.css", + "r": { + "dark_plus": "entity.other.attribute-name.pseudo-element.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.pseudo-element.css: #800000", + "dark_vs": "entity.other.attribute-name.pseudo-element.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.pseudo-element.css: #800000", + "hc_black": "entity.other.attribute-name.pseudo-element.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.pseudo-element.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.pseudo-element.css: #0F4A85", + "light_modern": "entity.other.attribute-name.pseudo-element.css: #800000" + } + }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5629,7 +5895,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5643,7 +5909,7 @@ }, { "c": "min-width", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -5657,7 +5923,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5671,7 +5937,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5685,7 +5951,7 @@ }, { "c": "470", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -5699,7 +5965,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -5713,7 +5979,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5727,7 +5993,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5741,7 +6007,7 @@ }, { "c": "width", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -5755,7 +6021,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5769,7 +6035,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5783,7 +6049,7 @@ }, { "c": "100", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -5797,7 +6063,7 @@ }, { "c": "%", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.percentage.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.percentage.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -5811,7 +6077,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5825,7 +6091,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5839,7 +6105,7 @@ }, { "c": "header", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -5853,7 +6119,7 @@ }, { "c": " ", - "t": "source.css meta.selector.css", + "t": "source.css meta.at-rule.body.css meta.selector.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5867,7 +6133,7 @@ }, { "c": "h1", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -5881,7 +6147,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5895,7 +6161,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5909,7 +6175,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5923,7 +6189,7 @@ }, { "c": "background", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -5937,7 +6203,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5951,7 +6217,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5965,7 +6231,7 @@ }, { "c": "transparent", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -5979,7 +6245,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -5993,7 +6259,7 @@ }, { "c": "url", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css support.function.url.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css support.function.url.css", "r": { "dark_plus": "support.function: #DCDCAA", "light_plus": "support.function: #795E26", @@ -6007,7 +6273,7 @@ }, { "c": "(", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.begin.bracket.round.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.begin.bracket.round.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6021,7 +6287,7 @@ }, { "c": "h1.gif", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css variable.parameter.url.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css variable.parameter.url.css", "r": { "dark_plus": "source.css variable: #9CDCFE", "light_plus": "source.css variable: #E50000", @@ -6035,7 +6301,7 @@ }, { "c": ")", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.end.bracket.round.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.end.bracket.round.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6049,7 +6315,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6063,7 +6329,7 @@ }, { "c": "no-repeat", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -6077,7 +6343,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6091,7 +6357,7 @@ }, { "c": "top", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -6105,7 +6371,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6119,7 +6385,7 @@ }, { "c": "left", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -6133,7 +6399,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6147,7 +6413,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6161,7 +6427,7 @@ }, { "c": "margin-top", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -6175,7 +6441,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6189,7 +6455,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6203,7 +6469,7 @@ }, { "c": "10", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -6217,7 +6483,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -6231,7 +6497,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6245,7 +6511,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6259,7 +6525,7 @@ }, { "c": "display", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -6273,7 +6539,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6287,7 +6553,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6301,7 +6567,7 @@ }, { "c": "block", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -6315,7 +6581,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6329,7 +6595,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6343,7 +6609,7 @@ }, { "c": "width", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -6357,7 +6623,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6371,7 +6637,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6385,7 +6651,7 @@ }, { "c": "219", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -6399,7 +6665,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -6413,7 +6679,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6427,7 +6693,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6441,7 +6707,7 @@ }, { "c": "height", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -6455,7 +6721,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6469,7 +6735,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6483,7 +6749,7 @@ }, { "c": "87", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -6497,7 +6763,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -6511,7 +6777,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6525,7 +6791,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6539,7 +6805,7 @@ }, { "c": "float", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -6553,7 +6819,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6567,7 +6833,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6581,7 +6847,7 @@ }, { "c": "left", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -6595,7 +6861,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6609,7 +6875,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6623,7 +6889,7 @@ }, { "c": "text-indent", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -6637,7 +6903,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6651,7 +6917,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6665,7 +6931,7 @@ }, { "c": "100", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -6679,7 +6945,7 @@ }, { "c": "%", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.percentage.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.percentage.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -6693,7 +6959,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6707,7 +6973,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6721,7 +6987,7 @@ }, { "c": "white-space", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -6735,7 +7001,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6749,7 +7015,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6763,7 +7029,7 @@ }, { "c": "nowrap", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -6777,7 +7043,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6791,7 +7057,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6805,7 +7071,7 @@ }, { "c": "overflow", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -6819,7 +7085,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6833,7 +7099,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6847,7 +7113,7 @@ }, { "c": "hidden", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -6861,7 +7127,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6875,7 +7141,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6889,7 +7155,7 @@ }, { "c": "header", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -6903,7 +7169,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6917,7 +7183,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6931,7 +7197,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6945,7 +7211,7 @@ }, { "c": "padding-top", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -6959,7 +7225,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6973,7 +7239,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -6987,7 +7253,7 @@ }, { "c": "20", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -7001,7 +7267,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -7015,7 +7281,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7029,7 +7295,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7043,7 +7309,7 @@ }, { "c": "height", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -7057,7 +7323,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7071,7 +7337,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7085,7 +7351,7 @@ }, { "c": "87", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -7099,7 +7365,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -7113,7 +7379,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7127,7 +7393,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7141,7 +7407,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -7155,7 +7421,7 @@ }, { "c": "summary", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -7169,7 +7435,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7183,7 +7449,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7197,7 +7463,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7211,7 +7477,7 @@ }, { "c": "clear", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -7225,7 +7491,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7239,7 +7505,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7253,7 +7519,7 @@ }, { "c": "both", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -7267,7 +7533,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7281,7 +7547,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7295,7 +7561,7 @@ }, { "c": "margin", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -7309,7 +7575,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7323,7 +7589,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7337,7 +7603,7 @@ }, { "c": "20", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -7351,7 +7617,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -7365,7 +7631,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7379,7 +7645,7 @@ }, { "c": "20", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -7393,7 +7659,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -7407,7 +7673,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7421,7 +7687,7 @@ }, { "c": "20", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -7435,7 +7701,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -7449,7 +7715,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7463,7 +7729,7 @@ }, { "c": "10", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -7477,7 +7743,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -7491,7 +7757,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7505,7 +7771,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7519,7 +7785,7 @@ }, { "c": "width", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -7533,7 +7799,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7547,7 +7813,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7561,7 +7827,7 @@ }, { "c": "160", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -7575,7 +7841,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -7589,7 +7855,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7603,7 +7869,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7617,7 +7883,7 @@ }, { "c": "float", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -7631,7 +7897,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7645,7 +7911,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7659,7 +7925,7 @@ }, { "c": "left", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -7673,7 +7939,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7687,7 +7953,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7701,7 +7967,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -7715,7 +7981,7 @@ }, { "c": "summary", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -7729,7 +7995,7 @@ }, { "c": " ", - "t": "source.css meta.selector.css", + "t": "source.css meta.at-rule.body.css meta.selector.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7743,7 +8009,7 @@ }, { "c": "p", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -7757,7 +8023,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7771,7 +8037,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7785,7 +8051,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7799,7 +8065,7 @@ }, { "c": "font", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -7813,7 +8079,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7827,7 +8093,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7841,7 +8107,7 @@ }, { "c": "italic", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -7855,7 +8121,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7869,7 +8135,7 @@ }, { "c": "1.1", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -7883,7 +8149,7 @@ }, { "c": "em", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.em.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.em.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -7897,7 +8163,7 @@ }, { "c": "/", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7911,7 +8177,7 @@ }, { "c": "2.2", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -7925,7 +8191,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7939,7 +8205,7 @@ }, { "c": "georgia", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.font-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.font-name.css", "r": { "dark_plus": "support.constant.font-name: #CE9178", "light_plus": "support.constant.font-name: #0451A5", @@ -7953,7 +8219,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7967,7 +8233,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -7981,7 +8247,7 @@ }, { "c": "text-align", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -7995,7 +8261,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8009,7 +8275,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8023,7 +8289,7 @@ }, { "c": "center", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -8037,7 +8303,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8051,7 +8317,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8065,7 +8331,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -8079,7 +8345,7 @@ }, { "c": "preamble", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -8093,7 +8359,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8107,7 +8373,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8121,7 +8387,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8135,7 +8401,7 @@ }, { "c": "clear", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -8149,7 +8415,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8163,7 +8429,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8177,7 +8443,7 @@ }, { "c": "right", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -8191,7 +8457,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8205,7 +8471,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8219,7 +8485,7 @@ }, { "c": "padding", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -8233,7 +8499,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8247,7 +8513,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8261,7 +8527,7 @@ }, { "c": "0", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -8275,7 +8541,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -8289,7 +8555,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8303,7 +8569,7 @@ }, { "c": "10", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -8317,7 +8583,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -8331,7 +8597,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8345,7 +8611,7 @@ }, { "c": "0", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -8359,7 +8625,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8373,7 +8639,7 @@ }, { "c": "10", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -8387,7 +8653,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -8401,7 +8667,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8415,7 +8681,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8429,7 +8695,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -8443,7 +8709,7 @@ }, { "c": "supporting", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -8457,7 +8723,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8471,7 +8737,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8485,7 +8751,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8499,7 +8765,7 @@ }, { "c": "padding-left", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -8513,7 +8779,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8527,7 +8793,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8541,7 +8807,7 @@ }, { "c": "10", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -8555,7 +8821,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -8569,7 +8835,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8583,7 +8849,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8597,7 +8863,7 @@ }, { "c": "margin-bottom", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -8611,7 +8877,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8625,7 +8891,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8639,7 +8905,7 @@ }, { "c": "40", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -8653,7 +8919,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -8667,7 +8933,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8681,7 +8947,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8695,7 +8961,7 @@ }, { "c": "#", - "t": "source.css meta.selector.css entity.other.attribute-name.id.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.id.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.id.css: #D7BA7D", "light_plus": "entity.other.attribute-name.id.css: #800000", @@ -8709,7 +8975,7 @@ }, { "c": "footer", - "t": "source.css meta.selector.css entity.other.attribute-name.id.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.id.css", "r": { "dark_plus": "entity.other.attribute-name.id.css: #D7BA7D", "light_plus": "entity.other.attribute-name.id.css: #800000", @@ -8723,7 +8989,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8737,7 +9003,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8751,7 +9017,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8765,7 +9031,7 @@ }, { "c": "text-align", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -8779,7 +9045,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8793,7 +9059,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8807,7 +9073,7 @@ }, { "c": "center", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -8821,7 +9087,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8835,7 +9101,7 @@ }, { "c": "footer", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -8849,7 +9115,7 @@ }, { "c": " ", - "t": "source.css meta.selector.css", + "t": "source.css meta.at-rule.body.css meta.selector.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8863,7 +9129,7 @@ }, { "c": "a", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -8877,7 +9143,7 @@ }, { "c": ":", - "t": "source.css meta.selector.css entity.other.attribute-name.pseudo-class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-class.css punctuation.definition.entity.css", "r": { "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", @@ -8891,7 +9157,7 @@ }, { "c": "link", - "t": "source.css meta.selector.css entity.other.attribute-name.pseudo-class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-class.css", "r": { "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", @@ -8905,7 +9171,7 @@ }, { "c": ",", - "t": "source.css meta.selector.css punctuation.separator.list.comma.css", + "t": "source.css meta.at-rule.body.css meta.selector.css punctuation.separator.list.comma.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8919,7 +9185,7 @@ }, { "c": "footer", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -8933,7 +9199,7 @@ }, { "c": " ", - "t": "source.css meta.selector.css", + "t": "source.css meta.at-rule.body.css meta.selector.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -8947,7 +9213,7 @@ }, { "c": "a", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -8961,7 +9227,7 @@ }, { "c": ":", - "t": "source.css meta.selector.css entity.other.attribute-name.pseudo-class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-class.css punctuation.definition.entity.css", "r": { "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", @@ -8975,7 +9241,7 @@ }, { "c": "visited", - "t": "source.css meta.selector.css entity.other.attribute-name.pseudo-class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-class.css", "r": { "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", @@ -8989,7 +9255,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9003,7 +9269,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9017,7 +9283,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9031,7 +9297,7 @@ }, { "c": "margin-right", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -9045,7 +9311,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9059,7 +9325,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9073,7 +9339,7 @@ }, { "c": "20", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -9087,7 +9353,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -9101,7 +9367,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9115,7 +9381,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9129,7 +9395,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -9143,7 +9409,7 @@ }, { "c": "sidebar", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -9157,7 +9423,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9171,7 +9437,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9185,7 +9451,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9199,7 +9465,7 @@ }, { "c": "margin-left", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -9213,7 +9479,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9227,7 +9493,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9241,7 +9507,7 @@ }, { "c": "600", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -9255,7 +9521,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -9269,7 +9535,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9283,7 +9549,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9297,7 +9563,7 @@ }, { "c": "position", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -9311,7 +9577,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9325,7 +9591,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9339,7 +9605,7 @@ }, { "c": "absolute", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -9353,7 +9619,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9367,7 +9633,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9381,7 +9647,7 @@ }, { "c": "top", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -9395,7 +9661,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9409,7 +9675,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9423,7 +9689,7 @@ }, { "c": "0", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -9437,7 +9703,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9451,7 +9717,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9465,7 +9731,7 @@ }, { "c": "right", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -9479,7 +9745,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9493,7 +9759,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9507,7 +9773,7 @@ }, { "c": "0", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -9521,7 +9787,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9535,7 +9801,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9549,7 +9815,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -9563,7 +9829,7 @@ }, { "c": "sidebar", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -9577,7 +9843,7 @@ }, { "c": " ", - "t": "source.css meta.selector.css", + "t": "source.css meta.at-rule.body.css meta.selector.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9591,7 +9857,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -9605,7 +9871,7 @@ }, { "c": "wrapper", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -9619,7 +9885,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9633,7 +9899,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9647,7 +9913,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9661,7 +9927,7 @@ }, { "c": "font", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -9675,7 +9941,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9689,7 +9955,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9703,7 +9969,7 @@ }, { "c": "10", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -9717,7 +9983,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -9731,7 +9997,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9745,7 +10011,7 @@ }, { "c": "verdana", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.font-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.font-name.css", "r": { "dark_plus": "support.constant.font-name: #CE9178", "light_plus": "support.constant.font-name: #0451A5", @@ -9759,7 +10025,7 @@ }, { "c": ",", - "t": "source.css meta.property-list.css meta.property-value.css punctuation.separator.list.comma.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css punctuation.separator.list.comma.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9773,7 +10039,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9787,7 +10053,7 @@ }, { "c": "sans-serif", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.font-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.font-name.css", "r": { "dark_plus": "support.constant.font-name: #CE9178", "light_plus": "support.constant.font-name: #0451A5", @@ -9801,7 +10067,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9815,7 +10081,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9829,7 +10095,7 @@ }, { "c": "background", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -9843,7 +10109,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9857,7 +10123,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9871,7 +10137,7 @@ }, { "c": "transparent", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -9885,7 +10151,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9899,7 +10165,7 @@ }, { "c": "url", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css support.function.url.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css support.function.url.css", "r": { "dark_plus": "support.function: #DCDCAA", "light_plus": "support.function: #795E26", @@ -9913,7 +10179,7 @@ }, { "c": "(", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.begin.bracket.round.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.begin.bracket.round.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9927,7 +10193,7 @@ }, { "c": "paper-bg.jpg", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css variable.parameter.url.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css variable.parameter.url.css", "r": { "dark_plus": "source.css variable: #9CDCFE", "light_plus": "source.css variable: #E50000", @@ -9941,7 +10207,7 @@ }, { "c": ")", - "t": "source.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.end.bracket.round.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css meta.function.url.css punctuation.section.function.end.bracket.round.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9955,7 +10221,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9969,7 +10235,7 @@ }, { "c": "top", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -9983,7 +10249,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -9997,7 +10263,7 @@ }, { "c": "left", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -10011,7 +10277,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css meta.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10025,7 +10291,7 @@ }, { "c": "repeat-y", - "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.property-value.css", "r": { "dark_plus": "support.constant.property-value: #CE9178", "light_plus": "support.constant.property-value: #0451A5", @@ -10039,7 +10305,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10053,7 +10319,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10067,7 +10333,7 @@ }, { "c": "padding", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -10081,7 +10347,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10095,7 +10361,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10109,7 +10375,7 @@ }, { "c": "10", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -10123,7 +10389,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -10137,7 +10403,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10151,7 +10417,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10165,7 +10431,7 @@ }, { "c": "margin-top", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -10179,7 +10445,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10193,7 +10459,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10207,7 +10473,7 @@ }, { "c": "150", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -10221,7 +10487,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -10235,7 +10501,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10249,7 +10515,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10263,7 +10529,7 @@ }, { "c": "width", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -10277,7 +10543,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10291,7 +10557,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10305,7 +10571,7 @@ }, { "c": "130", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css", "r": { "dark_plus": "constant.numeric: #B5CEA8", "light_plus": "constant.numeric: #098658", @@ -10319,7 +10585,7 @@ }, { "c": "px", - "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", "r": { "dark_plus": "keyword.other.unit: #B5CEA8", "light_plus": "keyword.other.unit: #098658", @@ -10333,7 +10599,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10347,7 +10613,7 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10361,7 +10627,7 @@ }, { "c": ".", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -10375,7 +10641,7 @@ }, { "c": "sidebar", - "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", "light_plus": "entity.other.attribute-name.class.css: #800000", @@ -10389,7 +10655,7 @@ }, { "c": " ", - "t": "source.css meta.selector.css", + "t": "source.css meta.at-rule.body.css meta.selector.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10403,7 +10669,7 @@ }, { "c": "li", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -10417,7 +10683,7 @@ }, { "c": " ", - "t": "source.css meta.selector.css", + "t": "source.css meta.at-rule.body.css meta.selector.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10431,7 +10697,7 @@ }, { "c": "a", - "t": "source.css meta.selector.css entity.name.tag.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", "r": { "dark_plus": "entity.name.tag.css: #D7BA7D", "light_plus": "entity.name.tag: #800000", @@ -10445,7 +10711,7 @@ }, { "c": ":", - "t": "source.css meta.selector.css entity.other.attribute-name.pseudo-class.css punctuation.definition.entity.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-class.css punctuation.definition.entity.css", "r": { "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", @@ -10459,7 +10725,7 @@ }, { "c": "link", - "t": "source.css meta.selector.css entity.other.attribute-name.pseudo-class.css", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-class.css", "r": { "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", @@ -10473,7 +10739,7 @@ }, { "c": " ", - "t": "source.css", + "t": "source.css meta.at-rule.body.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10487,7 +10753,7 @@ }, { "c": "{", - "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10501,7 +10767,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10515,7 +10781,7 @@ }, { "c": "color", - "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", "r": { "dark_plus": "support.type.property-name: #9CDCFE", "light_plus": "support.type.property-name: #E50000", @@ -10529,7 +10795,7 @@ }, { "c": ":", - "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10543,7 +10809,7 @@ }, { "c": " ", - "t": "source.css meta.property-list.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10557,7 +10823,7 @@ }, { "c": "#", - "t": "source.css meta.property-list.css meta.property-value.css constant.other.color.rgb-value.hex.css punctuation.definition.constant.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.other.color.rgb-value.hex.css punctuation.definition.constant.css", "r": { "dark_plus": "constant.other.color.rgb-value: #CE9178", "light_plus": "constant.other.color.rgb-value: #0451A5", @@ -10571,7 +10837,7 @@ }, { "c": "988F5E", - "t": "source.css meta.property-list.css meta.property-value.css constant.other.color.rgb-value.hex.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css constant.other.color.rgb-value.hex.css", "r": { "dark_plus": "constant.other.color.rgb-value: #CE9178", "light_plus": "constant.other.color.rgb-value: #0451A5", @@ -10585,7 +10851,7 @@ }, { "c": ";", - "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10599,7 +10865,581 @@ }, { "c": "}", - "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": ".", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "r": { + "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.class.css: #800000", + "dark_vs": "entity.other.attribute-name.class.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.class.css: #800000", + "hc_black": "entity.other.attribute-name.class.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.class.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.class.css: #0F4A85", + "light_modern": "entity.other.attribute-name.class.css: #800000" + } + }, + { + "c": "sidebar", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", + "r": { + "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.class.css: #800000", + "dark_vs": "entity.other.attribute-name.class.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.class.css: #800000", + "hc_black": "entity.other.attribute-name.class.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.class.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.class.css: #0F4A85", + "light_modern": "entity.other.attribute-name.class.css: #800000" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "li", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", + "r": { + "dark_plus": "entity.name.tag.css: #D7BA7D", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag.css: #D7BA7D", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag.css: #D7BA7D", + "dark_modern": "entity.name.tag.css: #D7BA7D", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "a", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.name.tag.css", + "r": { + "dark_plus": "entity.name.tag.css: #D7BA7D", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag.css: #D7BA7D", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag.css: #D7BA7D", + "dark_modern": "entity.name.tag.css: #D7BA7D", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": ":", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-class.css punctuation.definition.entity.css", + "r": { + "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", + "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", + "dark_vs": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", + "light_vs": "source.css entity.other.attribute-name.pseudo-class: #800000", + "hc_black": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", + "dark_modern": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", + "hc_light": "source.css entity.other.attribute-name.pseudo-class: #0F4A85", + "light_modern": "source.css entity.other.attribute-name.pseudo-class: #800000" + } + }, + { + "c": "visited", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.pseudo-class.css", + "r": { + "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", + "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", + "dark_vs": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", + "light_vs": "source.css entity.other.attribute-name.pseudo-class: #800000", + "hc_black": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", + "dark_modern": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", + "hc_light": "source.css entity.other.attribute-name.pseudo-class: #0F4A85", + "light_modern": "source.css entity.other.attribute-name.pseudo-class: #800000" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "{", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "color", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "'", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css string.quoted.single.css punctuation.definition.string.begin.css", + "r": { + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", + "dark_vs": "string: #CE9178", + "light_vs": "string: #A31515", + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" + } + }, + { + "c": "#B3AE94", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css string.quoted.single.css", + "r": { + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", + "dark_vs": "string: #CE9178", + "light_vs": "string: #A31515", + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" + } + }, + { + "c": "'", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css string.quoted.single.css punctuation.definition.string.end.css", + "r": { + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", + "dark_vs": "string: #CE9178", + "light_vs": "string: #A31515", + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" + } + }, + { + "c": ";", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": ".", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "r": { + "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.class.css: #800000", + "dark_vs": "entity.other.attribute-name.class.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.class.css: #800000", + "hc_black": "entity.other.attribute-name.class.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.class.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.class.css: #0F4A85", + "light_modern": "entity.other.attribute-name.class.css: #800000" + } + }, + { + "c": "parent", + "t": "source.css meta.at-rule.body.css meta.selector.css entity.other.attribute-name.class.css", + "r": { + "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.class.css: #800000", + "dark_vs": "entity.other.attribute-name.class.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.class.css: #800000", + "hc_black": "entity.other.attribute-name.class.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.class.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.class.css: #0F4A85", + "light_modern": "entity.other.attribute-name.class.css: #800000" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "{", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "color", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "tomato", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.color.w3c-extended-color-name.css", + "r": { + "dark_plus": "support.constant.color: #CE9178", + "light_plus": "support.constant.color: #0451A5", + "dark_vs": "default: #D4D4D4", + "light_vs": "support.constant.color: #0451A5", + "hc_black": "support.constant.color: #CE9178", + "dark_modern": "support.constant.color: #CE9178", + "hc_light": "support.constant.color: #0451A5", + "light_modern": "support.constant.color: #0451A5" + } + }, + { + "c": ";", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " .", + "t": "source.css meta.at-rule.body.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "child", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " {", + "t": "source.css meta.at-rule.body.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "color", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "blue", + "t": "source.css meta.at-rule.body.css meta.property-list.css meta.property-value.css support.constant.color.w3c-standard-color-name.css", + "r": { + "dark_plus": "support.constant.color: #CE9178", + "light_plus": "support.constant.color: #0451A5", + "dark_vs": "default: #D4D4D4", + "light_vs": "support.constant.color: #0451A5", + "hc_black": "support.constant.color: #CE9178", + "dark_modern": "support.constant.color: #CE9178", + "hc_light": "support.constant.color: #0451A5", + "light_modern": "support.constant.color: #0451A5" + } + }, + { + "c": ";", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.at-rule.body.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "source.css meta.at-rule.body.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "source.css meta.at-rule.body.css punctuation.section.end.bracket.curly.css", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -10626,7 +11466,7 @@ } }, { - "c": "sidebar", + "c": "parent", "t": "source.css meta.selector.css entity.other.attribute-name.class.css", "r": { "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", @@ -10639,90 +11479,6 @@ "light_modern": "entity.other.attribute-name.class.css: #800000" } }, - { - "c": " ", - "t": "source.css meta.selector.css", - "r": { - "dark_plus": "default: #D4D4D4", - "light_plus": "default: #000000", - "dark_vs": "default: #D4D4D4", - "light_vs": "default: #000000", - "hc_black": "default: #FFFFFF", - "dark_modern": "default: #CCCCCC", - "hc_light": "default: #292929", - "light_modern": "default: #3B3B3B" - } - }, - { - "c": "li", - "t": "source.css meta.selector.css entity.name.tag.css", - "r": { - "dark_plus": "entity.name.tag.css: #D7BA7D", - "light_plus": "entity.name.tag: #800000", - "dark_vs": "entity.name.tag.css: #D7BA7D", - "light_vs": "entity.name.tag: #800000", - "hc_black": "entity.name.tag.css: #D7BA7D", - "dark_modern": "entity.name.tag.css: #D7BA7D", - "hc_light": "entity.name.tag: #0F4A85", - "light_modern": "entity.name.tag: #800000" - } - }, - { - "c": " ", - "t": "source.css meta.selector.css", - "r": { - "dark_plus": "default: #D4D4D4", - "light_plus": "default: #000000", - "dark_vs": "default: #D4D4D4", - "light_vs": "default: #000000", - "hc_black": "default: #FFFFFF", - "dark_modern": "default: #CCCCCC", - "hc_light": "default: #292929", - "light_modern": "default: #3B3B3B" - } - }, - { - "c": "a", - "t": "source.css meta.selector.css entity.name.tag.css", - "r": { - "dark_plus": "entity.name.tag.css: #D7BA7D", - "light_plus": "entity.name.tag: #800000", - "dark_vs": "entity.name.tag.css: #D7BA7D", - "light_vs": "entity.name.tag: #800000", - "hc_black": "entity.name.tag.css: #D7BA7D", - "dark_modern": "entity.name.tag.css: #D7BA7D", - "hc_light": "entity.name.tag: #0F4A85", - "light_modern": "entity.name.tag: #800000" - } - }, - { - "c": ":", - "t": "source.css meta.selector.css entity.other.attribute-name.pseudo-class.css punctuation.definition.entity.css", - "r": { - "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", - "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", - "dark_vs": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", - "light_vs": "source.css entity.other.attribute-name.pseudo-class: #800000", - "hc_black": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", - "dark_modern": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", - "hc_light": "source.css entity.other.attribute-name.pseudo-class: #0F4A85", - "light_modern": "source.css entity.other.attribute-name.pseudo-class: #800000" - } - }, - { - "c": "visited", - "t": "source.css meta.selector.css entity.other.attribute-name.pseudo-class.css", - "r": { - "dark_plus": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", - "light_plus": "source.css entity.other.attribute-name.pseudo-class: #800000", - "dark_vs": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", - "light_vs": "source.css entity.other.attribute-name.pseudo-class: #800000", - "hc_black": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", - "dark_modern": "source.css entity.other.attribute-name.pseudo-class: #D7BA7D", - "hc_light": "source.css entity.other.attribute-name.pseudo-class: #0F4A85", - "light_modern": "source.css entity.other.attribute-name.pseudo-class: #800000" - } - }, { "c": " ", "t": "source.css", @@ -10808,45 +11564,17 @@ } }, { - "c": "'", - "t": "source.css meta.property-list.css meta.property-value.css string.quoted.single.css punctuation.definition.string.begin.css", + "c": "tomato", + "t": "source.css meta.property-list.css meta.property-value.css support.constant.color.w3c-extended-color-name.css", "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" - } - }, - { - "c": "#B3AE94", - "t": "source.css meta.property-list.css meta.property-value.css string.quoted.single.css", - "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" - } - }, - { - "c": "'", - "t": "source.css meta.property-list.css meta.property-value.css string.quoted.single.css punctuation.definition.string.end.css", - "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" + "dark_plus": "support.constant.color: #CE9178", + "light_plus": "support.constant.color: #0451A5", + "dark_vs": "default: #D4D4D4", + "light_vs": "support.constant.color: #0451A5", + "hc_black": "support.constant.color: #CE9178", + "dark_modern": "support.constant.color: #CE9178", + "hc_light": "support.constant.color: #0451A5", + "light_modern": "support.constant.color: #0451A5" } }, { @@ -10863,6 +11591,146 @@ "light_modern": "default: #3B3B3B" } }, + { + "c": " & .", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "child", + "t": "source.css meta.property-list.css meta.property-name.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " {", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "color", + "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "blue", + "t": "source.css meta.property-list.css meta.property-value.css support.constant.color.w3c-standard-color-name.css", + "r": { + "dark_plus": "support.constant.color: #CE9178", + "light_plus": "support.constant.color: #0451A5", + "dark_vs": "default: #D4D4D4", + "light_vs": "support.constant.color: #0451A5", + "hc_black": "support.constant.color: #CE9178", + "dark_modern": "support.constant.color: #CE9178", + "hc_light": "support.constant.color: #0451A5", + "light_modern": "support.constant.color: #0451A5" + } + }, + { + "c": ";", + "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, { "c": "}", "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", @@ -10877,6 +11745,20 @@ "light_modern": "default: #3B3B3B" } }, + { + "c": "}", + "t": "source.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, { "c": ".", "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", @@ -11633,6 +12515,1224 @@ "light_modern": "default: #3B3B3B" } }, + { + "c": "}", + "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": ".", + "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "r": { + "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.class.css: #800000", + "dark_vs": "entity.other.attribute-name.class.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.class.css: #800000", + "hc_black": "entity.other.attribute-name.class.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.class.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.class.css: #0F4A85", + "light_modern": "entity.other.attribute-name.class.css: #800000" + } + }, + { + "c": "chat-feature-container", + "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "r": { + "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.class.css: #800000", + "dark_vs": "entity.other.attribute-name.class.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.class.css: #800000", + "hc_black": "entity.other.attribute-name.class.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.class.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.class.css: #0F4A85", + "light_modern": "entity.other.attribute-name.class.css: #800000" + } + }, + { + "c": " ", + "t": "source.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": ".", + "t": "source.css meta.selector.css entity.other.attribute-name.class.css punctuation.definition.entity.css", + "r": { + "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.class.css: #800000", + "dark_vs": "entity.other.attribute-name.class.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.class.css: #800000", + "hc_black": "entity.other.attribute-name.class.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.class.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.class.css: #0F4A85", + "light_modern": "entity.other.attribute-name.class.css: #800000" + } + }, + { + "c": "codicon", + "t": "source.css meta.selector.css entity.other.attribute-name.class.css", + "r": { + "dark_plus": "entity.other.attribute-name.class.css: #D7BA7D", + "light_plus": "entity.other.attribute-name.class.css: #800000", + "dark_vs": "entity.other.attribute-name.class.css: #D7BA7D", + "light_vs": "entity.other.attribute-name.class.css: #800000", + "hc_black": "entity.other.attribute-name.class.css: #D7BA7D", + "dark_modern": "entity.other.attribute-name.class.css: #D7BA7D", + "hc_light": "entity.other.attribute-name.class.css: #0F4A85", + "light_modern": "entity.other.attribute-name.class.css: #800000" + } + }, + { + "c": "[", + "t": "source.css meta.selector.css meta.attribute-selector.css punctuation.definition.entity.begin.bracket.square.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "class", + "t": "source.css meta.selector.css meta.attribute-selector.css entity.other.attribute-name.css", + "r": { + "dark_plus": "entity.other.attribute-name: #9CDCFE", + "light_plus": "entity.other.attribute-name: #E50000", + "dark_vs": "entity.other.attribute-name: #9CDCFE", + "light_vs": "entity.other.attribute-name: #E50000", + "hc_black": "entity.other.attribute-name: #9CDCFE", + "dark_modern": "entity.other.attribute-name: #9CDCFE", + "hc_light": "entity.other.attribute-name: #264F78", + "light_modern": "entity.other.attribute-name: #E50000" + } + }, + { + "c": "*=", + "t": "source.css meta.selector.css meta.attribute-selector.css keyword.operator.pattern.css", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "'", + "t": "source.css meta.selector.css meta.attribute-selector.css string.quoted.single.css punctuation.definition.string.begin.css", + "r": { + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", + "dark_vs": "string: #CE9178", + "light_vs": "string: #A31515", + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" + } + }, + { + "c": "codicon-", + "t": "source.css meta.selector.css meta.attribute-selector.css string.quoted.single.css", + "r": { + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", + "dark_vs": "string: #CE9178", + "light_vs": "string: #A31515", + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" + } + }, + { + "c": "'", + "t": "source.css meta.selector.css meta.attribute-selector.css string.quoted.single.css punctuation.definition.string.end.css", + "r": { + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", + "dark_vs": "string: #CE9178", + "light_vs": "string: #A31515", + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" + } + }, + { + "c": "]", + "t": "source.css meta.selector.css meta.attribute-selector.css punctuation.definition.entity.end.bracket.square.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "{", + "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "font-size", + "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "16", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "r": { + "dark_plus": "constant.numeric: #B5CEA8", + "light_plus": "constant.numeric: #098658", + "dark_vs": "constant.numeric: #B5CEA8", + "light_vs": "constant.numeric: #098658", + "hc_black": "constant.numeric: #B5CEA8", + "dark_modern": "constant.numeric: #B5CEA8", + "hc_light": "constant.numeric: #096D48", + "light_modern": "constant.numeric: #098658" + } + }, + { + "c": "px", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "r": { + "dark_plus": "keyword.other.unit: #B5CEA8", + "light_plus": "keyword.other.unit: #098658", + "dark_vs": "keyword.other.unit: #B5CEA8", + "light_vs": "keyword.other.unit: #098658", + "hc_black": "keyword.other.unit: #B5CEA8", + "dark_modern": "keyword.other.unit: #B5CEA8", + "hc_light": "keyword.other.unit: #096D48", + "light_modern": "keyword.other.unit: #098658" + } + }, + { + "c": ";", + "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "figma-help-bubble", + "t": "source.css meta.selector.css entity.name.tag.custom.css", + "r": { + "dark_plus": "entity.name.tag: #569CD6", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag: #569CD6", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag: #569CD6", + "dark_modern": "entity.name.tag: #569CD6", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": " ", + "t": "source.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "{", + "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "\t", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "position", + "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "absolute", + "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "r": { + "dark_plus": "support.constant.property-value: #CE9178", + "light_plus": "support.constant.property-value: #0451A5", + "dark_vs": "default: #D4D4D4", + "light_vs": "support.constant.property-value: #0451A5", + "hc_black": "support.constant.property-value: #CE9178", + "dark_modern": "support.constant.property-value: #CE9178", + "hc_light": "support.constant.property-value: #0451A5", + "light_modern": "support.constant.property-value: #0451A5" + } + }, + { + "c": ";", + "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "\t", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "right", + "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "16", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "r": { + "dark_plus": "constant.numeric: #B5CEA8", + "light_plus": "constant.numeric: #098658", + "dark_vs": "constant.numeric: #B5CEA8", + "light_vs": "constant.numeric: #098658", + "hc_black": "constant.numeric: #B5CEA8", + "dark_modern": "constant.numeric: #B5CEA8", + "hc_light": "constant.numeric: #096D48", + "light_modern": "constant.numeric: #098658" + } + }, + { + "c": "px", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "r": { + "dark_plus": "keyword.other.unit: #B5CEA8", + "light_plus": "keyword.other.unit: #098658", + "dark_vs": "keyword.other.unit: #B5CEA8", + "light_vs": "keyword.other.unit: #098658", + "hc_black": "keyword.other.unit: #B5CEA8", + "dark_modern": "keyword.other.unit: #B5CEA8", + "hc_light": "keyword.other.unit: #096D48", + "light_modern": "keyword.other.unit: #098658" + } + }, + { + "c": ";", + "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "\t", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "bottom", + "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "16", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "r": { + "dark_plus": "constant.numeric: #B5CEA8", + "light_plus": "constant.numeric: #098658", + "dark_vs": "constant.numeric: #B5CEA8", + "light_vs": "constant.numeric: #098658", + "hc_black": "constant.numeric: #B5CEA8", + "dark_modern": "constant.numeric: #B5CEA8", + "hc_light": "constant.numeric: #096D48", + "light_modern": "constant.numeric: #098658" + } + }, + { + "c": "px", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "r": { + "dark_plus": "keyword.other.unit: #B5CEA8", + "light_plus": "keyword.other.unit: #098658", + "dark_vs": "keyword.other.unit: #B5CEA8", + "light_vs": "keyword.other.unit: #098658", + "hc_black": "keyword.other.unit: #B5CEA8", + "dark_modern": "keyword.other.unit: #B5CEA8", + "hc_light": "keyword.other.unit: #096D48", + "light_modern": "keyword.other.unit: #098658" + } + }, + { + "c": ";", + "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "figma-select", + "t": "source.css meta.selector.css entity.name.tag.custom.css", + "r": { + "dark_plus": "entity.name.tag: #569CD6", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag: #569CD6", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag: #569CD6", + "dark_modern": "entity.name.tag: #569CD6", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": "::part(listbox", + "t": "source.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": ") ", + "t": "source.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "{", + "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "\t", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "max-height", + "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "250", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "r": { + "dark_plus": "constant.numeric: #B5CEA8", + "light_plus": "constant.numeric: #098658", + "dark_vs": "constant.numeric: #B5CEA8", + "light_vs": "constant.numeric: #098658", + "hc_black": "constant.numeric: #B5CEA8", + "dark_modern": "constant.numeric: #B5CEA8", + "hc_light": "constant.numeric: #096D48", + "light_modern": "constant.numeric: #098658" + } + }, + { + "c": "px", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.px.css", + "r": { + "dark_plus": "keyword.other.unit: #B5CEA8", + "light_plus": "keyword.other.unit: #098658", + "dark_vs": "keyword.other.unit: #B5CEA8", + "light_vs": "keyword.other.unit: #098658", + "hc_black": "keyword.other.unit: #B5CEA8", + "dark_modern": "keyword.other.unit: #B5CEA8", + "hc_light": "keyword.other.unit: #096D48", + "light_modern": "keyword.other.unit: #098658" + } + }, + { + "c": ";", + "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "div", + "t": "source.css meta.selector.css entity.name.tag.css", + "r": { + "dark_plus": "entity.name.tag.css: #D7BA7D", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag.css: #D7BA7D", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag.css: #D7BA7D", + "dark_modern": "entity.name.tag.css: #D7BA7D", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": " ", + "t": "source.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": ">", + "t": "source.css meta.selector.css keyword.operator.combinator.css", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": " ", + "t": "source.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "*", + "t": "source.css meta.selector.css entity.name.tag.wildcard.css", + "r": { + "dark_plus": "entity.name.tag: #569CD6", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag: #569CD6", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag: #569CD6", + "dark_modern": "entity.name.tag: #569CD6", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": " ", + "t": "source.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "+", + "t": "source.css meta.selector.css keyword.operator.combinator.css", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": " ", + "t": "source.css meta.selector.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "*", + "t": "source.css meta.selector.css entity.name.tag.wildcard.css", + "r": { + "dark_plus": "entity.name.tag: #569CD6", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag: #569CD6", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag: #569CD6", + "dark_modern": "entity.name.tag: #569CD6", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": " ", + "t": "source.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "{", + "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "margin-top", + "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "4", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css", + "r": { + "dark_plus": "constant.numeric: #B5CEA8", + "light_plus": "constant.numeric: #098658", + "dark_vs": "constant.numeric: #B5CEA8", + "light_vs": "constant.numeric: #098658", + "hc_black": "constant.numeric: #B5CEA8", + "dark_modern": "constant.numeric: #B5CEA8", + "hc_light": "constant.numeric: #096D48", + "light_modern": "constant.numeric: #098658" + } + }, + { + "c": "rem", + "t": "source.css meta.property-list.css meta.property-value.css constant.numeric.css keyword.other.unit.rem.css", + "r": { + "dark_plus": "keyword.other.unit: #B5CEA8", + "light_plus": "keyword.other.unit: #098658", + "dark_vs": "keyword.other.unit: #B5CEA8", + "light_vs": "keyword.other.unit: #098658", + "hc_black": "keyword.other.unit: #B5CEA8", + "dark_modern": "keyword.other.unit: #B5CEA8", + "hc_light": "keyword.other.unit: #096D48", + "light_modern": "keyword.other.unit: #098658" + } + }, + { + "c": ";", + "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "*", + "t": "source.css meta.selector.css entity.name.tag.wildcard.css", + "r": { + "dark_plus": "entity.name.tag: #569CD6", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag: #569CD6", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag: #569CD6", + "dark_modern": "entity.name.tag: #569CD6", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": " ", + "t": "source.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "{", + "t": "source.css meta.property-list.css punctuation.section.property-list.begin.bracket.curly.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "box-sizing", + "t": "source.css meta.property-list.css meta.property-name.css support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "source.css meta.property-list.css punctuation.separator.key-value.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": " ", + "t": "source.css meta.property-list.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "border-box", + "t": "source.css meta.property-list.css meta.property-value.css support.constant.property-value.css", + "r": { + "dark_plus": "support.constant.property-value: #CE9178", + "light_plus": "support.constant.property-value: #0451A5", + "dark_vs": "default: #D4D4D4", + "light_vs": "support.constant.property-value: #0451A5", + "hc_black": "support.constant.property-value: #CE9178", + "dark_modern": "support.constant.property-value: #CE9178", + "hc_light": "support.constant.property-value: #0451A5", + "light_modern": "support.constant.property-value: #0451A5" + } + }, + { + "c": ";", + "t": "source.css meta.property-list.css punctuation.terminator.rule.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, { "c": "}", "t": "source.css meta.property-list.css punctuation.section.property-list.end.bracket.curly.css", diff --git a/extensions/vscode-colorize-tests/test/colorize-results/test_regexp.ts.json b/extensions/vscode-colorize-tests/test/colorize-results/test_regexp.ts.json new file mode 100644 index 00000000000..3a792cdb289 --- /dev/null +++ b/extensions/vscode-colorize-tests/test/colorize-results/test_regexp.ts.json @@ -0,0 +1,2732 @@ +[ + { + "c": "const", + "t": "source.ts meta.var.expr.ts storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "a", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts meta.definition.variable.ts variable.other.constant.ts", + "r": { + "dark_plus": "variable.other.constant: #4FC1FF", + "light_plus": "variable.other.constant: #0070C1", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable.other.constant: #4FC1FF", + "hc_light": "variable.other.constant: #02715D", + "light_modern": "variable.other.constant: #0070C1" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "=", + "t": "source.ts meta.var.expr.ts keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.begin.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\\\", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.character.escape.backslash.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "\\xFF", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.end.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "source.ts punctuation.terminator.statement.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "source.ts meta.var.expr.ts storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "b", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts meta.definition.variable.ts variable.other.constant.ts", + "r": { + "dark_plus": "variable.other.constant: #4FC1FF", + "light_plus": "variable.other.constant: #0070C1", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable.other.constant: #4FC1FF", + "hc_light": "variable.other.constant: #02715D", + "light_modern": "variable.other.constant: #0070C1" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "=", + "t": "source.ts meta.var.expr.ts keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.begin.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "[", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": ".", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "*+", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp", + "r": { + "dark_plus": "constant.other.character-class.set.regexp: #D16969", + "light_plus": "constant.other.character-class.set.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.set.regexp: #D16969", + "hc_light": "constant.other.character-class.set.regexp: #811F3F", + "light_modern": "constant.other.character-class.set.regexp: #811F3F" + } + }, + { + "c": "\\-?", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp constant.other.character-class.range.regexp", + "r": { + "dark_plus": "constant.other.character-class.set.regexp: #D16969", + "light_plus": "constant.other.character-class.set.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.set.regexp: #D16969", + "hc_light": "constant.other.character-class.set.regexp: #811F3F", + "light_modern": "constant.other.character-class.set.regexp: #811F3F" + } + }, + { + "c": "^${}()|[", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp", + "r": { + "dark_plus": "constant.other.character-class.set.regexp: #D16969", + "light_plus": "constant.other.character-class.set.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.set.regexp: #D16969", + "hc_light": "constant.other.character-class.set.regexp: #811F3F", + "light_modern": "constant.other.character-class.set.regexp: #811F3F" + } + }, + { + "c": "\\]\\\\", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp constant.character.escape.backslash.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "]", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.end.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "source.ts punctuation.terminator.statement.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "source.ts meta.var.expr.ts storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "c", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts meta.definition.variable.ts variable.other.constant.ts", + "r": { + "dark_plus": "variable.other.constant: #4FC1FF", + "light_plus": "variable.other.constant: #0070C1", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable.other.constant: #4FC1FF", + "hc_light": "variable.other.constant: #02715D", + "light_modern": "variable.other.constant: #0070C1" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "=", + "t": "source.ts meta.var.expr.ts keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.begin.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\r\\n", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "source.ts meta.var.expr.ts string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "\\r", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "source.ts meta.var.expr.ts string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "\\n", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.end.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "source.ts punctuation.terminator.statement.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "source.ts meta.var.expr.ts storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "d", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts meta.definition.variable.ts variable.other.constant.ts", + "r": { + "dark_plus": "variable.other.constant: #4FC1FF", + "light_plus": "variable.other.constant: #0070C1", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable.other.constant: #4FC1FF", + "hc_light": "variable.other.constant: #02715D", + "light_modern": "variable.other.constant: #0070C1" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "=", + "t": "source.ts meta.var.expr.ts keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.begin.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\/\\/", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.character.escape.backslash.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "# sourceMappingURL=", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "[", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "^", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp keyword.operator.negation.regexp", + "r": { + "dark_plus": "keyword.operator.negation.regexp: #CE9178", + "light_plus": "keyword.operator.negation.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.negation.regexp: #CE9178", + "hc_light": "keyword.operator.negation.regexp: #D16969", + "light_modern": "keyword.operator.negation.regexp: #D16969" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp", + "r": { + "dark_plus": "constant.other.character-class.set.regexp: #D16969", + "light_plus": "constant.other.character-class.set.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.set.regexp: #D16969", + "hc_light": "constant.other.character-class.set.regexp: #811F3F", + "light_modern": "constant.other.character-class.set.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "+", + "t": "source.ts meta.var.expr.ts string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "$", + "t": "source.ts meta.var.expr.ts string.regexp.ts keyword.control.anchor.regexp", + "r": { + "dark_plus": "keyword.control.anchor.regexp: #DCDCAA", + "light_plus": "keyword.control.anchor.regexp: #EE0000", + "dark_vs": "keyword.control: #569CD6", + "light_vs": "keyword.control: #0000FF", + "hc_black": "keyword.control: #C586C0", + "dark_modern": "keyword.control.anchor.regexp: #DCDCAA", + "hc_light": "keyword.control.anchor.regexp: #EE0000", + "light_modern": "keyword.control.anchor.regexp: #EE0000" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.end.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "source.ts punctuation.terminator.statement.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "source.ts meta.var.expr.ts storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "e", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts meta.definition.variable.ts variable.other.constant.ts", + "r": { + "dark_plus": "variable.other.constant: #4FC1FF", + "light_plus": "variable.other.constant: #0070C1", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable.other.constant: #4FC1FF", + "hc_light": "variable.other.constant: #02715D", + "light_modern": "variable.other.constant: #0070C1" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "=", + "t": "source.ts meta.var.expr.ts keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.begin.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "<%=", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\s", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "*", + "t": "source.ts meta.var.expr.ts string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "[", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "^", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp keyword.operator.negation.regexp", + "r": { + "dark_plus": "keyword.operator.negation.regexp: #CE9178", + "light_plus": "keyword.operator.negation.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.negation.regexp: #CE9178", + "hc_light": "keyword.operator.negation.regexp: #D16969", + "light_modern": "keyword.operator.negation.regexp: #D16969" + } + }, + { + "c": "\\s", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "+", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": ")", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\s", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "*", + "t": "source.ts meta.var.expr.ts string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "%>", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.end.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "source.ts punctuation.terminator.statement.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "source.ts meta.var.expr.ts storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "f", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts meta.definition.variable.ts variable.other.constant.ts", + "r": { + "dark_plus": "variable.other.constant: #4FC1FF", + "light_plus": "variable.other.constant: #0070C1", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable.other.constant: #4FC1FF", + "hc_light": "variable.other.constant: #02715D", + "light_modern": "variable.other.constant: #0070C1" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "=", + "t": "source.ts meta.var.expr.ts keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.begin.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "```suggestion", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\u0020", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "*", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\r\\n", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "\\n", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": ")", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": ")(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(?<", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "suggestion", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp punctuation.definition.group.regexp variable.other.regexp", + "r": { + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": ">", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "[", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "\\s\\S", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp constant.other.character-class.set.regexp constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "*?", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": ")(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\r\\n", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "\\n", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": ")", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": ")", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "?", + "t": "source.ts meta.var.expr.ts string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "```", + "t": "source.ts meta.var.expr.ts string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.end.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "source.ts punctuation.terminator.statement.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "source.ts meta.var.expr.ts storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "g", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts meta.definition.variable.ts variable.other.constant.ts", + "r": { + "dark_plus": "variable.other.constant: #4FC1FF", + "light_plus": "variable.other.constant: #0070C1", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable.other.constant: #4FC1FF", + "hc_light": "variable.other.constant: #02715D", + "light_modern": "variable.other.constant: #0070C1" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts meta.var-single-variable.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "=", + "t": "source.ts meta.var.expr.ts keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": " ", + "t": "source.ts meta.var.expr.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "/", + "t": "source.ts meta.var.expr.ts string.regexp.ts punctuation.definition.string.begin.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "?<=", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp punctuation.definition.group.assertion.regexp meta.assertion.look-behind.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "^", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp keyword.control.anchor.regexp", + "r": { + "dark_plus": "keyword.control.anchor.regexp: #DCDCAA", + "light_plus": "keyword.control.anchor.regexp: #EE0000", + "dark_vs": "keyword.control: #569CD6", + "light_vs": "keyword.control: #0000FF", + "hc_black": "keyword.control: #C586C0", + "dark_modern": "keyword.control.anchor.regexp: #DCDCAA", + "hc_light": "keyword.control.anchor.regexp: #EE0000", + "light_modern": "keyword.control.anchor.regexp: #EE0000" + } + }, + { + "c": "|", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "\\s", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": ")(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "?=", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp punctuation.definition.group.assertion.regexp meta.assertion.look-ahead.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "[", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "a-z", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp constant.other.character-class.set.regexp constant.other.character-class.range.regexp", + "r": { + "dark_plus": "constant.other.character-class.set.regexp: #D16969", + "light_plus": "constant.other.character-class.set.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.set.regexp: #D16969", + "hc_light": "constant.other.character-class.set.regexp: #811F3F", + "light_modern": "constant.other.character-class.set.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": ")", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "[", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "a-z", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp constant.other.character-class.range.regexp", + "r": { + "dark_plus": "constant.other.character-class.set.regexp: #D16969", + "light_plus": "constant.other.character-class.set.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.set.regexp: #D16969", + "hc_light": "constant.other.character-class.set.regexp: #811F3F", + "light_modern": "constant.other.character-class.set.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": ")", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "?=", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp punctuation.definition.group.assertion.regexp meta.assertion.look-ahead.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": ".", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "*", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "\\1", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp keyword.other.back-reference.regexp", + "r": { + "dark_plus": "keyword: #569CD6", + "light_plus": "keyword: #0000FF", + "dark_vs": "keyword: #569CD6", + "light_vs": "keyword: #0000FF", + "hc_black": "keyword: #569CD6", + "dark_modern": "keyword: #569CD6", + "hc_light": "keyword: #0F4A85", + "light_modern": "keyword: #0000FF" + } + }, + { + "c": "$", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp keyword.control.anchor.regexp", + "r": { + "dark_plus": "keyword.control.anchor.regexp: #DCDCAA", + "light_plus": "keyword.control.anchor.regexp: #EE0000", + "dark_vs": "keyword.control: #569CD6", + "light_vs": "keyword.control: #0000FF", + "hc_black": "keyword.control: #C586C0", + "dark_modern": "keyword.control.anchor.regexp: #DCDCAA", + "hc_light": "keyword.control.anchor.regexp: #EE0000", + "light_modern": "keyword.control.anchor.regexp: #EE0000" + } + }, + { + "c": ")", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\(", + "t": "source.ts meta.var.expr.ts string.regexp.ts constant.character.escape.backslash.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "[", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "^", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp keyword.operator.negation.regexp", + "r": { + "dark_plus": "keyword.operator.negation.regexp: #CE9178", + "light_plus": "keyword.operator.negation.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.negation.regexp: #CE9178", + "hc_light": "keyword.operator.negation.regexp: #D16969", + "light_modern": "keyword.operator.negation.regexp: #D16969" + } + }, + { + "c": "()", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp", + "r": { + "dark_plus": "constant.other.character-class.set.regexp: #D16969", + "light_plus": "constant.other.character-class.set.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.set.regexp: #D16969", + "hc_light": "constant.other.character-class.set.regexp: #811F3F", + "light_modern": "constant.other.character-class.set.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp constant.other.character-class.set.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "*", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "0", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "+", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": ")", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(", + "t": "source.ts meta.var.expr.ts string.regexp.ts meta.group.assertion.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "?", + "t": "meta.selector.css keyword.operator.combinator.css", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "*", + "t": "meta.selector.css entity.name.tag.wildcard.css", + "r": { + "dark_plus": "entity.name.tag: #569CD6", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag: #569CD6", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag: #569CD6", + "dark_modern": "entity.name.tag: #569CD6", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": "+", + "t": "meta.selector.css keyword.operator.combinator.css", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "*", + "t": "meta.selector.css entity.name.tag.wildcard.css", + "r": { + "dark_plus": "entity.name.tag: #569CD6", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag: #569CD6", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag: #569CD6", + "dark_modern": "entity.name.tag: #569CD6", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": "{", + "t": "punctuation.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "margin-top", + "t": "support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "rem", + "t": "constant.numeric.css keyword.other.unit.css", + "r": { + "dark_plus": "keyword.other.unit: #B5CEA8", + "light_plus": "keyword.other.unit: #098658", + "dark_vs": "keyword.other.unit: #B5CEA8", + "light_vs": "keyword.other.unit: #098658", + "hc_black": "keyword.other.unit: #B5CEA8", + "dark_modern": "keyword.other.unit: #B5CEA8", + "hc_light": "keyword.other.unit: #096D48", + "light_modern": "keyword.other.unit: #098658" + } + }, + { + "c": ";", + "t": "", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "punctuation.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "*", + "t": "meta.selector.css entity.name.tag.wildcard.css", + "r": { + "dark_plus": "entity.name.tag: #569CD6", + "light_plus": "entity.name.tag: #800000", + "dark_vs": "entity.name.tag: #569CD6", + "light_vs": "entity.name.tag: #800000", + "hc_black": "entity.name.tag: #569CD6", + "dark_modern": "entity.name.tag: #569CD6", + "hc_light": "entity.name.tag: #0F4A85", + "light_modern": "entity.name.tag: #800000" + } + }, + { + "c": "{", + "t": "punctuation.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "box-sizing", + "t": "support.type.property-name.css", + "r": { + "dark_plus": "support.type.property-name: #9CDCFE", + "light_plus": "support.type.property-name: #E50000", + "dark_vs": "support.type.property-name: #9CDCFE", + "light_vs": "support.type.property-name: #E50000", + "hc_black": "support.type.property-name: #D4D4D4", + "dark_modern": "support.type.property-name: #9CDCFE", + "hc_light": "support.type.property-name: #264F78", + "light_modern": "support.type.property-name: #E50000" + } + }, + { + "c": ":", + "t": "", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "border-box", + "t": "support.constant.property-value.css", + "r": { + "dark_plus": "support.constant.property-value: #CE9178", + "light_plus": "support.constant.property-value: #0451A5", + "dark_vs": "default: #D4D4D4", + "light_vs": "support.constant.property-value: #0451A5", + "hc_black": "support.constant.property-value: #CE9178", + "dark_modern": "support.constant.property-value: #CE9178", + "hc_light": "support.constant.property-value: #0451A5", + "light_modern": "support.constant.property-value: #0451A5" + } + }, + { + "c": ";", + "t": "", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "}", + "t": "punctuation.css", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + } +] \ No newline at end of file diff --git a/extensions/vscode-colorize-tests/test/colorize-tree-sitter-results/test_regexp.ts.json b/extensions/vscode-colorize-tests/test/colorize-tree-sitter-results/test_regexp.ts.json new file mode 100644 index 00000000000..e296b24c2ad --- /dev/null +++ b/extensions/vscode-colorize-tests/test/colorize-tree-sitter-results/test_regexp.ts.json @@ -0,0 +1,3670 @@ +[ + { + "c": "const", + "t": "storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": "a", + "t": "variable.ts", + "r": { + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": "=", + "t": "keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\\\", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "\\x", + "t": "string.regexp.ts constant.character.escape.regexp internal.regexp constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "F", + "t": "string.regexp.ts constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "F", + "t": "string.regexp.ts constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "punctuation.delimiter.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": "b", + "t": "variable.ts", + "r": { + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": "=", + "t": "keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "[", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": ".", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "*", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "+", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\-", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "?", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "^", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "$", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "{", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "}", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "(", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ")", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "[", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\]", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "\\\\", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "]", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "punctuation.delimiter.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": "c", + "t": "variable.ts", + "r": { + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": "=", + "t": "keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\r", + "t": "string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "\\n", + "t": "string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "\\r", + "t": "string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": ";", + "t": "punctuation.delimiter.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": "d", + "t": "variable.ts", + "r": { + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": "=", + "t": "keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\/", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "\\/", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "#", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": " ", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "s", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "o", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "u", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "r", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "c", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "e", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "M", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "a", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "p", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "p", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "i", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "n", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "g", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "U", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "R", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "L", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "=", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "[", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "^", + "t": "string.regexp.ts keyword.operator.negation.regexp", + "r": { + "dark_plus": "keyword.operator.negation.regexp: #CE9178", + "light_plus": "keyword.operator.negation.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.negation.regexp: #CE9178", + "hc_light": "keyword.operator.negation.regexp: #D16969", + "light_modern": "keyword.operator.negation.regexp: #D16969" + } + }, + { + "c": " ", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "+", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "$", + "t": "string.regexp.ts keyword.control.anchor.regexp", + "r": { + "dark_plus": "keyword.control.anchor.regexp: #DCDCAA", + "light_plus": "keyword.control.anchor.regexp: #EE0000", + "dark_vs": "keyword.control: #569CD6", + "light_vs": "keyword.control: #0000FF", + "hc_black": "keyword.control: #C586C0", + "dark_modern": "keyword.control.anchor.regexp: #DCDCAA", + "hc_light": "keyword.control.anchor.regexp: #EE0000", + "light_modern": "keyword.control.anchor.regexp: #EE0000" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "punctuation.delimiter.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": "e", + "t": "variable.ts", + "r": { + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": "=", + "t": "keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "<", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "%", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "=", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\s", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "*", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "(", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "[", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "^", + "t": "string.regexp.ts keyword.operator.negation.regexp", + "r": { + "dark_plus": "keyword.operator.negation.regexp: #CE9178", + "light_plus": "keyword.operator.negation.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.negation.regexp: #CE9178", + "hc_light": "keyword.operator.negation.regexp: #D16969", + "light_modern": "keyword.operator.negation.regexp: #D16969" + } + }, + { + "c": "\\s", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "]", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "+", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\s", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "*", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "%", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ">", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "punctuation.delimiter.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": "f", + "t": "variable.ts", + "r": { + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": "=", + "t": "keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "`", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "`", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "`", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "s", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "u", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "g", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "g", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "e", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "s", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "t", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "i", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "o", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "n", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "(", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\u", + "t": "string.regexp.ts constant.character.escape.regexp internal.regexp constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "0", + "t": "string.regexp.ts constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "0", + "t": "string.regexp.ts constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "2", + "t": "string.regexp.ts constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "0", + "t": "string.regexp.ts constant.character.numeric.regexp", + "r": { + "dark_plus": "constant.character: #569CD6", + "light_plus": "constant.character: #0000FF", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character: #569CD6", + "hc_light": "constant.character: #0F4A85", + "light_modern": "constant.character: #0000FF" + } + }, + { + "c": "*", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "(", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\r", + "t": "string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "\\n", + "t": "string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "\\n", + "t": "string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(?<", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "suggestion", + "t": "string.regexp.ts punctuation.definition.group.regexp variable.other.regexp", + "r": { + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": ">", + "t": "string.regexp.ts punctuation.definition.group.regexp punctuation.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "[", + "t": "string.regexp.ts punctuation.definition.group.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "\\s", + "t": "string.regexp.ts punctuation.definition.group.regexp constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "\\S", + "t": "string.regexp.ts punctuation.definition.group.regexp constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "]", + "t": "string.regexp.ts punctuation.definition.group.regexp punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "*", + "t": "string.regexp.ts punctuation.definition.group.regexp keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "?", + "t": "string.regexp.ts punctuation.definition.group.regexp keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\r", + "t": "string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "\\n", + "t": "string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "\\n", + "t": "string.regexp.ts constant.other.character-class.regexp", + "r": { + "dark_plus": "constant.other.character-class.regexp: #D16969", + "light_plus": "constant.other.character-class.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "constant.other.character-class.regexp: #D16969", + "hc_light": "constant.other.character-class.regexp: #811F3F", + "light_modern": "constant.other.character-class.regexp: #811F3F" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "?", + "t": "string.regexp.ts keyword.operator.regexp keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "`", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "`", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "`", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ";", + "t": "punctuation.delimiter.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + }, + { + "c": "const", + "t": "storage.type.ts", + "r": { + "dark_plus": "storage.type: #569CD6", + "light_plus": "storage.type: #0000FF", + "dark_vs": "storage.type: #569CD6", + "light_vs": "storage.type: #0000FF", + "hc_black": "storage.type: #569CD6", + "dark_modern": "storage.type: #569CD6", + "hc_light": "storage.type: #0F4A85", + "light_modern": "storage.type: #0000FF" + } + }, + { + "c": "g", + "t": "variable.ts", + "r": { + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": "=", + "t": "keyword.operator.assignment.ts", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "(?<", + "t": "string.regexp.ts punctuation.definition.group.assertion.regexp meta.assertion.look-behind.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "=", + "t": "string.regexp.ts keyword.operator.regexp punctuation.definition.group.assertion.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "^", + "t": "string.regexp.ts keyword.control.anchor.regexp", + "r": { + "dark_plus": "keyword.control.anchor.regexp: #DCDCAA", + "light_plus": "keyword.control.anchor.regexp: #EE0000", + "dark_vs": "keyword.control: #569CD6", + "light_vs": "keyword.control: #0000FF", + "hc_black": "keyword.control: #C586C0", + "dark_modern": "keyword.control.anchor.regexp: #DCDCAA", + "hc_light": "keyword.control.anchor.regexp: #EE0000", + "light_modern": "keyword.control.anchor.regexp: #EE0000" + } + }, + { + "c": "|", + "t": "string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "\\s", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(?", + "t": "string.regexp.ts punctuation.definition.group.assertion.regexp meta.assertion.look-ahead.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "=", + "t": "string.regexp.ts keyword.operator.regexp punctuation.definition.group.assertion.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "[", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "a", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "-", + "t": "string.regexp.ts constant.other.character-class.range.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "z", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "[", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "a", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "-", + "t": "string.regexp.ts constant.other.character-class.range.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "z", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(?", + "t": "string.regexp.ts punctuation.definition.group.assertion.regexp meta.assertion.look-ahead.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "=", + "t": "string.regexp.ts keyword.operator.regexp punctuation.definition.group.assertion.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": ".", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "*", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "\\1", + "t": "string.regexp.ts keyword.other.back-reference.regexp", + "r": { + "dark_plus": "keyword: #569CD6", + "light_plus": "keyword: #0000FF", + "dark_vs": "keyword: #569CD6", + "light_vs": "keyword: #0000FF", + "hc_black": "keyword: #569CD6", + "dark_modern": "keyword: #569CD6", + "hc_light": "keyword: #0F4A85", + "light_modern": "keyword: #0000FF" + } + }, + { + "c": "$", + "t": "string.regexp.ts keyword.control.anchor.regexp", + "r": { + "dark_plus": "keyword.control.anchor.regexp: #DCDCAA", + "light_plus": "keyword.control.anchor.regexp: #EE0000", + "dark_vs": "keyword.control: #569CD6", + "light_vs": "keyword.control: #0000FF", + "hc_black": "keyword.control: #C586C0", + "dark_modern": "keyword.control.anchor.regexp: #DCDCAA", + "hc_light": "keyword.control.anchor.regexp: #EE0000", + "light_modern": "keyword.control.anchor.regexp: #EE0000" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\(", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "(", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "[", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "^", + "t": "string.regexp.ts keyword.operator.negation.regexp", + "r": { + "dark_plus": "keyword.operator.negation.regexp: #CE9178", + "light_plus": "keyword.operator.negation.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.negation.regexp: #CE9178", + "hc_light": "keyword.operator.negation.regexp: #D16969", + "light_modern": "keyword.operator.negation.regexp: #D16969" + } + }, + { + "c": "(", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ")", + "t": "string.regexp.ts constant.character-class.regexp", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "]", + "t": "string.regexp.ts punctuation.definition.character-class.regexp", + "r": { + "dark_plus": "punctuation.definition.character-class.regexp: #CE9178", + "light_plus": "punctuation.definition.character-class.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.character-class.regexp: #CE9178", + "hc_light": "punctuation.definition.character-class.regexp: #D16969", + "light_modern": "punctuation.definition.character-class.regexp: #D16969" + } + }, + { + "c": "*", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "0", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "+", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(?<", + "t": "string.regexp.ts punctuation.definition.group.assertion.regexp meta.assertion.look-behind.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "!", + "t": "string.regexp.ts keyword.operator.regexp punctuation.definition.group.assertion.regexp punctuation.definition.group.assertion.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "p", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "a", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "s", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "s", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "w", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "o", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "r", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "d", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "t", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "o", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "k", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "e", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "n", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "\\)", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "(?", + "t": "string.regexp.ts punctuation.definition.group.assertion.regexp meta.assertion.look-ahead.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "!", + "t": "string.regexp.ts keyword.operator.regexp punctuation.definition.group.assertion.regexp punctuation.definition.group.assertion.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": ".", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "*", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "?", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "(", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "p", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "a", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "s", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "s", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "w", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "o", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "r", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "d", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "|", + "t": "string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "t", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "o", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "k", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "e", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "n", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "{", + "t": "string.regexp.ts constant.character.escape.regexp punctuation.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "L", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "}", + "t": "string.regexp.ts constant.character.escape.regexp punctuation.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "(?:", + "t": "string.regexp.ts punctuation.definition.group.regexp punctuation.definition.group.no-capture.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(?<", + "t": "string.regexp.ts punctuation.definition.group.assertion.regexp meta.assertion.look-behind.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "=", + "t": "string.regexp.ts keyword.operator.regexp punctuation.definition.group.assertion.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "\\(", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "\\d", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": "{", + "t": "string.regexp.ts keyword.operator.quantifier.regexp punctuation.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "3", + "t": "string.regexp.ts keyword.operator.quantifier.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "}", + "t": "string.regexp.ts keyword.operator.quantifier.regexp punctuation.regexp", + "r": { + "dark_plus": "keyword.operator.quantifier.regexp: #D7BA7D", + "light_plus": "keyword.operator.quantifier.regexp: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.quantifier.regexp: #D7BA7D", + "hc_light": "keyword.operator.quantifier.regexp: #000000", + "light_modern": "keyword.operator.quantifier.regexp: #000000" + } + }, + { + "c": "\\)", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "-", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\1", + "t": "string.regexp.ts keyword.other.back-reference.regexp", + "r": { + "dark_plus": "keyword: #569CD6", + "light_plus": "keyword: #0000FF", + "dark_vs": "keyword: #569CD6", + "light_vs": "keyword: #0000FF", + "hc_black": "keyword: #569CD6", + "dark_modern": "keyword: #569CD6", + "hc_light": "keyword: #0F4A85", + "light_modern": "keyword: #0000FF" + } + }, + { + "c": "|", + "t": "string.regexp.ts keyword.operator.or.regexp", + "r": { + "dark_plus": "keyword.operator.or.regexp: #DCDCAA", + "light_plus": "keyword.operator.or.regexp: #EE0000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator.or.regexp: #DCDCAA", + "hc_light": "keyword.operator.or.regexp: #EE0000", + "light_modern": "keyword.operator.or.regexp: #EE0000" + } + }, + { + "c": "-", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "\\1", + "t": "string.regexp.ts keyword.other.back-reference.regexp", + "r": { + "dark_plus": "keyword: #569CD6", + "light_plus": "keyword: #0000FF", + "dark_vs": "keyword: #569CD6", + "light_vs": "keyword: #0000FF", + "hc_black": "keyword: #569CD6", + "dark_modern": "keyword: #569CD6", + "hc_light": "keyword: #0F4A85", + "light_modern": "keyword: #0000FF" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "(?", + "t": "string.regexp.ts punctuation.definition.group.assertion.regexp meta.assertion.look-ahead.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "!", + "t": "string.regexp.ts keyword.operator.regexp punctuation.definition.group.assertion.regexp punctuation.definition.group.assertion.regexp", + "r": { + "dark_plus": "punctuation.definition.group.assertion.regexp: #CE9178", + "light_plus": "punctuation.definition.group.assertion.regexp: #D16969", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "punctuation.definition.group.assertion.regexp: #CE9178", + "hc_light": "punctuation.definition.group.assertion.regexp: #D16969", + "light_modern": "punctuation.definition.group.assertion.regexp: #D16969" + } + }, + { + "c": "\\s", + "t": "string.regexp.ts constant.character.escape.regexp", + "r": { + "dark_plus": "constant.character.escape: #D7BA7D", + "light_plus": "constant.character.escape: #EE0000", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "constant.character: #569CD6", + "dark_modern": "constant.character.escape: #D7BA7D", + "hc_light": "constant.character.escape: #EE0000", + "light_modern": "constant.character.escape: #EE0000" + } + }, + { + "c": ")", + "t": "string.regexp.ts punctuation.definition.group.regexp", + "r": { + "dark_plus": "punctuation.definition.group.regexp: #CE9178", + "light_plus": "punctuation.definition.group.regexp: #D16969", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "punctuation.definition.group.regexp: #CE9178", + "hc_light": "punctuation.definition.group.regexp: #D16969", + "light_modern": "punctuation.definition.group.regexp: #D16969" + } + }, + { + "c": "/", + "t": "string.regexp.ts", + "r": { + "dark_plus": "string.regexp: #D16969", + "light_plus": "string.regexp: #811F3F", + "dark_vs": "string.regexp: #D16969", + "light_vs": "string.regexp: #811F3F", + "hc_black": "string.regexp: #D16969", + "dark_modern": "string.regexp: #D16969", + "hc_light": "string.regexp: #811F3F", + "light_modern": "string.regexp: #811F3F" + } + }, + { + "c": "u", + "t": "string.regexp.ts keyword.ts", + "r": { + "dark_plus": "keyword: #569CD6", + "light_plus": "keyword: #0000FF", + "dark_vs": "keyword: #569CD6", + "light_vs": "keyword: #0000FF", + "hc_black": "keyword: #569CD6", + "dark_modern": "keyword: #569CD6", + "hc_light": "keyword: #0F4A85", + "light_modern": "keyword: #0000FF" + } + }, + { + "c": ";", + "t": "punctuation.delimiter.ts", + "r": { + "dark_plus": "default: #D4D4D4", + "light_plus": "default: #000000", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "default: #FFFFFF", + "dark_modern": "default: #CCCCCC", + "hc_light": "default: #292929", + "light_modern": "default: #3B3B3B" + } + } +] \ No newline at end of file diff --git a/extensions/vscode-test-resolver/package-lock.json b/extensions/vscode-test-resolver/package-lock.json index 367c4dca2e0..3bdbeac9b3e 100644 --- a/extensions/vscode-test-resolver/package-lock.json +++ b/extensions/vscode-test-resolver/package-lock.json @@ -16,19 +16,21 @@ } }, "node_modules/@types/node": { - "version": "20.11.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", - "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" } } } diff --git a/extensions/yaml/package.json b/extensions/yaml/package.json index 486458e8ad6..66f0a200d08 100644 --- a/extensions/yaml/package.json +++ b/extensions/yaml/package.json @@ -95,7 +95,10 @@ "editor.tabSize": 2, "editor.autoIndent": "advanced", "diffEditor.ignoreTrimWhitespace": false, - "editor.defaultColorDecorators": "never" + "editor.defaultColorDecorators": "never", + "editor.quickSuggestions": { + "strings": "on" + } }, "[dockercompose]": { "editor.insertSpaces": true, diff --git a/package-lock.json b/package-lock.json index a9f75e0a24e..beb023f21d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,43 +1,42 @@ { "name": "code-oss-dev", - "version": "1.99.0", + "version": "1.100.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.99.0", + "version": "1.100.0", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@c4312/eventsource-umd": "^3.0.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "2.5.1", "@types/semver": "^7.5.8", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.0", - "@vscode/policy-watcher": "^1.3.0", + "@vscode/policy-watcher": "^1.3.2", "@vscode/proxy-agent": "^0.32.0", "@vscode/ripgrep": "^1.15.11", "@vscode/spdlog": "^0.15.0", "@vscode/sqlite3": "5.1.8-vscode", "@vscode/sudo-prompt": "9.3.1", - "@vscode/tree-sitter-wasm": "^0.1.3", + "@vscode/tree-sitter-wasm": "^0.1.4", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.82", - "@xterm/addon-image": "^0.9.0-beta.99", - "@xterm/addon-ligatures": "^0.10.0-beta.99", - "@xterm/addon-progress": "^0.2.0-beta.5", - "@xterm/addon-search": "^0.16.0-beta.99", - "@xterm/addon-serialize": "^0.14.0-beta.99", - "@xterm/addon-unicode11": "^0.9.0-beta.99", - "@xterm/addon-webgl": "^0.19.0-beta.99", - "@xterm/headless": "^5.6.0-beta.99", - "@xterm/xterm": "^5.6.0-beta.99", + "@xterm/addon-clipboard": "^0.2.0-beta.84", + "@xterm/addon-image": "^0.9.0-beta.101", + "@xterm/addon-ligatures": "^0.10.0-beta.101", + "@xterm/addon-progress": "^0.2.0-beta.7", + "@xterm/addon-search": "^0.16.0-beta.101", + "@xterm/addon-serialize": "^0.14.0-beta.101", + "@xterm/addon-unicode11": "^0.9.0-beta.101", + "@xterm/addon-webgl": "^0.19.0-beta.101", + "@xterm/headless": "^5.6.0-beta.101", + "@xterm/xterm": "^5.6.0-beta.101", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -46,7 +45,7 @@ "native-is-elevated": "0.7.0", "native-keymap": "^3.3.5", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta32", + "node-pty": "^1.1.0-beta33", "open": "^8.4.2", "tas-client-umd": "0.2.0", "v8-inspect-profiler": "^0.1.1", @@ -96,8 +95,8 @@ "css-loader": "^6.9.1", "cssnano": "^6.0.3", "debounce": "^1.0.0", - "deemon": "^1.8.0", - "electron": "34.3.2", + "deemon": "^1.13.4", + "electron": "34.5.1", "eslint": "^9.11.1", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -155,7 +154,7 @@ "ts-node": "^10.9.1", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^5.8.0-dev.20250207", + "typescript": "^5.9.0-dev.20250416", "typescript-eslint": "^8.8.0", "util": "^0.12.4", "webpack": "^5.94.0", @@ -247,29 +246,31 @@ } }, "node_modules/@azure/core-http": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@azure/core-http/-/core-http-2.2.2.tgz", - "integrity": "sha512-V1DdoO9V/sFimKpdWoNBgsE+QUjQgpXYnxrTdUp5RyhsTJjvEVn/HKmTQXIHuLUUo6IyIWj+B+Dg4VaXse9dIA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@azure/core-http/-/core-http-2.3.2.tgz", + "integrity": "sha512-Z4dfbglV9kNZO177CNx4bo5ekFuYwwsvjLiKdZI4r84bYGv3irrbQz7JC3/rUfFH2l4T/W6OFleJaa2X0IaQqw==", + "deprecated": "This package is no longer supported. Please migrate to use @azure/core-rest-pipeline", "dev": true, + "license": "MIT", "dependencies": { "@azure/abort-controller": "^1.0.0", - "@azure/core-asynciterator-polyfill": "^1.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-tracing": "1.0.0-preview.13", + "@azure/core-util": "^1.1.1", "@azure/logger": "^1.0.0", "@types/node-fetch": "^2.5.0", "@types/tunnel": "^0.0.3", "form-data": "^4.0.0", - "node-fetch": "^2.6.0", + "node-fetch": "^2.6.7", "process": "^0.11.10", "tough-cookie": "^4.0.0", "tslib": "^2.2.0", "tunnel": "^0.0.6", "uuid": "^8.3.0", - "xml2js": "^0.4.19" + "xml2js": "^0.5.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" } }, "node_modules/@azure/core-http/node_modules/@azure/abort-controller": { @@ -306,28 +307,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/@azure/core-http/node_modules/xml2js": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", - "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", - "dev": true, - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/@azure/core-http/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/@azure/core-lro": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.2.1.tgz", @@ -482,80 +461,20 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.22.13", - "chalk": "^2.4.2" + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/compat-data": { "version": "7.18.8", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.18.8.tgz", @@ -750,19 +669,21 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -777,100 +698,28 @@ } }, "node_modules/@babel/helpers": { - "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.18.9.tgz", - "integrity": "sha512-Jf5a+rbrLoR4eNdUmnFu8cN5eNJT6qdTdOg5IHIzq87WwyRw9PwguLFOWYgktN/60IP4fgDUawJvs7PjQIzELQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.18.6", - "@babel/traverse": "^7.18.9", - "@babel/types": "^7.18.9" + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", - "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.0" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -879,14 +728,15 @@ } }, "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -923,14 +773,14 @@ } }, "node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -942,18 +792,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "node_modules/@c4312/eventsource-umd": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@c4312/eventsource-umd/-/eventsource-umd-3.0.5.tgz", - "integrity": "sha512-0QhLg51eFB+SS/a4Pv5tHaRSnjJBpdFsjT3WN/Vfh6qzeFXqvaE+evVIIToYvr2lRBLg1NIB635ip8ML+/84Sg==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1181,17 +1019,32 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", - "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", + "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", "dev": true, + "license": "Apache-2.0", "dependencies": { + "@eslint/core": "^0.12.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@gulp-sourcemaps/identity-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", @@ -1646,18 +1499,20 @@ } }, "node_modules/@octokit/openapi-types": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-11.2.0.tgz", - "integrity": "sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA==", - "dev": true + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", + "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==", + "dev": true, + "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.17.0.tgz", - "integrity": "sha512-tzMbrbnam2Mt4AhuyCHvpRkS0oZ5MvwwcQPYGtMv4tUa5kkzG58SVB0fcsLulOZQeRnOgdkZWkRUiyBlh0Bkyw==", + "version": "2.21.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.21.3.tgz", + "integrity": "sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/types": "^6.34.0" + "@octokit/types": "^6.40.0" }, "peerDependencies": { "@octokit/core": ">=2" @@ -1723,12 +1578,13 @@ } }, "node_modules/@octokit/types": { - "version": "6.34.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.34.0.tgz", - "integrity": "sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw==", + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", + "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", "dev": true, + "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^11.2.0" + "@octokit/openapi-types": "^12.11.0" } }, "node_modules/@opentelemetry/api": { @@ -2436,12 +2292,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.14.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.13.tgz", - "integrity": "sha512-+bHoGiZb8UiQ0+WEtmph2IWQCjIqg8MDZMAV+ppRRhUZnquF5mQkP/9vpSwJClEiSM/C7fZZExPzfU0vJTyp8w==", + "version": "20.17.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.27.tgz", + "integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/node-fetch": { @@ -2843,9 +2700,9 @@ } }, "node_modules/@vscode/policy-watcher": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.0.tgz", - "integrity": "sha512-a8pPxlZlMJWOOj2NZ/2ceXgHdDU/NXo+8Pn/InV/sPBfbvTnf/MpMc4pscm9pdU4UIrTGR5+OduQW7mTK8DK7Q==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.2.tgz", + "integrity": "sha512-fmNPYysU2ioH99uCaBPiRblEZSnir5cTmc7w91hAxAoYoGpHt2PZPxT5eIOn7FGmPOsjLdQcd6fduFJGYVD4Mw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3170,9 +3027,9 @@ } }, "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.3.tgz", - "integrity": "sha512-gs0+tlOfriWc6h1kXTCZvmPTuusN+SeDs7yDVZn/kKLGgqlhXFbl/gWKItxaTeryTDalN8N+ikneni6+3UDOag==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz", + "integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==", "license": "MIT" }, "node_modules/@vscode/v8-heap-parser": { @@ -3455,30 +3312,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.2.0-beta.82", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.82.tgz", - "integrity": "sha512-6PCRV0AHm/+ogeRdz2Txndau3l2Z7X7Buu8v5kpnNB30DKyvMh5p9J35maBPIwKF8XUSBvgywu+AW5x6mVqu9g==", + "version": "0.2.0-beta.84", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.84.tgz", + "integrity": "sha512-/7lRpyLboTDKa1SMQCkLkUnH5hawiDsZ1VDMhfgjEr44ltw3cv2YuTtPQYkKen0vfu/0uzZeHWCwsZpQK25nRA==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-image": { - "version": "0.9.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.99.tgz", - "integrity": "sha512-fU6VsnB3X6RUVo5Y2ZACEnbS/3CSFPhWxkDML6r+fgPz6pV4IwGBFLuyvUPxfyfpYt5+3muh6ChDDwUjxG1Ldg==", + "version": "0.9.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.101.tgz", + "integrity": "sha512-iAp4DFxqEhN1DWcCy3d66NgrAklKXfZhHlE8T0rvGS1mfK8ubO5WODXUdMO0rwU5TSrnt4l21DVwFhSs+2oWQw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.10.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.99.tgz", - "integrity": "sha512-QlhUtBlIC7ZgEykpWxFl5lc2MtIFJD41pT8bQVRD1wGShgUmceNTk4xd3CjiQdVOtTrHcgOTM75YmS5GOlobOA==", + "version": "0.10.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.101.tgz", + "integrity": "sha512-QgixJpyzP4ZFhv0YJJgNFXih7escNod9cGTAG7eW/dYwnunZwSmi7Bal/u3m6IC5SZbjAAOjKBGZyfvHefK7SA==", "license": "MIT", "dependencies": { "font-finder": "^1.1.0", @@ -3488,64 +3345,64 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-progress": { - "version": "0.2.0-beta.5", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0-beta.5.tgz", - "integrity": "sha512-6dfUtCqK/anFiVilv1KNyVWbEql2hJwINlAXnl5YtIyEwR8F/i+zWBuzUj9152gT3rDASTmgXE5HG6mnyaUI9w==", + "version": "0.2.0-beta.7", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0-beta.7.tgz", + "integrity": "sha512-1FrJcHm2R+s7auGTrb3rzTevFz5nTP8dLHmY24iVq1a3rPxrprCkfDkugQJsCNG0rd5GT1qk9YWjVcu3GO7gQw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-search": { - "version": "0.16.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.99.tgz", - "integrity": "sha512-nyqGsZDR/l0Gp4gaS+Brrjm53dpaNAqOUtAC8BXmvuzK21sQgyLsC99MTLNR5yh3dYJAfWAAhG5ke/Re3AaamQ==", + "version": "0.16.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.101.tgz", + "integrity": "sha512-rrT9KQsQb/OUQwSVvAIKNFslEM2ux6824GZYPB6uYJbFkRwI+aGKiqs8UM264UcZotHylMSg3dYybGPBImTH3A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.14.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.99.tgz", - "integrity": "sha512-7ULW4BUUGL1Bv8vGBNylaBTpKFDm7rjMdkOwJt+LVd/oJkyL8RFSGgQSuYb+5DyiEhBSpeahg3bi8bStxufvMQ==", + "version": "0.14.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.101.tgz", + "integrity": "sha512-LOJtJroDjoHY9EhSAr0UuWZ59bnZFnZ73xvBT0AyEH0Oqd7MC0LZtI0oV4ifcQU90Eb1oDq3LRfgHm9vAtUrFg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.99.tgz", - "integrity": "sha512-4epGzbOc7X+NyPIMPxnQxaUlZYhCRTEPRsvfuIx55+Yyzip/zGX5ahy/Z22YrGTVv7qjxhVsu1tCbCgiF9HtTA==", + "version": "0.9.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.101.tgz", + "integrity": "sha512-iu1Ry7im8NO3hITbYHbsxZKTxiJQSvg/tGR1EXK1lFIXe9gHc6bqTQPhvFYZ8xgPNt+V1AHZY9SpwcxgBOuxUA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.99.tgz", - "integrity": "sha512-t4vTtwDLYWgzcH86s3hlCGaZWJWzTXLXUcgw/2l+Fkq9LFy4cLuQgWTVjOWLB0KOJ0FmT+g0sBWLApUw9bYa2w==", + "version": "0.19.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.101.tgz", + "integrity": "sha512-g9YzOEqYS7MW1QirNRQhUsRJeFKxsksVQ6iT1dOScjZg7DRwil7/HNS03hQkgigW2Ku3hP5hK0WdXDe4np20gQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/headless": { - "version": "5.6.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.99.tgz", - "integrity": "sha512-E+TR7Cgdb9tHGYw96cexH9l5ghsQEFfw4LaXKxmdlogs43qk2HPwwI7fR/i7t7Ci9ScBXf2gMP76NPpfeX1hZQ==", + "version": "5.6.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.101.tgz", + "integrity": "sha512-uAIo1b50keq3Ybps3Q5QcakVz570hY7gdU/71v52N2BxbvXy0wPk92Q4lCapsKmdtQ3+HbLtRsh1s4k0oP4VGw==", "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.6.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.99.tgz", - "integrity": "sha512-TwBXSyio63Sr2+eJ24BtrPiwTA8JpRbdzhNBYzCXs32yWX30X47UAcdgkahjkyt4JHSqhu7614/w5FOzHsNc/g==", + "version": "5.6.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.101.tgz", + "integrity": "sha512-kV6Ad/KTcCgKWTYshufBEfT3OyadFLuskW+R+nJIJKrlAB34vRsX7TXFJ0P9QoMAeqXQpgngDfTn+RTAESyVyw==", "license": "MIT" }, "node_modules/@xtuc/ieee754": { @@ -4167,49 +4024,80 @@ "dev": true }, "node_modules/bare-events": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz", - "integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", "dev": true, + "license": "Apache-2.0", "optional": true }, "node_modules/bare-fs": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.1.tgz", - "integrity": "sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz", + "integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==", "dev": true, + "license": "Apache-2.0", "optional": true, "dependencies": { - "bare-events": "^2.0.0", - "bare-path": "^2.0.0", - "bare-stream": "^2.0.0" + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, "node_modules/bare-os": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.0.tgz", - "integrity": "sha512-v8DTT08AS/G0F9xrhyLtepoo9EJBJ85FRSMbu1pQUlAf6A8T0tEEQGMVObWeqpjhSPXsE0VGlluFBJu2fdoTNg==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", "dev": true, - "optional": true + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } }, "node_modules/bare-path": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz", - "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "dev": true, + "license": "Apache-2.0", "optional": true, "dependencies": { - "bare-os": "^2.1.0" + "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.1.3.tgz", - "integrity": "sha512-tiDAH9H/kP+tvNO5sczyn9ZAA7utrSMobyDchsnyyXBuUe2FSQWbxhtuHB8jwpHYYevVo2UJpcmvvjrbHboUUQ==", + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", "dev": true, + "license": "Apache-2.0", "optional": true, "dependencies": { - "streamx": "^2.18.0" + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } } }, "node_modules/base": { @@ -5708,10 +5596,11 @@ } }, "node_modules/deemon": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/deemon/-/deemon-1.8.0.tgz", - "integrity": "sha512-qcuSMls/W5DdoEKKAF0PiNQrc8+tItFjvszfjNm1YqNv1p5wwEt+6qILA9sws6eM81nmNwD38ducqlgIXzQlsQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/deemon/-/deemon-1.13.4.tgz", + "integrity": "sha512-O+7MRrNEddXeZXJusSSkFfBsJ5faVt+XpjfIosciuaK6StTjMi5Q4poYUxFlrIPHusrhrc4isC1gxt9nLijf6Q==", "dev": true, + "license": "MIT", "dependencies": { "bl": "^4.0.2", "tree-kill": "^1.2.2" @@ -6079,10 +5968,11 @@ } }, "node_modules/editorconfig/node_modules/@types/node": { - "version": "10.12.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.21.tgz", - "integrity": "sha512-CBgLNk4o3XMnqMc0rhb6lc77IwShMEglz05deDcn2lQxyXEZivfwgYJu7SMha9V5XcrP6qZuevTHV/QrN2vjKQ==", - "dev": true + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "dev": true, + "license": "MIT" }, "node_modules/editorconfig/node_modules/@types/semver": { "version": "5.5.0", @@ -6112,9 +6002,9 @@ "dev": true }, "node_modules/electron": { - "version": "34.3.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-34.3.2.tgz", - "integrity": "sha512-n9tzmFexVLxipZXwMTY30H10f0X9k2OP0SkpSwL5VvnDZi0l/Hc+8CEArKkQPbbSf/IS7nxgc96gtTaR+XoSBg==", + "version": "34.5.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-34.5.1.tgz", + "integrity": "sha512-z2Wm7QjhnJ5592fLITynj8UwIk1mBiT402mOakxSYiADrERIci3IOPk7xWHAFOMvt/eoG5RW16PPhgJiedZcGA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6700,15 +6590,6 @@ "node": ">=0.8.x" } }, - "node_modules/eventsource-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", - "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -10922,7 +10803,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -11121,9 +11003,9 @@ } }, "node_modules/koa": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.4.tgz", - "integrity": "sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.1.tgz", + "integrity": "sha512-umfX9d3iuSxTQP4pnzLOz0HKnPg0FaUUIKcye2lOiz3KPu1Y3M3xlz76dISdFPQs37P9eJz1wUpcTS6KDPn9fA==", "dev": true, "license": "MIT", "dependencies": { @@ -11687,8 +11569,9 @@ "node_modules/matchdep": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", - "integrity": "sha1-xvNINKDY28OzfCfui7yyfHd1WC4= sha512-LFgVbaHIHMqCRuCZyfCtUOq9/Lnzhi7Z0KFUE2fhD54+JN2jLh3hC02RLkqauJ3U4soU6H1J3tfj/Byk7GoEjA==", + "integrity": "sha512-LFgVbaHIHMqCRuCZyfCtUOq9/Lnzhi7Z0KFUE2fhD54+JN2jLh3hC02RLkqauJ3U4soU6H1J3tfj/Byk7GoEjA==", "dev": true, + "license": "MIT", "dependencies": { "findup-sync": "^2.0.0", "micromatch": "^3.0.4", @@ -11762,8 +11645,9 @@ "node_modules/matchdep/node_modules/findup-sync": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-2.0.0.tgz", - "integrity": "sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw= sha512-vs+3unmJT45eczmcAZ6zMJtxN3l/QXeccaXQx5cu/MeJMhewVfoWZqibRkOxPnmoR59+Zy5hjabfQc6JLSah4g==", + "integrity": "sha512-vs+3unmJT45eczmcAZ6zMJtxN3l/QXeccaXQx5cu/MeJMhewVfoWZqibRkOxPnmoR59+Zy5hjabfQc6JLSah4g==", "dev": true, + "license": "MIT", "dependencies": { "detect-file": "^1.0.0", "is-glob": "^3.1.0", @@ -12648,9 +12532,9 @@ } }, "node_modules/node-pty": { - "version": "1.1.0-beta32", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta32.tgz", - "integrity": "sha512-4sp3VUcZuInNOpd0F7Ai8I0z3is5JEfs+4kJECx32u0y64BsoeKKUzPBwWYE4A0kpIeBSFmjDGmkQA317cD/eg==", + "version": "1.1.0-beta33", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta33.tgz", + "integrity": "sha512-+BN2bT/KqO+fmCHnpFS99VMVJr7VUBCUa2VIBEw0oEvszkR7ri0kwD1lF91OeQToUJ2dXKA8j6scPjbO4eRWOQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -14615,9 +14499,10 @@ } }, "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "license": "MIT", "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -14803,12 +14688,6 @@ "inherits": "~2.0.0" } }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", - "dev": true - }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -16474,13 +16353,13 @@ } }, "node_modules/streamx": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.18.0.tgz", - "integrity": "sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", "dev": true, + "license": "MIT", "dependencies": { "fast-fifo": "^1.3.2", - "queue-tick": "^1.0.1", "text-decoder": "^1.1.0" }, "optionalDependencies": { @@ -16889,17 +16768,18 @@ } }, "node_modules/tar-fs": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", - "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", + "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", "dev": true, + "license": "MIT", "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { - "bare-fs": "^2.1.1", - "bare-path": "^2.1.0" + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" } }, "node_modules/tar-fs/node_modules/pump": { @@ -17185,15 +17065,6 @@ "node": ">=0.10.0" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-object-path": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", @@ -17562,9 +17433,9 @@ "dev": true }, "node_modules/typescript": { - "version": "5.8.0-dev.20250207", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.0-dev.20250207.tgz", - "integrity": "sha512-bRCO1GkVxTLd/UFJWOg9R1oRiSMidcfpICzuQlDJlHspv6hlcJvvIJP0BvQxrBYpu4dbzqp/Fh8rRYIkEjbSlQ==", + "version": "5.9.0-dev.20250416", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.0-dev.20250416.tgz", + "integrity": "sha512-NCJUSWqeGImKoCTOydww1MhHvtNjU5GmhJ5LAhqjvZOYcHJSO0DfYSKb39klxfwZoHS9aNgay9bVIWbfODpCUA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -17753,10 +17624,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/union-value": { "version": "1.0.1", diff --git a/package.json b/package.json index bd2044c43da..c4ea33e6073 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", - "version": "1.99.0", - "distro": "c3ec5ba4852b5682b94358c92bf31484d2739db9", + "version": "1.100.0", + "distro": "04f9cea428fe222c396078326db8d260481d0e0b", "author": { "name": "Microsoft Corporation" }, @@ -55,7 +55,7 @@ "eslint": "node build/eslint", "stylelint": "node build/stylelint", "playwright-install": "npm exec playwright install", - "compile-build": "node ./node_modules/gulp/bin/gulp.js compile-build", + "compile-build": "node ./node_modules/gulp/bin/gulp.js compile-build-with-mangling", "compile-extensions-build": "node ./node_modules/gulp/bin/gulp.js compile-extensions-build", "minify-vscode": "node ./node_modules/gulp/bin/gulp.js minify-vscode", "minify-vscode-reh": "node ./node_modules/gulp/bin/gulp.js minify-vscode-reh", @@ -69,34 +69,33 @@ "update-build-ts-version": "npm install typescript@next && tsc -p ./build/tsconfig.build.json" }, "dependencies": { - "@c4312/eventsource-umd": "^3.0.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "2.5.1", "@types/semver": "^7.5.8", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.0", - "@vscode/policy-watcher": "^1.3.0", + "@vscode/policy-watcher": "^1.3.2", "@vscode/proxy-agent": "^0.32.0", "@vscode/ripgrep": "^1.15.11", "@vscode/spdlog": "^0.15.0", "@vscode/sqlite3": "5.1.8-vscode", "@vscode/sudo-prompt": "9.3.1", - "@vscode/tree-sitter-wasm": "^0.1.3", + "@vscode/tree-sitter-wasm": "^0.1.4", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.82", - "@xterm/addon-image": "^0.9.0-beta.99", - "@xterm/addon-ligatures": "^0.10.0-beta.99", - "@xterm/addon-progress": "^0.2.0-beta.5", - "@xterm/addon-search": "^0.16.0-beta.99", - "@xterm/addon-serialize": "^0.14.0-beta.99", - "@xterm/addon-unicode11": "^0.9.0-beta.99", - "@xterm/addon-webgl": "^0.19.0-beta.99", - "@xterm/headless": "^5.6.0-beta.99", - "@xterm/xterm": "^5.6.0-beta.99", + "@xterm/addon-clipboard": "^0.2.0-beta.84", + "@xterm/addon-image": "^0.9.0-beta.101", + "@xterm/addon-ligatures": "^0.10.0-beta.101", + "@xterm/addon-progress": "^0.2.0-beta.7", + "@xterm/addon-search": "^0.16.0-beta.101", + "@xterm/addon-serialize": "^0.14.0-beta.101", + "@xterm/addon-unicode11": "^0.9.0-beta.101", + "@xterm/addon-webgl": "^0.19.0-beta.101", + "@xterm/headless": "^5.6.0-beta.101", + "@xterm/xterm": "^5.6.0-beta.101", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -105,7 +104,7 @@ "native-is-elevated": "0.7.0", "native-keymap": "^3.3.5", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta32", + "node-pty": "^1.1.0-beta33", "open": "^8.4.2", "tas-client-umd": "0.2.0", "v8-inspect-profiler": "^0.1.1", @@ -155,8 +154,8 @@ "css-loader": "^6.9.1", "cssnano": "^6.0.3", "debounce": "^1.0.0", - "deemon": "^1.8.0", - "electron": "34.3.2", + "deemon": "^1.13.4", + "electron": "34.5.1", "eslint": "^9.11.1", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -214,7 +213,7 @@ "ts-node": "^10.9.1", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^5.8.0-dev.20250207", + "typescript": "^5.9.0-dev.20250416", "typescript-eslint": "^8.8.0", "util": "^0.12.4", "webpack": "^5.94.0", diff --git a/remote/.npmrc b/remote/.npmrc index e2c53927b15..3f17dea3fd9 100644 --- a/remote/.npmrc +++ b/remote/.npmrc @@ -1,6 +1,6 @@ disturl="https://nodejs.org/dist" -target="20.18.3" -ms_build_id="323695" +target="20.19.0" +ms_build_id="332907" runtime="node" build_from_source="true" legacy-peer-deps="true" diff --git a/remote/package-lock.json b/remote/package-lock.json index b48c0a32be6..a99aa799f69 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -16,20 +16,20 @@ "@vscode/proxy-agent": "^0.32.0", "@vscode/ripgrep": "^1.15.11", "@vscode/spdlog": "^0.15.0", - "@vscode/tree-sitter-wasm": "^0.1.3", + "@vscode/tree-sitter-wasm": "^0.1.4", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.82", - "@xterm/addon-image": "^0.9.0-beta.99", - "@xterm/addon-ligatures": "^0.10.0-beta.99", - "@xterm/addon-progress": "^0.2.0-beta.5", - "@xterm/addon-search": "^0.16.0-beta.99", - "@xterm/addon-serialize": "^0.14.0-beta.99", - "@xterm/addon-unicode11": "^0.9.0-beta.99", - "@xterm/addon-webgl": "^0.19.0-beta.99", - "@xterm/headless": "^5.6.0-beta.99", - "@xterm/xterm": "^5.6.0-beta.99", + "@xterm/addon-clipboard": "^0.2.0-beta.84", + "@xterm/addon-image": "^0.9.0-beta.101", + "@xterm/addon-ligatures": "^0.10.0-beta.101", + "@xterm/addon-progress": "^0.2.0-beta.7", + "@xterm/addon-search": "^0.16.0-beta.101", + "@xterm/addon-serialize": "^0.14.0-beta.101", + "@xterm/addon-unicode11": "^0.9.0-beta.101", + "@xterm/addon-webgl": "^0.19.0-beta.101", + "@xterm/headless": "^5.6.0-beta.101", + "@xterm/xterm": "^5.6.0-beta.101", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -37,7 +37,7 @@ "kerberos": "2.1.1", "minimist": "^1.2.6", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta32", + "node-pty": "^1.1.0-beta33", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", @@ -470,9 +470,9 @@ } }, "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.3.tgz", - "integrity": "sha512-gs0+tlOfriWc6h1kXTCZvmPTuusN+SeDs7yDVZn/kKLGgqlhXFbl/gWKItxaTeryTDalN8N+ikneni6+3UDOag==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz", + "integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==", "license": "MIT" }, "node_modules/@vscode/vscode-languagedetection": { @@ -523,30 +523,30 @@ "hasInstallScript": true }, "node_modules/@xterm/addon-clipboard": { - "version": "0.2.0-beta.82", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.82.tgz", - "integrity": "sha512-6PCRV0AHm/+ogeRdz2Txndau3l2Z7X7Buu8v5kpnNB30DKyvMh5p9J35maBPIwKF8XUSBvgywu+AW5x6mVqu9g==", + "version": "0.2.0-beta.84", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.84.tgz", + "integrity": "sha512-/7lRpyLboTDKa1SMQCkLkUnH5hawiDsZ1VDMhfgjEr44ltw3cv2YuTtPQYkKen0vfu/0uzZeHWCwsZpQK25nRA==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-image": { - "version": "0.9.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.99.tgz", - "integrity": "sha512-fU6VsnB3X6RUVo5Y2ZACEnbS/3CSFPhWxkDML6r+fgPz6pV4IwGBFLuyvUPxfyfpYt5+3muh6ChDDwUjxG1Ldg==", + "version": "0.9.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.101.tgz", + "integrity": "sha512-iAp4DFxqEhN1DWcCy3d66NgrAklKXfZhHlE8T0rvGS1mfK8ubO5WODXUdMO0rwU5TSrnt4l21DVwFhSs+2oWQw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.10.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.99.tgz", - "integrity": "sha512-QlhUtBlIC7ZgEykpWxFl5lc2MtIFJD41pT8bQVRD1wGShgUmceNTk4xd3CjiQdVOtTrHcgOTM75YmS5GOlobOA==", + "version": "0.10.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.101.tgz", + "integrity": "sha512-QgixJpyzP4ZFhv0YJJgNFXih7escNod9cGTAG7eW/dYwnunZwSmi7Bal/u3m6IC5SZbjAAOjKBGZyfvHefK7SA==", "license": "MIT", "dependencies": { "font-finder": "^1.1.0", @@ -556,64 +556,64 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-progress": { - "version": "0.2.0-beta.5", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0-beta.5.tgz", - "integrity": "sha512-6dfUtCqK/anFiVilv1KNyVWbEql2hJwINlAXnl5YtIyEwR8F/i+zWBuzUj9152gT3rDASTmgXE5HG6mnyaUI9w==", + "version": "0.2.0-beta.7", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0-beta.7.tgz", + "integrity": "sha512-1FrJcHm2R+s7auGTrb3rzTevFz5nTP8dLHmY24iVq1a3rPxrprCkfDkugQJsCNG0rd5GT1qk9YWjVcu3GO7gQw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-search": { - "version": "0.16.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.99.tgz", - "integrity": "sha512-nyqGsZDR/l0Gp4gaS+Brrjm53dpaNAqOUtAC8BXmvuzK21sQgyLsC99MTLNR5yh3dYJAfWAAhG5ke/Re3AaamQ==", + "version": "0.16.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.101.tgz", + "integrity": "sha512-rrT9KQsQb/OUQwSVvAIKNFslEM2ux6824GZYPB6uYJbFkRwI+aGKiqs8UM264UcZotHylMSg3dYybGPBImTH3A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.14.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.99.tgz", - "integrity": "sha512-7ULW4BUUGL1Bv8vGBNylaBTpKFDm7rjMdkOwJt+LVd/oJkyL8RFSGgQSuYb+5DyiEhBSpeahg3bi8bStxufvMQ==", + "version": "0.14.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.101.tgz", + "integrity": "sha512-LOJtJroDjoHY9EhSAr0UuWZ59bnZFnZ73xvBT0AyEH0Oqd7MC0LZtI0oV4ifcQU90Eb1oDq3LRfgHm9vAtUrFg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.99.tgz", - "integrity": "sha512-4epGzbOc7X+NyPIMPxnQxaUlZYhCRTEPRsvfuIx55+Yyzip/zGX5ahy/Z22YrGTVv7qjxhVsu1tCbCgiF9HtTA==", + "version": "0.9.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.101.tgz", + "integrity": "sha512-iu1Ry7im8NO3hITbYHbsxZKTxiJQSvg/tGR1EXK1lFIXe9gHc6bqTQPhvFYZ8xgPNt+V1AHZY9SpwcxgBOuxUA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.99.tgz", - "integrity": "sha512-t4vTtwDLYWgzcH86s3hlCGaZWJWzTXLXUcgw/2l+Fkq9LFy4cLuQgWTVjOWLB0KOJ0FmT+g0sBWLApUw9bYa2w==", + "version": "0.19.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.101.tgz", + "integrity": "sha512-g9YzOEqYS7MW1QirNRQhUsRJeFKxsksVQ6iT1dOScjZg7DRwil7/HNS03hQkgigW2Ku3hP5hK0WdXDe4np20gQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/headless": { - "version": "5.6.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.99.tgz", - "integrity": "sha512-E+TR7Cgdb9tHGYw96cexH9l5ghsQEFfw4LaXKxmdlogs43qk2HPwwI7fR/i7t7Ci9ScBXf2gMP76NPpfeX1hZQ==", + "version": "5.6.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.101.tgz", + "integrity": "sha512-uAIo1b50keq3Ybps3Q5QcakVz570hY7gdU/71v52N2BxbvXy0wPk92Q4lCapsKmdtQ3+HbLtRsh1s4k0oP4VGw==", "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.6.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.99.tgz", - "integrity": "sha512-TwBXSyio63Sr2+eJ24BtrPiwTA8JpRbdzhNBYzCXs32yWX30X47UAcdgkahjkyt4JHSqhu7614/w5FOzHsNc/g==", + "version": "5.6.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.101.tgz", + "integrity": "sha512-kV6Ad/KTcCgKWTYshufBEfT3OyadFLuskW+R+nJIJKrlAB34vRsX7TXFJ0P9QoMAeqXQpgngDfTn+RTAESyVyw==", "license": "MIT" }, "node_modules/agent-base": { @@ -1098,9 +1098,9 @@ } }, "node_modules/node-pty": { - "version": "1.1.0-beta32", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta32.tgz", - "integrity": "sha512-4sp3VUcZuInNOpd0F7Ai8I0z3is5JEfs+4kJECx32u0y64BsoeKKUzPBwWYE4A0kpIeBSFmjDGmkQA317cD/eg==", + "version": "1.1.0-beta33", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta33.tgz", + "integrity": "sha512-+BN2bT/KqO+fmCHnpFS99VMVJr7VUBCUa2VIBEw0oEvszkR7ri0kwD1lF91OeQToUJ2dXKA8j6scPjbO4eRWOQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1351,9 +1351,10 @@ } }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "license": "MIT", "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", diff --git a/remote/package.json b/remote/package.json index 5dbe6748383..e15e4dce8e2 100644 --- a/remote/package.json +++ b/remote/package.json @@ -11,20 +11,20 @@ "@vscode/proxy-agent": "^0.32.0", "@vscode/ripgrep": "^1.15.11", "@vscode/spdlog": "^0.15.0", - "@vscode/tree-sitter-wasm": "^0.1.3", + "@vscode/tree-sitter-wasm": "^0.1.4", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.82", - "@xterm/addon-image": "^0.9.0-beta.99", - "@xterm/addon-ligatures": "^0.10.0-beta.99", - "@xterm/addon-progress": "^0.2.0-beta.5", - "@xterm/addon-search": "^0.16.0-beta.99", - "@xterm/addon-serialize": "^0.14.0-beta.99", - "@xterm/addon-unicode11": "^0.9.0-beta.99", - "@xterm/addon-webgl": "^0.19.0-beta.99", - "@xterm/headless": "^5.6.0-beta.99", - "@xterm/xterm": "^5.6.0-beta.99", + "@xterm/addon-clipboard": "^0.2.0-beta.84", + "@xterm/addon-image": "^0.9.0-beta.101", + "@xterm/addon-ligatures": "^0.10.0-beta.101", + "@xterm/addon-progress": "^0.2.0-beta.7", + "@xterm/addon-search": "^0.16.0-beta.101", + "@xterm/addon-serialize": "^0.14.0-beta.101", + "@xterm/addon-unicode11": "^0.9.0-beta.101", + "@xterm/addon-webgl": "^0.19.0-beta.101", + "@xterm/headless": "^5.6.0-beta.101", + "@xterm/xterm": "^5.6.0-beta.101", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -32,7 +32,7 @@ "kerberos": "2.1.1", "minimist": "^1.2.6", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta32", + "node-pty": "^1.1.0-beta33", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 235d2d3ba46..a6c826c2a73 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -11,17 +11,17 @@ "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@vscode/iconv-lite-umd": "0.7.0", - "@vscode/tree-sitter-wasm": "^0.1.3", + "@vscode/tree-sitter-wasm": "^0.1.4", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.2.0-beta.82", - "@xterm/addon-image": "^0.9.0-beta.99", - "@xterm/addon-ligatures": "^0.10.0-beta.99", - "@xterm/addon-progress": "^0.2.0-beta.5", - "@xterm/addon-search": "^0.16.0-beta.99", - "@xterm/addon-serialize": "^0.14.0-beta.99", - "@xterm/addon-unicode11": "^0.9.0-beta.99", - "@xterm/addon-webgl": "^0.19.0-beta.99", - "@xterm/xterm": "^5.6.0-beta.99", + "@xterm/addon-clipboard": "^0.2.0-beta.84", + "@xterm/addon-image": "^0.9.0-beta.101", + "@xterm/addon-ligatures": "^0.10.0-beta.101", + "@xterm/addon-progress": "^0.2.0-beta.7", + "@xterm/addon-search": "^0.16.0-beta.101", + "@xterm/addon-serialize": "^0.14.0-beta.101", + "@xterm/addon-unicode11": "^0.9.0-beta.101", + "@xterm/addon-webgl": "^0.19.0-beta.101", + "@xterm/xterm": "^5.6.0-beta.101", "jschardet": "3.1.4", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", @@ -76,9 +76,9 @@ "integrity": "sha512-bRRFxLfg5dtAyl5XyiVWz/ZBPahpOpPrNYnnHpOpUZvam4tKH35wdhP4Kj6PbM0+KdliOsPzbGWpkxcdpNB/sg==" }, "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.3.tgz", - "integrity": "sha512-gs0+tlOfriWc6h1kXTCZvmPTuusN+SeDs7yDVZn/kKLGgqlhXFbl/gWKItxaTeryTDalN8N+ikneni6+3UDOag==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz", + "integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==", "license": "MIT" }, "node_modules/@vscode/vscode-languagedetection": { @@ -90,30 +90,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.2.0-beta.82", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.82.tgz", - "integrity": "sha512-6PCRV0AHm/+ogeRdz2Txndau3l2Z7X7Buu8v5kpnNB30DKyvMh5p9J35maBPIwKF8XUSBvgywu+AW5x6mVqu9g==", + "version": "0.2.0-beta.84", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.84.tgz", + "integrity": "sha512-/7lRpyLboTDKa1SMQCkLkUnH5hawiDsZ1VDMhfgjEr44ltw3cv2YuTtPQYkKen0vfu/0uzZeHWCwsZpQK25nRA==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-image": { - "version": "0.9.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.99.tgz", - "integrity": "sha512-fU6VsnB3X6RUVo5Y2ZACEnbS/3CSFPhWxkDML6r+fgPz6pV4IwGBFLuyvUPxfyfpYt5+3muh6ChDDwUjxG1Ldg==", + "version": "0.9.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.101.tgz", + "integrity": "sha512-iAp4DFxqEhN1DWcCy3d66NgrAklKXfZhHlE8T0rvGS1mfK8ubO5WODXUdMO0rwU5TSrnt4l21DVwFhSs+2oWQw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.10.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.99.tgz", - "integrity": "sha512-QlhUtBlIC7ZgEykpWxFl5lc2MtIFJD41pT8bQVRD1wGShgUmceNTk4xd3CjiQdVOtTrHcgOTM75YmS5GOlobOA==", + "version": "0.10.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.101.tgz", + "integrity": "sha512-QgixJpyzP4ZFhv0YJJgNFXih7escNod9cGTAG7eW/dYwnunZwSmi7Bal/u3m6IC5SZbjAAOjKBGZyfvHefK7SA==", "license": "MIT", "dependencies": { "font-finder": "^1.1.0", @@ -123,58 +123,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-progress": { - "version": "0.2.0-beta.5", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0-beta.5.tgz", - "integrity": "sha512-6dfUtCqK/anFiVilv1KNyVWbEql2hJwINlAXnl5YtIyEwR8F/i+zWBuzUj9152gT3rDASTmgXE5HG6mnyaUI9w==", + "version": "0.2.0-beta.7", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0-beta.7.tgz", + "integrity": "sha512-1FrJcHm2R+s7auGTrb3rzTevFz5nTP8dLHmY24iVq1a3rPxrprCkfDkugQJsCNG0rd5GT1qk9YWjVcu3GO7gQw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-search": { - "version": "0.16.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.99.tgz", - "integrity": "sha512-nyqGsZDR/l0Gp4gaS+Brrjm53dpaNAqOUtAC8BXmvuzK21sQgyLsC99MTLNR5yh3dYJAfWAAhG5ke/Re3AaamQ==", + "version": "0.16.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.101.tgz", + "integrity": "sha512-rrT9KQsQb/OUQwSVvAIKNFslEM2ux6824GZYPB6uYJbFkRwI+aGKiqs8UM264UcZotHylMSg3dYybGPBImTH3A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.14.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.99.tgz", - "integrity": "sha512-7ULW4BUUGL1Bv8vGBNylaBTpKFDm7rjMdkOwJt+LVd/oJkyL8RFSGgQSuYb+5DyiEhBSpeahg3bi8bStxufvMQ==", + "version": "0.14.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.101.tgz", + "integrity": "sha512-LOJtJroDjoHY9EhSAr0UuWZ59bnZFnZ73xvBT0AyEH0Oqd7MC0LZtI0oV4ifcQU90Eb1oDq3LRfgHm9vAtUrFg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.99.tgz", - "integrity": "sha512-4epGzbOc7X+NyPIMPxnQxaUlZYhCRTEPRsvfuIx55+Yyzip/zGX5ahy/Z22YrGTVv7qjxhVsu1tCbCgiF9HtTA==", + "version": "0.9.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.101.tgz", + "integrity": "sha512-iu1Ry7im8NO3hITbYHbsxZKTxiJQSvg/tGR1EXK1lFIXe9gHc6bqTQPhvFYZ8xgPNt+V1AHZY9SpwcxgBOuxUA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.99.tgz", - "integrity": "sha512-t4vTtwDLYWgzcH86s3hlCGaZWJWzTXLXUcgw/2l+Fkq9LFy4cLuQgWTVjOWLB0KOJ0FmT+g0sBWLApUw9bYa2w==", + "version": "0.19.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.101.tgz", + "integrity": "sha512-g9YzOEqYS7MW1QirNRQhUsRJeFKxsksVQ6iT1dOScjZg7DRwil7/HNS03hQkgigW2Ku3hP5hK0WdXDe4np20gQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.99" + "@xterm/xterm": "^5.6.0-beta.101" } }, "node_modules/@xterm/xterm": { - "version": "5.6.0-beta.99", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.99.tgz", - "integrity": "sha512-TwBXSyio63Sr2+eJ24BtrPiwTA8JpRbdzhNBYzCXs32yWX30X47UAcdgkahjkyt4JHSqhu7614/w5FOzHsNc/g==", + "version": "5.6.0-beta.101", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.101.tgz", + "integrity": "sha512-kV6Ad/KTcCgKWTYshufBEfT3OyadFLuskW+R+nJIJKrlAB34vRsX7TXFJ0P9QoMAeqXQpgngDfTn+RTAESyVyw==", "license": "MIT" }, "node_modules/font-finder": { diff --git a/remote/web/package.json b/remote/web/package.json index c54922cdb2e..167d8e4dbba 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -6,17 +6,17 @@ "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@vscode/iconv-lite-umd": "0.7.0", - "@vscode/tree-sitter-wasm": "^0.1.3", + "@vscode/tree-sitter-wasm": "^0.1.4", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.2.0-beta.82", - "@xterm/addon-image": "^0.9.0-beta.99", - "@xterm/addon-ligatures": "^0.10.0-beta.99", - "@xterm/addon-progress": "^0.2.0-beta.5", - "@xterm/addon-search": "^0.16.0-beta.99", - "@xterm/addon-serialize": "^0.14.0-beta.99", - "@xterm/addon-unicode11": "^0.9.0-beta.99", - "@xterm/addon-webgl": "^0.19.0-beta.99", - "@xterm/xterm": "^5.6.0-beta.99", + "@xterm/addon-clipboard": "^0.2.0-beta.84", + "@xterm/addon-image": "^0.9.0-beta.101", + "@xterm/addon-ligatures": "^0.10.0-beta.101", + "@xterm/addon-progress": "^0.2.0-beta.7", + "@xterm/addon-search": "^0.16.0-beta.101", + "@xterm/addon-serialize": "^0.14.0-beta.101", + "@xterm/addon-unicode11": "^0.9.0-beta.101", + "@xterm/addon-webgl": "^0.19.0-beta.101", + "@xterm/xterm": "^5.6.0-beta.101", "jschardet": "3.1.4", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", diff --git a/resources/linux/snap/snapcraft.yaml b/resources/linux/snap/snapcraft.yaml index 1d7412bdc71..6f4962af70a 100644 --- a/resources/linux/snap/snapcraft.yaml +++ b/resources/linux/snap/snapcraft.yaml @@ -61,6 +61,7 @@ parts: override-build: | snapcraftctl build patchelf --force-rpath --set-rpath '$ORIGIN/../../lib/x86_64-linux-gnu:$ORIGIN:/snap/core20/current/lib/x86_64-linux-gnu' $SNAPCRAFT_PART_INSTALL/usr/share/@@NAME@@/chrome_crashpad_handler + chmod 0755 $SNAPCRAFT_PART_INSTALL/usr/share/@@NAME@@/chrome-sandbox cleanup: after: - code diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index 89006480308..33d00615359 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -6,9 +6,6 @@ if [[ "$OSTYPE" == "darwin"* ]]; then ROOT=$(dirname $(dirname $(realpath "$0"))) else ROOT=$(dirname $(dirname $(readlink -f $0))) - # --disable-dev-shm-usage: when run on docker containers where size of /dev/shm - # partition < 64MB which causes OOM failure for chromium compositor that uses the partition for shared memory - LINUX_EXTRA_ARGS="--disable-dev-shm-usage" fi VSCODEUSERDATADIR=`mktemp -d 2>/dev/null` @@ -55,13 +52,13 @@ fi echo echo "### API tests (folder)" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $ROOT/extensions/vscode-api-tests/testWorkspace --enable-proposed-api=vscode.vscode-api-tests --extensionDevelopmentPath=$ROOT/extensions/vscode-api-tests --extensionTestsPath=$ROOT/extensions/vscode-api-tests/out/singlefolder-tests $API_TESTS_EXTRA_ARGS +"$INTEGRATION_TEST_ELECTRON_PATH" $ROOT/extensions/vscode-api-tests/testWorkspace --enable-proposed-api=vscode.vscode-api-tests --extensionDevelopmentPath=$ROOT/extensions/vscode-api-tests --extensionTestsPath=$ROOT/extensions/vscode-api-tests/out/singlefolder-tests $API_TESTS_EXTRA_ARGS kill_app echo echo "### API tests (workspace)" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $ROOT/extensions/vscode-api-tests/testworkspace.code-workspace --enable-proposed-api=vscode.vscode-api-tests --extensionDevelopmentPath=$ROOT/extensions/vscode-api-tests --extensionTestsPath=$ROOT/extensions/vscode-api-tests/out/workspace-tests $API_TESTS_EXTRA_ARGS +"$INTEGRATION_TEST_ELECTRON_PATH" $ROOT/extensions/vscode-api-tests/testworkspace.code-workspace --enable-proposed-api=vscode.vscode-api-tests --extensionDevelopmentPath=$ROOT/extensions/vscode-api-tests --extensionTestsPath=$ROOT/extensions/vscode-api-tests/out/workspace-tests $API_TESTS_EXTRA_ARGS kill_app echo @@ -71,7 +68,7 @@ npm run test-extension -- -l vscode-colorize-tests kill_app echo -echo "### Terminal Suggest tests" +echo "### Terminal Suggest tests" echo npm run test-extension -- -l terminal-suggest --enable-proposed-api=vscode.vscode-api-tests kill_app @@ -79,7 +76,7 @@ kill_app echo echo "### TypeScript tests" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $ROOT/extensions/typescript-language-features/test-workspace --extensionDevelopmentPath=$ROOT/extensions/typescript-language-features --extensionTestsPath=$ROOT/extensions/typescript-language-features/out/test/unit $API_TESTS_EXTRA_ARGS +"$INTEGRATION_TEST_ELECTRON_PATH" $ROOT/extensions/typescript-language-features/test-workspace --extensionDevelopmentPath=$ROOT/extensions/typescript-language-features --extensionTestsPath=$ROOT/extensions/typescript-language-features/out/test/unit $API_TESTS_EXTRA_ARGS kill_app echo @@ -91,13 +88,13 @@ kill_app echo echo "### Emmet tests" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $ROOT/extensions/emmet/test-workspace --extensionDevelopmentPath=$ROOT/extensions/emmet --extensionTestsPath=$ROOT/extensions/emmet/out/test $API_TESTS_EXTRA_ARGS +"$INTEGRATION_TEST_ELECTRON_PATH" $ROOT/extensions/emmet/test-workspace --extensionDevelopmentPath=$ROOT/extensions/emmet --extensionTestsPath=$ROOT/extensions/emmet/out/test $API_TESTS_EXTRA_ARGS kill_app echo echo "### Git tests" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$ROOT/extensions/git --extensionTestsPath=$ROOT/extensions/git/out/test $API_TESTS_EXTRA_ARGS +"$INTEGRATION_TEST_ELECTRON_PATH" $(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$ROOT/extensions/git --extensionTestsPath=$ROOT/extensions/git/out/test $API_TESTS_EXTRA_ARGS kill_app echo diff --git a/scripts/test-remote-integration.sh b/scripts/test-remote-integration.sh index 4695dfa12e3..7325757418e 100755 --- a/scripts/test-remote-integration.sh +++ b/scripts/test-remote-integration.sh @@ -6,9 +6,6 @@ if [[ "$OSTYPE" == "darwin"* ]]; then ROOT=$(dirname $(dirname $(realpath "$0"))) else ROOT=$(dirname $(dirname $(readlink -f $0))) - # --disable-dev-shm-usage: when run on docker containers where size of /dev/shm - # partition < 64MB which causes OOM failure for chromium compositor that uses the partition for shared memory - LINUX_EXTRA_ARGS="--disable-dev-shm-usage" fi VSCODEUSERDATADIR=`mktemp -d 2>/dev/null` @@ -79,49 +76,49 @@ echo "Storing log files into '$VSCODELOGSDIR'." echo echo "### API tests (folder)" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --folder-uri=$REMOTE_VSCODE/vscode-api-tests/testWorkspace --extensionDevelopmentPath=$REMOTE_VSCODE/vscode-api-tests --extensionTestsPath=$REMOTE_VSCODE/vscode-api-tests/out/singlefolder-tests $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS +"$INTEGRATION_TEST_ELECTRON_PATH" --folder-uri=$REMOTE_VSCODE/vscode-api-tests/testWorkspace --extensionDevelopmentPath=$REMOTE_VSCODE/vscode-api-tests --extensionTestsPath=$REMOTE_VSCODE/vscode-api-tests/out/singlefolder-tests $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS kill_app echo echo "### API tests (workspace)" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --file-uri=$REMOTE_VSCODE/vscode-api-tests/testworkspace.code-workspace --extensionDevelopmentPath=$REMOTE_VSCODE/vscode-api-tests --extensionTestsPath=$REMOTE_VSCODE/vscode-api-tests/out/workspace-tests $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS +"$INTEGRATION_TEST_ELECTRON_PATH" --file-uri=$REMOTE_VSCODE/vscode-api-tests/testworkspace.code-workspace --extensionDevelopmentPath=$REMOTE_VSCODE/vscode-api-tests --extensionTestsPath=$REMOTE_VSCODE/vscode-api-tests/out/workspace-tests $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS kill_app echo echo "### TypeScript tests" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --folder-uri=$REMOTE_VSCODE/typescript-language-features/test-workspace --extensionDevelopmentPath=$REMOTE_VSCODE/typescript-language-features --extensionTestsPath=$REMOTE_VSCODE/typescript-language-features/out/test/unit $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS +"$INTEGRATION_TEST_ELECTRON_PATH" --folder-uri=$REMOTE_VSCODE/typescript-language-features/test-workspace --extensionDevelopmentPath=$REMOTE_VSCODE/typescript-language-features --extensionTestsPath=$REMOTE_VSCODE/typescript-language-features/out/test/unit $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS kill_app echo echo "### Markdown tests" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --folder-uri=$REMOTE_VSCODE/markdown-language-features/test-workspace --extensionDevelopmentPath=$REMOTE_VSCODE/markdown-language-features --extensionTestsPath=$REMOTE_VSCODE/markdown-language-features/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS +"$INTEGRATION_TEST_ELECTRON_PATH" --folder-uri=$REMOTE_VSCODE/markdown-language-features/test-workspace --extensionDevelopmentPath=$REMOTE_VSCODE/markdown-language-features --extensionTestsPath=$REMOTE_VSCODE/markdown-language-features/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS kill_app echo echo "### Emmet tests" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --folder-uri=$REMOTE_VSCODE/emmet/test-workspace --extensionDevelopmentPath=$REMOTE_VSCODE/emmet --extensionTestsPath=$REMOTE_VSCODE/emmet/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS +"$INTEGRATION_TEST_ELECTRON_PATH" --folder-uri=$REMOTE_VSCODE/emmet/test-workspace --extensionDevelopmentPath=$REMOTE_VSCODE/emmet --extensionTestsPath=$REMOTE_VSCODE/emmet/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS kill_app echo echo "### Git tests" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --folder-uri=$AUTHORITY$(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$REMOTE_VSCODE/git --extensionTestsPath=$REMOTE_VSCODE/git/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS +"$INTEGRATION_TEST_ELECTRON_PATH" --folder-uri=$AUTHORITY$(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$REMOTE_VSCODE/git --extensionTestsPath=$REMOTE_VSCODE/git/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS kill_app echo echo "### Ipynb tests" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --folder-uri=$AUTHORITY$(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$REMOTE_VSCODE/ipynb --extensionTestsPath=$REMOTE_VSCODE/ipynb/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS +"$INTEGRATION_TEST_ELECTRON_PATH" --folder-uri=$AUTHORITY$(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$REMOTE_VSCODE/ipynb --extensionTestsPath=$REMOTE_VSCODE/ipynb/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS kill_app echo echo "### Configuration editing tests" echo -"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --folder-uri=$AUTHORITY$(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$REMOTE_VSCODE/configuration-editing --extensionTestsPath=$REMOTE_VSCODE/configuration-editing/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS +"$INTEGRATION_TEST_ELECTRON_PATH" --folder-uri=$AUTHORITY$(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$REMOTE_VSCODE/configuration-editing --extensionTestsPath=$REMOTE_VSCODE/configuration-editing/out/test $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS kill_app # Cleanup diff --git a/scripts/test.sh b/scripts/test.sh index ae0d88cc734..9ba8dedee0f 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -6,9 +6,6 @@ if [[ "$OSTYPE" == "darwin"* ]]; then ROOT=$(dirname $(dirname $(realpath "$0"))) else ROOT=$(dirname $(dirname $(readlink -f $0))) - # --disable-dev-shm-usage: when run on docker containers where size of /dev/shm - # partition < 64MB which causes OOM failure for chromium compositor that uses the partition for shared memory - LINUX_EXTRA_ARGS="--disable-dev-shm-usage" fi cd $ROOT @@ -39,5 +36,5 @@ else cd $ROOT ; \ ELECTRON_ENABLE_LOGGING=1 \ "$CODE" \ - test/unit/electron/index.js --crash-reporter-directory=$VSCODECRASHDIR $LINUX_EXTRA_ARGS "$@" + test/unit/electron/index.js --crash-reporter-directory=$VSCODECRASHDIR "$@" fi diff --git a/src/main.ts b/src/main.ts index fdc424e1087..1af3c941e00 100644 --- a/src/main.ts +++ b/src/main.ts @@ -311,11 +311,18 @@ function configureCommandlineSwitchesSync(cliArgs: NativeParsedArgs) { } }); + // Following features are enabled from the runtime: + // `DocumentPolicyIncludeJSCallStacksInCrashReports` - https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental + // `EarlyEstablishGpuChannel` - Refs https://issues.chromium.org/issues/40208065 + // `EstablishGpuChannelAsync` - Refs https://issues.chromium.org/issues/40208065 + const featuresToEnable = + `DocumentPolicyIncludeJSCallStacksInCrashReports,EarlyEstablishGpuChannel,EstablishGpuChannelAsync,${app.commandLine.getSwitchValue('enable-features')}`; + app.commandLine.appendSwitch('enable-features', featuresToEnable); + // Following features are disabled from the runtime: // `CalculateNativeWinOcclusion` - Disable native window occlusion tracker (https://groups.google.com/a/chromium.org/g/embedder-dev/c/ZF3uHHyWLKw/m/VDN2hDXMAAAJ) - // `PlzDedicatedWorker` - Refs https://github.com/microsoft/vscode/issues/233060#issuecomment-2523212427 const featuresToDisable = - `CalculateNativeWinOcclusion,PlzDedicatedWorker,${app.commandLine.getSwitchValue('disable-features')}`; + `CalculateNativeWinOcclusion,${app.commandLine.getSwitchValue('disable-features')}`; app.commandLine.appendSwitch('disable-features', featuresToDisable); // Blink features to configure. diff --git a/src/tsconfig.base.json b/src/tsconfig.base.json index e354b0ed463..6d276949648 100644 --- a/src/tsconfig.base.json +++ b/src/tsconfig.base.json @@ -7,6 +7,7 @@ "noImplicitReturns": true, "noImplicitOverride": true, "noUnusedLocals": true, + "noUncheckedSideEffectImports": true, "allowUnreachableCode": false, "strict": true, "exactOptionalPropertyTypes": false, diff --git a/src/tsconfig.monaco.json b/src/tsconfig.monaco.json index d64d6bd8c8e..cad1e06383d 100644 --- a/src/tsconfig.monaco.json +++ b/src/tsconfig.monaco.json @@ -17,6 +17,7 @@ "declaration": true }, "include": [ + "typings/css.d.ts", "typings/thenable.d.ts", "typings/vscode-globals-product.d.ts", "typings/vscode-globals-nls.d.ts", diff --git a/src/tsec.exemptions.json b/src/tsec.exemptions.json index d5e979d578a..1bfa145bbc1 100644 --- a/src/tsec.exemptions.json +++ b/src/tsec.exemptions.json @@ -4,12 +4,10 @@ "vs/editor/contrib/clipboard/browser/clipboard.ts" ], "ban-eval-calls": [ - "vs/workbench/api/worker/extHostExtensionService.ts", - "vs/base/worker/workerMain.ts" + "vs/workbench/api/worker/extHostExtensionService.ts" ], "ban-function-calls": [ "vs/workbench/api/worker/extHostExtensionService.ts", - "vs/base/worker/workerMain.ts", "vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts", "vs/workbench/services/keybinding/test/node/keyboardMapperTestUtils.ts" ], @@ -17,18 +15,16 @@ "bootstrap-window.ts", "vs/amdX.ts", "vs/base/browser/trustedTypes.ts", - "vs/base/worker/workerMain.ts", "vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts" ], "ban-worker-calls": [ - "vs/base/browser/defaultWorkerFactory.ts", + "vs/base/browser/webWorkerFactory.ts", "vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts" ], "ban-worker-importscripts": [ "vs/amdX.ts", "vs/workbench/services/extensions/worker/polyfillNestedWorker.ts", - "vs/workbench/api/worker/extensionHostWorker.ts", - "vs/base/worker/workerMain.ts" + "vs/workbench/api/worker/extensionHostWorker.ts" ], "ban-domparser-parsefromstring": [ "vs/base/browser/markdownRenderer.ts", diff --git a/src/vs/editor/editor.worker.ts b/src/typings/css.d.ts similarity index 76% rename from src/vs/editor/editor.worker.ts rename to src/typings/css.d.ts index 0fd98caff25..abb66921781 100644 --- a/src/vs/editor/editor.worker.ts +++ b/src/typings/css.d.ts @@ -3,4 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export * from './common/services/editorWorkerBootstrap.js'; + +// Recognize all CSS files as valid module imports +declare module "vs/css!*" { } +declare module "*.css" { } diff --git a/src/vs/amdX.ts b/src/vs/amdX.ts index ee149951b54..9fd519fd130 100644 --- a/src/vs/amdX.ts +++ b/src/vs/amdX.ts @@ -212,7 +212,7 @@ export async function importAMDNodeModule(nodeModuleName: string, pathInsideN let scriptSrc: string; if (/^\w[\w\d+.-]*:\/\//.test(nodeModulePath)) { // looks like a URL - // bit of a special case for: src/vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorker.ts + // bit of a special case for: src/vs/workbench/services/languageDetection/browser/languageDetectionWebWorker.ts scriptSrc = nodeModulePath; } else { const useASAR = (canASAR && isBuilt && !platform.isWeb); diff --git a/src/vs/base/browser/contextmenu.ts b/src/vs/base/browser/contextmenu.ts index 568808c078c..1f999bb75e4 100644 --- a/src/vs/base/browser/contextmenu.ts +++ b/src/vs/base/browser/contextmenu.ts @@ -46,6 +46,10 @@ export interface IContextMenuDelegate { anchorAlignment?: AnchorAlignment; anchorAxisAlignment?: AnchorAxisAlignment; domForShadowRoot?: HTMLElement; + /** + * custom context menus with higher layers are rendered higher in z-index order + */ + layer?: number; } export interface IContextMenuProvider { diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 670525a0109..7227aba24f3 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -696,6 +696,20 @@ export function getDomNodePagePosition(domNode: HTMLElement): IDomNodePagePositi }; } +/** + * Returns whether the element is in the bottom right quarter of the container. + * + * @param element the element to check for being in the bottom right quarter + * @param container the container to check against + * @returns true if the element is in the bottom right quarter of the container + */ +export function isElementInBottomRightQuarter(element: HTMLElement, container: HTMLElement): boolean { + const position = getDomNodePagePosition(element); + const clientArea = getClientArea(container); + + return position.left > clientArea.width / 2 && position.top > clientArea.height / 2; +} + /** * Returns the effective zoom on a given element before window zoom level is applied */ diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index cf15d43b0ab..bf2739b3d43 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -5,7 +5,7 @@ import { onUnexpectedError } from '../common/errors.js'; import { Event } from '../common/event.js'; -import { escapeDoubleQuotes, IMarkdownString, MarkdownStringTrustedOptions, parseHrefAndDimensions, removeMarkdownEscapes } from '../common/htmlContent.js'; +import { escapeDoubleQuotes, IMarkdownString, isMarkdownString, MarkdownStringTrustedOptions, parseHrefAndDimensions, removeMarkdownEscapes } from '../common/htmlContent.js'; import { markdownEscapeEscapedIcons } from '../common/iconLabels.js'; import { defaultGenerator } from '../common/idGenerator.js'; import { KeyCode } from '../common/keyCodes.js'; @@ -552,7 +552,7 @@ function getSanitizerOptions(options: IInternalSanitizerOptions): { config: domp * `# Header` would be output as `Header`. If it's not, the string is returned. */ export function renderStringAsPlaintext(string: IMarkdownString | string) { - return typeof string === 'string' ? string : renderMarkdownAsPlaintext(string); + return isMarkdownString(string) ? renderMarkdownAsPlaintext(string) : string; } /** diff --git a/src/vs/base/browser/mouseEvent.ts b/src/vs/base/browser/mouseEvent.ts index 1e6f8b3032a..3cad09d7dcc 100644 --- a/src/vs/base/browser/mouseEvent.ts +++ b/src/vs/base/browser/mouseEvent.ts @@ -22,6 +22,7 @@ export interface IMouseEvent { readonly altKey: boolean; readonly metaKey: boolean; readonly timestamp: number; + readonly defaultPrevented: boolean; preventDefault(): void; stopPropagation(): void; @@ -44,6 +45,7 @@ export class StandardMouseEvent implements IMouseEvent { public readonly altKey: boolean; public readonly metaKey: boolean; public readonly timestamp: number; + public readonly defaultPrevented: boolean; constructor(targetWindow: Window, e: MouseEvent) { this.timestamp = Date.now(); @@ -52,6 +54,7 @@ export class StandardMouseEvent implements IMouseEvent { this.middleButton = e.button === 1; this.rightButton = e.button === 2; this.buttons = e.buttons; + this.defaultPrevented = e.defaultPrevented; this.target = e.target; diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index 6b4e595d493..aea9be08fcc 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -295,6 +295,9 @@ export class Button extends Disposable implements IButton { set icon(icon: ThemeIcon) { this._setAriaLabel(); + + const oldIcons = Array.from(this._element.classList).filter(item => item.startsWith('codicon-')); + this._element.classList.remove(...oldIcons); this._element.classList.add(...ThemeIcon.asClassNameArray(icon)); } @@ -349,13 +352,17 @@ export interface IButtonWithDropdownOptions extends IButtonOptions { readonly actions: readonly IAction[] | IActionProvider; readonly actionRunner?: IActionRunner; readonly addPrimaryActionToDropdown?: boolean; + /** + * dropdown menus with higher layers are rendered higher in z-index order + */ + readonly dropdownLayer?: number; } export class ButtonWithDropdown extends Disposable implements IButton { - private readonly button: Button; + readonly primaryButton: Button; private readonly action: Action; - private readonly dropdownButton: Button; + readonly dropdownButton: Button; private readonly separatorContainer: HTMLDivElement; private readonly separator: HTMLDivElement; @@ -374,9 +381,9 @@ export class ButtonWithDropdown extends Disposable implements IButton { options = { ...options, hoverDelegate: this._register(createInstantHoverDelegate()) }; } - this.button = this._register(new Button(this.element, options)); - this._register(this.button.onDidClick(e => this._onDidClick.fire(e))); - this.action = this._register(new Action('primaryAction', renderStringAsPlaintext(this.button.label), undefined, true, async () => this._onDidClick.fire(undefined))); + this.primaryButton = this._register(new Button(this.element, options)); + this._register(this.primaryButton.onDidClick(e => this._onDidClick.fire(e))); + this.action = this._register(new Action('primaryAction', renderStringAsPlaintext(this.primaryButton.label), undefined, true, async () => this._onDidClick.fire(undefined))); this.separatorContainer = document.createElement('div'); this.separatorContainer.classList.add('monaco-button-dropdown-separator'); @@ -407,7 +414,8 @@ export class ButtonWithDropdown extends Disposable implements IButton { getAnchor: () => this.dropdownButton.element, getActions: () => options.addPrimaryActionToDropdown === false ? [...actions] : [this.action, ...actions], actionRunner: options.actionRunner, - onHide: () => this.dropdownButton.element.setAttribute('aria-expanded', 'false') + onHide: () => this.dropdownButton.element.setAttribute('aria-expanded', 'false'), + layer: options.dropdownLayer }); this.dropdownButton.element.setAttribute('aria-expanded', 'true'); })); @@ -419,39 +427,39 @@ export class ButtonWithDropdown extends Disposable implements IButton { } set label(value: string) { - this.button.label = value; + this.primaryButton.label = value; this.action.label = value; } set icon(icon: ThemeIcon) { - this.button.icon = icon; + this.primaryButton.icon = icon; } set enabled(enabled: boolean) { - this.button.enabled = enabled; + this.primaryButton.enabled = enabled; this.dropdownButton.enabled = enabled; this.element.classList.toggle('disabled', !enabled); } get enabled(): boolean { - return this.button.enabled; + return this.primaryButton.enabled; } set checked(value: boolean) { - this.button.checked = value; + this.primaryButton.checked = value; } get checked() { - return this.button.checked; + return this.primaryButton.checked; } focus(): void { - this.button.focus(); + this.primaryButton.focus(); } hasFocus(): boolean { - return this.button.hasFocus() || this.dropdownButton.hasFocus(); + return this.primaryButton.hasFocus() || this.dropdownButton.hasFocus(); } } diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf index 872f328858f..e36555222ca 100644 Binary files a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf and b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf differ diff --git a/src/vs/base/browser/ui/contextview/contextview.ts b/src/vs/base/browser/ui/contextview/contextview.ts index e5b95d29409..356d813e34b 100644 --- a/src/vs/base/browser/ui/contextview/contextview.ts +++ b/src/vs/base/browser/ui/contextview/contextview.ts @@ -61,7 +61,9 @@ export interface IDelegate { onDOMEvent?(e: Event, activeElement: HTMLElement): void; onHide?(data?: unknown): void; - // context views with higher layers are rendered over contet views with lower layers + /** + * context views with higher layers are rendered higher in z-index order + */ layer?: number; // Default: 0 } diff --git a/src/vs/base/browser/ui/dialog/dialog.css b/src/vs/base/browser/ui/dialog/dialog.css index bdca8bcfd74..c7d4da10488 100644 --- a/src/vs/base/browser/ui/dialog/dialog.css +++ b/src/vs/base/browser/ui/dialog/dialog.css @@ -8,9 +8,9 @@ position: fixed; height: 100%; width: 100%; - left:0; - top:0; - z-index: 2600; + left: 0; + top: 0; + z-index: 2575; /* Above Context Views, Below Workbench Hover */ display: flex; justify-content: center; align-items: center; @@ -163,6 +163,19 @@ margin: 4px 5px; } +.monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button-dropdown:focus-within { + /** + * This is a trick to make the focus outline appear on the entire + * container of the dropdown button to ensure the dialog box looks + * consistent to dialogs without dropdown buttons. + */ + outline-offset: 2px !important; + outline-width: 1px; + outline-style: solid; + outline-color: var(--vscode-focusBorder); + border-radius: 2px; +} + .monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button-dropdown > .monaco-text-button { padding-left: 10px; padding-right: 10px; diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index 5490dde83dc..6e68f0f8e68 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -8,7 +8,7 @@ import { localize } from '../../../../nls.js'; import { $, addDisposableListener, clearNode, EventHelper, EventType, getWindow, hide, isActiveElement, isAncestor, show } from '../../dom.js'; import { StandardKeyboardEvent } from '../../keyboardEvent.js'; import { ActionBar } from '../actionbar/actionbar.js'; -import { ButtonBar, ButtonWithDescription, IButton, IButtonStyles, IButtonWithDropdownOptions } from '../button/button.js'; +import { ButtonBar, ButtonWithDescription, ButtonWithDropdown, IButton, IButtonStyles, IButtonWithDropdownOptions } from '../button/button.js'; import { ICheckboxStyles, Checkbox } from '../toggle/toggle.js'; import { IInputBoxStyles, InputBox } from '../inputbox/inputBox.js'; import { Action, toAction } from '../../../common/actions.js'; @@ -70,7 +70,7 @@ interface ButtonMapEntry { export class Dialog extends Disposable { - readonly element: HTMLElement; + private readonly element: HTMLElement; private readonly shadowElement: HTMLElement; private modalElement: HTMLElement | undefined; @@ -237,6 +237,7 @@ export class Dialog extends Disposable { button = this._register(buttonBar.addButtonWithDropdown({ ...this.options.primaryButtonDropdown, ...this.buttonStyles, + dropdownLayer: 2600, // ensure the dropdown is above the dialog actions: actions.map(action => toAction({ ...action, run: async () => { @@ -345,9 +346,20 @@ export class Dialog extends Disposable { if (this.buttonBar) { for (const button of this.buttonBar.buttons) { - focusableElements.push(button); - if (button.hasFocus()) { - focusedIndex = focusableElements.length - 1; + if (button instanceof ButtonWithDropdown) { + focusableElements.push(button.primaryButton); + if (button.primaryButton.hasFocus()) { + focusedIndex = focusableElements.length - 1; + } + focusableElements.push(button.dropdownButton); + if (button.dropdownButton.hasFocus()) { + focusedIndex = focusableElements.length - 1; + } + } else { + focusableElements.push(button); + if (button.hasFocus()) { + focusedIndex = focusableElements.length - 1; + } } } } diff --git a/src/vs/base/browser/ui/dropdown/dropdown.ts b/src/vs/base/browser/ui/dropdown/dropdown.ts index b4a7d38a6fb..4bb319918f3 100644 --- a/src/vs/base/browser/ui/dropdown/dropdown.ts +++ b/src/vs/base/browser/ui/dropdown/dropdown.ts @@ -22,12 +22,12 @@ export interface ILabelRenderer { (container: HTMLElement): IDisposable | null; } -interface IBaseDropdownOptions { +export interface IBaseDropdownOptions { label?: string; labelRenderer?: ILabelRenderer; } -class BaseDropdown extends ActionRunner { +export class BaseDropdown extends ActionRunner { private _element: HTMLElement; private boxContainer?: HTMLElement; private _label?: HTMLElement; diff --git a/src/vs/base/browser/ui/hover/hover.ts b/src/vs/base/browser/ui/hover/hover.ts index 6b877ec17fd..6c682c3c083 100644 --- a/src/vs/base/browser/ui/hover/hover.ts +++ b/src/vs/base/browser/ui/hover/hover.ts @@ -325,6 +325,13 @@ export interface IHoverAppearanceOptions { * another in the same group so it looks like the hover is moving from one element to the other. */ skipFadeInAnimation?: boolean; + + /** + * The max height of the hover relative to the window height. + * Accepted values: (0,1] + * Default: 0.5 + */ + maxHeightRatio?: number; } export interface IHoverAction { diff --git a/src/vs/base/browser/ui/hover/hoverDelegate2.ts b/src/vs/base/browser/ui/hover/hoverDelegate2.ts index b49cb84951c..0f57de0379a 100644 --- a/src/vs/base/browser/ui/hover/hoverDelegate2.ts +++ b/src/vs/base/browser/ui/hover/hoverDelegate2.ts @@ -13,7 +13,12 @@ let baseHoverDelegate: IHoverDelegate2 = { setupDelayedHoverAtMouse: () => Disposable.None, hideHover: () => undefined, showAndFocusLastHover: () => undefined, - setupManagedHover: () => null!, + setupManagedHover: () => ({ + dispose: () => undefined, + show: () => undefined, + hide: () => undefined, + update: () => undefined, + }), showManagedHover: () => undefined }; diff --git a/src/vs/base/browser/ui/list/listPaging.ts b/src/vs/base/browser/ui/list/listPaging.ts index e2608993f70..bb01170201c 100644 --- a/src/vs/base/browser/ui/list/listPaging.ts +++ b/src/vs/base/browser/ui/list/listPaging.ts @@ -110,6 +110,7 @@ export interface IPagedListOptions { readonly horizontalScrolling?: boolean; readonly scrollByPage?: boolean; readonly paddingBottom?: number; + readonly alwaysConsumeMouseWheel?: boolean; } function fromPagedListOptions(modelProvider: () => IPagedModel, options: IPagedListOptions): IListOptions { diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index 8aec45d8fac..eb6814c42f4 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -1170,6 +1170,7 @@ ${formatRule(Codicon.menuSubmenu)} text-align: right; font-size: 12px; line-height: 1; + opacity: 0.7; } .monaco-menu .monaco-action-bar.vertical .submenu-indicator { diff --git a/src/vs/base/browser/ui/severityIcon/media/severityIcon.css b/src/vs/base/browser/ui/severityIcon/media/severityIcon.css index 62d99edf337..0e0a140a89c 100644 --- a/src/vs/base/browser/ui/severityIcon/media/severityIcon.css +++ b/src/vs/base/browser/ui/severityIcon/media/severityIcon.css @@ -8,7 +8,6 @@ .text-search-provider-messages .providerMessage .codicon.codicon-error, .extensions-viewlet > .extensions .codicon.codicon-error, .extension-editor .codicon.codicon-error, -.preferences-editor .codicon.codicon-error, .chat-attached-context-attachment .codicon.codicon-error { color: var(--vscode-problemsErrorIcon-foreground); } @@ -26,7 +25,6 @@ .markers-panel .marker-icon.info, .markers-panel .marker-icon .codicon.codicon-info, .text-search-provider-messages .providerMessage .codicon.codicon-info, .extensions-viewlet > .extensions .codicon.codicon-info, -.extension-editor .codicon.codicon-info, -.preferences-editor .codicon.codicon-info { +.extension-editor .codicon.codicon-info { color: var(--vscode-problemsInfoIcon-foreground); } diff --git a/src/vs/base/browser/ui/toggle/toggle.ts b/src/vs/base/browser/ui/toggle/toggle.ts index a6c913662f9..b4d6e145634 100644 --- a/src/vs/base/browser/ui/toggle/toggle.ts +++ b/src/vs/base/browser/ui/toggle/toggle.ts @@ -37,6 +37,9 @@ export interface ICheckboxStyles { readonly checkboxBackground: string | undefined; readonly checkboxBorder: string | undefined; readonly checkboxForeground: string | undefined; + readonly checkboxDisabledBackground: string | undefined; + readonly checkboxDisabledForeground: string | undefined; + readonly size?: number; } export const unthemedToggleStyles = { @@ -156,6 +159,10 @@ export class Toggle extends Widget { this._register(this.ignoreGesture(this.domNode)); this.onkeydown(this.domNode, (keyboardEvent) => { + if (!this.enabled) { + return; + } + if (keyboardEvent.keyCode === KeyCode.Space || keyboardEvent.keyCode === KeyCode.Enter) { this.checked = !this._checked; this._onChange.fire(true); @@ -286,16 +293,28 @@ export class Checkbox extends Widget { enable(): void { this.checkbox.enable(); + this.applyStyles(true); } disable(): void { this.checkbox.disable(); + this.applyStyles(false); } - protected applyStyles(): void { - this.domNode.style.color = this.styles.checkboxForeground || ''; - this.domNode.style.backgroundColor = this.styles.checkboxBackground || ''; - this.domNode.style.borderColor = this.styles.checkboxBorder || ''; + setTitle(newTitle: string): void { + this.checkbox.setTitle(newTitle); + } + + protected applyStyles(enabled = this.enabled): void { + this.domNode.style.color = (enabled ? this.styles.checkboxForeground : this.styles.checkboxDisabledForeground) || ''; + this.domNode.style.backgroundColor = (enabled ? this.styles.checkboxBackground : this.styles.checkboxDisabledBackground) || ''; + this.domNode.style.borderColor = (enabled ? this.styles.checkboxBorder : this.styles.checkboxDisabledBackground) || ''; + + const size = this.styles.size || 18; + this.domNode.style.width = + this.domNode.style.height = + this.domNode.style.fontSize = `${size}px`; + this.domNode.style.fontSize = `${size - 2}px`; } } diff --git a/src/vs/base/browser/defaultWorkerFactory.ts b/src/vs/base/browser/webWorkerFactory.ts similarity index 70% rename from src/vs/base/browser/defaultWorkerFactory.ts rename to src/vs/base/browser/webWorkerFactory.ts index f6054bf6402..0cd7255e525 100644 --- a/src/vs/base/browser/defaultWorkerFactory.ts +++ b/src/vs/base/browser/webWorkerFactory.ts @@ -5,12 +5,13 @@ import { createTrustedTypesPolicy } from './trustedTypes.js'; import { onUnexpectedError } from '../common/errors.js'; -import { AppResourcePath, COI, FileAccess } from '../common/network.js'; +import { COI } from '../common/network.js'; import { URI } from '../common/uri.js'; -import { IWorker, IWorkerCallback, IWorkerClient, IWorkerDescriptor, IWorkerFactory, logOnceWebWorkerWarning, SimpleWorkerClient } from '../common/worker/simpleWorker.js'; +import { IWebWorker, IWebWorkerClient, Message, WebWorkerClient } from '../common/worker/webWorker.js'; import { Disposable, toDisposable } from '../common/lifecycle.js'; import { coalesce } from '../common/arrays.js'; import { getNLSLanguage, getNLSMessages } from '../../nls.js'; +import { Emitter } from '../common/event.js'; // Reuse the trusted types policy defined from worker bootstrap // when available. @@ -29,7 +30,9 @@ export function createBlobWorker(blobUrl: string, options?: WorkerOptions): Work return new Worker(ttPolicy ? ttPolicy.createScriptURL(blobUrl) as unknown as string : blobUrl, { ...options, type: 'module' }); } -function getWorker(esmWorkerLocation: URI | undefined, label: string): Worker | Promise { +function getWorker(descriptor: IWebWorkerDescriptor, id: number): Worker | Promise { + const label = descriptor.label || 'anonymous' + id; + // Option for hosts to overwrite the worker script (used in the standalone editor) interface IMonacoEnvironment { getWorker?(moduleId: string, label: string): Worker | Promise; @@ -45,11 +48,14 @@ function getWorker(esmWorkerLocation: URI | undefined, label: string): Worker | return new Worker(ttPolicy ? ttPolicy.createScriptURL(workerUrl) as unknown as string : workerUrl, { name: label, type: 'module' }); } } + + const esmWorkerLocation = descriptor.esmModuleLocation; if (esmWorkerLocation) { const workerUrl = getWorkerBootstrapUrl(label, esmWorkerLocation.toString(true)); const worker = new Worker(ttPolicy ? ttPolicy.createScriptURL(workerUrl) as unknown as string : workerUrl, { name: label, type: 'module' }); return whenESMWorkerReady(worker); } + throw new Error(`You must define a function MonacoEnvironment.getWorkerUrl or MonacoEnvironment.getWorker`); } @@ -113,37 +119,52 @@ function isPromiseLike(obj: any): obj is PromiseLike { * A worker that uses HTML5 web workers so that is has * its own global scope and its own thread. */ -class WebWorker extends Disposable implements IWorker { +class WebWorker extends Disposable implements IWebWorker { + + private static LAST_WORKER_ID = 0; private readonly id: number; - private readonly label: string; private worker: Promise | null; - constructor(esmWorkerLocation: URI | undefined, moduleId: string, id: number, label: string, onMessageCallback: IWorkerCallback, onErrorCallback: (err: any) => void) { + private readonly _onMessage = this._register(new Emitter()); + public readonly onMessage = this._onMessage.event; + + private readonly _onError = this._register(new Emitter()); + public readonly onError = this._onError.event; + + constructor(descriptorOrWorker: IWebWorkerDescriptor | Worker) { super(); - this.id = id; - this.label = label; - const workerOrPromise = getWorker(esmWorkerLocation, label); + this.id = ++WebWorker.LAST_WORKER_ID; + const workerOrPromise = ( + descriptorOrWorker instanceof Worker + ? descriptorOrWorker + : getWorker(descriptorOrWorker, this.id) + ); if (isPromiseLike(workerOrPromise)) { this.worker = workerOrPromise; } else { this.worker = Promise.resolve(workerOrPromise); } - this.postMessage(moduleId, []); + this.postMessage('-please-ignore-', []); // TODO: Eliminate this extra message + const errorHandler = (ev: ErrorEvent) => { + this._onError.fire(ev); + }; this.worker.then((w) => { - w.onmessage = function (ev) { - onMessageCallback(ev.data); + w.onmessage = (ev) => { + this._onMessage.fire(ev.data); + }; + w.onmessageerror = (ev) => { + this._onError.fire(ev); }; - w.onmessageerror = onErrorCallback; if (typeof w.addEventListener === 'function') { - w.addEventListener('error', onErrorCallback); + w.addEventListener('error', errorHandler); } }); this._register(toDisposable(() => { this.worker?.then(w => { w.onmessage = null; w.onmessageerror = null; - w.removeEventListener('error', onErrorCallback); + w.removeEventListener('error', errorHandler); w.terminate(); }); this.worker = null; @@ -160,51 +181,27 @@ class WebWorker extends Disposable implements IWorker { w.postMessage(message, transfer); } catch (err) { onUnexpectedError(err); - onUnexpectedError(new Error(`FAILED to post message to '${this.label}'-worker`, { cause: err })); + onUnexpectedError(new Error(`FAILED to post message to worker`, { cause: err })); } }); } } -export class WorkerDescriptor implements IWorkerDescriptor { - - public readonly esmModuleLocation: URI | undefined; +export interface IWebWorkerDescriptor { + readonly esmModuleLocation: URI | undefined; + readonly label: string | undefined; +} +export class WebWorkerDescriptor implements IWebWorkerDescriptor { constructor( - public readonly moduleId: string, - readonly label: string | undefined, - ) { - this.esmModuleLocation = FileAccess.asBrowserUri(`${moduleId}Main.js` as AppResourcePath); - } + public readonly esmModuleLocation: URI, + public readonly label: string | undefined, + ) { } } -class DefaultWorkerFactory implements IWorkerFactory { - - private static LAST_WORKER_ID = 0; - private _webWorkerFailedBeforeError: any; - - constructor() { - this._webWorkerFailedBeforeError = false; - } - - public create(desc: IWorkerDescriptor, onMessageCallback: IWorkerCallback, onErrorCallback: (err: any) => void): IWorker { - const workerId = (++DefaultWorkerFactory.LAST_WORKER_ID); - - if (this._webWorkerFailedBeforeError) { - throw this._webWorkerFailedBeforeError; - } - - return new WebWorker(desc.esmModuleLocation, desc.moduleId, workerId, desc.label || 'anonymous' + workerId, onMessageCallback, (err) => { - logOnceWebWorkerWarning(err); - this._webWorkerFailedBeforeError = err; - onErrorCallback(err); - }); - } -} - -export function createWebWorker(moduleId: string, label: string | undefined): IWorkerClient; -export function createWebWorker(workerDescriptor: IWorkerDescriptor): IWorkerClient; -export function createWebWorker(arg0: string | IWorkerDescriptor, arg1?: string | undefined): IWorkerClient { - const workerDescriptor = (typeof arg0 === 'string' ? new WorkerDescriptor(arg0, arg1) : arg0); - return new SimpleWorkerClient(new DefaultWorkerFactory(), workerDescriptor); +export function createWebWorker(esmModuleLocation: URI, label: string | undefined): IWebWorkerClient; +export function createWebWorker(workerDescriptor: IWebWorkerDescriptor | Worker): IWebWorkerClient; +export function createWebWorker(arg0: URI | IWebWorkerDescriptor | Worker, arg1?: string | undefined): IWebWorkerClient { + const workerDescriptorOrWorker = (URI.isUri(arg0) ? new WebWorkerDescriptor(arg0, arg1) : arg0); + return new WebWorkerClient(new WebWorker(workerDescriptorOrWorker)); } diff --git a/src/vs/base/common/arrays.ts b/src/vs/base/common/arrays.ts index 98bf168f39d..7efd1f97fc9 100644 --- a/src/vs/base/common/arrays.ts +++ b/src/vs/base/common/arrays.ts @@ -193,6 +193,10 @@ export function forEachWithNeighbors(arr: T[], f: (before: T | undefined, ele } } +export function concatArrays(...arrays: TArr): TArr[number][number][] { + return ([] as any[]).concat(...arrays); +} + interface IMutableSplice extends ISplice { readonly toInsert: T[]; deleteCount: number; @@ -731,13 +735,17 @@ export function compareUndefinedSmallest(comparator: Comparator): Comparat } export class ArrayQueue { + private readonly items: readonly T[]; private firstIdx = 0; - private lastIdx = this.items.length - 1; + private lastIdx: number; /** * Constructs a queue that is backed by the given array. Runtime is O(1). */ - constructor(private readonly items: readonly T[]) { } + constructor(items: readonly T[]) { + this.items = items; + this.lastIdx = this.items.length - 1; + } get length(): number { return this.lastIdx - this.firstIdx + 1; diff --git a/src/vs/base/common/async.ts b/src/vs/base/common/async.ts index bd772e5d2e9..a53c2c490c9 100644 --- a/src/vs/base/common/async.ts +++ b/src/vs/base/common/async.ts @@ -6,7 +6,7 @@ import { CancellationToken, CancellationTokenSource } from './cancellation.js'; import { BugIndicatingError, CancellationError } from './errors.js'; import { Emitter, Event } from './event.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from './lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, isDisposable, MutableDisposable, toDisposable } from './lifecycle.js'; import { extUri as defaultExtUri, IExtUri } from './resources.js'; import { URI } from './uri.js'; import { setTimeout0 } from './platform.js'; @@ -21,19 +21,41 @@ export interface CancelablePromise extends Promise { cancel(): void; } +/** + * Returns a promise that can be cancelled using the provided cancellation token. + * + * @remarks When cancellation is requested, the promise will be rejected with a {@link CancellationError}. + * If the promise resolves to a disposable object, it will be automatically disposed when cancellation + * is requested. + * + * @param callback A function that accepts a cancellation token and returns a promise + * @returns A promise that can be cancelled + */ export function createCancelablePromise(callback: (token: CancellationToken) => Promise): CancelablePromise { const source = new CancellationTokenSource(); const thenable = callback(source.token); + + let isCancelled = false; + const promise = new Promise((resolve, reject) => { const subscription = source.token.onCancellationRequested(() => { + isCancelled = true; subscription.dispose(); reject(new CancellationError()); }); Promise.resolve(thenable).then(value => { subscription.dispose(); source.dispose(); - resolve(value); + + if (!isCancelled) { + resolve(value); + + } else if (isDisposable(value)) { + // promise has been cancelled, result is disposable and will + // be cleaned up + value.dispose(); + } }, err => { subscription.dispose(); source.dispose(); @@ -1303,6 +1325,7 @@ export class ThrottledWorker extends Disposable { override dispose(): void { super.dispose(); + this.pendingWork.length = 0; this.disposed = true; } } diff --git a/src/vs/base/common/codecs/baseDecoder.ts b/src/vs/base/common/codecs/baseDecoder.ts index 586ea61f56a..6af19eb2dfc 100644 --- a/src/vs/base/common/codecs/baseDecoder.ts +++ b/src/vs/base/common/codecs/baseDecoder.ts @@ -41,7 +41,7 @@ export abstract class BaseDecoder< private readonly _listeners: Map> = new Map(); /** - * This method is called when a new incomming data + * This method is called when a new incoming data * is received from the input stream. */ protected abstract onStreamData(data: K): void; @@ -97,7 +97,7 @@ export abstract class BaseDecoder< } /** - * Start receiveing data from the stream. + * Start receiving data from the stream. * @throws if the decoder stream has already ended. */ public start(): this { @@ -121,7 +121,7 @@ export abstract class BaseDecoder< this.stream.on('end', this.onStreamEnd); // this allows to compose decoders together, - if a decoder - // instance is passed as a readble stream to this decoder, + // instance is passed as a readable stream to this decoder, // then we need to call `start` on it too if (this.stream instanceof BaseDecoder) { this.stream.start(); @@ -275,7 +275,7 @@ export abstract class BaseDecoder< } /** - * Removes a priorly-registered event listener for a specified event. + * Removes a previously-registered event listener for a specified event. * * Note! * - the callback function must be the same as the one that was used when @@ -283,7 +283,7 @@ export abstract class BaseDecoder< * remove the listener * - this method is idempotent and results in no-op if the listener is * not found, therefore passing incorrect `callback` function may - * result in silent unexpected behaviour + * result in silent unexpected behavior */ public removeListener(event: string, callback: Function): void { for (const [nameName, listeners] of this._listeners.entries()) { diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 13a3a4f0c31..b736481d252 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -592,4 +592,8 @@ export const codiconsLibrary = { flag: register('flag', 0xec3f), lightbulbEmpty: register('lightbulb-empty', 0xec40), symbolMethodArrow: register('symbol-method-arrow', 0xec41), + copilotUnavailable: register('copilot-unavailable', 0xec42), + repoPinned: register('repo-pinned', 0xec43), + keyboardTabAbove: register('keyboard-tab-above', 0xec44), + keyboardTabBelow: register('keyboard-tab-below', 0xec45), } as const; diff --git a/src/vs/base/common/color.ts b/src/vs/base/common/color.ts index 9f20f7080ab..3263e2d9922 100644 --- a/src/vs/base/common/color.ts +++ b/src/vs/base/common/color.ts @@ -494,6 +494,25 @@ export class Color { return new Color(new RGBA(r, g, b, a)); } + /** + * Mixes the current color with the provided color based on the given factor. + * @param color The color to mix with + * @param factor The factor of mixing (0 means this color, 1 means the input color, 0.5 means equal mix) + * @returns A new color representing the mix + */ + mix(color: Color, factor: number = 0.5): Color { + const normalize = Math.min(Math.max(factor, 0), 1); + const thisRGBA = this.rgba; + const otherRGBA = color.rgba; + + const r = thisRGBA.r + (otherRGBA.r - thisRGBA.r) * normalize; + const g = thisRGBA.g + (otherRGBA.g - thisRGBA.g) * normalize; + const b = thisRGBA.b + (otherRGBA.b - thisRGBA.b) * normalize; + const a = thisRGBA.a + (otherRGBA.a - thisRGBA.a) * normalize; + + return new Color(new RGBA(r, g, b, a)); + } + makeOpaque(opaqueBackground: Color): Color { if (this.isOpaque() || opaqueBackground.rgba.a !== 1) { // only allow to blend onto a non-opaque color onto a opaque color diff --git a/src/vs/base/common/diff/diff.ts b/src/vs/base/common/diff/diff.ts index 45eba8e4386..ee2073099d6 100644 --- a/src/vs/base/common/diff/diff.ts +++ b/src/vs/base/common/diff/diff.ts @@ -1137,3 +1137,179 @@ export class LcsDiff { } } } + + +/** + * Precomputed equality array for character codes. + */ +const precomputedEqualityArray = new Uint32Array(0x10000); + +/** + * Computes the Levenshtein distance for strings of length <= 32. + * @param firstString - The first string. + * @param secondString - The second string. + * @returns The Levenshtein distance. + */ +const computeLevenshteinDistanceForShortStrings = (firstString: string, secondString: string): number => { + const firstStringLength = firstString.length; + const secondStringLength = secondString.length; + const lastBitMask = 1 << (firstStringLength - 1); + let positiveVector = -1; + let negativeVector = 0; + let distance = firstStringLength; + let index = firstStringLength; + + // Initialize precomputedEqualityArray for firstString + while (index--) { + precomputedEqualityArray[firstString.charCodeAt(index)] |= 1 << index; + } + + // Process each character of secondString + for (index = 0; index < secondStringLength; index++) { + let equalityMask = precomputedEqualityArray[secondString.charCodeAt(index)]; + const combinedVector = equalityMask | negativeVector; + equalityMask |= ((equalityMask & positiveVector) + positiveVector) ^ positiveVector; + negativeVector |= ~(equalityMask | positiveVector); + positiveVector &= equalityMask; + if (negativeVector & lastBitMask) { + distance++; + } + if (positiveVector & lastBitMask) { + distance--; + } + negativeVector = (negativeVector << 1) | 1; + positiveVector = (positiveVector << 1) | ~(combinedVector | negativeVector); + negativeVector &= combinedVector; + } + + // Reset precomputedEqualityArray + index = firstStringLength; + while (index--) { + precomputedEqualityArray[firstString.charCodeAt(index)] = 0; + } + + return distance; +}; + +/** + * Computes the Levenshtein distance for strings of length > 32. + * @param firstString - The first string. + * @param secondString - The second string. + * @returns The Levenshtein distance. + */ +function computeLevenshteinDistanceForLongStrings(firstString: string, secondString: string): number { + const firstStringLength = firstString.length; + const secondStringLength = secondString.length; + const horizontalBitArray = []; + const verticalBitArray = []; + const horizontalSize = Math.ceil(firstStringLength / 32); + const verticalSize = Math.ceil(secondStringLength / 32); + + // Initialize horizontal and vertical bit arrays + for (let i = 0; i < horizontalSize; i++) { + horizontalBitArray[i] = -1; + verticalBitArray[i] = 0; + } + + let verticalIndex = 0; + for (; verticalIndex < verticalSize - 1; verticalIndex++) { + let negativeVector = 0; + let positiveVector = -1; + const start = verticalIndex * 32; + const verticalLength = Math.min(32, secondStringLength) + start; + + // Initialize precomputedEqualityArray for secondString + for (let k = start; k < verticalLength; k++) { + precomputedEqualityArray[secondString.charCodeAt(k)] |= 1 << k; + } + + // Process each character of firstString + for (let i = 0; i < firstStringLength; i++) { + const equalityMask = precomputedEqualityArray[firstString.charCodeAt(i)]; + const previousBit = (horizontalBitArray[(i / 32) | 0] >>> i) & 1; + const matchBit = (verticalBitArray[(i / 32) | 0] >>> i) & 1; + const combinedVector = equalityMask | negativeVector; + const combinedHorizontalVector = ((((equalityMask | matchBit) & positiveVector) + positiveVector) ^ positiveVector) | equalityMask | matchBit; + let positiveHorizontalVector = negativeVector | ~(combinedHorizontalVector | positiveVector); + let negativeHorizontalVector = positiveVector & combinedHorizontalVector; + if ((positiveHorizontalVector >>> 31) ^ previousBit) { + horizontalBitArray[(i / 32) | 0] ^= 1 << i; + } + if ((negativeHorizontalVector >>> 31) ^ matchBit) { + verticalBitArray[(i / 32) | 0] ^= 1 << i; + } + positiveHorizontalVector = (positiveHorizontalVector << 1) | previousBit; + negativeHorizontalVector = (negativeHorizontalVector << 1) | matchBit; + positiveVector = negativeHorizontalVector | ~(combinedVector | positiveHorizontalVector); + negativeVector = positiveHorizontalVector & combinedVector; + } + + // Reset precomputedEqualityArray + for (let k = start; k < verticalLength; k++) { + precomputedEqualityArray[secondString.charCodeAt(k)] = 0; + } + } + + let negativeVector = 0; + let positiveVector = -1; + const start = verticalIndex * 32; + const verticalLength = Math.min(32, secondStringLength - start) + start; + + // Initialize precomputedEqualityArray for secondString + for (let k = start; k < verticalLength; k++) { + precomputedEqualityArray[secondString.charCodeAt(k)] |= 1 << k; + } + + let distance = secondStringLength; + + // Process each character of firstString + for (let i = 0; i < firstStringLength; i++) { + const equalityMask = precomputedEqualityArray[firstString.charCodeAt(i)]; + const previousBit = (horizontalBitArray[(i / 32) | 0] >>> i) & 1; + const matchBit = (verticalBitArray[(i / 32) | 0] >>> i) & 1; + const combinedVector = equalityMask | negativeVector; + const combinedHorizontalVector = ((((equalityMask | matchBit) & positiveVector) + positiveVector) ^ positiveVector) | equalityMask | matchBit; + let positiveHorizontalVector = negativeVector | ~(combinedHorizontalVector | positiveVector); + let negativeHorizontalVector = positiveVector & combinedHorizontalVector; + distance += (positiveHorizontalVector >>> (secondStringLength - 1)) & 1; + distance -= (negativeHorizontalVector >>> (secondStringLength - 1)) & 1; + if ((positiveHorizontalVector >>> 31) ^ previousBit) { + horizontalBitArray[(i / 32) | 0] ^= 1 << i; + } + if ((negativeHorizontalVector >>> 31) ^ matchBit) { + verticalBitArray[(i / 32) | 0] ^= 1 << i; + } + positiveHorizontalVector = (positiveHorizontalVector << 1) | previousBit; + negativeHorizontalVector = (negativeHorizontalVector << 1) | matchBit; + positiveVector = negativeHorizontalVector | ~(combinedVector | positiveHorizontalVector); + negativeVector = positiveHorizontalVector & combinedVector; + } + + // Reset precomputedEqualityArray + for (let k = start; k < verticalLength; k++) { + precomputedEqualityArray[secondString.charCodeAt(k)] = 0; + } + + return distance; +} + +/** + * Computes the Levenshtein distance between two strings. + * @param firstString - The first string. + * @param secondString - The second string. + * @returns The Levenshtein distance. + */ +export function computeLevenshteinDistance(firstString: string, secondString: string): number { + if (firstString.length < secondString.length) { + const temp = secondString; + secondString = firstString; + firstString = temp; + } + if (secondString.length === 0) { + return firstString.length; + } + if (firstString.length <= 32) { + return computeLevenshteinDistanceForShortStrings(firstString, secondString); + } + return computeLevenshteinDistanceForLongStrings(firstString, secondString); +} diff --git a/src/vs/base/common/envfile.ts b/src/vs/base/common/envfile.ts new file mode 100644 index 00000000000..9cb40d93507 --- /dev/null +++ b/src/vs/base/common/envfile.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Parses a standard .env/.envrc file into a map of the environment variables + * it defines. + * + * todo@connor4312: this can go away (if only used in Node.js targets) and be + * replaced with `util.parseEnv`. However, currently calling that makes the + * extension host crash. + */ +export function parseEnvFile(src: string) { + const result = new Map(); + + // Normalize line breaks + const normalizedSrc = src.replace(/\r\n?/g, '\n'); + const lines = normalizedSrc.split('\n'); + + for (let line of lines) { + // Skip empty lines and comments + line = line.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + // Parse the line into key and value + const [key, value] = parseLine(line); + if (key) { + result.set(key, value); + } + } + + return result; + + function parseLine(line: string): [string, string] | [null, null] { + // Handle export prefix + if (line.startsWith('export ')) { + line = line.substring(7).trim(); + } + + // Find the key-value separator + const separatorIndex = findIndexOutsideQuotes(line, c => c === '=' || c === ':'); + if (separatorIndex === -1) { + return [null, null]; + } + + const key = line.substring(0, separatorIndex).trim(); + let value = line.substring(separatorIndex + 1).trim(); + + // Handle comments and remove them + const commentIndex = findIndexOutsideQuotes(value, c => c === '#'); + if (commentIndex !== -1) { + value = value.substring(0, commentIndex).trim(); + } + + // Process quoted values + if (value.length >= 2) { + const firstChar = value[0]; + const lastChar = value[value.length - 1]; + + if ((firstChar === '"' && lastChar === '"') || + (firstChar === '\'' && lastChar === '\'') || + (firstChar === '`' && lastChar === '`')) { + // Remove surrounding quotes + value = value.substring(1, value.length - 1); + + // Handle escaped characters in double quotes + if (firstChar === '"') { + value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r'); + } + } + } + + return [key, value]; + } + + function findIndexOutsideQuotes(text: string, predicate: (char: string) => boolean): number { + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + + if (inQuote) { + if (char === quoteChar && text[i - 1] !== '\\') { + inQuote = false; + } + } else if (char === '"' || char === '\'' || char === '`') { + inQuote = true; + quoteChar = char; + } else if (predicate(char)) { + return i; + } + } + + return -1; + } +} diff --git a/src/vs/base/common/glob.ts b/src/vs/base/common/glob.ts index 79fc4fe96dd..1057364d7fc 100644 --- a/src/vs/base/common/glob.ts +++ b/src/vs/base/common/glob.ts @@ -305,6 +305,26 @@ const NULL = function (): string | null { return null; }; +/** + * Check if a provided parsed pattern or expression + * is empty - hence it won't ever match anything. + * + * See {@link FALSE} and {@link NULL}. + */ +export const isEmptyPattern = ( + pattern: ParsedPattern | ParsedExpression, +): pattern is (typeof FALSE | typeof NULL) => { + if (pattern === FALSE) { + return true; + } + + if (pattern === NULL) { + return true; + } + + return false; +}; + function parsePattern(arg1: string | IRelativePattern, options: IGlobOptions): ParsedStringPattern { if (!arg1) { return NULL; diff --git a/src/vs/base/common/htmlContent.ts b/src/vs/base/common/htmlContent.ts index 070103b838d..fe1cbbb34d2 100644 --- a/src/vs/base/common/htmlContent.ts +++ b/src/vs/base/common/htmlContent.ts @@ -5,6 +5,7 @@ import { illegalArgument } from './errors.js'; import { escapeIcons } from './iconLabels.js'; +import { Schemas } from './network.js'; import { isEqual } from './resources.js'; import { escapeRegExpCharacters } from './strings.js'; import { URI, UriComponents } from './uri.js'; @@ -37,10 +38,6 @@ export class MarkdownString implements IMarkdownString { public uris?: { [href: string]: UriComponents } | undefined; public static lift(dto: IMarkdownString): MarkdownString { - if (dto instanceof MarkdownString) { - return dto; - } - const markdownString = new MarkdownString(dto.value, dto); markdownString.uris = dto.uris; markdownString.baseUri = dto.baseUri ? URI.revive(dto.baseUri) : undefined; @@ -201,3 +198,13 @@ export function parseHrefAndDimensions(href: string): { href: string; dimensions } return { href, dimensions }; } + +export function markdownCommandLink(command: { title: string; id: string; arguments?: unknown[] }): string { + const uri = URI.from({ + scheme: Schemas.command, + path: command.id, + query: command.arguments?.length ? encodeURIComponent(JSON.stringify(command.arguments)) : undefined, + }).toString(); + + return `[${escapeMarkdownSyntaxTokens(command.title)}](${uri})`; +} diff --git a/src/vs/base/common/iterator.ts b/src/vs/base/common/iterator.ts index fedcfe7edef..2ec62fe5c67 100644 --- a/src/vs/base/common/iterator.ts +++ b/src/vs/base/common/iterator.ts @@ -110,6 +110,14 @@ export namespace Iterable { return value; } + export function length(iterable: Iterable): number { + let count = 0; + for (const _ of iterable) { + count++; + } + return count; + } + /** * Returns an iterable slice of the array, with the same semantics as `array.slice()`. */ diff --git a/src/vs/base/common/marshallingIds.ts b/src/vs/base/common/marshallingIds.ts index fcec6b0a2c3..9ea80910eeb 100644 --- a/src/vs/base/common/marshallingIds.ts +++ b/src/vs/base/common/marshallingIds.ts @@ -26,4 +26,6 @@ export const enum MarshalledId { LanguageModelToolResult, LanguageModelTextPart, LanguageModelPromptTsxPart, + LanguageModelDataPart, + LanguageModelExtraDataPart, } diff --git a/src/vs/base/common/mime.ts b/src/vs/base/common/mime.ts index 41fe38f786b..2a229597e95 100644 --- a/src/vs/base/common/mime.ts +++ b/src/vs/base/common/mime.ts @@ -12,6 +12,7 @@ export const Mimes = Object.freeze({ markdown: 'text/markdown', latex: 'text/latex', uriList: 'text/uri-list', + html: 'text/html', }); interface MapExtToMediaMimes { diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index 16bbfe74115..193f687da24 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -333,7 +333,7 @@ class FileAccessImpl { return uri; } - private toUri(uriOrModule: URI | string, moduleIdToUrl?: { toUrl(moduleId: string): string }): URI { + private toUri(uriOrModule: URI | string): URI { if (URI.isUri(uriOrModule)) { return uriOrModule; } @@ -351,14 +351,18 @@ class FileAccessImpl { return URI.file(modulePath); } - return URI.parse(moduleIdToUrl!.toUrl(uriOrModule)); + throw new Error('Cannot determine URI for module id!'); } } export const FileAccess = new FileAccessImpl(); export const CacheControlheaders: Record = Object.freeze({ - 'Cache-Control': 'no-cache, no-store', + 'Cache-Control': 'no-cache, no-store' +}); + +export const DocumentPolicyheaders: Record = Object.freeze({ + 'Document-Policy': 'include-js-call-stacks-in-crash-reports' }); export namespace COI { diff --git a/src/vs/base/common/numbers.ts b/src/vs/base/common/numbers.ts index 6d3d90d5cd8..1d847fa2467 100644 --- a/src/vs/base/common/numbers.ts +++ b/src/vs/base/common/numbers.ts @@ -102,7 +102,7 @@ export function isPointWithinTriangle( /** * Function to get a (pseudo)random integer from a provided `max`...[`min`] range. * Both `min` and `max` values are inclusive. The `min` value is optional and defaults - * to `0` if not explicitely specified. + * to `0` if not explicitly specified. * * @throws in the next cases: * - if provided `min` or `max` is not a number diff --git a/src/vs/base/common/objects.ts b/src/vs/base/common/objects.ts index 94c2fb717d2..cd976d77377 100644 --- a/src/vs/base/common/objects.ts +++ b/src/vs/base/common/objects.ts @@ -231,41 +231,6 @@ export function filter(obj: obj, predicate: (key: string, value: any) => boolean return result; } -export function getAllPropertyNames(obj: object): string[] { - let res: string[] = []; - while (Object.prototype !== obj) { - res = res.concat(Object.getOwnPropertyNames(obj)); - obj = Object.getPrototypeOf(obj); - } - return res; -} - -export function getAllMethodNames(obj: object): string[] { - const methods: string[] = []; - for (const prop of getAllPropertyNames(obj)) { - if (typeof (obj as any)[prop] === 'function') { - methods.push(prop); - } - } - return methods; -} - -export function createProxyObject(methodNames: string[], invoke: (method: string, args: unknown[]) => unknown): T { - const createProxyMethod = (method: string): () => unknown => { - return function () { - const args = Array.prototype.slice.call(arguments, 0); - return invoke(method, args); - }; - }; - - // eslint-disable-next-line local/code-no-dangerous-type-assertions - const result = {} as T; - for (const methodName of methodNames) { - (result)[methodName] = createProxyMethod(methodName); - } - return result; -} - export function mapValues(obj: T, fn: (value: T[keyof T], key: string) => R): { [K in keyof T]: R } { const result: { [key: string]: R } = {}; for (const [key, value] of Object.entries(obj)) { diff --git a/src/vs/base/common/observableInternal/autorun.ts b/src/vs/base/common/observableInternal/autorun.ts index 03c37aa6c19..25055082928 100644 --- a/src/vs/base/common/observableInternal/autorun.ts +++ b/src/vs/base/common/observableInternal/autorun.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IChangeContext, IObservable, IObservableWithChange, IObserver, IReader } from './base.js'; +import { IObservable, IObservableWithChange, IObserver, IReader } from './base.js'; import { DebugNameData, IDebugNameData } from './debugName.js'; import { assertFn, BugIndicatingError, DisposableStore, IDisposable, markAsDisposed, onBugIndicatingError, toDisposable, trackDisposable } from './commonFacade/deps.js'; import { getLogger } from './logging/logging.js'; +import { IChangeTracker } from './changeTracker.js'; /** * Runs immediately and whenever a transaction ends and an observed observable changed. @@ -16,7 +17,6 @@ export function autorun(fn: (reader: IReader) => void): IDisposable { return new AutorunObserver( new DebugNameData(undefined, undefined, fn), fn, - undefined, undefined ); } @@ -29,7 +29,6 @@ export function autorunOpts(options: IDebugNameData & {}, fn: (reader: IReader) return new AutorunObserver( new DebugNameData(options.owner, options.debugName, options.debugReferenceFn ?? fn), fn, - undefined, undefined ); } @@ -38,8 +37,8 @@ export function autorunOpts(options: IDebugNameData & {}, fn: (reader: IReader) * Runs immediately and whenever a transaction ends and an observed observable changed. * {@link fn} should start with a JS Doc using `@description` to name the autorun. * - * Use `createEmptyChangeSummary` to create a "change summary" that can collect the changes. - * Use `handleChange` to add a reported change to the change summary. + * Use `changeTracker.createChangeSummary` to create a "change summary" that can collect the changes. + * Use `changeTracker.handleChange` to add a reported change to the change summary. * The run function is given the last change summary. * The change summary is discarded after the run function was called. * @@ -47,16 +46,14 @@ export function autorunOpts(options: IDebugNameData & {}, fn: (reader: IReader) */ export function autorunHandleChanges( options: IDebugNameData & { - createEmptyChangeSummary?: () => TChangeSummary; - handleChange: (context: IChangeContext, changeSummary: TChangeSummary) => boolean; + changeTracker: IChangeTracker; }, fn: (reader: IReader, changeSummary: TChangeSummary) => void ): IDisposable { return new AutorunObserver( new DebugNameData(options.owner, options.debugName, options.debugReferenceFn ?? fn), fn, - options.createEmptyChangeSummary, - options.handleChange + options.changeTracker, ); } @@ -65,8 +62,7 @@ export function autorunHandleChanges( */ export function autorunWithStoreHandleChanges( options: IDebugNameData & { - createEmptyChangeSummary?: () => TChangeSummary; - handleChange: (context: IChangeContext, changeSummary: TChangeSummary) => boolean; + changeTracker: IChangeTracker; }, fn: (reader: IReader, changeSummary: TChangeSummary, store: DisposableStore) => void ): IDisposable { @@ -76,8 +72,7 @@ export function autorunWithStoreHandleChanges( owner: options.owner, debugName: options.debugName, debugReferenceFn: options.debugReferenceFn ?? fn, - createEmptyChangeSummary: options.createEmptyChangeSummary, - handleChange: options.handleChange, + changeTracker: options.changeTracker, }, (reader, changeSummary) => { store.clear(); @@ -183,10 +178,9 @@ export class AutorunObserver implements IObserver, IReader constructor( public readonly _debugNameData: DebugNameData, public readonly _runFn: (reader: IReader, changeSummary: TChangeSummary) => void, - private readonly createChangeSummary: (() => TChangeSummary) | undefined, - private readonly _handleChange: ((context: IChangeContext, summary: TChangeSummary) => boolean) | undefined, + private readonly _changeTracker: IChangeTracker | undefined, ) { - this._changeSummary = this.createChangeSummary?.(); + this._changeSummary = this._changeTracker?.createChangeSummary(undefined); getLogger()?.handleAutorunCreated(this); this._run(); @@ -194,6 +188,9 @@ export class AutorunObserver implements IObserver, IReader } public dispose(): void { + if (this._disposed) { + return; + } this._disposed = true; for (const o of this._dependencies) { o.removeObserver(this); // Warning: external call! @@ -216,8 +213,11 @@ export class AutorunObserver implements IObserver, IReader getLogger()?.handleAutorunStarted(this); const changeSummary = this._changeSummary!; try { - this._changeSummary = this.createChangeSummary?.(); // Warning: external call! this._isRunning = true; + if (this._changeTracker) { + this._changeTracker.beforeUpdate?.(this, changeSummary); + this._changeSummary = this._changeTracker.createChangeSummary(changeSummary); // Warning: external call! + } this._runFn(this, changeSummary); // Warning: external call! } catch (e) { onBugIndicatingError(e); @@ -288,7 +288,7 @@ export class AutorunObserver implements IObserver, IReader getLogger()?.handleAutorunDependencyChanged(this, observable, change); try { // Warning: external call! - const shouldReact = this._handleChange ? this._handleChange({ + const shouldReact = this._changeTracker ? this._changeTracker.handleChange({ changedObservable: observable, change, didChange: (o): this is any => o === observable as any, diff --git a/src/vs/base/common/observableInternal/base.ts b/src/vs/base/common/observableInternal/base.ts index 4d7d4ea9e19..94c454d75ab 100644 --- a/src/vs/base/common/observableInternal/base.ts +++ b/src/vs/base/common/observableInternal/base.ts @@ -8,6 +8,7 @@ import { DisposableStore, EqualityComparer, IDisposable, strictEquals } from './ import type { derivedOpts } from './derived.js'; import { getLogger, logObservable } from './logging/logging.js'; import { keepObserved, recomputeInitiallyAndOnChange } from './utils.js'; +import { onUnexpectedError } from '../errors.js'; /** * Represents an observable value. @@ -403,13 +404,29 @@ export class TransactionImpl implements ITransaction { } public updateObserver(observer: IObserver, observable: IObservable): void { + if (!this._updatingObservers) { + // This happens when a transaction is used in a callback or async function. + // If an async transaction is used, make sure the promise awaits all users of the transaction (e.g. no race). + handleBugIndicatingErrorRecovery('Transaction already finished!'); + // Error recovery + transaction(tx => { + tx.updateObserver(observer, observable); + }); + return; + } + // When this gets called while finish is active, they will still get considered - this._updatingObservers!.push({ observer, observable }); + this._updatingObservers.push({ observer, observable }); observer.beginUpdate(observable); } public finish(): void { - const updatingObservers = this._updatingObservers!; + const updatingObservers = this._updatingObservers; + if (!updatingObservers) { + handleBugIndicatingErrorRecovery('transaction.finish() has already been called!'); + return; + } + for (let i = 0; i < updatingObservers.length; i++) { const { observer, observable } = updatingObservers[i]; observer.endUpdate(observable); @@ -424,6 +441,15 @@ export class TransactionImpl implements ITransaction { } } +/** + * This function is used to indicate that the caller recovered from an error that indicates a bug. +*/ +function handleBugIndicatingErrorRecovery(message: string) { + const err = new Error('BugIndicatingErrorRecovery: ' + message); + onUnexpectedError(err); + console.error('recovered from an error that indicates a bug', err); +} + /** * A settable observable. */ @@ -544,21 +570,3 @@ export class DisposableObservableValue; - readonly change: unknown; - - /** - * Returns if the given observable caused the change. - */ - didChange(observable: IObservableWithChange): this is { change: TChange }; -} diff --git a/src/vs/base/common/observableInternal/changeTracker.ts b/src/vs/base/common/observableInternal/changeTracker.ts new file mode 100644 index 00000000000..8b160128978 --- /dev/null +++ b/src/vs/base/common/observableInternal/changeTracker.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BugIndicatingError } from '../errors.js'; +import { IObservableWithChange, IReader } from './base.js'; + +export interface IChangeTracker { + createChangeSummary(previousChangeSummary: TChangeSummary | undefined): TChangeSummary; + handleChange(ctx: IChangeContext, change: TChangeSummary): boolean; + beforeUpdate?(reader: IReader, change: TChangeSummary): void; +} + +export interface IChangeContext { + readonly changedObservable: IObservableWithChange; + readonly change: unknown; + + /** + * Returns if the given observable caused the change. + */ + didChange(observable: IObservableWithChange): this is { change: TChange }; +} + +/** + * Subscribes to and records changes and the last value of the given observables. + * Don't use the key "changes", as it is reserved for the changes array! +*/ +export function recordChanges>>(obs: TObs): + IChangeTracker<{ [TKey in keyof TObs]: ReturnType } + & { changes: readonly ({ [TKey in keyof TObs]: { key: TKey; change: TObs[TKey]['TChange'] } }[keyof TObs])[] }> { + return { + createChangeSummary: (_previousChangeSummary) => { + return { + changes: [], + } as any; + }, + handleChange(ctx, changeSummary) { + for (const key in obs) { + if (ctx.didChange(obs[key])) { + (changeSummary.changes as any).push({ key, change: ctx.change }); + } + } + return true; + }, + beforeUpdate(reader, changeSummary) { + for (const key in obs) { + if (key === 'changes') { + throw new BugIndicatingError('property name "changes" is reserved for change tracking'); + } + changeSummary[key] = obs[key].read(reader); + } + } + }; +} diff --git a/src/vs/base/common/observableInternal/derived.ts b/src/vs/base/common/observableInternal/derived.ts index 59d8dc725a7..df876e5dee7 100644 --- a/src/vs/base/common/observableInternal/derived.ts +++ b/src/vs/base/common/observableInternal/derived.ts @@ -3,10 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseObservable, IChangeContext, IObservable, IObservableWithChange, IObserver, IReader, ISettableObservable, ITransaction, _setDerivedOpts, } from './base.js'; +import { BaseObservable, IObservable, IObservableWithChange, IObserver, IReader, ISettableObservable, ITransaction, _setDerivedOpts, } from './base.js'; import { DebugNameData, DebugOwner, IDebugNameData } from './debugName.js'; import { BugIndicatingError, DisposableStore, EqualityComparer, IDisposable, assertFn, onBugIndicatingError, strictEquals } from './commonFacade/deps.js'; import { getLogger } from './logging/logging.js'; +import { IChangeTracker } from './changeTracker.js'; + +export interface IDerivedReader extends IReader { + /** + * Call this to report a change delta or to force report a change, even if the new value is the same as the old value. + */ + reportChange(change: TChange): void; +} /** * Creates an observable that is derived from other observables. @@ -14,16 +22,15 @@ import { getLogger } from './logging/logging.js'; * * {@link computeFn} should start with a JS Doc using `@description` to name the derived. */ -export function derived(computeFn: (reader: IReader) => T): IObservable; -export function derived(owner: DebugOwner, computeFn: (reader: IReader) => T): IObservable; -export function derived(computeFnOrOwner: ((reader: IReader) => T) | DebugOwner, computeFn?: ((reader: IReader) => T) | undefined): IObservable { +export function derived(computeFn: (reader: IDerivedReader) => T): IObservable; +export function derived(owner: DebugOwner, computeFn: (reader: IDerivedReader) => T): IObservable; +export function derived(computeFnOrOwner: ((reader: IDerivedReader) => T) | DebugOwner, computeFn?: ((reader: IDerivedReader) => T) | undefined): IObservable { if (computeFn !== undefined) { return new Derived( new DebugNameData(computeFnOrOwner, undefined, computeFn), computeFn, undefined, undefined, - undefined, strictEquals ); } @@ -32,7 +39,6 @@ export function derived(computeFnOrOwner: ((reader: IReader) => T) | DebugOwn computeFnOrOwner as any, undefined, undefined, - undefined, strictEquals ); } @@ -43,7 +49,6 @@ export function derivedWithSetter(owner: DebugOwner | undefined, computeFn: ( computeFn, undefined, undefined, - undefined, strictEquals, setter, ); @@ -60,7 +65,6 @@ export function derivedOpts( new DebugNameData(options.owner, options.debugName, options.debugReferenceFn), computeFn, undefined, - undefined, options.onLastObserverRemoved, options.equalsFn ?? strictEquals ); @@ -83,8 +87,7 @@ _setDerivedOpts(derivedOpts); */ export function derivedHandleChanges( options: IDebugNameData & { - createEmptyChangeSummary: () => TChangeSummary; - handleChange: (context: IChangeContext, changeSummary: TChangeSummary) => boolean; + changeTracker: IChangeTracker; equalityComparer?: EqualityComparer; }, computeFn: (reader: IReader, changeSummary: TChangeSummary) => T @@ -92,8 +95,7 @@ export function derivedHandleChanges( return new Derived( new DebugNameData(options.owner, options.debugName, undefined), computeFn, - options.createEmptyChangeSummary, - options.handleChange, + options.changeTracker, undefined, options.equalityComparer ?? strictEquals ); @@ -125,7 +127,7 @@ export function derivedWithStore(computeFnOrOwner: ((reader: IReader, store: store.clear(); } return computeFn(r, store); - }, undefined, + }, undefined, () => store.dispose(), strictEquals, @@ -159,7 +161,7 @@ export function derivedDisposable(computeFnOr store.add(result); } return result; - }, undefined, + }, undefined, () => { if (store) { @@ -193,7 +195,7 @@ export const enum DerivedState { upToDate = 3, } -export class Derived extends BaseObservable implements IReader, IObserver { +export class Derived extends BaseObservable implements IDerivedReader, IObserver { private _state = DerivedState.initial; private _value: T | undefined = undefined; private _updateCount = 0; @@ -202,6 +204,7 @@ export class Derived extends BaseObservable im private _changeSummary: TChangeSummary | undefined = undefined; private _isUpdating = false; private _isComputing = false; + private _didReportChange = false; public override get debugName(): string { return this._debugNameData.getDebugName(this) ?? '(anonymous)'; @@ -209,14 +212,13 @@ export class Derived extends BaseObservable im constructor( public readonly _debugNameData: DebugNameData, - public readonly _computeFn: (reader: IReader, changeSummary: TChangeSummary) => T, - private readonly createChangeSummary: (() => TChangeSummary) | undefined, - private readonly _handleChange: ((context: IChangeContext, summary: TChangeSummary) => boolean) | undefined, + public readonly _computeFn: (reader: IDerivedReader, changeSummary: TChangeSummary) => T, + private readonly _changeTracker: IChangeTracker | undefined, private readonly _handleLastObserverRemoved: (() => void) | undefined = undefined, private readonly _equalityComparator: EqualityComparer, ) { super(); - this._changeSummary = this.createChangeSummary?.(); + this._changeSummary = this._changeTracker?.createChangeSummary(undefined); } protected override onLastObserverRemoved(): void { @@ -248,7 +250,12 @@ export class Derived extends BaseObservable im // Thus, we don't cache anything to prevent memory leaks. try { this._isReaderValid = true; - result = this._computeFn(this, this.createChangeSummary?.()!); + let changeSummary = undefined; + if (this._changeTracker) { + changeSummary = this._changeTracker.createChangeSummary(undefined); + this._changeTracker.beforeUpdate?.(this, changeSummary); + } + result = this._computeFn(this, changeSummary!); } finally { this._isReaderValid = false; } @@ -299,12 +306,16 @@ export class Derived extends BaseObservable im let didChange = false; this._isComputing = true; + this._didReportChange = false; try { const changeSummary = this._changeSummary!; - this._changeSummary = this.createChangeSummary?.(); try { this._isReaderValid = true; + if (this._changeTracker) { + this._changeTracker.beforeUpdate?.(this, changeSummary); + this._changeSummary = this._changeTracker?.createChangeSummary(changeSummary); + } /** might call {@link handleChange} indirectly, which could invalidate us */ this._value = this._computeFn(this, changeSummary); } finally { @@ -317,7 +328,7 @@ export class Derived extends BaseObservable im this._dependenciesToBeRemoved.clear(); } - didChange = hadValue && !(this._equalityComparator(oldValue!, this._value)); + didChange = this._didReportChange || (hadValue && !(this._equalityComparator(oldValue!, this._value))); getLogger()?.handleObservableUpdated(this, { oldValue, @@ -332,10 +343,12 @@ export class Derived extends BaseObservable im this._isComputing = false; - if (didChange) { + if (!this._didReportChange && didChange) { for (const r of this._observers) { r.handleChange(this, undefined); } + } else { + this._didReportChange = false; } } @@ -410,7 +423,7 @@ export class Derived extends BaseObservable im let shouldReact = false; try { - shouldReact = this._handleChange ? this._handleChange({ + shouldReact = this._changeTracker ? this._changeTracker.handleChange({ changedObservable: observable, change, didChange: (o): this is any => o === observable as any, @@ -447,6 +460,16 @@ export class Derived extends BaseObservable im return value; } + public reportChange(change: TChange): void { + if (!this._isReaderValid) { throw new BugIndicatingError('The reader object cannot be used outside its compute function!'); } + + this._didReportChange = true; + // TODO add logging + for (const r of this._observers) { + r.handleChange(this, change); + } + } + public override addObserver(observer: IObserver): void { const shouldCallBeginUpdate = !this._observers.has(observer) && this._updateCount > 0; super.addObserver(observer); @@ -484,24 +507,30 @@ export class Derived extends BaseObservable im this._value = newValue as any; } + public setValue(newValue: T, tx: ITransaction, change: TChange): void { + this._value = newValue; + const observers = this._observers; + tx.updateObserver(this, this); + for (const d of observers) { + d.handleChange(this, change); + } + } } -export class DerivedWithSetter extends Derived implements ISettableObservable { +export class DerivedWithSetter extends Derived implements ISettableObservable { constructor( debugNameData: DebugNameData, - computeFn: (reader: IReader, changeSummary: TChangeSummary) => T, - createChangeSummary: (() => TChangeSummary) | undefined, - handleChange: ((context: IChangeContext, summary: TChangeSummary) => boolean) | undefined, + computeFn: (reader: IDerivedReader, changeSummary: TChangeSummary) => T, + changeTracker: IChangeTracker | undefined, handleLastObserverRemoved: (() => void) | undefined = undefined, equalityComparator: EqualityComparer, - public readonly set: (value: T, tx: ITransaction | undefined) => void, + public readonly set: (value: T, tx: ITransaction | undefined, change: TOutChanges) => void, ) { super( debugNameData, computeFn, - createChangeSummary, - handleChange, + changeTracker, handleLastObserverRemoved, equalityComparator, ); diff --git a/src/vs/base/common/observableInternal/index.ts b/src/vs/base/common/observableInternal/index.ts index c86501e702a..bc47cf25b7c 100644 --- a/src/vs/base/common/observableInternal/index.ts +++ b/src/vs/base/common/observableInternal/index.ts @@ -6,13 +6,14 @@ // This is a facade for the observable implementation. Only import from here! export { observableValueOpts } from './api.js'; -export { autorun, autorunDelta, autorunHandleChanges, autorunOpts, autorunWithStore, autorunWithStoreHandleChanges } from './autorun.js'; -export { asyncTransaction, disposableObservableValue, globalTransaction, observableValue, subtransaction, transaction, TransactionImpl, type IChangeContext, type IChangeTracker, type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type ISettableObservable, type ITransaction, } from './base.js'; -export { derived, derivedDisposable, derivedHandleChanges, derivedOpts, derivedWithSetter, derivedWithStore } from './derived.js'; +export { autorun, autorunDelta, autorunHandleChanges, autorunOpts, autorunWithStore, autorunWithStoreHandleChanges, autorunIterableDelta } from './autorun.js'; +export { asyncTransaction, disposableObservableValue, globalTransaction, observableValue, subtransaction, transaction, TransactionImpl, type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type ISettableObservable, type ITransaction, } from './base.js'; +export { derived, derivedDisposable, derivedHandleChanges, derivedOpts, derivedWithSetter, derivedWithStore, type IDerivedReader } from './derived.js'; export { ObservableLazy, ObservableLazyPromise, ObservablePromise, PromiseResult, } from './promise.js'; export { derivedWithCancellationToken, waitForState } from './utilsCancellation.js'; -export { constObservable, debouncedObservableDeprecated, derivedConstOnceDefined, derivedObservableWithCache, derivedObservableWithWritableCache, keepObserved, latestChangedValue, mapObservableArrayCached, observableFromEvent, observableFromEventOpts, observableFromPromise, observableFromValueWithChangeEvent, observableSignal, observableSignalFromEvent, recomputeInitiallyAndOnChange, runOnChange, runOnChangeWithStore, signalFromObservable, ValueWithChangeEventFromObservable, wasEventTriggeredRecently, type IObservableSignal, } from './utils.js'; +export { constObservable, debouncedObservableDeprecated, debouncedObservable, derivedConstOnceDefined, derivedObservableWithCache, derivedObservableWithWritableCache, keepObserved, latestChangedValue, mapObservableArrayCached, observableFromEvent, observableFromEventOpts, observableFromPromise, observableFromValueWithChangeEvent, observableSignal, observableSignalFromEvent, recomputeInitiallyAndOnChange, runOnChange, runOnChangeWithStore, runOnChangeWithCancellationToken, signalFromObservable, ValueWithChangeEventFromObservable, wasEventTriggeredRecently, type IObservableSignal, } from './utils.js'; export { type DebugOwner } from './debugName.js'; +export { type IChangeContext, type IChangeTracker, recordChanges } from './changeTracker.js'; import { addLogger, setLogObservableFn } from './logging/logging.js'; import { ConsoleObservableLogger, logObservableToConsole } from './logging/consoleObservableLogger.js'; diff --git a/src/vs/base/common/observableInternal/logging/logging.ts b/src/vs/base/common/observableInternal/logging/logging.ts index dd6ba48416d..7e31bbe5393 100644 --- a/src/vs/base/common/observableInternal/logging/logging.ts +++ b/src/vs/base/common/observableInternal/logging/logging.ts @@ -54,8 +54,8 @@ export interface IObservableLogger { handleAutorunStarted(autorun: AutorunObserver): void; handleAutorunFinished(autorun: AutorunObserver): void; - handleDerivedDependencyChanged(derived: Derived, observable: IObservable, change: unknown): void; - handleDerivedCleared(observable: Derived): void; + handleDerivedDependencyChanged(derived: Derived, observable: IObservable, change: unknown): void; + handleDerivedCleared(observable: Derived): void; handleBeginTransaction(transaction: TransactionImpl): void; handleEndTransaction(transaction: TransactionImpl): void; diff --git a/src/vs/base/common/observableInternal/reducer.ts b/src/vs/base/common/observableInternal/reducer.ts new file mode 100644 index 00000000000..0408bb5ed2a --- /dev/null +++ b/src/vs/base/common/observableInternal/reducer.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EqualityComparer, strictEquals } from '../equals.js'; +import { BugIndicatingError } from '../errors.js'; +import { IObservable, IObservableWithChange, ISettableObservable, subtransaction } from './base.js'; +import { IChangeTracker } from './changeTracker.js'; +import { DebugNameData, DebugOwner } from './debugName.js'; +import { DerivedWithSetter, IDerivedReader } from './derived.js'; + +export interface IReducerOptions { + /** + * Is called to create the initial value of the observable when it becomes observed. + */ + initial: T | (() => T); + /** + * Is called to dispose the observable value when it is no longer observed. + */ + disposeFinal?(value: T): void; + changeTracker?: IChangeTracker; + equalityComparer?: EqualityComparer; + /** + * Applies the changes to the value. + * Use `reader.reportChange` to report change details or to report a change if the same value is returned. + */ + update(reader: IDerivedReader, previousValue: T, changes: TChangeSummary): T; +} + +/** + * Creates an observable value that is based on values and changes from other observables. + * Additionally, a reducer can report how that state changed. +*/ +export function observableReducer(owner: DebugOwner, options: IReducerOptions): SimplifyObservableWithChange { + return observableReducerSettable(owner, options) as any; +} + +/** + * Creates an observable value that is based on values and changes from other observables. + * Additionally, a reducer can report how that state changed. +*/ +export function observableReducerSettable(owner: DebugOwner, options: IReducerOptions): ISettableObservable { + let prevValue: T | undefined = undefined; + let hasValue = false; + + const d = new DerivedWithSetter( + new DebugNameData(owner, undefined, options.update), + (reader: IDerivedReader, changeSummary) => { + if (!hasValue) { + prevValue = options.initial instanceof Function ? options.initial() : options.initial; + hasValue = true; + } + const newValue = options.update(reader, prevValue!, changeSummary); + prevValue = newValue; + return newValue; + }, + options.changeTracker, + () => { + if (hasValue) { + options.disposeFinal?.(prevValue!); + hasValue = false; + } + }, + options.equalityComparer ?? strictEquals, + (value, tx, change) => { + if (!hasValue) { + throw new BugIndicatingError('Can only set when there is a listener! This is to prevent leaks.'); + } + subtransaction(tx, tx => { + prevValue = value; + d.setValue(value, tx, change); + }); + } + ); + + return d; +} + +/** + * Returns IObservable if TChange is void, otherwise IObservableWithChange +*/ +type SimplifyObservableWithChange = TChange extends void ? IObservable : IObservableWithChange; diff --git a/src/vs/base/common/observableInternal/utils.ts b/src/vs/base/common/observableInternal/utils.ts index 530474b334e..d914f96cc63 100644 --- a/src/vs/base/common/observableInternal/utils.ts +++ b/src/vs/base/common/observableInternal/utils.ts @@ -9,6 +9,7 @@ import { DebugNameData, DebugOwner, IDebugNameData, getDebugName, } from './debu import { BugIndicatingError, DisposableStore, EqualityComparer, Event, IDisposable, IValueWithChangeEvent, strictEquals, toDisposable } from './commonFacade/deps.js'; import { derived, derivedOpts } from './derived.js'; import { getLogger } from './logging/logging.js'; +import { CancellationToken, cancelOnDispose } from '../cancellation.js'; /** * Represents an efficient observable whose value never changes. @@ -640,17 +641,19 @@ type RemoveUndefined = T extends undefined ? never : T; export function runOnChange(observable: IObservableWithChange, cb: (value: T, previousValue: undefined | T, deltas: RemoveUndefined[]) => void): IDisposable { let _previousValue: T | undefined; return autorunWithStoreHandleChanges({ - createEmptyChangeSummary: () => ({ deltas: [] as RemoveUndefined[], didChange: false }), - handleChange: (context, changeSummary) => { - if (context.didChange(observable)) { - const e = context.change; - if (e !== undefined) { - changeSummary.deltas.push(e as RemoveUndefined); + changeTracker: { + createChangeSummary: () => ({ deltas: [] as RemoveUndefined[], didChange: false }), + handleChange: (context, changeSummary) => { + if (context.didChange(observable)) { + const e = context.change; + if (e !== undefined) { + changeSummary.deltas.push(e as RemoveUndefined); + } + changeSummary.didChange = true; } - changeSummary.didChange = true; - } - return true; - }, + return true; + }, + } }, (reader, changeSummary) => { const value = observable.read(reader); const previousValue = _previousValue; @@ -674,3 +677,9 @@ export function runOnChangeWithStore(observable: IObservableWithChan } }; } + +export function runOnChangeWithCancellationToken(observable: IObservableWithChange, cb: (value: T, previousValue: undefined | T, deltas: RemoveUndefined[], token: CancellationToken) => Promise): IDisposable { + return runOnChangeWithStore(observable, (value, previousValue: undefined | T, deltas, store) => { + cb(value, previousValue, deltas, cancelOnDispose(store)); + }); +} diff --git a/src/vs/base/common/observableInternal/utilsCancellation.ts b/src/vs/base/common/observableInternal/utilsCancellation.ts index 17e4ba9e308..f76b58e1412 100644 --- a/src/vs/base/common/observableInternal/utilsCancellation.ts +++ b/src/vs/base/common/observableInternal/utilsCancellation.ts @@ -91,7 +91,6 @@ export function derivedWithCancellationToken(computeFnOrOwner: ((reader: IRea cancellationTokenSource = new CancellationTokenSource(); return computeFn(r, cancellationTokenSource.token); }, undefined, - undefined, () => cancellationTokenSource?.dispose(), strictEquals ); diff --git a/src/vs/base/common/policy.ts b/src/vs/base/common/policy.ts new file mode 100644 index 00000000000..8600c43854e --- /dev/null +++ b/src/vs/base/common/policy.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type PolicyName = string; + +export interface IPolicy { + + /** + * The policy name. + */ + readonly name: PolicyName; + + /** + * The Code version in which this policy was introduced. + */ + readonly minimumVersion: `${number}.${number}`; + + /** + * The policy description (optional). + */ + readonly description?: string; + + /** + * Is preview feature + */ + readonly previewFeature?: boolean; + + /** + * Default value when enabled. Default is `false`. + */ + readonly defaultValue?: string | number | boolean; +} diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 0527af13b60..ff80ac8bd2d 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -5,6 +5,7 @@ import { IStringDictionary } from './collections.js'; import { PlatformName } from './platform.js'; +import { IPolicy } from './policy.js'; export interface IBuiltInExtension { readonly name: string; @@ -99,6 +100,7 @@ export interface IProductConfiguration { readonly extensionUrlTemplate: string; readonly resourceUrlTemplate: string; readonly nlsBaseUrl: string; + readonly accessSKUs?: string[]; }; readonly extensionPublisherOrgs?: readonly string[]; @@ -178,10 +180,25 @@ export interface IProductConfiguration { readonly extensionEnabledApiProposals?: { readonly [extensionId: string]: string[] }; readonly extensionUntrustedWorkspaceSupport?: { readonly [extensionId: string]: ExtensionUntrustedWorkspaceSupport }; readonly extensionVirtualWorkspacesSupport?: { readonly [extensionId: string]: ExtensionVirtualWorkspaceSupport }; + readonly extensionProperties: IStringDictionary<{ + readonly hasPrereleaseVersion?: boolean; + readonly excludeVersionRange?: string; + }>; readonly msftInternalDomains?: string[]; readonly linkProtectionTrustedDomains?: readonly string[]; + readonly defaultAccount?: { + readonly authenticationProvider: { + readonly id: string; + readonly enterpriseProviderId: string; + readonly enterpriseProviderConfig: string; + readonly scopes: string[]; + }; + readonly tokenEntitlementUrl: string; + readonly chatEntitlementUrl: string; + }; + readonly 'configurationSync.store'?: ConfigurationSyncStore; readonly 'editSessions.store'?: Omit; @@ -196,6 +213,10 @@ export interface IProductConfiguration { readonly chatParticipantRegistry?: string; readonly emergencyAlertUrl?: string; + + readonly remoteDefaultExtensionsIfInstalledLocally?: string[]; + + readonly extensionConfigurationPolicy?: IStringDictionary; } export interface ITunnelApplicationConfig { @@ -312,6 +333,7 @@ export interface IDefaultChatAgent { readonly publicCodeMatchesUrl: string; readonly manageSettingsUrl: string; readonly managePlanUrl: string; + readonly manageOverageUrl: string; readonly upgradePlanUrl: string; readonly providerId: string; diff --git a/src/vs/base/common/sseParser.ts b/src/vs/base/common/sseParser.ts new file mode 100644 index 00000000000..0990e0247d9 --- /dev/null +++ b/src/vs/base/common/sseParser.ts @@ -0,0 +1,245 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Parser for Server-Sent Events (SSE) streams according to the HTML specification. + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + */ + +/** + * Represents an event dispatched from an SSE stream. + */ +export interface ISSEEvent { + /** + * The event type. If not specified, the type is "message". + */ + type: string; + + /** + * The event data. + */ + data: string; + + /** + * The last event ID, used for reconnection. + */ + id?: string; + + /** + * Reconnection time in milliseconds. + */ + retry?: number; +} + +/** + * Callback function type for event dispatch. + */ +export type SSEEventHandler = (event: ISSEEvent) => void; + +const enum Chr { + CR = 13, // '\r' + LF = 10, // '\n' + COLON = 58, // ':' + SPACE = 32, // ' ' +} + +/** + * Parser for Server-Sent Events (SSE) streams. + */ +export class SSEParser { + private dataBuffer = ''; + private eventTypeBuffer = ''; + private currentEventId?: string; + private lastEventIdBuffer?: string; + private reconnectionTime?: number; + private buffer: Uint8Array[] = []; + private endedOnCR = false; + private readonly onEventHandler: SSEEventHandler; + private readonly decoder: TextDecoder; + /** + * Creates a new SSE parser. + * @param onEvent The callback to invoke when an event is dispatched. + */ + constructor(onEvent: SSEEventHandler) { + this.onEventHandler = onEvent; + this.decoder = new TextDecoder('utf-8'); + } + + /** + * Gets the last event ID received by this parser. + */ + public getLastEventId(): string | undefined { + return this.lastEventIdBuffer; + } + /** + * Gets the reconnection time in milliseconds, if one was specified by the server. + */ + public getReconnectionTime(): number | undefined { + return this.reconnectionTime; + } + + /** + * Feeds a chunk of the SSE stream to the parser. + * @param chunk The chunk to parse as a Uint8Array of UTF-8 encoded data. + */ + public feed(chunk: Uint8Array): void { + if (chunk.length === 0) { + return; + } + + let offset = 0; + + // If the data stream was bifurcated between a CR and LF, avoid processing the CR as an extra newline + if (this.endedOnCR && chunk[0] === Chr.LF) { + offset++; + } + this.endedOnCR = false; + + // Process complete lines from the buffer + while (offset < chunk.length) { + const indexCR = chunk.indexOf(Chr.CR, offset); + const indexLF = chunk.indexOf(Chr.LF, offset); + const index = indexCR === -1 ? indexLF : (indexLF === -1 ? indexCR : Math.min(indexCR, indexLF)); + if (index === -1) { + break; + } + + let str = ''; + for (const buf of this.buffer) { + str += this.decoder.decode(buf, { stream: true }); + } + str += this.decoder.decode(chunk.subarray(offset, index)); + this.processLine(str); + + this.buffer.length = 0; + offset = index + (chunk[index] === Chr.CR && chunk[index + 1] === Chr.LF ? 2 : 1); + } + + + if (offset < chunk.length) { + this.buffer.push(chunk.subarray(offset)); + } else { + this.endedOnCR = chunk[chunk.length - 1] === Chr.CR; + } + } + /** + * Processes a single line from the SSE stream. + */ + private processLine(line: string): void { + if (!line.length) { + this.dispatchEvent(); + return; + } + + if (line.startsWith(':')) { + return; + } + + // Parse the field name and value + let field: string; + let value: string; + + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) { + // Line with no colon - the entire line is the field name, value is empty + field = line; + value = ''; + } else { + // Line with a colon - split into field name and value + field = line.substring(0, colonIndex); + value = line.substring(colonIndex + 1); + + // If value starts with a space, remove it + if (value.startsWith(' ')) { + value = value.substring(1); + } + } + + this.processField(field, value); + } + /** + * Processes a field with the given name and value. + */ + private processField(field: string, value: string): void { + switch (field) { + case 'event': + this.eventTypeBuffer = value; + break; + + case 'data': + // Append the value to the data buffer, followed by a newline + this.dataBuffer += value; + this.dataBuffer += '\n'; + break; + + case 'id': + // If the field value doesn't contain NULL, set the last event ID buffer + if (!value.includes('\0')) { + this.currentEventId = this.lastEventIdBuffer = value; + } else { + this.currentEventId = undefined; + } + break; + + case 'retry': + // If the field value consists only of ASCII digits, set the reconnection time + if (/^\d+$/.test(value)) { + this.reconnectionTime = parseInt(value, 10); + } + break; + + // Ignore any other fields + } + } + /** + * Dispatches the event based on the current buffer states. + */ + private dispatchEvent(): void { + // If the data buffer is empty, reset the buffers and return + if (this.dataBuffer === '') { + this.dataBuffer = ''; + this.eventTypeBuffer = ''; + return; + } + + // If the data buffer's last character is a newline, remove it + if (this.dataBuffer.endsWith('\n')) { + this.dataBuffer = this.dataBuffer.substring(0, this.dataBuffer.length - 1); + } + + // Create and dispatch the event + const event: ISSEEvent = { + type: this.eventTypeBuffer || 'message', + data: this.dataBuffer, + }; + + // Add optional fields if they exist + if (this.currentEventId !== undefined) { + event.id = this.currentEventId; + } + + if (this.reconnectionTime !== undefined) { + event.retry = this.reconnectionTime; + } + + // Dispatch the event + this.onEventHandler(event); + + // Reset the data and event type buffers + this.reset(); + } + + /** + * Resets the parser state. + */ + public reset(): void { + this.dataBuffer = ''; + this.eventTypeBuffer = ''; + this.currentEventId = undefined; + // Note: lastEventIdBuffer is not reset as it's used for reconnection + } +} + + diff --git a/src/vs/base/common/stopwatch.ts b/src/vs/base/common/stopwatch.ts index e32c0dd9d91..ca3cb6388bc 100644 --- a/src/vs/base/common/stopwatch.ts +++ b/src/vs/base/common/stopwatch.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ // fake definition so that the valid layers check won't trip on this -declare const globalThis: { performance?: { now(): number } }; +declare const globalThis: { performance: { now(): number } }; -const hasPerformanceNow = (globalThis.performance && typeof globalThis.performance.now === 'function'); +const performanceNow = globalThis.performance.now.bind(globalThis.performance); export class StopWatch { @@ -20,7 +20,7 @@ export class StopWatch { } constructor(highResolution?: boolean) { - this._now = hasPerformanceNow && highResolution === false ? Date.now : globalThis.performance!.now.bind(globalThis.performance); + this._now = highResolution === false ? Date.now : performanceNow; this._startTime = this._now(); this._stopTime = -1; } diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index 1a8467170b5..1b12d1a4212 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -266,6 +266,14 @@ export function splitLinesIncludeSeparators(str: string): string[] { return linesWithSeparators; } +export function indexOfPattern(str: string, re: RegExp) { + const match = re.exec(str); + if (match) { + return match.index; + } + return -1; +} + /** * Returns first index of the string that is not whitespace. * If string is empty or contains only whitespaces, returns -1 diff --git a/src/vs/base/common/ternarySearchTree.ts b/src/vs/base/common/ternarySearchTree.ts index 624b7c93327..fef1f8e1cf0 100644 --- a/src/vs/base/common/ternarySearchTree.ts +++ b/src/vs/base/common/ternarySearchTree.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { shuffle } from './arrays.js'; +import { assert } from './assert.js'; import { CharCode } from './charCode.js'; import { compare, compareIgnoreCase, compareSubstring, compareSubstringIgnoreCase } from './strings.js'; import { URI } from './uri.js'; @@ -264,11 +265,11 @@ abstract class Undef { class TernarySearchTreeNode { height: number = 1; segment!: string; - value: V | typeof Undef.Val | undefined; - key: K | undefined; - left: TernarySearchTreeNode | undefined; - mid: TernarySearchTreeNode | undefined; - right: TernarySearchTreeNode | undefined; + value: V | typeof Undef.Val | undefined = undefined; + key: K | undefined = undefined; + left: TernarySearchTreeNode | undefined = undefined; + mid: TernarySearchTreeNode | undefined = undefined; + right: TernarySearchTreeNode | undefined = undefined; isEmpty(): boolean { return !this.left && !this.mid && !this.right && this.value === undefined; @@ -563,13 +564,40 @@ export class TernarySearchTree { // full node // replace deleted-node with the min-node of the right branch. // If there is no true min-node leave things as they are - const min = this._min(node.right); + const stack2: typeof stack = [[Dir.Right, node]]; + const min = this._min(node.right, stack2); + if (min.key) { - const { key, value, segment } = min; - this._delete(min.key, false); - node.key = key; - node.value = value; - node.segment = segment; + + node.key = min.key; + node.value = min.value; + node.segment = min.segment; + + // remove NODE (inorder successor can only have right child) + const newChild = min.right; + if (stack2.length > 1) { + const [dir, parent] = stack2[stack2.length - 1]; + switch (dir) { + case Dir.Left: parent.left = newChild; break; + case Dir.Mid: assert(false); + case Dir.Right: assert(false); + } + } else { + node.right = newChild; + } + + // balance right branch and UPDATE parent pointer for stack + const newChild2 = this._balanceByStack(stack2)!; + if (stack.length > 0) { + const [dir, parent] = stack[stack.length - 1]; + switch (dir) { + case Dir.Left: parent.left = newChild2; break; + case Dir.Mid: parent.mid = newChild2; break; + case Dir.Right: parent.right = newChild2; break; + } + } else { + this._root = newChild2; + } } } else { @@ -589,6 +617,19 @@ export class TernarySearchTree { } // AVL balance + this._root = this._balanceByStack(stack) ?? this._root; + } + + private _min(node: TernarySearchTreeNode, stack: [Dir, TernarySearchTreeNode][]): TernarySearchTreeNode { + while (node.left) { + stack.push([Dir.Left, node]); + node = node.left; + } + return node; + } + + private _balanceByStack(stack: [Dir, TernarySearchTreeNode][]) { + for (let i = stack.length - 1; i >= 0; i--) { const node = stack[i][1]; @@ -631,16 +672,11 @@ export class TernarySearchTree { break; } } else { - this._root = stack[0][1]; + return stack[0][1]; } } - } - private _min(node: TernarySearchTreeNode): TernarySearchTreeNode { - while (node.left) { - node = node.left; - } - return node; + return undefined; } findSubstr(key: K): V | undefined { diff --git a/src/vs/base/common/worker/simpleWorker.ts b/src/vs/base/common/worker/webWorker.ts similarity index 77% rename from src/vs/base/common/worker/simpleWorker.ts rename to src/vs/base/common/worker/webWorker.ts index b04319bc9a3..666bd15aa03 100644 --- a/src/vs/base/common/worker/simpleWorker.ts +++ b/src/vs/base/common/worker/webWorker.ts @@ -7,33 +7,19 @@ import { CharCode } from '../charCode.js'; import { onUnexpectedError, transformErrorForSerialization } from '../errors.js'; import { Emitter, Event } from '../event.js'; import { Disposable, IDisposable } from '../lifecycle.js'; -import { AppResourcePath, FileAccess } from '../network.js'; import { isWeb } from '../platform.js'; import * as strings from '../strings.js'; -import { URI } from '../uri.js'; const DEFAULT_CHANNEL = 'default'; const INITIALIZE = '$initialize'; -export interface IWorker extends IDisposable { +export interface IWebWorker extends IDisposable { getId(): number; + onMessage: Event; + onError: Event; postMessage(message: Message, transfer: ArrayBuffer[]): void; } -export interface IWorkerCallback { - (message: Message): void; -} - -export interface IWorkerFactory { - create(modules: IWorkerDescriptor, callback: IWorkerCallback, onErrorCallback: (err: any) => void): IWorker; -} - -export interface IWorkerDescriptor { - readonly moduleId: string; - readonly esmModuleLocation: URI | undefined; - readonly label: string | undefined; -} - let webWorkerWarningLogged = false; export function logOnceWebWorkerWarning(err: any): void { if (!isWeb) { @@ -98,7 +84,7 @@ class UnsubscribeEventMessage { public readonly req: string ) { } } -type Message = RequestMessage | ReplyMessage | SubscribeEventMessage | EventMessage | UnsubscribeEventMessage; +export type Message = RequestMessage | ReplyMessage | SubscribeEventMessage | EventMessage | UnsubscribeEventMessage; interface IMessageReply { resolve: (value?: any) => void; @@ -111,7 +97,7 @@ interface IMessageHandler { handleEvent(channel: string, eventName: string, arg: any): Event; } -class SimpleWorkerProtocol { +class WebWorkerProtocol { private _workerId: number; private _lastSentReq: number; @@ -300,14 +286,14 @@ export type Proxied = { [K in keyof T]: T[K] extends (...args: infer A) => in : never }; -export interface IWorkerClient { - proxy: Proxied; +export interface IWebWorkerClient { + proxy: Proxied; dispose(): void; setChannel(channel: string, handler: T): void; getChannel(channel: string): Proxied; } -export interface IWorkerServer { +export interface IWebWorkerServer { setChannel(channel: string, handler: T): void; getChannel(channel: string): Proxied; } @@ -315,38 +301,30 @@ export interface IWorkerServer { /** * Main thread side */ -export class SimpleWorkerClient extends Disposable implements IWorkerClient { +export class WebWorkerClient extends Disposable implements IWebWorkerClient { - private readonly _worker: IWorker; + private readonly _worker: IWebWorker; private readonly _onModuleLoaded: Promise; - private readonly _protocol: SimpleWorkerProtocol; + private readonly _protocol: WebWorkerProtocol; public readonly proxy: Proxied; private readonly _localChannels: Map = new Map(); private readonly _remoteChannels: Map = new Map(); constructor( - workerFactory: IWorkerFactory, - workerDescriptor: IWorkerDescriptor, + worker: IWebWorker ) { super(); - this._worker = this._register(workerFactory.create( - { - moduleId: 'vs/base/common/worker/simpleWorker', - esmModuleLocation: workerDescriptor.esmModuleLocation, - label: workerDescriptor.label - }, - (msg: Message) => { - this._protocol.handleMessage(msg); - }, - (err: any) => { - // in Firefox, web workers fail lazily :( - // we will reject the proxy - onUnexpectedError(err); - } - )); + this._worker = worker; + this._register(this._worker.onMessage((msg) => { + this._protocol.handleMessage(msg); + })); + this._register(this._worker.onError((err) => { + logOnceWebWorkerWarning(err); + onUnexpectedError(err); + })); - this._protocol = new SimpleWorkerProtocol({ + this._protocol = new WebWorkerProtocol({ sendMessage: (msg: any, transfer: ArrayBuffer[]): void => { this._worker.postMessage(msg, transfer); }, @@ -359,28 +337,14 @@ export class SimpleWorkerClient extends Disposable implements }); this._protocol.setWorkerId(this._worker.getId()); - // Gather loader configuration - let loaderConfiguration: any = null; - - const globalRequire: { getConfig?(): object } | undefined = (globalThis as any).require; - if (typeof globalRequire !== 'undefined' && typeof globalRequire.getConfig === 'function') { - // Get the configuration from the Monaco AMD Loader - loaderConfiguration = globalRequire.getConfig(); - } else if (typeof (globalThis as any).requirejs !== 'undefined') { - // Get the configuration from requirejs - loaderConfiguration = (globalThis as any).requirejs.s.contexts._.config; - } - // Send initialize message this._onModuleLoaded = this._protocol.sendMessage(DEFAULT_CHANNEL, INITIALIZE, [ this._worker.getId(), - JSON.parse(JSON.stringify(loaderConfiguration)), - workerDescriptor.moduleId, ]); this.proxy = this._protocol.createProxyToRemoteChannel(DEFAULT_CHANNEL, async () => { await this._onModuleLoaded; }); this._onModuleLoaded.catch((e) => { - this._onError('Worker failed to load ' + workerDescriptor.moduleId, e); + this._onError('Worker failed to load ', e); }); } @@ -450,36 +414,34 @@ function propertyIsDynamicEvent(name: string): boolean { return /^onDynamic/.test(name) && strings.isUpperAsciiLetter(name.charCodeAt(9)); } -export interface IRequestHandler { +export interface IWebWorkerServerRequestHandler { _requestHandlerBrand: any; [prop: string]: any; } -export interface IRequestHandlerFactory { - (workerServer: IWorkerServer): IRequestHandler; +export interface IWebWorkerServerRequestHandlerFactory { + (workerServer: IWebWorkerServer): T; } /** * Worker side */ -export class SimpleWorkerServer implements IWorkerServer { +export class WebWorkerServer implements IWebWorkerServer { - private _requestHandlerFactory: IRequestHandlerFactory | null; - private _requestHandler: IRequestHandler | null; - private _protocol: SimpleWorkerProtocol; + public readonly requestHandler: T; + private _protocol: WebWorkerProtocol; private readonly _localChannels: Map = new Map(); private readonly _remoteChannels: Map = new Map(); - constructor(postMessage: (msg: Message, transfer?: ArrayBuffer[]) => void, requestHandlerFactory: IRequestHandlerFactory | null) { - this._requestHandlerFactory = requestHandlerFactory; - this._requestHandler = null; - this._protocol = new SimpleWorkerProtocol({ + constructor(postMessage: (msg: Message, transfer?: ArrayBuffer[]) => void, requestHandlerFactory: IWebWorkerServerRequestHandlerFactory) { + this._protocol = new WebWorkerProtocol({ sendMessage: (msg: any, transfer: ArrayBuffer[]): void => { postMessage(msg, transfer); }, handleMessage: (channel: string, method: string, args: any[]): Promise => this._handleMessage(channel, method, args), handleEvent: (channel: string, eventName: string, arg: any): Event => this._handleEvent(channel, eventName, arg) }); + this.requestHandler = requestHandlerFactory(this); } public onmessage(msg: any): void { @@ -488,10 +450,10 @@ export class SimpleWorkerServer implements IWorkerServer { private _handleMessage(channel: string, method: string, args: any[]): Promise { if (channel === DEFAULT_CHANNEL && method === INITIALIZE) { - return this.initialize(args[0], args[1], args[2]); + return this.initialize(args[0]); } - const requestHandler: object | null | undefined = (channel === DEFAULT_CHANNEL ? this._requestHandler : this._localChannels.get(channel)); + const requestHandler: object | null | undefined = (channel === DEFAULT_CHANNEL ? this.requestHandler : this._localChannels.get(channel)); if (!requestHandler) { return Promise.reject(new Error(`Missing channel ${channel} on worker thread`)); } @@ -507,7 +469,7 @@ export class SimpleWorkerServer implements IWorkerServer { } private _handleEvent(channel: string, eventName: string, arg: any): Event { - const requestHandler: object | null | undefined = (channel === DEFAULT_CHANNEL ? this._requestHandler : this._localChannels.get(channel)); + const requestHandler: object | null | undefined = (channel === DEFAULT_CHANNEL ? this.requestHandler : this._localChannels.get(channel)); if (!requestHandler) { throw new Error(`Missing channel ${channel} on worker thread`); } @@ -540,50 +502,7 @@ export class SimpleWorkerServer implements IWorkerServer { return this._remoteChannels.get(channel) as Proxied; } - private async initialize(workerId: number, loaderConfig: any, moduleId: string): Promise { + private async initialize(workerId: number): Promise { this._protocol.setWorkerId(workerId); - - if (this._requestHandlerFactory) { - // static request handler - this._requestHandler = this._requestHandlerFactory(this); - return; - } - - if (loaderConfig) { - // Remove 'baseUrl', handling it is beyond scope for now - if (typeof loaderConfig.baseUrl !== 'undefined') { - delete loaderConfig['baseUrl']; - } - if (typeof loaderConfig.paths !== 'undefined') { - if (typeof loaderConfig.paths.vs !== 'undefined') { - delete loaderConfig.paths['vs']; - } - } - if (typeof loaderConfig.trustedTypesPolicy !== 'undefined') { - // don't use, it has been destroyed during serialize - delete loaderConfig['trustedTypesPolicy']; - } - - // Since this is in a web worker, enable catching errors - loaderConfig.catchError = true; - (globalThis as any).require.config(loaderConfig); - } - - const url = FileAccess.asBrowserUri(`${moduleId}.js` as AppResourcePath).toString(true); - return import(`${url}`).then((module: { create: IRequestHandlerFactory }) => { - this._requestHandler = module.create(this); - - if (!this._requestHandler) { - throw new Error(`No RequestHandler!`); - } - }); } } - -/** - * Defines the worker entry point. Must be exported and named `create`. - * @skipMangle - */ -export function create(postMessage: (msg: Message, transfer?: ArrayBuffer[]) => void): SimpleWorkerServer { - return new SimpleWorkerServer(postMessage, null); -} diff --git a/src/vs/base/common/worker/simpleWorkerBootstrap.ts b/src/vs/base/common/worker/webWorkerBootstrap.ts similarity index 64% rename from src/vs/base/common/worker/simpleWorkerBootstrap.ts rename to src/vs/base/common/worker/webWorkerBootstrap.ts index f6b544e4ba5..dce9e79b757 100644 --- a/src/vs/base/common/worker/simpleWorkerBootstrap.ts +++ b/src/vs/base/common/worker/webWorkerBootstrap.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IRequestHandlerFactory, SimpleWorkerServer } from './simpleWorker.js'; +import { IWebWorkerServerRequestHandler, IWebWorkerServerRequestHandlerFactory, WebWorkerServer } from './webWorker.js'; type MessageEvent = { data: any; @@ -16,23 +16,25 @@ declare const globalThis: { let initialized = false; -function initialize(factory: IRequestHandlerFactory) { +export function initialize(factory: IWebWorkerServerRequestHandlerFactory) { if (initialized) { - return; + throw new Error('WebWorker already initialized!'); } initialized = true; - const simpleWorker = new SimpleWorkerServer( + const webWorkerServer = new WebWorkerServer( msg => globalThis.postMessage(msg), (workerServer) => factory(workerServer) ); globalThis.onmessage = (e: MessageEvent) => { - simpleWorker.onmessage(e.data); + webWorkerServer.onmessage(e.data); }; + + return webWorkerServer; } -export function bootstrapSimpleWorker(factory: IRequestHandlerFactory) { +export function bootstrapWebWorker(factory: IWebWorkerServerRequestHandlerFactory) { globalThis.onmessage = (_e: MessageEvent) => { // Ignore first message in this case and initialize if not yet initialized if (!initialized) { diff --git a/src/vs/base/node/nls.ts b/src/vs/base/node/nls.ts index c0784d05155..d47ef067a7d 100644 --- a/src/vs/base/node/nls.ts +++ b/src/vs/base/node/nls.ts @@ -123,9 +123,9 @@ export async function resolveNLSConfiguration({ userLocale, osLocale, userDataPa // ^moduleId ^nlsKeys ^moduleId ^nlsKey ^nlsValue = await Promise.all([ fs.promises.mkdir(commitLanguagePackCachePath, { recursive: true }), - JSON.parse(await fs.promises.readFile(path.join(nlsMetadataPath, 'nls.keys.json'), 'utf-8')), - JSON.parse(await fs.promises.readFile(path.join(nlsMetadataPath, 'nls.messages.json'), 'utf-8')), - JSON.parse(await fs.promises.readFile(mainLanguagePackPath, 'utf-8')) + fs.promises.readFile(path.join(nlsMetadataPath, 'nls.keys.json'), 'utf-8').then(content => JSON.parse(content)), + fs.promises.readFile(path.join(nlsMetadataPath, 'nls.messages.json'), 'utf-8').then(content => JSON.parse(content)), + fs.promises.readFile(mainLanguagePackPath, 'utf-8').then(content => JSON.parse(content)), ]); const nlsResult: string[] = []; diff --git a/src/vs/base/node/processes.ts b/src/vs/base/node/processes.ts index ea1cafb9748..dbb321d009d 100644 --- a/src/vs/base/node/processes.ts +++ b/src/vs/base/node/processes.ts @@ -12,7 +12,7 @@ import * as process from '../common/process.js'; import { CommandOptions, ForkOptions, Source, SuccessData, TerminateResponse, TerminateResponseCode } from '../common/processes.js'; import * as Types from '../common/types.js'; import * as pfs from './pfs.js'; -export { type CommandOptions, type ForkOptions, type SuccessData, Source, type TerminateResponse, TerminateResponseCode }; +export { Source, TerminateResponseCode, type CommandOptions, type ForkOptions, type SuccessData, type TerminateResponse }; export type ValueCallback = (value: T | Promise) => void; export type ErrorCallback = (error?: any) => void; @@ -116,19 +116,23 @@ export async function findExecutable(command: string, cwd?: string, paths?: stri } else { fullPath = path.join(cwd, pathEntry, command); } + if (Platform.isWindows) { + const pathExt = getCaseInsensitive(env, 'PATHEXT') as string || '.COM;.EXE;.BAT;.CMD'; + const pathExtsFound = pathExt.split(';').map(async ext => { + const withExtension = fullPath + ext; + return await fileExists(withExtension) ? withExtension : undefined; + }); + for (const foundPromise of pathExtsFound) { + const found = await foundPromise; + if (found) { + return found; + } + } + } + if (await fileExists(fullPath)) { return fullPath; } - if (Platform.isWindows) { - let withExtension = fullPath + '.com'; - if (await fileExists(withExtension)) { - return withExtension; - } - withExtension = fullPath + '.exe'; - if (await fileExists(withExtension)) { - return withExtension; - } - } } const fullPath = path.join(cwd, command); return await fileExists(fullPath) ? fullPath : undefined; diff --git a/src/vs/base/test/common/async.test.ts b/src/vs/base/test/common/async.test.ts index c33c51aa0ff..5f34212928a 100644 --- a/src/vs/base/test/common/async.test.ts +++ b/src/vs/base/test/common/async.test.ts @@ -48,6 +48,22 @@ suite('Async', () => { return result; }); + test('cancel disposes result', function () { + + const store = new DisposableStore(); + + const promise = async.createCancelablePromise(async token => { + return store; + }); + promise.then(_ => assert.ok(false), err => { + + assert.ok(isCancellationError(err)); + assert.ok(store.isDisposed); + }); + + promise.cancel(); + }); + // Cancelling a sync cancelable promise will fire the cancelled token. // Also, every `then` callback runs in another execution frame. test('execution order (sync)', function () { diff --git a/src/vs/base/test/common/envfile.test.ts b/src/vs/base/test/common/envfile.test.ts new file mode 100644 index 00000000000..753a8c6b534 --- /dev/null +++ b/src/vs/base/test/common/envfile.test.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { parseEnvFile } from '../../common/envfile.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; +import * as assert from 'assert'; + +/* +Test cases from https://github.com/motdotla/dotenv/blob/master/tests/.env + + Copyright (c) 2015, Scott Motte + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +const example = ` +BASIC=basic + +# previous line intentionally left blank +AFTER_LINE=after_line +EMPTY= +EMPTY_SINGLE_QUOTES='' +EMPTY_DOUBLE_QUOTES="" +EMPTY_BACKTICKS=\`\` +SINGLE_QUOTES='single_quotes' +SINGLE_QUOTES_SPACED=' single quotes ' +DOUBLE_QUOTES="double_quotes" +DOUBLE_QUOTES_SPACED=" double quotes " +DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes' +DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET="{ port: $MONGOLAB_PORT}" +SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes" +BACKTICKS_INSIDE_SINGLE='\`backticks\` work inside single quotes' +BACKTICKS_INSIDE_DOUBLE="\`backticks\` work inside double quotes" +BACKTICKS=\`backticks\` +BACKTICKS_SPACED=\` backticks \` +DOUBLE_QUOTES_INSIDE_BACKTICKS=\`double "quotes" work inside backticks\` +SINGLE_QUOTES_INSIDE_BACKTICKS=\`single 'quotes' work inside backticks\` +DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS=\`double "quotes" and single 'quotes' work inside backticks\` +EXPAND_NEWLINES="expand\\nnew\\nlines" +DONT_EXPAND_UNQUOTED=dontexpand\\nnewlines +DONT_EXPAND_SQUOTED='dontexpand\\nnewlines' +# COMMENTS=work +INLINE_COMMENTS=inline comments # work #very #well +INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work +INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work +INLINE_COMMENTS_BACKTICKS=\`inline comments outside of #backticks\` # work +INLINE_COMMENTS_SPACE=inline comments start with a#number sign. no space required. +EQUAL_SIGNS=equals== +RETAIN_INNER_QUOTES={"foo": "bar"} +RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' +RETAIN_INNER_QUOTES_AS_BACKTICKS=\`{"foo": "bar's"}\` +TRIM_SPACE_FROM_UNQUOTED= some spaced out string +USERNAME=therealnerdybeast@example.tld + SPACED_KEY = parsed +`; + +suite('parseEnvFile', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('parses', () => { + const parsed = parseEnvFile(example); + assert.strictEqual(parsed.get('BASIC'), 'basic'); + assert.strictEqual(parsed.get('AFTER_LINE'), 'after_line'); + assert.strictEqual(parsed.get('EMPTY'), ''); + assert.strictEqual(parsed.get('EMPTY_SINGLE_QUOTES'), ''); + assert.strictEqual(parsed.get('EMPTY_DOUBLE_QUOTES'), ''); + assert.strictEqual(parsed.get('EMPTY_BACKTICKS'), ''); + assert.strictEqual(parsed.get('SINGLE_QUOTES'), 'single_quotes'); + assert.strictEqual(parsed.get('SINGLE_QUOTES_SPACED'), ' single quotes '); + assert.strictEqual(parsed.get('DOUBLE_QUOTES'), 'double_quotes'); + assert.strictEqual(parsed.get('DOUBLE_QUOTES_SPACED'), ' double quotes '); + assert.strictEqual(parsed.get('DOUBLE_QUOTES_INSIDE_SINGLE'), 'double "quotes" work inside single quotes'); + assert.strictEqual(parsed.get('DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET'), '{ port: $MONGOLAB_PORT}'); + assert.strictEqual(parsed.get('SINGLE_QUOTES_INSIDE_DOUBLE'), "single 'quotes' work inside double quotes"); + assert.strictEqual(parsed.get('BACKTICKS_INSIDE_SINGLE'), '`backticks` work inside single quotes'); + assert.strictEqual(parsed.get('BACKTICKS_INSIDE_DOUBLE'), '`backticks` work inside double quotes'); + assert.strictEqual(parsed.get('BACKTICKS'), 'backticks'); + assert.strictEqual(parsed.get('BACKTICKS_SPACED'), ' backticks '); + assert.strictEqual(parsed.get('DOUBLE_QUOTES_INSIDE_BACKTICKS'), 'double "quotes" work inside backticks'); + assert.strictEqual(parsed.get('SINGLE_QUOTES_INSIDE_BACKTICKS'), "single 'quotes' work inside backticks"); + assert.strictEqual(parsed.get('DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS'), "double \"quotes\" and single 'quotes' work inside backticks"); + assert.strictEqual(parsed.get('EXPAND_NEWLINES'), 'expand\nnew\nlines'); + assert.strictEqual(parsed.get('DONT_EXPAND_UNQUOTED'), 'dontexpand\\nnewlines'); + assert.strictEqual(parsed.get('DONT_EXPAND_SQUOTED'), 'dontexpand\\nnewlines'); + assert.strictEqual(parsed.get('COMMENTS'), undefined); + assert.strictEqual(parsed.get('INLINE_COMMENTS'), 'inline comments'); + assert.strictEqual(parsed.get('INLINE_COMMENTS_SINGLE_QUOTES'), 'inline comments outside of #singlequotes'); + assert.strictEqual(parsed.get('INLINE_COMMENTS_DOUBLE_QUOTES'), 'inline comments outside of #doublequotes'); + assert.strictEqual(parsed.get('INLINE_COMMENTS_BACKTICKS'), 'inline comments outside of #backticks'); + assert.strictEqual(parsed.get('INLINE_COMMENTS_SPACE'), 'inline comments start with a'); + assert.strictEqual(parsed.get('EQUAL_SIGNS'), 'equals=='); + assert.strictEqual(parsed.get('RETAIN_INNER_QUOTES'), '{"foo": "bar"}'); + assert.strictEqual(parsed.get('RETAIN_INNER_QUOTES_AS_STRING'), '{"foo": "bar"}'); + assert.strictEqual(parsed.get('RETAIN_INNER_QUOTES_AS_BACKTICKS'), '{"foo": "bar\'s"}'); + assert.strictEqual(parsed.get('TRIM_SPACE_FROM_UNQUOTED'), 'some spaced out string'); + assert.strictEqual(parsed.get('USERNAME'), 'therealnerdybeast@example.tld'); + assert.strictEqual(parsed.get('SPACED_KEY'), 'parsed'); + const payload = parseEnvFile('BUFFER=true'); + assert.strictEqual(payload.get('BUFFER'), 'true'); + const expectedPayload = Object.entries({ SERVER: 'localhost', PASSWORD: 'password', DB: 'tests' }); + const RPayload = parseEnvFile('SERVER=localhost\rPASSWORD=password\rDB=tests\r'); + assert.deepStrictEqual([...RPayload], expectedPayload); + const NPayload = parseEnvFile('SERVER=localhost\nPASSWORD=password\nDB=tests\n'); + assert.deepStrictEqual([...NPayload], expectedPayload); + const RNPayload = parseEnvFile('SERVER=localhost\r\nPASSWORD=password\r\nDB=tests\r\n'); + assert.deepStrictEqual([...RNPayload], expectedPayload); + }); +}); diff --git a/src/vs/base/test/common/glob.test.ts b/src/vs/base/test/common/glob.test.ts index 3a57ae72492..74fbdf6f65d 100644 --- a/src/vs/base/test/common/glob.test.ts +++ b/src/vs/base/test/common/glob.test.ts @@ -1158,5 +1158,15 @@ suite('Glob', () => { assert.ok(!glob.patternsEquals(['a'], undefined)); }); + test('isEmptyPattern', () => { + assert.ok(glob.isEmptyPattern(glob.parse(''))); + assert.ok(glob.isEmptyPattern(glob.parse(undefined!))); + assert.ok(glob.isEmptyPattern(glob.parse(null!))); + + assert.ok(glob.isEmptyPattern(glob.parse({}))); + assert.ok(glob.isEmptyPattern(glob.parse({ '': true }))); + assert.ok(glob.isEmptyPattern(glob.parse({ '**/*.js': false }))); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/base/test/common/markdownString.test.ts b/src/vs/base/test/common/markdownString.test.ts index 28fd291fb14..dcaa49b079f 100644 --- a/src/vs/base/test/common/markdownString.test.ts +++ b/src/vs/base/test/common/markdownString.test.ts @@ -87,6 +87,13 @@ suite('MarkdownString', () => { assert.deepStrictEqual(mds.uris, dto.uris); }); + test('lift returns new instance', () => { + const instance = new MarkdownString('hello'); + const mds2 = MarkdownString.lift(instance).appendText('world'); + assert.strictEqual(mds2.value, 'helloworld'); + assert.strictEqual(instance.value, 'hello'); + }); + suite('appendCodeBlock', () => { function assertCodeBlock(lang: string, code: string, result: string) { const mds = new MarkdownString(); @@ -183,6 +190,5 @@ suite('MarkdownString', () => { }); }); - }); }); diff --git a/src/vs/base/test/common/numbers.test.ts b/src/vs/base/test/common/numbers.test.ts index 7916bf6710d..94c07090585 100644 --- a/src/vs/base/test/common/numbers.test.ts +++ b/src/vs/base/test/common/numbers.test.ts @@ -54,8 +54,8 @@ suite('randomInt', () => { }); } - test(`should include min and max`, async () => { - let iterations = 100; + test('should include min and max', async () => { + let iterations = 125; const results = []; while (iterations-- > 0) { results.push(randomInt(max, min)); @@ -74,9 +74,9 @@ suite('randomInt', () => { }; suite('positive numbers', () => { - testRandomIntUtil(5, 2, 'max: 5, min: 2'); - testRandomIntUtil(5, 0, 'max: 5, min: 0'); - testRandomIntUtil(5, undefined, 'max: 5, min: undefined'); + testRandomIntUtil(4, 2, 'max: 4, min: 2'); + testRandomIntUtil(4, 0, 'max: 4, min: 0'); + testRandomIntUtil(4, undefined, 'max: 4, min: undefined'); testRandomIntUtil(1, 0, 'max: 0, min: 0'); }); @@ -137,14 +137,14 @@ suite('randomInt', () => { test('should throw if "min" is > "max" #5', () => { assert.throws(() => { - randomInt(-5, 0); - }, `"max"(-5) param should be greater than "min"(0)."`); + randomInt(-4, 0); + }, `"max"(-4) param should be greater than "min"(0)."`); }); test('should throw if "min" is > "max" #6', () => { assert.throws(() => { - randomInt(-5); - }, `"max"(-5) param should be greater than "min"(0)."`); + randomInt(-4); + }, `"max"(-4) param should be greater than "min"(0)."`); }); test('should throw if "max" is `NaN`', () => { @@ -155,7 +155,7 @@ suite('randomInt', () => { test('should throw if "min" is `NaN`', () => { assert.throws(() => { - randomInt(5, NaN); + randomInt(4, NaN); }, `"min" param is not a number."`); }); diff --git a/src/vs/base/test/common/observable.test.ts b/src/vs/base/test/common/observable.test.ts index 26b0517c740..6b640a275bb 100644 --- a/src/vs/base/test/common/observable.test.ts +++ b/src/vs/base/test/common/observable.test.ts @@ -7,9 +7,12 @@ import assert from 'assert'; import { setUnexpectedErrorHandler } from '../../common/errors.js'; import { Emitter, Event } from '../../common/event.js'; import { DisposableStore } from '../../common/lifecycle.js'; -import { autorun, autorunHandleChanges, derived, derivedDisposable, IObservable, IObserver, ISettableObservable, ITransaction, keepObserved, observableFromEvent, observableSignal, observableValue, transaction, waitForState } from '../../common/observable.js'; -import { BaseObservable, IObservableWithChange } from '../../common/observableInternal/base.js'; +import { IDerivedReader, IObservableWithChange, autorun, autorunHandleChanges, autorunWithStoreHandleChanges, derived, derivedDisposable, IObservable, IObserver, ISettableObservable, ITransaction, keepObserved, observableFromEvent, observableSignal, observableValue, recordChanges, transaction, waitForState } from '../../common/observable.js'; +// eslint-disable-next-line local/code-no-deep-import-of-internal +import { BaseObservable } from '../../common/observableInternal/base.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; +// eslint-disable-next-line local/code-no-deep-import-of-internal +import { observableReducer } from '../../common/observableInternal/reducer.js'; suite('observables', () => { const ds = ensureNoDisposablesAreLeakedInTestSuite(); @@ -312,15 +315,17 @@ suite('observables', () => { const signal = observableSignal<{ msg: string }>('signal'); const disposable = autorunHandleChanges({ - // The change summary is used to collect the changes - createEmptyChangeSummary: () => ({ msgs: [] as string[] }), - handleChange(context, changeSummary) { - if (context.didChange(signal)) { - // We just push the changes into an array - changeSummary.msgs.push(context.change.msg); - } - return true; // We want to handle the change - }, + changeTracker: { + // The change summary is used to collect the changes + createChangeSummary: () => ({ msgs: [] as string[] }), + handleChange(context, changeSummary) { + if (context.didChange(signal)) { + // We just push the changes into an array + changeSummary.msgs.push(context.change.msg); + } + return true; // We want to handle the change + }, + } }, (reader, changeSummary) => { // When handling the change, make sure to read the signal! signal.read(reader); @@ -1527,6 +1532,87 @@ suite('observables', () => { disp.dispose(); }); }); + + suite('observableReducer', () => { + test('main', () => { + const store = new DisposableStore(); + const log = new Log(); + + const myObservable1 = observableValue('myObservable1', 5); + const myObservable2 = observableValue('myObservable2', 9); + + const sum = observableReducer(this, { + initial: () => { + log.log('createInitial'); + return myObservable1.get() + myObservable2.get(); + }, + disposeFinal: (values) => { + log.log(`disposeFinal ${values}`); + }, + changeTracker: recordChanges({ myObservable1, myObservable2 }), + update: (reader: IDerivedReader, previousValue, changes) => { + log.log(`update ${JSON.stringify(changes)}`); + let delta = 0; + for (const change of changes.changes) { + delta += change.change; + } + + reader.reportChange(delta); + const resultValue = previousValue + delta; + log.log(`update -> ${resultValue}`); + return resultValue; + } + }); + + assert.deepStrictEqual(log.getAndClearEntries(), ([])); + + store.add(autorunWithStoreHandleChanges({ + changeTracker: recordChanges({ sum }) + }, (_reader, changes) => { + log.log(`autorun ${JSON.stringify(changes)}`); + })); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + "createInitial", + 'update {"changes":[],"myObservable1":5,"myObservable2":9}', + "update -> 14", + 'autorun {"changes":[],"sum":14}', + ]); + + transaction(tx => { + myObservable1.set(myObservable1.get() + 1, tx, 1); + myObservable2.set(myObservable2.get() + 3, tx, 3); + }); + + assert.deepStrictEqual(log.getAndClearEntries(), ([ + "update {\"changes\":[{\"key\":\"myObservable1\",\"change\":1},{\"key\":\"myObservable2\",\"change\":3}],\"myObservable1\":6,\"myObservable2\":12}", + "update -> 18", + "autorun {\"changes\":[{\"key\":\"sum\",\"change\":4}],\"sum\":18}" + ])); + + transaction(tx => { + myObservable1.set(myObservable1.get() + 1, tx, 1); + const s = sum.get(); + log.log(`sum.get() ${s}`); + myObservable2.set(myObservable2.get() + 3, tx, 3); + }); + + assert.deepStrictEqual(log.getAndClearEntries(), ([ + "update {\"changes\":[{\"key\":\"myObservable1\",\"change\":1}],\"myObservable1\":7,\"myObservable2\":12}", + "update -> 19", + "sum.get() 19", + "update {\"changes\":[{\"key\":\"myObservable2\",\"change\":3}],\"myObservable1\":7,\"myObservable2\":15}", + "update -> 22", + "autorun {\"changes\":[{\"key\":\"sum\",\"change\":1}],\"sum\":22}" + ])); + + store.dispose(); + + assert.deepStrictEqual(log.getAndClearEntries(), ([ + "disposeFinal 22" + ])); + }); + }); }); export class LoggingObserver implements IObserver { diff --git a/src/vs/base/test/common/sseParser.test.ts b/src/vs/base/test/common/sseParser.test.ts new file mode 100644 index 00000000000..c87664b6fb1 --- /dev/null +++ b/src/vs/base/test/common/sseParser.test.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ISSEEvent, SSEParser } from '../../common/sseParser.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; + +// Helper function to convert string to Uint8Array for testing +function toUint8Array(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +suite('SSEParser', () => { + let receivedEvents: ISSEEvent[]; + let parser: SSEParser; + + ensureNoDisposablesAreLeakedInTestSuite(); + + setup(() => { + receivedEvents = []; + parser = new SSEParser((event) => receivedEvents.push(event)); + }); + test('handles basic events', () => { + parser.feed(toUint8Array('data: hello world\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].type, 'message'); + assert.strictEqual(receivedEvents[0].data, 'hello world'); + }); + test('handles events with multiple data fields', () => { + parser.feed(toUint8Array('data: first line\ndata: second line\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, 'first line\nsecond line'); + }); + test('handles events with explicit event type', () => { + parser.feed(toUint8Array('event: custom\ndata: hello world\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].type, 'custom'); + assert.strictEqual(receivedEvents[0].data, 'hello world'); + }); + test('handles events with explicit event type (CRLF)', () => { + parser.feed(toUint8Array('event: custom\r\ndata: hello world\r\n\r\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].type, 'custom'); + assert.strictEqual(receivedEvents[0].data, 'hello world'); + }); + test('stream processing chunks', () => { + for (const lf of ['\n', '\r\n', '\r']) { + const message = toUint8Array(`event: custom${lf}data: hello world${lf}${lf}event: custom2${lf}data: hello world2${lf}${lf}`); + for (let chunkSize = 1; chunkSize < 5; chunkSize++) { + receivedEvents.length = 0; + + for (let i = 0; i < message.length; i += chunkSize) { + const chunk = message.slice(i, i + chunkSize); + parser.feed(chunk); + } + + assert.deepStrictEqual(receivedEvents, [ + { type: 'custom', data: 'hello world' }, + { type: 'custom2', data: 'hello world2' } + ], `Failed for chunk size ${chunkSize} and line ending ${JSON.stringify(lf)}`); + } + } + }); + test('handles events with ID', () => { + parser.feed(toUint8Array('event: custom\ndata: hello\nid: 123\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].type, 'custom'); + assert.strictEqual(receivedEvents[0].data, 'hello'); + assert.strictEqual(receivedEvents[0].id, '123'); + assert.strictEqual(parser.getLastEventId(), '123'); + }); + + test('ignores comments', () => { + parser.feed(toUint8Array('event: custom\n:this is a comment\ndata: hello\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, 'hello'); + }); + + test('handles retry field', () => { + parser.feed(toUint8Array('retry: 5000\ndata: hello\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, 'hello'); + assert.strictEqual(receivedEvents[0].retry, 5000); + assert.strictEqual(parser.getReconnectionTime(), 5000); + }); + test('handles invalid retry field', () => { + parser.feed(toUint8Array('retry: invalid\ndata: hello\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, 'hello'); + assert.strictEqual(receivedEvents[0].retry, undefined); + assert.strictEqual(parser.getReconnectionTime(), undefined); + }); + + test('ignores fields with NULL character in ID', () => { + parser.feed(toUint8Array('id: 12\0 3\ndata: hello\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].id, undefined); + assert.strictEqual(parser.getLastEventId(), undefined); + }); + + test('handles fields with no value', () => { + parser.feed(toUint8Array('data\nid\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, ''); + assert.strictEqual(receivedEvents[0].id, ''); + }); + test('handles fields with space after colon', () => { + parser.feed(toUint8Array('data: hello\nevent: custom\nid: 123\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, 'hello'); + assert.strictEqual(receivedEvents[0].type, 'custom'); + assert.strictEqual(receivedEvents[0].id, '123'); + }); + + test('handles different line endings (LF)', () => { + parser.feed(toUint8Array('data: hello\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, 'hello'); + }); + + test('handles different line endings (CR)', () => { + parser.feed(toUint8Array('data: hello\r\r')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, 'hello'); + }); + + test('handles different line endings (CRLF)', () => { + parser.feed(toUint8Array('data: hello\r\n\r\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, 'hello'); + }); + test('handles empty data with blank line', () => { + parser.feed(toUint8Array('data:\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, ''); + }); + + test('ignores events with no data after blank line', () => { + parser.feed(toUint8Array('event: custom\n\n')); + + assert.strictEqual(receivedEvents.length, 0); + }); + + test('supports chunked data', () => { + parser.feed(toUint8Array('event: cus')); + parser.feed(toUint8Array('tom\nda')); + parser.feed(toUint8Array('ta: hello\n')); + parser.feed(toUint8Array('\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].type, 'custom'); + assert.strictEqual(receivedEvents[0].data, 'hello'); + }); + + test('supports spec example', () => { + // Example from the spec + parser.feed(toUint8Array(':This is a comment\ndata: first event\nid: 1\n\n')); + parser.feed(toUint8Array('data:second event\nid\n\n')); + parser.feed(toUint8Array('data: third event\n\n')); + + assert.strictEqual(receivedEvents.length, 3); + assert.strictEqual(receivedEvents[0].data, 'first event'); + assert.strictEqual(receivedEvents[0].id, '1'); + assert.strictEqual(receivedEvents[1].data, 'second event'); + assert.strictEqual(receivedEvents[1].id, ''); + assert.strictEqual(receivedEvents[2].data, ' third event'); + }); + + test('resets correctly', () => { + parser.feed(toUint8Array('data: hello\n')); + parser.reset(); + parser.feed(toUint8Array('data: world\n\n')); + + assert.strictEqual(receivedEvents.length, 1); + assert.strictEqual(receivedEvents[0].data, 'world'); + }); +}); diff --git a/src/vs/base/test/common/ternarySearchtree.test.ts b/src/vs/base/test/common/ternarySearchtree.test.ts index df36727e2d1..290ee4b4966 100644 --- a/src/vs/base/test/common/ternarySearchtree.test.ts +++ b/src/vs/base/test/common/ternarySearchtree.test.ts @@ -573,6 +573,43 @@ suite('Ternary Search Tree', () => { } }); + test('https://github.com/microsoft/vscode/issues/227147', function () { + + const raw = `fake-fs:CAOnRvUuxO,fake-fs:1qcbfq54rg,fake-fs:UtDstYUQ56,fake-fs:d5ktqDysll,fake-fs:w5NSAKA4Ch,fake-fs:QcIIIY6WHX,fake-fs:WCedQu9Ogd,fake-fs:cKUC5LunBr,fake-fs:XrIIYjI3HB,fake-fs:xgTkoneFzF,fake-fs:QYkCVx2nYC,fake-fs:ePrIDEKEpJ,fake-fs:nrOPYCW81a,fake-fs:MQbkFLcDsA,fake-fs:wXG8YiOrBI,fake-fs:4tHTWi240D,fake-fs:5uQWjgZGGJ,fake-fs:famP6pZXyx,fake-fs:aB9sUhwP1J,fake-fs:DlS0CssyhG,fake-fs:9vK2k3rL2V,fake-fs:iqWeu7zF6t,fake-fs:8vC6bQX2WH,fake-fs:nFILXMQTRg,fake-fs:miiV72aajE,fake-fs:9VRbqvaw0q,fake-fs:WnEHS1arfZ,fake-fs:Fco75PJ5pM,fake-fs:6CsEpoZ7VW,fake-fs:B2PrCtDpWu,fake-fs:y8Hi94Oekg,fake-fs:wyEjPNa5lo,fake-fs:zw1Ljv0erc,fake-fs:y4KWPUOMx0,fake-fs:1basrPTlTp,fake-fs:5iErr4YM34,fake-fs:Q2TQaujh8Q,fake-fs:QxcYzNNxZw,fake-fs:3QUDHjU55a,fake-fs:23ymf9ggMV,fake-fs:qQhuKFdy29,fake-fs:JuwmxA33oJ,fake-fs:NQeUyfMNUo,fake-fs:2Vo3eR1jxM,fake-fs:NzUXQidwel,fake-fs:aESYKGPxIx,fake-fs:mxLdeJartN,fake-fs:PhSd2xLwVe,fake-fs:9nmWjUUMRz,fake-fs:Wc6a4RsGhn,fake-fs:5a0AlFHALQ,fake-fs:Q93jnNZBxJ,fake-fs:4CuVkbfPSG,fake-fs:mdFlJ7WQva,fake-fs:fgVsaRm1KG,fake-fs:P7UXWiRJYj,fake-fs:q6nz5Q9BEW,fake-fs:1UZmGkvNTn,fake-fs:AKY8cnUQFl,fake-fs:RezYuPU7FD,fake-fs:5zaYc72Bit,fake-fs:yh8FTxFfQq,fake-fs:ayNPgEuc2q,fake-fs:EdOb27cRhF,fake-fs:h4c2uNyI4l,fake-fs:BhzOLNL4JO,fake-fs:HVPTdAMWpS,fake-fs:7K7IlacaZe,fake-fs:iUKJonC5eq,fake-fs:Y9E3NX3eJD,fake-fs:66h80uK32I,fake-fs:gFXpry1Y09,fake-fs:qOqvvXPcu4,fake-fs:UbbLn2NFSJ,fake-fs:TzJ07HsAGz,fake-fs:nQngmvgx4m,fake-fs:6bZQCR8epb,fake-fs:xb3SJKX1bi,fake-fs:GF3DPK4zDj,fake-fs:HmxgAqEegt,fake-fs:yT2OAMQYal,fake-fs:MiVX4VYXHk,fake-fs:QMbsUbjJTI,fake-fs:KzAbDNsmPc,fake-fs:m6CGOwOcdT,fake-fs:0cyHx9zsA3,fake-fs:SIwjWfFLSY,fake-fs:uZSDXCEqLY,fake-fs:HuoTL3nK7k,fake-fs:oyoejYE0CI,fake-fs:56WLhiCxbz,fake-fs:SqYOi0z5sM,fake-fs:LZq3ei28Ez,fake-fs:pTc4pCtwk8,fake-fs:AAJSFf0RHS,fake-fs:up6EHkEbO9,fake-fs:GB1Pesdnxd,fake-fs:Oyvq4Z96S4,fake-fs:rYXrhklgf6,fake-fs:g1HdUkQziH`; + const keys: URI[] = raw.split(',').map(value => URI.parse(value, true)); + + + const tst = TernarySearchTree.forUris(); + for (const item of keys) { + tst.set(item, true); + assert.ok(tst._isBalanced(), `SET${item}|${keys.map(String).join()}`); + } + + const lengthNow = Array.from(tst).length; + assert.strictEqual(lengthNow, keys.length); + + const keys2 = keys.slice(0); + + for (const [index, item] of keys.entries()) { + tst.delete(item); + assert.ok(tst._isBalanced(), `DEL${item}|${keys.map(String).join()}`); + + const idx = keys2.indexOf(item); + assert.ok(idx >= 0); + keys2.splice(idx, 1); + + const actualKeys = Array.from(tst).map(value => value[0]); + + assert.strictEqual( + actualKeys.length, + keys2.length, + `FAILED with ${index} -> ${item.toString()}\nWANTED:${keys2.map(String).sort().join()}\nACTUAL:${actualKeys.map(String).sort().join()}` + ); + } + + assert.strictEqual(Array.from(tst).length, 0); + }); + test('TernarySearchTree: Cannot read properties of undefined (reading \'length\'): #161618 (simple)', function () { const raw = 'config.debug.toolBarLocation,floating,config.editor.renderControlCharacters,true,config.editor.renderWhitespace,selection,config.files.autoSave,off,config.git.enabled,true,config.notebook.globalToolbar,true,config.terminal.integrated.tabs.enabled,true,config.terminal.integrated.tabs.showActions,singleTerminalOrNarrow,config.terminal.integrated.tabs.showActiveTerminal,singleTerminalOrNarrow,config.workbench.activityBar.visible,true,config.workbench.experimental.settingsProfiles.enabled,true,config.workbench.layoutControl.type,both,config.workbench.sideBar.location,left,config.workbench.statusBar.visible,true'; const array = raw.split(','); diff --git a/src/vs/base/worker/workerMain.ts b/src/vs/base/worker/workerMain.ts deleted file mode 100644 index bd1880e77a7..00000000000 --- a/src/vs/base/worker/workerMain.ts +++ /dev/null @@ -1,53 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// TODO @hediet @alexdima check where this code is used or remove this file -// (code oss runs fine without this file, but is probably needed by the monaco-editor). - -(function () { - - function loadCode(moduleId: string): Promise { - const moduleUrl = new URL(`${moduleId}.js`, globalThis._VSCODE_FILE_ROOT); - return import(moduleUrl.href); - } - - interface MessageHandler { - onmessage(msg: any, ports: readonly MessagePort[]): void; - } - - // shape of vs/base/common/worker/simpleWorker.ts - interface SimpleWorkerModule { - create(postMessage: (msg: any, transfer?: Transferable[]) => void): MessageHandler; - } - - function setupWorkerServer(ws: SimpleWorkerModule) { - setTimeout(function () { - const messageHandler = ws.create((msg: any, transfer?: Transferable[]) => { - (globalThis).postMessage(msg, transfer); - }); - - self.onmessage = (e: MessageEvent) => messageHandler.onmessage(e.data, e.ports); - while (beforeReadyMessages.length > 0) { - self.onmessage(beforeReadyMessages.shift()!); - } - }, 0); - } - - let isFirstMessage = true; - const beforeReadyMessages: MessageEvent[] = []; - globalThis.onmessage = (message: MessageEvent) => { - if (!isFirstMessage) { - beforeReadyMessages.push(message); - return; - } - - isFirstMessage = false; - loadCode(message.data).then((ws) => { - setupWorkerServer(ws); - }, (err) => { - console.error(err); - }); - }; -})(); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index d6a2098241e..588e1cb4521 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -3,13 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { app, BrowserWindow, protocol, session, Session, systemPreferences, WebFrameMain } from 'electron'; +import { app, protocol, session, Session, systemPreferences, WebFrameMain } from 'electron'; import { addUNCHostToAllowlist, disableUNCAccessRestrictions } from '../../base/node/unc.js'; import { validatedIpcMain } from '../../base/parts/ipc/electron-main/ipcMain.js'; import { hostname, release } from 'os'; import { VSBuffer } from '../../base/common/buffer.js'; import { toErrorMessage } from '../../base/common/errorMessage.js'; -import { isSigPipeError, onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; import { Event } from '../../base/common/event.js'; import { parse } from '../../base/common/jsonc.js'; import { getPathLabel } from '../../base/common/labels.js'; @@ -84,7 +83,7 @@ import { ElectronURLListener } from '../../platform/url/electron-main/electronUr import { IWebviewManagerService } from '../../platform/webview/common/webviewManagerService.js'; import { WebviewMainService } from '../../platform/webview/electron-main/webviewMainService.js'; import { isFolderToOpen, isWorkspaceToOpen, IWindowOpenable } from '../../platform/window/common/window.js'; -import { IWindowsMainService, OpenContext } from '../../platform/windows/electron-main/windows.js'; +import { getAllWindowsExcludingOffscreen, IWindowsMainService, OpenContext } from '../../platform/windows/electron-main/windows.js'; import { ICodeWindow } from '../../platform/window/electron-main/window.js'; import { WindowsMainService } from '../../platform/windows/electron-main/windowsMainService.js'; import { ActiveWindowManager } from '../../platform/windows/node/windowTracker.js'; @@ -122,6 +121,7 @@ import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeMcpDiscoveryHelperService.js'; import { IWebContentExtractorService } from '../../platform/webContentExtractor/common/webContentExtractor.js'; import { NativeWebContentExtractorService } from '../../platform/webContentExtractor/electron-main/webContentExtractorService.js'; +import ErrorTelemetry from '../../platform/telemetry/electron-main/errorTelemetry.js'; /** * The main VS Code application. There will only ever be one instance, @@ -236,7 +236,7 @@ export class CodeApplication extends Disposable { } // Check to see if the request comes from one of the main windows (or shared process) and not from embedded content - const windows = BrowserWindow.getAllWindows(); + const windows = getAllWindowsExcludingOffscreen(); for (const window of windows) { if (frame.processId === window.webContents.mainFrame.processId) { return true; @@ -377,15 +377,6 @@ export class CodeApplication extends Disposable { private registerListeners(): void { - // We handle uncaught exceptions here to prevent electron from opening a dialog to the user - setUnexpectedErrorHandler(error => this.onUnexpectedError(error)); - process.on('uncaughtException', error => { - if (!isSigPipeError(error)) { - onUnexpectedError(error); - } - }); - process.on('unhandledRejection', (reason: unknown) => onUnexpectedError(reason)); - // Dispose on shutdown Event.once(this.lifecycleMainService.onWillShutdown)(() => this.dispose()); @@ -532,25 +523,6 @@ export class CodeApplication extends Disposable { //#endregion } - private onUnexpectedError(error: Error): void { - if (error) { - - // take only the message and stack property - const friendlyError = { - message: `[uncaught exception in main]: ${error.message}`, - stack: error.stack - }; - - // handle on client side - this.windowsMainService?.sendToFocused('vscode:reportError', JSON.stringify(friendlyError)); - } - - this.logService.error(`[uncaught exception in main]: ${error}`); - if (error.stack) { - this.logService.error(error.stack); - } - } - async startup(): Promise { this.logService.debug('Starting VS Code'); this.logService.debug(`from: ${this.environmentMainService.appRoot}`); @@ -607,6 +579,9 @@ export class CodeApplication extends Disposable { // Services const appInstantiationService = await this.initServices(machineId, sqmId, devDeviceId, sharedProcessReady); + // Error telemetry + appInstantiationService.invokeFunction(accessor => this._register(new ErrorTelemetry(accessor.get(ILogService), accessor.get(ITelemetryService)))); + // Auth Handler appInstantiationService.invokeFunction(accessor => accessor.get(IProxyAuthService)); diff --git a/src/vs/code/electron-sandbox/workbench/workbench.ts b/src/vs/code/electron-sandbox/workbench/workbench.ts index 44a6051573c..ac30e72fd13 100644 --- a/src/vs/code/electron-sandbox/workbench/workbench.ts +++ b/src/vs/code/electron-sandbox/workbench/workbench.ts @@ -165,8 +165,8 @@ } } - // part: side bar (only when opening workspace/folder) - if (configuration.workspace && layoutInfo.sideBarWidth > 0) { + // part: side bar + if (layoutInfo.sideBarWidth > 0) { const sideDiv = document.createElement('div'); sideDiv.style.position = 'absolute'; sideDiv.style.width = `${layoutInfo.sideBarWidth}px`; diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index e0240babbcf..e077d72c84c 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -122,6 +122,8 @@ import { DefaultExtensionsInitializer } from './contrib/defaultExtensionsInitial import { AllowedExtensionsService } from '../../../platform/extensionManagement/common/allowedExtensionsService.js'; import { IExtensionGalleryManifestService } from '../../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { ExtensionGalleryManifestIPCService } from '../../../platform/extensionManagement/common/extensionGalleryManifestServiceIpc.js'; +import { ISharedWebContentExtractorService } from '../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { SharedWebContentExtractorService } from '../../../platform/webContentExtractor/node/sharedWebContentExtractorService.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -374,6 +376,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Remote Tunnel services.set(IRemoteTunnelService, new SyncDescriptor(RemoteTunnelService)); + // Web Content Extractor + services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService)); + return new InstantiationService(services); } @@ -432,6 +437,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Remote Tunnel const remoteTunnelChannel = ProxyChannel.fromService(accessor.get(IRemoteTunnelService), this._store); this.server.registerChannel('remoteTunnel', remoteTunnelChannel); + + // Web Content Extractor + const webContentExtractorChannel = ProxyChannel.fromService(accessor.get(ISharedWebContentExtractorService), this._store); + this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel); } private registerErrorHandler(logService: ILogService): void { diff --git a/src/vs/editor/browser/config/editorConfiguration.ts b/src/vs/editor/browser/config/editorConfiguration.ts index 701c940c736..1bf70f155d7 100644 --- a/src/vs/editor/browser/config/editorConfiguration.ts +++ b/src/vs/editor/browser/config/editorConfiguration.ts @@ -130,7 +130,8 @@ export class EditorConfiguration extends Disposable implements IEditorConfigurat tabFocusMode: TabFocus.getTabFocusMode(), inputMode: InputMode.getInputMode(), accessibilitySupport: partialEnv.accessibilitySupport, - glyphMarginDecorationLaneCount: this._glyphMarginDecorationLaneCount + glyphMarginDecorationLaneCount: this._glyphMarginDecorationLaneCount, + editContextSupported: partialEnv.editContextSupported }; return EditorOptionsUtil.computeOptions(this._validatedOptions, env); } @@ -142,6 +143,7 @@ export class EditorConfiguration extends Disposable implements IEditorConfigurat outerHeight: this._containerObserver.getHeight(), emptySelectionClipboard: browser.isWebKit || browser.isFirefox, pixelRatio: PixelRatio.getInstance(getWindowById(this._targetWindowId, true).window).value, + editContextSupported: typeof (globalThis as any).EditContext === 'function', accessibilitySupport: ( this._accessibilityService.isScreenReaderOptimized() ? AccessibilitySupport.Enabled @@ -249,6 +251,7 @@ export interface IEnvConfiguration { emptySelectionClipboard: boolean; pixelRatio: number; accessibilitySupport: AccessibilitySupport; + editContextSupported: boolean; } class ValidatedEditorOptions implements IValidatedEditorOptions { diff --git a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.css b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.css index 5ca36c59620..00170ceefc6 100644 --- a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.css +++ b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.css @@ -13,7 +13,7 @@ white-space: pre-wrap; } -.monaco-editor .native-edit-context-textarea { +.monaco-editor .ime-text-area { min-width: 0; min-height: 0; margin: 0; diff --git a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts index a6f5d41ad09..05e980bee57 100644 --- a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts @@ -30,6 +30,8 @@ import { EditContext } from './editContextFactory.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { NativeEditContextRegistry } from './nativeEditContextRegistry.js'; import { IEditorAriaOptions } from '../../../editorBrowser.js'; +import { isHighSurrogate, isLowSurrogate } from '../../../../../base/common/strings.js'; +import { IME } from '../../../../../base/common/ime.js'; // Corresponds to classes in nativeEditContext.css enum CompositionClassName { @@ -38,11 +40,19 @@ enum CompositionClassName { PRIMARY = 'edit-context-composition-primary', } +interface ITextUpdateEvent { + text: string; + selectionStart: number; + selectionEnd: number; + updateRangeStart: number; + updateRangeEnd: number; +} + export class NativeEditContext extends AbstractEditContext { // Text area used to handle paste events - public readonly textArea: FastDomNode; public readonly domNode: FastDomNode; + private readonly _imeTextArea: FastDomNode; private readonly _editContext: EditContext; private readonly _screenReaderSupport: ScreenReaderSupport; private _editContextPrimarySelection: Selection = new Selection(1, 1, 1, 1); @@ -65,18 +75,18 @@ export class NativeEditContext extends AbstractEditContext { ownerID: string, context: ViewContext, overflowGuardContainer: FastDomNode, - viewController: ViewController, + private readonly _viewController: ViewController, private readonly _visibleRangeProvider: IVisibleRangeProvider, @IInstantiationService instantiationService: IInstantiationService, - @IAccessibilityService private readonly _accessibilityService: IAccessibilityService + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, ) { super(context); this.domNode = new FastDomNode(document.createElement('div')); this.domNode.setClassName(`native-edit-context`); - this.textArea = new FastDomNode(document.createElement('textarea')); - this.textArea.setClassName('native-edit-context-textarea'); - this.textArea.setAttribute('tabindex', '-1'); + this._imeTextArea = new FastDomNode(document.createElement('textarea')); + this._imeTextArea.setClassName(`ime-text-area`); + this._imeTextArea.setAttribute('readonly', 'true'); this.domNode.setAttribute('autocorrect', 'off'); this.domNode.setAttribute('autocapitalize', 'off'); this.domNode.setAttribute('autocomplete', 'off'); @@ -85,13 +95,13 @@ export class NativeEditContext extends AbstractEditContext { this._updateDomAttributes(); overflowGuardContainer.appendChild(this.domNode); - overflowGuardContainer.appendChild(this.textArea); + overflowGuardContainer.appendChild(this._imeTextArea); this._parent = overflowGuardContainer.domNode; this._selectionChangeListener = this._register(new MutableDisposable()); this._focusTracker = this._register(new FocusTracker(this.domNode.domNode, (newFocusValue: boolean) => { if (newFocusValue) { - this._selectionChangeListener.value = this._setSelectionChangeListener(viewController); + this._selectionChangeListener.value = this._setSelectionChangeListener(this._viewController); this._screenReaderSupport.setIgnoreSelectionChangeTime('onFocus'); } else { this._selectionChangeListener.value = undefined; @@ -111,50 +121,19 @@ export class NativeEditContext extends AbstractEditContext { // result in a `selectionchange` event which we want to ignore this._screenReaderSupport.setIgnoreSelectionChangeTime('onCut'); this._ensureClipboardGetsEditorSelection(e); - viewController.cut(); + this._viewController.cut(); })); - this._register(addDisposableListener(this.domNode.domNode, 'keyup', (e) => viewController.emitKeyUp(new StandardKeyboardEvent(e)))); - this._register(addDisposableListener(this.domNode.domNode, 'keydown', async (e) => { - - const standardKeyboardEvent = new StandardKeyboardEvent(e); - - // When the IME is visible, the keys, like arrow-left and arrow-right, should be used to navigate in the IME, and should not be propagated further - if (standardKeyboardEvent.keyCode === KeyCode.KEY_IN_COMPOSITION) { - standardKeyboardEvent.stopPropagation(); - } - viewController.emitKeyDown(standardKeyboardEvent); - })); + this._register(addDisposableListener(this.domNode.domNode, 'keyup', (e) => this._onKeyUp(e))); + this._register(addDisposableListener(this.domNode.domNode, 'keydown', async (e) => this._onKeyDown(e))); + this._register(addDisposableListener(this._imeTextArea.domNode, 'keyup', (e) => this._onKeyUp(e))); + this._register(addDisposableListener(this._imeTextArea.domNode, 'keydown', async (e) => this._onKeyDown(e))); this._register(addDisposableListener(this.domNode.domNode, 'beforeinput', async (e) => { if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') { - this._onType(viewController, { text: '\n', replacePrevCharCnt: 0, replaceNextCharCnt: 0, positionDelta: 0 }); + this._onType(this._viewController, { text: '\n', replacePrevCharCnt: 0, replaceNextCharCnt: 0, positionDelta: 0 }); } })); - - // Edit context events - this._register(editContextAddDisposableListener(this._editContext, 'textformatupdate', (e) => this._handleTextFormatUpdate(e))); - this._register(editContextAddDisposableListener(this._editContext, 'characterboundsupdate', (e) => this._updateCharacterBounds(e))); - this._register(editContextAddDisposableListener(this._editContext, 'textupdate', (e) => { - this._emitTypeEvent(viewController, e); - })); - this._register(editContextAddDisposableListener(this._editContext, 'compositionstart', (e) => { - // Utlimately fires onDidCompositionStart() on the editor to notify for example suggest model of composition state - // Updates the composition state of the cursor controller which determines behavior of typing with interceptors - viewController.compositionStart(); - // Emits ViewCompositionStartEvent which can be depended on by ViewEventHandlers - this._context.viewModel.onCompositionStart(); - })); - this._register(editContextAddDisposableListener(this._editContext, 'compositionend', (e) => { - // Utlimately fires compositionEnd() on the editor to notify for example suggest model of composition state - // Updates the composition state of the cursor controller which determines behavior of typing with interceptors - viewController.compositionEnd(); - // Emits ViewCompositionEndEvent which can be depended on by ViewEventHandlers - this._context.viewModel.onCompositionEnd(); - })); - this._register(addDisposableListener(this.textArea.domNode, 'paste', (e) => { - // Pretend here we touched the text area, as the `paste` event will most likely - // result in a `selectionchange` event which we want to ignore - this._screenReaderSupport.setIgnoreSelectionChangeTime('onPaste'); + this._register(addDisposableListener(this.domNode.domNode, 'paste', (e) => { e.preventDefault(); if (!e.clipboardData) { return; @@ -174,7 +153,62 @@ export class NativeEditContext extends AbstractEditContext { multicursorText = typeof metadata.multicursorText !== 'undefined' ? metadata.multicursorText : null; mode = metadata.mode; } - viewController.paste(text, pasteOnNewLine, multicursorText, mode); + this._viewController.paste(text, pasteOnNewLine, multicursorText, mode); + })); + + // Edit context events + this._register(editContextAddDisposableListener(this._editContext, 'textformatupdate', (e) => this._handleTextFormatUpdate(e))); + this._register(editContextAddDisposableListener(this._editContext, 'characterboundsupdate', (e) => this._updateCharacterBounds(e))); + let highSurrogateCharacter: string | undefined; + this._register(editContextAddDisposableListener(this._editContext, 'textupdate', (e) => { + const text = e.text; + if (text.length === 1) { + const charCode = text.charCodeAt(0); + if (isHighSurrogate(charCode)) { + highSurrogateCharacter = text; + return; + } + if (isLowSurrogate(charCode) && highSurrogateCharacter) { + const textUpdateEvent: ITextUpdateEvent = { + text: highSurrogateCharacter + text, + selectionEnd: e.selectionEnd, + selectionStart: e.selectionStart, + updateRangeStart: e.updateRangeStart - 1, + updateRangeEnd: e.updateRangeEnd - 1 + }; + highSurrogateCharacter = undefined; + this._emitTypeEvent(this._viewController, textUpdateEvent); + return; + } + } + this._emitTypeEvent(this._viewController, e); + })); + this._register(editContextAddDisposableListener(this._editContext, 'compositionstart', (e) => { + // Utlimately fires onDidCompositionStart() on the editor to notify for example suggest model of composition state + // Updates the composition state of the cursor controller which determines behavior of typing with interceptors + this._viewController.compositionStart(); + // Emits ViewCompositionStartEvent which can be depended on by ViewEventHandlers + this._context.viewModel.onCompositionStart(); + })); + this._register(editContextAddDisposableListener(this._editContext, 'compositionend', (e) => { + // Utlimately fires compositionEnd() on the editor to notify for example suggest model of composition state + // Updates the composition state of the cursor controller which determines behavior of typing with interceptors + this._viewController.compositionEnd(); + // Emits ViewCompositionEndEvent which can be depended on by ViewEventHandlers + this._context.viewModel.onCompositionEnd(); + })); + let reenableTracking: boolean = false; + this._register(IME.onDidChange(() => { + if (IME.enabled && reenableTracking) { + this.domNode.focus(); + this._focusTracker.resume(); + reenableTracking = false; + } + if (!IME.enabled && this.isFocused()) { + this._focusTracker.pause(); + this._imeTextArea.focus(); + reenableTracking = true; + } })); this._register(NativeEditContextRegistry.register(ownerID, this)); } @@ -185,7 +219,7 @@ export class NativeEditContext extends AbstractEditContext { // Force blue the dom node so can write in pane with no native edit context after disposal this.domNode.domNode.blur(); this.domNode.domNode.remove(); - this.textArea.domNode.remove(); + this._imeTextArea.domNode.remove(); super.dispose(); } @@ -254,6 +288,10 @@ export class NativeEditContext extends AbstractEditContext { } public onWillPaste(): void { + this._onWillPaste(); + } + + private _onWillPaste(): void { this._screenReaderSupport.setIgnoreSelectionChangeTime('onWillPaste'); } @@ -262,7 +300,7 @@ export class NativeEditContext extends AbstractEditContext { } public isFocused(): boolean { - return this._focusTracker.isFocused || (getActiveWindow().document.activeElement === this.textArea.domNode); + return this._focusTracker.isFocused; } public focus(): void { @@ -289,6 +327,19 @@ export class NativeEditContext extends AbstractEditContext { // --- Private methods --- + private _onKeyUp(e: KeyboardEvent) { + this._viewController.emitKeyUp(new StandardKeyboardEvent(e)); + } + + private _onKeyDown(e: KeyboardEvent) { + const standardKeyboardEvent = new StandardKeyboardEvent(e); + // When the IME is visible, the keys, like arrow-left and arrow-right, should be used to navigate in the IME, and should not be propagated further + if (standardKeyboardEvent.keyCode === KeyCode.KEY_IN_COMPOSITION) { + standardKeyboardEvent.stopPropagation(); + } + this._viewController.emitKeyDown(standardKeyboardEvent); + } + private _updateDomAttributes(): void { const options = this._context.configuration.options; this.domNode.domNode.setAttribute('tabindex', String(options.get(EditorOption.tabIndex))); @@ -299,12 +350,12 @@ export class NativeEditContext extends AbstractEditContext { if (!editContextState) { return; } - this._editContext.updateText(0, Number.MAX_SAFE_INTEGER, editContextState.text); + this._editContext.updateText(0, Number.MAX_SAFE_INTEGER, editContextState.text ?? ' '); this._editContext.updateSelection(editContextState.selectionStartOffset, editContextState.selectionEndOffset); this._editContextPrimarySelection = editContextState.editContextPrimarySelection; } - private _emitTypeEvent(viewController: ViewController, e: TextUpdateEvent): void { + private _emitTypeEvent(viewController: ViewController, e: ITextUpdateEvent): void { if (!this._editContext) { return; } @@ -425,20 +476,19 @@ export class NativeEditContext extends AbstractEditContext { return; } const options = this._context.configuration.options; - const lineHeight = options.get(EditorOption.lineHeight); const contentLeft = options.get(EditorOption.layoutInfo).contentLeft; const parentBounds = this._parent.getBoundingClientRect(); - const modelStartPosition = this._primarySelection.getStartPosition(); - const viewStartPosition = this._context.viewModel.coordinatesConverter.convertModelPositionToViewPosition(modelStartPosition); - const verticalOffsetStart = this._context.viewLayout.getVerticalOffsetForLineNumber(viewStartPosition.lineNumber); + const viewSelection = this._context.viewModel.coordinatesConverter.convertModelRangeToViewRange(this._primarySelection); + const verticalOffsetStart = this._context.viewLayout.getVerticalOffsetForLineNumber(viewSelection.startLineNumber); const top = parentBounds.top + verticalOffsetStart - this._scrollTop; - const height = (this._primarySelection.endLineNumber - this._primarySelection.startLineNumber + 1) * lineHeight; + const verticalOffsetEnd = this._context.viewLayout.getVerticalOffsetAfterLineNumber(viewSelection.endLineNumber); + const height = verticalOffsetEnd - verticalOffsetStart; let left = parentBounds.left + contentLeft - this._scrollLeft; let width: number; if (this._primarySelection.isEmpty()) { - const linesVisibleRanges = ctx.visibleRangeForPosition(viewStartPosition); + const linesVisibleRanges = ctx.visibleRangeForPosition(viewSelection.getStartPosition()); if (linesVisibleRanges) { left += linesVisibleRanges.left; } @@ -458,7 +508,6 @@ export class NativeEditContext extends AbstractEditContext { } const options = this._context.configuration.options; const typicalHalfWidthCharacterWidth = options.get(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; - const lineHeight = options.get(EditorOption.lineHeight); const contentLeft = options.get(EditorOption.layoutInfo).contentLeft; const parentBounds = this._parent.getBoundingClientRect(); @@ -472,7 +521,8 @@ export class NativeEditContext extends AbstractEditContext { const characterModelRange = Range.fromPositions(characterStartPosition, characterEndPosition); const characterViewRange = this._context.viewModel.coordinatesConverter.convertModelRangeToViewRange(characterModelRange); const characterLinesVisibleRanges = this._visibleRangeProvider.linesVisibleRangesForRange(characterViewRange, true) ?? []; - const characterVerticalOffset = this._context.viewLayout.getVerticalOffsetForLineNumber(characterViewRange.startLineNumber); + const lineNumber = characterViewRange.startLineNumber; + const characterVerticalOffset = this._context.viewLayout.getVerticalOffsetForLineNumber(lineNumber); const top = parentBounds.top + characterVerticalOffset - this._scrollTop; let left = 0; @@ -484,6 +534,7 @@ export class NativeEditContext extends AbstractEditContext { break; } } + const lineHeight = this._context.viewLayout.getLineHeightForLineNumber(lineNumber); characterBounds.push(new DOMRect(parentBounds.left + contentLeft + left - this._scrollLeft, top, width, lineHeight)); } this._editContext.updateCharacterBounds(e.rangeStart, characterBounds); @@ -523,7 +574,7 @@ export class NativeEditContext extends AbstractEditContext { let previousSelectionChangeEventTime = 0; return addDisposableListener(this.domNode.domNode.ownerDocument, 'selectionchange', () => { const isScreenReaderOptimized = this._accessibilityService.isScreenReaderOptimized(); - if (!this.isFocused() || !isScreenReaderOptimized) { + if (!this.isFocused() || !isScreenReaderOptimized || !IME.enabled) { return; } const screenReaderContentState = this._screenReaderSupport.screenReaderContentState; diff --git a/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts b/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts index b3166de0437..90f3e9839be 100644 --- a/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts +++ b/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableListener, getActiveWindow } from '../../../../../base/browser/dom.js'; +import { addDisposableListener, getActiveElement, getShadowRoot } from '../../../../../base/browser/dom.js'; import { IDisposable, Disposable } from '../../../../../base/common/lifecycle.js'; export interface ITypeData { @@ -15,14 +15,37 @@ export interface ITypeData { export class FocusTracker extends Disposable { private _isFocused: boolean = false; + private _isPaused: boolean = false; constructor( private readonly _domNode: HTMLElement, private readonly _onFocusChange: (newFocusValue: boolean) => void, ) { super(); - this._register(addDisposableListener(this._domNode, 'focus', () => this._handleFocusedChanged(true))); - this._register(addDisposableListener(this._domNode, 'blur', () => this._handleFocusedChanged(false))); + this._register(addDisposableListener(this._domNode, 'focus', () => { + if (this._isPaused) { + return; + } + // Here we don't trust the browser and instead we check + // that the active element is the one we are tracking + // (this happens when cmd+tab is used to switch apps) + this.refreshFocusState(); + })); + this._register(addDisposableListener(this._domNode, 'blur', () => { + if (this._isPaused) { + return; + } + this._handleFocusedChanged(false); + })); + } + + public pause(): void { + this._isPaused = true; + } + + public resume(): void { + this._isPaused = false; + this.refreshFocusState(); } private _handleFocusedChanged(focused: boolean): void { @@ -34,14 +57,14 @@ export class FocusTracker extends Disposable { } public focus(): void { - // fixes: https://github.com/microsoft/vscode/issues/228147 - // Immediately call this method in order to directly set the field isFocused to true so the textInputFocus context key is evaluated correctly - this._handleFocusedChanged(true); this._domNode.focus(); + this.refreshFocusState(); } public refreshFocusState(): void { - const focused = this._domNode === getActiveWindow().document.activeElement; + const shadowRoot = getShadowRoot(this._domNode); + const activeElement = shadowRoot ? shadowRoot.activeElement : getActiveElement(); + const focused = this._domNode === activeElement; this._handleFocusedChanged(focused); } diff --git a/src/vs/editor/browser/controller/editContext/native/screenReaderSupport.ts b/src/vs/editor/browser/controller/editContext/native/screenReaderSupport.ts index 93a03823220..dad5348efa9 100644 --- a/src/vs/editor/browser/controller/editContext/native/screenReaderSupport.ts +++ b/src/vs/editor/browser/controller/editContext/native/screenReaderSupport.ts @@ -28,7 +28,6 @@ export class ScreenReaderSupport { private _contentWidth: number = 1; private _contentHeight: number = 1; private _divWidth: number = 1; - private _lineHeight: number = 1; private _fontInfo!: FontInfo; private _accessibilityPageSize: number = 1; private _ignoreSelectionChangeTime: number = 0; @@ -75,7 +74,6 @@ export class ScreenReaderSupport { this._contentWidth = layoutInfo.contentWidth; this._contentHeight = layoutInfo.height; this._fontInfo = options.get(EditorOption.fontInfo); - this._lineHeight = options.get(EditorOption.lineHeight); this._accessibilityPageSize = options.get(EditorOption.accessibilityPageSize); this._divWidth = Math.round(wrappingColumn * this._fontInfo.typicalHalfwidthCharacterWidth); } @@ -133,10 +131,13 @@ export class ScreenReaderSupport { return; } - const offsetForStartPositionWithinEditor = this._context.viewLayout.getVerticalOffsetForLineNumber(this._screenReaderContentState.startPositionWithinEditor.lineNumber); - const offsetForPositionLineNumber = this._context.viewLayout.getVerticalOffsetForLineNumber(positionLineNumber); - const scrollTop = offsetForPositionLineNumber - offsetForStartPositionWithinEditor; - this._doRender(scrollTop, top, this._contentLeft, this._divWidth, this._lineHeight); + // The
where we render the screen reader content does not support variable line heights, + // all the lines must have the same height. We use the line height of the cursor position as the + // line height for all lines. + const lineHeight = this._context.viewLayout.getLineHeightForLineNumber(positionLineNumber); + const lineNumberWithinStateAboveCursor = positionLineNumber - this._screenReaderContentState.startPositionWithinEditor.lineNumber; + const scrollTop = lineNumberWithinStateAboveCursor * lineHeight; + this._doRender(scrollTop, top, this._contentLeft, this._divWidth, lineHeight); } private _renderAtTopLeft(): void { @@ -151,6 +152,7 @@ export class ScreenReaderSupport { this._domNode.setLeft(left); this._domNode.setWidth(width); this._domNode.setHeight(height); + this._domNode.setLineHeight(height); this._domNode.domNode.scrollTop = scrollTop; } @@ -177,9 +179,14 @@ export class ScreenReaderSupport { const isScreenReaderOptimized = this._accessibilityService.isScreenReaderOptimized(); if (isScreenReaderOptimized) { this._screenReaderContentState = this._getScreenReaderContentState(); - if (this._domNode.domNode.textContent !== this._screenReaderContentState.value) { + const endPosition = this._context.viewModel.model.getPositionAt(Infinity); + let value = this._screenReaderContentState.value; + if (endPosition.column === 1 && this._primarySelection.getEndPosition().equals(endPosition)) { + value += '\n'; + } + if (this._domNode.domNode.textContent !== value) { this.setIgnoreSelectionChangeTime('setValue'); - this._domNode.domNode.textContent = this._screenReaderContentState.value; + this._domNode.domNode.textContent = value; } this._setSelectionOfScreenReaderContent(this._screenReaderContentState.selectionStart, this._screenReaderContentState.selectionEnd); } else { diff --git a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts index a3f8b75544b..11a1f09ca04 100644 --- a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts @@ -125,7 +125,6 @@ export class TextAreaEditContext extends AbstractEditContext { private _contentWidth: number; private _contentHeight: number; private _fontInfo: FontInfo; - private _lineHeight: number; private _emptySelectionClipboard: boolean; private _copyWithSyntaxHighlighting: boolean; @@ -169,7 +168,6 @@ export class TextAreaEditContext extends AbstractEditContext { this._contentWidth = layoutInfo.contentWidth; this._contentHeight = layoutInfo.height; this._fontInfo = options.get(EditorOption.fontInfo); - this._lineHeight = options.get(EditorOption.lineHeight); this._emptySelectionClipboard = options.get(EditorOption.emptySelectionClipboard); this._copyWithSyntaxHighlighting = options.get(EditorOption.copyWithSyntaxHighlighting); @@ -591,7 +589,6 @@ export class TextAreaEditContext extends AbstractEditContext { this._contentWidth = layoutInfo.contentWidth; this._contentHeight = layoutInfo.height; this._fontInfo = options.get(EditorOption.fontInfo); - this._lineHeight = options.get(EditorOption.lineHeight); this._emptySelectionClipboard = options.get(EditorOption.emptySelectionClipboard); this._copyWithSyntaxHighlighting = options.get(EditorOption.copyWithSyntaxHighlighting); this.textArea.setAttribute('wrap', this._textAreaWrapping && !this._visibleTextArea ? 'on' : 'off'); @@ -745,6 +742,8 @@ export class TextAreaEditContext extends AbstractEditContext { } // Try to render the textarea with the color/font style to match the text under it + const viewPosition = this._context.viewModel.coordinatesConverter.convertViewPositionToModelPosition(new Position(startPosition.lineNumber, 1)); + const lineHeight = this._context.viewLayout.getLineHeightForLineNumber(viewPosition.lineNumber); const viewLineData = this._context.viewModel.getViewLineData(startPosition.lineNumber); const startTokenIndex = viewLineData.tokens.findTokenIndexAtOffset(startPosition.column - 1); const endTokenIndex = viewLineData.tokens.findTokenIndexAtOffset(endPosition.column - 1); @@ -753,7 +752,7 @@ export class TextAreaEditContext extends AbstractEditContext { (textareaSpansSingleToken ? viewLineData.tokens.getPresentation(startTokenIndex) : null) ); - this.textArea.domNode.scrollTop = lineCount * this._lineHeight; + this.textArea.domNode.scrollTop = lineCount * lineHeight; this.textArea.domNode.scrollLeft = scrollLeft; this._doRender({ @@ -761,7 +760,7 @@ export class TextAreaEditContext extends AbstractEditContext { top: top, left: left, width: width, - height: this._lineHeight, + height: lineHeight, useCover: false, color: (TokenizationRegistry.getColorMap() || [])[presentation.foreground], italic: presentation.italic, @@ -798,19 +797,21 @@ export class TextAreaEditContext extends AbstractEditContext { if (platform.isMacintosh || this._accessibilitySupport === AccessibilitySupport.Enabled) { // For the popup emoji input, we will make the text area as high as the line height // We will also make the fontSize and lineHeight the correct dimensions to help with the placement of these pickers + const lineNumber = this._primaryCursorPosition.lineNumber; + const lineHeight = this._context.viewLayout.getLineHeightForLineNumber(lineNumber); this._doRender({ lastRenderPosition: this._primaryCursorPosition, top, left: this._textAreaWrapping ? this._contentLeft : left, width: this._textAreaWidth, - height: this._lineHeight, + height: lineHeight, useCover: false }); // In case the textarea contains a word, we're going to try to align the textarea's cursor // with our cursor by scrolling the textarea as much as possible this.textArea.domNode.scrollLeft = this._primaryCursorVisibleRange.left; const lineCount = this._textAreaInput.textAreaState.newlineCountBeforeSelection ?? newlinecount(this.textArea.domNode.value.substring(0, this.textArea.domNode.selectionStart)); - this.textArea.domNode.scrollTop = lineCount * this._lineHeight; + this.textArea.domNode.scrollTop = lineCount * lineHeight; return; } @@ -848,6 +849,7 @@ export class TextAreaEditContext extends AbstractEditContext { ta.setLeft(renderData.left); ta.setWidth(renderData.width); ta.setHeight(renderData.height); + ta.setLineHeight(renderData.height); ta.setColor(renderData.color ? Color.Format.CSS.formatHex(renderData.color) : ''); ta.setFontStyle(renderData.italic ? 'italic' : ''); diff --git a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts index fc2dc0dd5ea..8e8d1e5c5a1 100644 --- a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts +++ b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts @@ -619,19 +619,19 @@ export class TextAreaInput extends Disposable { export class TextAreaWrapper extends Disposable implements ICompleteTextAreaWrapper { - public readonly onKeyDown = this._register(new DomEmitter(this._actual, 'keydown')).event; - public readonly onKeyPress = this._register(new DomEmitter(this._actual, 'keypress')).event; - public readonly onKeyUp = this._register(new DomEmitter(this._actual, 'keyup')).event; - public readonly onCompositionStart = this._register(new DomEmitter(this._actual, 'compositionstart')).event; - public readonly onCompositionUpdate = this._register(new DomEmitter(this._actual, 'compositionupdate')).event; - public readonly onCompositionEnd = this._register(new DomEmitter(this._actual, 'compositionend')).event; - public readonly onBeforeInput = this._register(new DomEmitter(this._actual, 'beforeinput')).event; - public readonly onInput = >this._register(new DomEmitter(this._actual, 'input')).event; - public readonly onCut = this._register(new DomEmitter(this._actual, 'cut')).event; - public readonly onCopy = this._register(new DomEmitter(this._actual, 'copy')).event; - public readonly onPaste = this._register(new DomEmitter(this._actual, 'paste')).event; - public readonly onFocus = this._register(new DomEmitter(this._actual, 'focus')).event; - public readonly onBlur = this._register(new DomEmitter(this._actual, 'blur')).event; + public readonly onKeyDown: Event; + public readonly onKeyPress: Event; + public readonly onKeyUp: Event; + public readonly onCompositionStart: Event; + public readonly onCompositionUpdate: Event; + public readonly onCompositionEnd: Event; + public readonly onBeforeInput: Event; + public readonly onInput: Event; + public readonly onCut: Event; + public readonly onCopy: Event; + public readonly onPaste: Event; + public readonly onFocus: Event; + public readonly onBlur: Event; // = this._register(new DomEmitter(this._actual, 'blur')).event; public get ownerDocument(): Document { return this._actual.ownerDocument; @@ -647,12 +647,24 @@ export class TextAreaWrapper extends Disposable implements ICompleteTextAreaWrap ) { super(); this._ignoreSelectionChangeTime = 0; + this.onKeyDown = this._register(new DomEmitter(this._actual, 'keydown')).event; + this.onKeyPress = this._register(new DomEmitter(this._actual, 'keypress')).event; + this.onKeyUp = this._register(new DomEmitter(this._actual, 'keyup')).event; + this.onCompositionStart = this._register(new DomEmitter(this._actual, 'compositionstart')).event; + this.onCompositionUpdate = this._register(new DomEmitter(this._actual, 'compositionupdate')).event; + this.onCompositionEnd = this._register(new DomEmitter(this._actual, 'compositionend')).event; + this.onBeforeInput = this._register(new DomEmitter(this._actual, 'beforeinput')).event; + this.onInput = >this._register(new DomEmitter(this._actual, 'input')).event; + this.onCut = this._register(new DomEmitter(this._actual, 'cut')).event; + this.onCopy = this._register(new DomEmitter(this._actual, 'copy')).event; + this.onPaste = this._register(new DomEmitter(this._actual, 'paste')).event; + this.onFocus = this._register(new DomEmitter(this._actual, 'focus')).event; + this.onBlur = this._register(new DomEmitter(this._actual, 'blur')).event; this._register(this.onKeyDown(() => inputLatency.onKeyDown())); this._register(this.onBeforeInput(() => inputLatency.onBeforeInput())); this._register(this.onInput(() => inputLatency.onInput())); this._register(this.onKeyUp(() => inputLatency.onKeyUp())); - this._register(dom.addDisposableListener(this._actual, TextAreaSyntethicEvents.Tap, () => this._onSyntheticTap.fire())); } diff --git a/src/vs/editor/browser/editorBrowser.ts b/src/vs/editor/browser/editorBrowser.ts index 082c14ea1d3..8f0ed8dabb8 100644 --- a/src/vs/editor/browser/editorBrowser.ts +++ b/src/vs/editor/browser/editorBrowser.ts @@ -19,7 +19,7 @@ import { IDiffComputationResult, ILineChange } from '../common/diff/legacyLinesD import * as editorCommon from '../common/editorCommon.js'; import { GlyphMarginLane, ICursorStateComputer, IIdentifiedSingleEditOperation, IModelDecoration, IModelDeltaDecoration, ITextModel, PositionAffinity } from '../common/model.js'; import { InjectedText } from '../common/modelLineProjectionData.js'; -import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent } from '../common/textModelEvents.js'; +import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent, ModelLineHeightChangedEvent } from '../common/textModelEvents.js'; import { IEditorWhitespace, IViewModel } from '../common/viewModel.js'; import { OverviewRulerZone } from '../common/viewModel/overviewZoneManager.js'; import { MenuId } from '../../platform/actions/common/actions.js'; @@ -891,6 +891,13 @@ export interface ICodeEditor extends editorCommon.IEditor { */ getConfiguredWordAtPosition(position: Position): IWordAtPosition | null; + /** + * An event emitted when line heights from decorations change + * @internal + * @event + */ + onDidChangeLineHeight: Event; + /** * Get value of the current model attached to this editor. * @see {@link ITextModel.getValue} @@ -1019,7 +1026,7 @@ export interface ICodeEditor extends editorCommon.IEditor { /** * @internal */ - setDecorationsByType(description: string, decorationTypeKey: string, ranges: editorCommon.IDecorationOptions[]): void; + setDecorationsByType(description: string, decorationTypeKey: string, ranges: editorCommon.IDecorationOptions[]): readonly string[]; /** * @internal @@ -1068,6 +1075,11 @@ export interface ICodeEditor extends editorCommon.IEditor { */ getTopForPosition(lineNumber: number, column: number): number; + /** + * Get the line height for the line number. + */ + getLineHeightForLineNumber(lineNumber: number): number; + /** * Set the model ranges that will be hidden in the view. * Hidden areas are stored per source. diff --git a/src/vs/editor/browser/gpu/objectCollectionBuffer.ts b/src/vs/editor/browser/gpu/objectCollectionBuffer.ts index a61fe8c09de..9a918e1abcb 100644 --- a/src/vs/editor/browser/gpu/objectCollectionBuffer.ts +++ b/src/vs/editor/browser/gpu/objectCollectionBuffer.ts @@ -20,7 +20,7 @@ export interface IObjectCollectionBuffer extends Disposable implements IObjectCollectionBuffer { - buffer: ArrayBuffer; + buffer: ArrayBufferLike; view: Float32Array; get bufferUsedSize() { diff --git a/src/vs/editor/browser/rect.ts b/src/vs/editor/browser/rect.ts index 66c1bd87ebd..e6de59e4fc8 100644 --- a/src/vs/editor/browser/rect.ts +++ b/src/vs/editor/browser/rect.ts @@ -62,16 +62,56 @@ export class Rect { } } - withMargin(margin: number): Rect { - return new Rect(this.left - margin, this.top - margin, this.right + margin, this.bottom + margin); + withMargin(margin: number): Rect; + withMargin(marginVertical: number, marginHorizontal: number): Rect; + withMargin(marginTop: number, marginRight: number, marginBottom: number, marginLeft: number): Rect; + withMargin(marginOrVerticalOrTop: number, rightOrHorizontal?: number, bottom?: number, left?: number): Rect { + let marginLeft, marginRight, marginTop, marginBottom; + + // Single margin value + if (rightOrHorizontal === undefined && bottom === undefined && left === undefined) { + marginLeft = marginRight = marginTop = marginBottom = marginOrVerticalOrTop; + } + // Vertical and horizontal margins + else if (bottom === undefined && left === undefined) { + marginLeft = marginRight = rightOrHorizontal!; + marginTop = marginBottom = marginOrVerticalOrTop; + } + // Individual margins for all sides + else { + marginLeft = left!; + marginRight = rightOrHorizontal!; + marginTop = marginOrVerticalOrTop; + marginBottom = bottom!; + } + + return new Rect( + this.left - marginLeft, + this.top - marginTop, + this.right + marginRight, + this.bottom + marginBottom, + ); } intersectVertical(range: OffsetRange): Rect { + const newTop = Math.max(this.top, range.start); + const newBottom = Math.min(this.bottom, range.endExclusive); return new Rect( this.left, - Math.max(this.top, range.start), + newTop, this.right, - Math.min(this.bottom, range.endExclusive), + Math.max(newTop, newBottom), + ); + } + + intersectHorizontal(range: OffsetRange): Rect { + const newLeft = Math.max(this.left, range.start); + const newRight = Math.min(this.right, range.endExclusive); + return new Rect( + newLeft, + this.top, + Math.max(newLeft, newRight), + this.bottom, ); } @@ -149,6 +189,10 @@ export class Rect { return new Rect(this.left, top, this.right, this.bottom); } + withLeft(left: number): Rect { + return new Rect(left, this.top, this.right, this.bottom); + } + translateX(delta: number): Rect { return new Rect(this.left + delta, this.top, this.right + delta, this.bottom); } @@ -188,4 +232,14 @@ export class Rect { getRightTop(): Point { return new Point(this.right, this.top); } + + toStyles() { + return { + position: 'absolute', + left: `${this.left}px`, + top: `${this.top}px`, + width: `${this.width}px`, + height: `${this.height}px`, + }; + } } diff --git a/src/vs/editor/browser/services/abstractCodeEditorService.ts b/src/vs/editor/browser/services/abstractCodeEditorService.ts index 295d5bf1ca1..39a0bce8add 100644 --- a/src/vs/editor/browser/services/abstractCodeEditorService.ts +++ b/src/vs/editor/browser/services/abstractCodeEditorService.ts @@ -460,6 +460,7 @@ class DecorationTypeOptionsProvider implements IModelDecorationOptionsProvider { public afterContentClassName: string | undefined; public glyphMarginClassName: string | undefined; public isWholeLine: boolean; + public lineHeight?: number; public overviewRuler: IModelDecorationOverviewRulerOptions | undefined; public stickiness: TrackedRangeStickiness | undefined; public beforeInjectedText: InjectedTextOptions | undefined; @@ -520,6 +521,7 @@ class DecorationTypeOptionsProvider implements IModelDecorationOptionsProvider { const options = providerArgs.options; this.isWholeLine = Boolean(options.isWholeLine); + this.lineHeight = options.lineHeight; this.stickiness = options.rangeBehavior; const lightOverviewRulerColor = options.light && options.light.overviewRulerColor || options.overviewRulerColor; @@ -549,6 +551,7 @@ class DecorationTypeOptionsProvider implements IModelDecorationOptionsProvider { className: this.className, glyphMarginClassName: this.glyphMarginClassName, isWholeLine: this.isWholeLine, + lineHeight: this.lineHeight, overviewRuler: this.overviewRuler, stickiness: this.stickiness, before: this.beforeInjectedText, diff --git a/src/vs/editor/browser/services/editorWorkerService.ts b/src/vs/editor/browser/services/editorWorkerService.ts index 6e01c738ab5..f19765d9397 100644 --- a/src/vs/editor/browser/services/editorWorkerService.ts +++ b/src/vs/editor/browser/services/editorWorkerService.ts @@ -6,14 +6,14 @@ import { timeout } from '../../../base/common/async.js'; import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; -import { logOnceWebWorkerWarning, IWorkerClient, Proxied, IWorkerDescriptor } from '../../../base/common/worker/simpleWorker.js'; -import { createWebWorker } from '../../../base/browser/defaultWorkerFactory.js'; +import { logOnceWebWorkerWarning, IWebWorkerClient, Proxied } from '../../../base/common/worker/webWorker.js'; +import { createWebWorker, IWebWorkerDescriptor } from '../../../base/browser/webWorkerFactory.js'; import { Position } from '../../common/core/position.js'; import { IRange, Range } from '../../common/core/range.js'; import { ITextModel } from '../../common/model.js'; import * as languages from '../../common/languages.js'; import { ILanguageConfigurationService } from '../../common/languages/languageConfigurationRegistry.js'; -import { EditorSimpleWorker } from '../../common/services/editorSimpleWorker.js'; +import { EditorWorker } from '../../common/services/editorWebWorker.js'; import { DiffAlgorithmName, IEditorWorkerService, ILineChange, IUnicodeHighlightsResult } from '../../common/services/editorWorker.js'; import { IModelService } from '../../common/services/model.js'; import { ITextResourceConfigurationService } from '../../common/services/textResourceConfiguration.js'; @@ -59,7 +59,7 @@ export abstract class EditorWorkerService extends Disposable implements IEditorW private readonly _logService: ILogService; constructor( - workerDescriptor: IWorkerDescriptor, + workerDescriptor: IWebWorkerDescriptor, @IModelService modelService: IModelService, @ITextResourceConfigurationService configurationService: ITextResourceConfigurationService, @ILogService logService: ILogService, @@ -222,7 +222,7 @@ export abstract class EditorWorkerService extends Disposable implements IEditorW return worker.$computeDefaultDocumentColors(uri.toString()); } - private async _workerWithResources(resources: URI[], forceLargeModels: boolean = false): Promise> { + private async _workerWithResources(resources: URI[], forceLargeModels: boolean = false): Promise> { const worker = await this._workerManager.withWorker(); return await worker.workerWithSyncedResources(resources, forceLargeModels); } @@ -313,7 +313,7 @@ class WorkerManager extends Disposable { private _lastWorkerUsedTime: number; constructor( - private readonly _workerDescriptor: IWorkerDescriptor, + private readonly _workerDescriptor: IWebWorkerDescriptor, @IModelService modelService: IModelService ) { super(); @@ -375,7 +375,7 @@ class WorkerManager extends Disposable { } } -class SynchronousWorkerClient implements IWorkerClient { +class SynchronousWorkerClient implements IWebWorkerClient { private readonly _instance: T; public readonly proxy: Proxied; @@ -405,12 +405,12 @@ export class EditorWorkerClient extends Disposable implements IEditorWorkerClien private readonly _modelService: IModelService; private readonly _keepIdleModels: boolean; - private _worker: IWorkerClient | null; + private _worker: IWebWorkerClient | null; private _modelManager: WorkerTextModelSyncClient | null; private _disposed = false; constructor( - private readonly _workerDescriptor: IWorkerDescriptor, + private readonly _workerDescriptorOrWorker: IWebWorkerDescriptor | Worker, keepIdleModels: boolean, @IModelService modelService: IModelService, ) { @@ -426,10 +426,10 @@ export class EditorWorkerClient extends Disposable implements IEditorWorkerClien throw new Error(`Not implemented!`); } - private _getOrCreateWorker(): IWorkerClient { + private _getOrCreateWorker(): IWebWorkerClient { if (!this._worker) { try { - this._worker = this._register(createWebWorker(this._workerDescriptor)); + this._worker = this._register(createWebWorker(this._workerDescriptorOrWorker)); EditorWorkerHost.setChannel(this._worker, this._createEditorWorkerHost()); } catch (err) { logOnceWebWorkerWarning(err); @@ -439,7 +439,7 @@ export class EditorWorkerClient extends Disposable implements IEditorWorkerClien return this._worker; } - protected async _getProxy(): Promise> { + protected async _getProxy(): Promise> { try { const proxy = this._getOrCreateWorker().proxy; await proxy.$ping(); @@ -451,8 +451,8 @@ export class EditorWorkerClient extends Disposable implements IEditorWorkerClien } } - private _createFallbackLocalWorker(): SynchronousWorkerClient { - return new SynchronousWorkerClient(new EditorSimpleWorker(this._createEditorWorkerHost(), null)); + private _createFallbackLocalWorker(): SynchronousWorkerClient { + return new SynchronousWorkerClient(new EditorWorker(null)); } private _createEditorWorkerHost(): EditorWorkerHost { @@ -461,14 +461,14 @@ export class EditorWorkerClient extends Disposable implements IEditorWorkerClien }; } - private _getOrCreateModelManager(proxy: Proxied): WorkerTextModelSyncClient { + private _getOrCreateModelManager(proxy: Proxied): WorkerTextModelSyncClient { if (!this._modelManager) { this._modelManager = this._register(new WorkerTextModelSyncClient(proxy, this._modelService, this._keepIdleModels)); } return this._modelManager; } - public async workerWithSyncedResources(resources: URI[], forceLargeModels: boolean = false): Promise> { + public async workerWithSyncedResources(resources: URI[], forceLargeModels: boolean = false): Promise> { if (this._disposed) { return Promise.reject(canceled()); } diff --git a/src/vs/editor/browser/services/hoverService/hover.css b/src/vs/editor/browser/services/hoverService/hover.css index 8d483a54163..d7c20fbcde9 100644 --- a/src/vs/editor/browser/services/hoverService/hover.css +++ b/src/vs/editor/browser/services/hoverService/hover.css @@ -18,6 +18,12 @@ box-shadow: 0 2px 8px var(--vscode-widget-shadow); } +.monaco-workbench .workbench-hover .monaco-action-bar .action-item .codicon { + /* Given our font-size, adjust action icons accordingly */ + width: 13px; + height: 13px; +} + .monaco-workbench .workbench-hover hr { border-bottom: none; } @@ -26,6 +32,12 @@ font-size: 12px; } +.monaco-workbench .workbench-hover.compact .monaco-action-bar .action-item .codicon { + /* Given our font-size, adjust action icons accordingly */ + width: 12px; + height: 12px; +} + .monaco-workbench .workbench-hover.compact .hover-contents { padding: 2px 8px; } diff --git a/src/vs/editor/browser/services/hoverService/hoverWidget.ts b/src/vs/editor/browser/services/hoverService/hoverWidget.ts index c33296a34e6..9635e03c818 100644 --- a/src/vs/editor/browser/services/hoverService/hoverWidget.ts +++ b/src/vs/editor/browser/services/hoverService/hoverWidget.ts @@ -61,6 +61,7 @@ export class HoverWidget extends Widget implements IHoverWidget { private _isLocked: boolean = false; private _enableFocusTraps: boolean = false; private _addedFocusTrap: boolean = false; + private _maxHeightRatioRelativeToWindow: number = 0.5; private get _targetWindow(): Window { return dom.getWindow(this._target.targetElements[0]); @@ -127,6 +128,11 @@ export class HoverWidget extends Widget implements IHoverWidget { this._enableFocusTraps = true; } + const maxHeightRatio = options.appearance?.maxHeightRatio; + if (maxHeightRatio !== undefined && maxHeightRatio > 0 && maxHeightRatio <= 1) { + this._maxHeightRatioRelativeToWindow = maxHeightRatio; + } + // Default to position above when the position is unspecified or a mouse event this._hoverPosition = options.position?.hoverPosition === undefined ? HoverPosition.ABOVE @@ -551,7 +557,7 @@ export class HoverWidget extends Widget implements IHoverWidget { } private adjustHoverMaxHeight(target: TargetRect): void { - let maxHeight = this._targetWindow.innerHeight / 2; + let maxHeight = this._targetWindow.innerHeight * this._maxHeightRatioRelativeToWindow; // When force position is enabled, restrict max height if (this._forcePosition) { diff --git a/src/vs/editor/browser/view/renderingContext.ts b/src/vs/editor/browser/view/renderingContext.ts index e5ec4b30eca..6fe17c1a9f7 100644 --- a/src/vs/editor/browser/view/renderingContext.ts +++ b/src/vs/editor/browser/view/renderingContext.ts @@ -61,6 +61,10 @@ export abstract class RestrictedRenderingContext { return this._viewLayout.getVerticalOffsetAfterLineNumber(lineNumber, includeViewZones); } + public getLineHeightForLineNumber(lineNumber: number): number { + return this._viewLayout.getLineHeightForLineNumber(lineNumber); + } + public getDecorationsInViewport(): ViewModelDecoration[] { return this.viewportData.getDecorationsInViewport(); } diff --git a/src/vs/editor/browser/view/viewLayer.ts b/src/vs/editor/browser/view/viewLayer.ts index 4058205be2f..1b64760a7f4 100644 --- a/src/vs/editor/browser/view/viewLayer.ts +++ b/src/vs/editor/browser/view/viewLayer.ts @@ -10,6 +10,7 @@ import { EditorOption } from '../../common/config/editorOptions.js'; import { StringBuilder } from '../../common/core/stringBuilder.js'; import * as viewEvents from '../../common/viewEvents.js'; import { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js'; +import { ViewContext } from '../../common/viewModel/viewContext.js'; /** * Represents a visible line @@ -255,7 +256,8 @@ export class VisibleLinesCollection { private readonly _linesCollection: RenderedLinesCollection = new RenderedLinesCollection(this._lineFactory); constructor( - private readonly _lineFactory: ILineFactory + private readonly _viewContext: ViewContext, + private readonly _lineFactory: ILineFactory, ) { } @@ -354,7 +356,7 @@ export class VisibleLinesCollection { const inp = this._linesCollection._get(); - const renderer = new ViewLayerRenderer(this.domNode.domNode, this._lineFactory, viewportData); + const renderer = new ViewLayerRenderer(this.domNode.domNode, this._lineFactory, viewportData, this._viewContext); const ctx: IRendererContext = { rendLineNumberStart: inp.rendLineNumberStart, @@ -383,6 +385,7 @@ class ViewLayerRenderer { private readonly _domNode: HTMLElement, private readonly _lineFactory: ILineFactory, private readonly _viewportData: ViewportData, + private readonly _viewContext: ViewContext ) { } @@ -467,7 +470,7 @@ class ViewLayerRenderer { for (let i = startIndex; i <= endIndex; i++) { const lineNumber = rendLineNumberStart + i; - lines[i].layoutLine(lineNumber, deltaTop[lineNumber - deltaLN], this._viewportData.lineHeight); + lines[i].layoutLine(lineNumber, deltaTop[lineNumber - deltaLN], this._lineHeightForLineNumber(lineNumber)); } } @@ -571,7 +574,8 @@ class ViewLayerRenderer { continue; } - const renderResult = line.renderLine(i + rendLineNumberStart, deltaTop[i], this._viewportData.lineHeight, this._viewportData, sb); + const renderedLineNumber = i + rendLineNumberStart; + const renderResult = line.renderLine(renderedLineNumber, deltaTop[i], this._lineHeightForLineNumber(renderedLineNumber), this._viewportData, sb); if (!renderResult) { // line does not need rendering continue; @@ -601,7 +605,8 @@ class ViewLayerRenderer { continue; } - const renderResult = line.renderLine(i + rendLineNumberStart, deltaTop[i], this._viewportData.lineHeight, this._viewportData, sb); + const renderedLineNumber = i + rendLineNumberStart; + const renderResult = line.renderLine(renderedLineNumber, deltaTop[i], this._lineHeightForLineNumber(renderedLineNumber), this._viewportData, sb); if (!renderResult) { // line does not need rendering continue; @@ -616,4 +621,8 @@ class ViewLayerRenderer { } } } + + private _lineHeightForLineNumber(lineNumber: number): number { + return this._viewContext.viewLayout.getLineHeightForLineNumber(lineNumber); + } } diff --git a/src/vs/editor/browser/view/viewOverlays.ts b/src/vs/editor/browser/view/viewOverlays.ts index 6b8e10f341c..1f351bb3571 100644 --- a/src/vs/editor/browser/view/viewOverlays.ts +++ b/src/vs/editor/browser/view/viewOverlays.ts @@ -24,7 +24,7 @@ export class ViewOverlays extends ViewPart { constructor(context: ViewContext) { super(context); - this._visibleLines = new VisibleLinesCollection({ + this._visibleLines = new VisibleLinesCollection(this._context, { createLine: () => new ViewOverlayLine(this._dynamicOverlays) }); this.domNode = this._visibleLines.domNode; @@ -178,6 +178,8 @@ export class ViewOverlayLine implements IVisibleLine { sb.appendString(String(deltaTop)); sb.appendString('px;height:'); sb.appendString(String(lineHeight)); + sb.appendString('px;line-height:'); + sb.appendString(String(lineHeight)); sb.appendString('px;">'); sb.appendString(result); sb.appendString('
'); @@ -189,6 +191,7 @@ export class ViewOverlayLine implements IVisibleLine { if (this._domNode) { this._domNode.setTop(deltaTop); this._domNode.setHeight(lineHeight); + this._domNode.setLineHeight(lineHeight); } } } diff --git a/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts b/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts index 6ae696f90dd..f3e89362b99 100644 --- a/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts +++ b/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts @@ -198,7 +198,6 @@ class Widget { private readonly _fixedOverflowWidgets: boolean; private _contentWidth: number; private _contentLeft: number; - private _lineHeight: number; private _primaryAnchor: PositionPair = new PositionPair(null, null); private _secondaryAnchor: PositionPair = new PositionPair(null, null); @@ -227,7 +226,6 @@ class Widget { this._fixedOverflowWidgets = options.get(EditorOption.fixedOverflowWidgets); this._contentWidth = layoutInfo.contentWidth; this._contentLeft = layoutInfo.contentLeft; - this._lineHeight = options.get(EditorOption.lineHeight); this._affinity = null; this._preference = []; @@ -246,7 +244,6 @@ class Widget { public onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): void { const options = this._context.configuration.options; - this._lineHeight = options.get(EditorOption.lineHeight); if (e.hasChanged(EditorOption.layoutInfo)) { const layoutInfo = options.get(EditorOption.layoutInfo); this._contentLeft = layoutInfo.contentLeft; @@ -403,12 +400,12 @@ class Widget { * The content widget should touch if possible the secondary anchor. */ private _getAnchorsCoordinates(ctx: RenderingContext): { primary: AnchorCoordinate | null; secondary: AnchorCoordinate | null } { - const primary = getCoordinates(this._primaryAnchor.viewPosition, this._affinity, this._lineHeight); + const primary = getCoordinates(this._primaryAnchor.viewPosition, this._affinity); const secondaryViewPosition = (this._secondaryAnchor.viewPosition?.lineNumber === this._primaryAnchor.viewPosition?.lineNumber ? this._secondaryAnchor.viewPosition : null); - const secondary = getCoordinates(secondaryViewPosition, this._affinity, this._lineHeight); + const secondary = getCoordinates(secondaryViewPosition, this._affinity); return { primary, secondary }; - function getCoordinates(position: Position | null, affinity: PositionAffinity | null, lineHeight: number): AnchorCoordinate | null { + function getCoordinates(position: Position | null, affinity: PositionAffinity | null): AnchorCoordinate | null { if (!position) { return null; } @@ -421,6 +418,7 @@ class Widget { // Left-align widgets that should appear :before content const left = (position.column === 1 && affinity === PositionAffinity.LeftOfInjectedText ? 0 : horizontalPosition.left); const top = ctx.getVerticalOffsetForLineNumber(position.lineNumber) - ctx.scrollTop; + const lineHeight = ctx.getLineHeightForLineNumber(position.lineNumber); return new AnchorCoordinate(top, left, lineHeight); } } diff --git a/src/vs/editor/browser/viewParts/glyphMargin/glyphMargin.ts b/src/vs/editor/browser/viewParts/glyphMargin/glyphMargin.ts index c1234141862..dd565eac9e4 100644 --- a/src/vs/editor/browser/viewParts/glyphMargin/glyphMargin.ts +++ b/src/vs/editor/browser/viewParts/glyphMargin/glyphMargin.ts @@ -415,7 +415,8 @@ export class GlyphMarginWidgets extends ViewPart { // Render decorations, reusing previous dom nodes as possible for (let i = 0; i < this._decorationGlyphsToRender.length; i++) { const dec = this._decorationGlyphsToRender[i]; - const top = ctx.viewportData.relativeVerticalOffset[dec.lineNumber - ctx.viewportData.startLineNumber]; + const decLineNumber = dec.lineNumber; + const top = ctx.viewportData.relativeVerticalOffset[decLineNumber - ctx.viewportData.startLineNumber]; const left = this._glyphMarginLeft + dec.laneIndex * this._lineHeight; let domNode: FastDomNode; @@ -426,13 +427,14 @@ export class GlyphMarginWidgets extends ViewPart { this._managedDomNodes.push(domNode); this.domNode.appendChild(domNode); } + const lineHeight = this._context.viewLayout.getLineHeightForLineNumber(decLineNumber); domNode.setClassName(`cgmr codicon ` + dec.combinedClassName); domNode.setPosition(`absolute`); domNode.setTop(top); domNode.setLeft(left); domNode.setWidth(width); - domNode.setHeight(this._lineHeight); + domNode.setHeight(lineHeight); } // remove extra dom nodes diff --git a/src/vs/editor/browser/viewParts/viewCursors/viewCursor.ts b/src/vs/editor/browser/viewParts/viewCursors/viewCursor.ts index b4380373c52..1e4164e9f81 100644 --- a/src/vs/editor/browser/viewParts/viewCursors/viewCursor.ts +++ b/src/vs/editor/browser/viewParts/viewCursors/viewCursor.ts @@ -47,7 +47,6 @@ export class ViewCursor { private _cursorStyle: TextEditorCursorStyle; private _lineCursorWidth: number; - private _lineHeight: number; private _typicalHalfwidthCharacterWidth: number; private _isVisible: boolean; @@ -64,7 +63,6 @@ export class ViewCursor { const fontInfo = options.get(EditorOption.fontInfo); this._cursorStyle = options.get(EditorOption.effectiveCursorStyle); - this._lineHeight = options.get(EditorOption.lineHeight); this._typicalHalfwidthCharacterWidth = fontInfo.typicalHalfwidthCharacterWidth; this._lineCursorWidth = Math.min(options.get(EditorOption.cursorWidth), this._typicalHalfwidthCharacterWidth); @@ -73,7 +71,7 @@ export class ViewCursor { // Create the dom node this._domNode = createFastDomNode(document.createElement('div')); this._domNode.setClassName(`cursor ${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`); - this._domNode.setHeight(this._lineHeight); + this._domNode.setHeight(this._context.viewLayout.getLineHeightForLineNumber(1)); this._domNode.setTop(0); this._domNode.setLeft(0); applyFontInfo(this._domNode, fontInfo); @@ -131,7 +129,6 @@ export class ViewCursor { const fontInfo = options.get(EditorOption.fontInfo); this._cursorStyle = options.get(EditorOption.effectiveCursorStyle); - this._lineHeight = options.get(EditorOption.lineHeight); this._typicalHalfwidthCharacterWidth = fontInfo.typicalHalfwidthCharacterWidth; this._lineCursorWidth = Math.min(options.get(EditorOption.cursorWidth), this._typicalHalfwidthCharacterWidth); applyFontInfo(this._domNode, fontInfo); @@ -193,7 +190,8 @@ export class ViewCursor { } const top = ctx.getVerticalOffsetForLineNumber(position.lineNumber) - ctx.bigNumbersDelta; - return new ViewCursorRenderData(top, left, paddingLeft, width, this._lineHeight, textContent, textContentClassName); + const lineHeight = this._context.viewLayout.getLineHeightForLineNumber(position.lineNumber); + return new ViewCursorRenderData(top, left, paddingLeft, width, lineHeight, textContent, textContentClassName); } const visibleRangeForCharacter = ctx.linesVisibleRangesForRange(new Range(position.lineNumber, position.column, position.lineNumber, position.column + nextGrapheme.length), false); @@ -223,11 +221,12 @@ export class ViewCursor { } let top = ctx.getVerticalOffsetForLineNumber(position.lineNumber) - ctx.bigNumbersDelta; - let height = this._lineHeight; + const lineHeight = this._context.viewLayout.getLineHeightForLineNumber(position.lineNumber); + let height = lineHeight; // Underline might interfere with clicking if (this._cursorStyle === TextEditorCursorStyle.Underline || this._cursorStyle === TextEditorCursorStyle.UnderlineThin) { - top += this._lineHeight - 2; + top += lineHeight - 2; height = 2; } diff --git a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts index 16229cd928a..476bdeeb68c 100644 --- a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts +++ b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts @@ -175,6 +175,8 @@ export class ViewLine implements IVisibleLine { sb.appendString(String(deltaTop)); sb.appendString('px;height:'); sb.appendString(String(lineHeight)); + sb.appendString('px;line-height:'); + sb.appendString(String(lineHeight)); sb.appendString('px;" class="'); sb.appendString(ViewLine.CLASS_NAME); sb.appendString('">'); @@ -211,6 +213,7 @@ export class ViewLine implements IVisibleLine { if (this._renderedViewLine && this._renderedViewLine.domNode) { this._renderedViewLine.domNode.setTop(deltaTop); this._renderedViewLine.domNode.setHeight(lineHeight); + this._renderedViewLine.domNode.setLineHeight(lineHeight); } } diff --git a/src/vs/editor/browser/viewParts/viewLines/viewLines.ts b/src/vs/editor/browser/viewParts/viewLines/viewLines.ts index 78324773a68..140b62be89e 100644 --- a/src/vs/editor/browser/viewParts/viewLines/viewLines.ts +++ b/src/vs/editor/browser/viewParts/viewLines/viewLines.ts @@ -145,7 +145,7 @@ export class ViewLines extends ViewPart implements IViewLines { this._linesContent = linesContent; this._textRangeRestingSpot = document.createElement('div'); - this._visibleLines = new VisibleLinesCollection({ + this._visibleLines = new VisibleLinesCollection(this._context, { createLine: () => new ViewLine(viewGpuContext, this._viewLineOptions), }); this.domNode = this._visibleLines.domNode; @@ -444,7 +444,7 @@ export class ViewLines extends ViewPart implements IViewLines { } const startColumn = lineNumber === range.startLineNumber ? range.startColumn : 1; - const continuesInNextLine = lineNumber !== range.endLineNumber; + const continuesInNextLine = lineNumber !== originalEndLineNumber; const endColumn = continuesInNextLine ? this._context.viewModel.getLineMaxColumn(lineNumber) : range.endColumn; const visibleRangesForLine = this._visibleLines.getVisibleLine(lineNumber).getVisibleRangesForRange(lineNumber, startColumn, endColumn, domReadingContext); diff --git a/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts b/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts index a6cabd55951..dd0666b3e3b 100644 --- a/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts +++ b/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts @@ -535,7 +535,7 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { continue; } const startColumn = lineNumber === range.startLineNumber ? range.startColumn : 1; - const continuesInNextLine = lineNumber !== range.endLineNumber; + const continuesInNextLine = lineNumber !== originalEndLineNumber; const endColumn = continuesInNextLine ? this._context.viewModel.getLineMaxColumn(lineNumber) : range.endColumn; const visibleRangesForLine = this._visibleRangesForLineRange(lineNumber, startColumn, endColumn); diff --git a/src/vs/editor/browser/viewParts/whitespace/whitespace.ts b/src/vs/editor/browser/viewParts/whitespace/whitespace.ts index 56cc4692dc0..d26aba26a26 100644 --- a/src/vs/editor/browser/viewParts/whitespace/whitespace.ts +++ b/src/vs/editor/browser/viewParts/whitespace/whitespace.ts @@ -146,7 +146,7 @@ export class WhitespaceOverlay extends DynamicViewOverlay { const fauxIndentLength = lineData.minColumn - 1; const onlyBoundary = (this._options.renderWhitespace === 'boundary'); const onlyTrailing = (this._options.renderWhitespace === 'trailing'); - const lineHeight = this._options.lineHeight; + const lineHeight = ctx.getLineHeightForLineNumber(lineNumber); const middotWidth = this._options.middotWidth; const wsmiddotWidth = this._options.wsmiddotWidth; const spaceWidth = this._options.spaceWidth; diff --git a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index 042152e7dde..f2a45afad39 100644 --- a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -44,7 +44,7 @@ import { EndOfLinePreference, IAttachedView, ICursorStateComputer, IIdentifiedSi import { ClassName } from '../../../common/model/intervalTree.js'; import { ModelDecorationOptions } from '../../../common/model/textModel.js'; import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js'; -import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent } from '../../../common/textModelEvents.js'; +import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent, ModelLineHeightChangedEvent } from '../../../common/textModelEvents.js'; import { VerticalRevealType } from '../../../common/viewEvents.js'; import { IEditorWhitespace, IViewModel } from '../../../common/viewModel.js'; import { MonospaceLineBreaksComputerFactory } from '../../../common/viewModel/monospaceLineBreaksComputer.js'; @@ -91,6 +91,9 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE private readonly _onDidChangeModelDecorations: Emitter = this._register(new Emitter({ deliveryQueue: this._deliveryQueue })); public readonly onDidChangeModelDecorations: Event = this._onDidChangeModelDecorations.event; + private readonly _onDidChangeLineHeight: Emitter = this._register(new Emitter({ deliveryQueue: this._deliveryQueue })); + public readonly onDidChangeLineHeight: Event = this._onDidChangeLineHeight.event; + private readonly _onDidChangeModelTokens: Emitter = this._register(new Emitter({ deliveryQueue: this._deliveryQueue })); public readonly onDidChangeModelTokens: Event = this._onDidChangeModelTokens.event; @@ -498,8 +501,16 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE const hasTextFocus = this.hasTextFocus(); const detachedModel = this._detachModel(); this._attachModel(model); - if (hasTextFocus && this.hasModel()) { - this.focus(); + if (this.hasModel()) { + // we have a new model (with a new view)! + if (hasTextFocus) { + this.focus(); + } + } else { + // we have no model (and no view) anymore + // make sure the outside world knows we are not focused + this._editorTextFocus.setValue(false); + this._editorWidgetFocus.setValue(false); } this._removeDecorationTypes(); @@ -586,6 +597,14 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE return CodeEditorWidget._getVerticalOffsetAfterPosition(this._modelData, lineNumber, maxCol, includeViewZones); } + public getLineHeightForLineNumber(lineNumber: number): number { + if (!this._modelData) { + return -1; + } + const viewPosition = this._modelData.viewModel.coordinatesConverter.convertModelPositionToViewPosition(new Position(lineNumber, 1)); + return this._modelData.viewModel.viewLayout.getLineHeightForLineNumber(viewPosition.lineNumber); + } + public setHiddenAreas(ranges: IRange[], source?: unknown, forceUpdate?: boolean): void { this._modelData?.viewModel.setHiddenAreas(ranges.map(r => Range.lift(r)), source, forceUpdate); } @@ -1306,7 +1325,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE }); } - public setDecorationsByType(description: string, decorationTypeKey: string, decorationOptions: editorCommon.IDecorationOptions[]): void { + public setDecorationsByType(description: string, decorationTypeKey: string, decorationOptions: editorCommon.IDecorationOptions[]): readonly string[] { const newDecorationsSubTypes: { [key: string]: boolean } = {}; const oldDecorationsSubTypes = this._decorationTypeSubtypes[decorationTypeKey] || {}; @@ -1346,6 +1365,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE // update all decorations const oldDecorationsIds = this._decorationTypeKeysToIds[decorationTypeKey] || []; this.changeDecorations(accessor => this._decorationTypeKeysToIds[decorationTypeKey] = accessor.deltaDecorations(oldDecorationsIds, newModelDecorations)); + return this._decorationTypeKeysToIds[decorationTypeKey] || []; } public setDecorationsByTypeFast(decorationTypeKey: string, ranges: IRange[]): void { @@ -1589,11 +1609,11 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE const top = CodeEditorWidget._getVerticalOffsetForPosition(this._modelData, position.lineNumber, position.column) - this.getScrollTop(); const left = this._modelData.view.getOffsetForColumn(position.lineNumber, position.column) + layoutInfo.glyphMarginWidth + layoutInfo.lineNumbersWidth + layoutInfo.decorationsWidth - this.getScrollLeft(); - + const height = this.getLineHeightForLineNumber(position.lineNumber); return { top: top, left: left, - height: options.get(EditorOption.lineHeight) + height }; } @@ -1767,6 +1787,9 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE case OutgoingViewModelEventKind.ModelTokensChanged: this._onDidChangeModelTokens.fire(e.event); break; + case OutgoingViewModelEventKind.ModelLineHeightChanged: + this._onDidChangeLineHeight.fire(e.event); + break; } })); diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts index 638c9fe8bd5..3dfb032516c 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts @@ -61,12 +61,14 @@ export class DiffEditorEditors extends Disposable { this._argCodeEditorWidgetOptions = null as any; this._register(autorunHandleChanges({ - createEmptyChangeSummary: (): IDiffEditorConstructionOptions => ({}), - handleChange: (ctx, changeSummary) => { - if (ctx.didChange(_options.editorOptions)) { - Object.assign(changeSummary, ctx.change.changedOptions); + changeTracker: { + createChangeSummary: (): IDiffEditorConstructionOptions => ({}), + handleChange: (ctx, changeSummary) => { + if (ctx.didChange(_options.editorOptions)) { + Object.assign(changeSummary, ctx.change.changedOptions); + } + return true; } - return true; } }, (reader, changeSummary) => { /** @description update editor options */ diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts index ce0eea0bcaa..ac4aee78443 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts @@ -146,6 +146,23 @@ export class RenderOptions { setWidth, ); } + + public withScrollBeyondLastColumn(scrollBeyondLastColumn: number): RenderOptions { + return new RenderOptions( + this.tabSize, + this.fontInfo, + this.disableMonospaceOptimizations, + this.typicalHalfwidthCharacterWidth, + scrollBeyondLastColumn, + this.lineHeight, + this.lineDecorationsWidth, + this.stopRenderingLineAfter, + this.renderWhitespace, + this.renderControlCharacters, + this.fontLigatures, + this.setWidth, + ); + } } export interface RenderLinesResult { diff --git a/src/vs/editor/browser/widget/diffEditor/features/movedBlocksLinesFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/movedBlocksLinesFeature.ts index a06947da06d..cbf57933eb5 100644 --- a/src/vs/editor/browser/widget/diffEditor/features/movedBlocksLinesFeature.ts +++ b/src/vs/editor/browser/widget/diffEditor/features/movedBlocksLinesFeature.ts @@ -95,11 +95,13 @@ export class MovedBlocksLinesFeature extends Disposable { let lastChangedEditor: 'original' | 'modified' = 'modified'; this._register(autorunHandleChanges({ - createEmptyChangeSummary: () => undefined, - handleChange: (ctx, summary) => { - if (ctx.didChange(originalHasFocus)) { lastChangedEditor = 'original'; } - if (ctx.didChange(modifiedHasFocus)) { lastChangedEditor = 'modified'; } - return true; + changeTracker: { + createChangeSummary: () => undefined, + handleChange: (ctx, summary) => { + if (ctx.didChange(originalHasFocus)) { lastChangedEditor = 'original'; } + if (ctx.didChange(modifiedHasFocus)) { lastChangedEditor = 'modified'; } + return true; + } } }, reader => { /** @description MovedBlocksLines.setActiveMovedTextFromCursor */ diff --git a/src/vs/editor/browser/widget/diffEditor/utils.ts b/src/vs/editor/browser/widget/diffEditor/utils.ts index 47faca2f8cf..8fbcaddc48a 100644 --- a/src/vs/editor/browser/widget/diffEditor/utils.ts +++ b/src/vs/editor/browser/widget/diffEditor/utils.ts @@ -137,12 +137,14 @@ export function animatedObservable(targetWindow: Window, base: IObservableWithCh let animationFrame: number | undefined = undefined; store.add(autorunHandleChanges({ - createEmptyChangeSummary: () => ({ animate: false }), - handleChange: (ctx, s) => { - if (ctx.didChange(base)) { - s.animate = s.animate || ctx.change; + changeTracker: { + createChangeSummary: () => ({ animate: false }), + handleChange: (ctx, s) => { + if (ctx.didChange(base)) { + s.animate = s.animate || ctx.change; + } + return true; } - return true; } }, (reader, s) => { /** @description update value */ @@ -328,14 +330,16 @@ export function applyViewZones(editor: ICodeEditor, viewZones: IObservable { /** @description layoutZone on change */ for (const vz of curViewZones) { diff --git a/src/vs/editor/common/codecs/baseToken.ts b/src/vs/editor/common/codecs/baseToken.ts index 6430ffb61a5..99c4df36758 100644 --- a/src/vs/editor/common/codecs/baseToken.ts +++ b/src/vs/editor/common/codecs/baseToken.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { pick } from '../../../base/common/arrays.js'; +import { assert } from '../../../base/common/assert.js'; import { IRange, Range } from '../../../editor/common/core/range.js'; /** @@ -59,4 +61,107 @@ export abstract class BaseToken { return this; } + + /** + * Render a list of tokens into a string. + */ + public static render(tokens: readonly BaseToken[]): string { + return tokens.map(pick('text')).join(''); + } + + /** + * Returns the full range of a list of tokens in which the first token is + * used as the start of a tokens sequence and the last token reflects the end. + * + * @throws if: + * - provided {@link tokens} list is empty + * - the first token start number is greater than the start line of the last token + * - if the first and last token are on the same line, the first token start column must + * be smaller than the start column of the last token + */ + public static fullRange(tokens: readonly BaseToken[]): Range { + assert( + tokens.length > 0, + 'Cannot get full range for an empty list of tokens.', + ); + + const firstToken = tokens[0]; + const lastToken = tokens[tokens.length - 1]; + + // sanity checks for the full range we would construct + assert( + firstToken.range.startLineNumber <= lastToken.range.startLineNumber, + 'First token must start on previous or the same line as the last token.', + ); + if ((firstToken !== lastToken) && (firstToken.range.startLineNumber === lastToken.range.startLineNumber)) { + assert( + firstToken.range.endColumn <= lastToken.range.startColumn, + [ + 'First token must end at least on previous or the same column as the last token.', + `First token: ${firstToken}; Last token: ${lastToken}.`, + ].join('\n'), + ); + } + + return new Range( + firstToken.range.startLineNumber, + firstToken.range.startColumn, + lastToken.range.endLineNumber, + lastToken.range.endColumn, + ); + } + + /** + * Shorten version of the {@link text} property. + */ + public shortText( + maxLength: number = 32, + ): string { + if (this.text.length <= maxLength) { + return this.text; + } + + return `${this.text.slice(0, maxLength - 1)}...`; + } +} + +/** + * Tokens that represent a sequence of tokens that does not + * hold an additional meaning in the text. + */ +export class Text extends BaseToken { + public get text(): string { + return BaseToken.render(this.tokens); + } + + constructor( + range: Range, + public readonly tokens: readonly TToken[], + ) { + super(range); + } + + /** + * Create new instance of the token from a provided list of tokens. + * + * @throws if the provided tokens list is empty because this function + * automatically infers the range of the resulting token based + * on the first and last token in the list. + */ + public static fromTokens( + tokens: readonly TToken[], + ): Text { + assert( + tokens.length > 0, + 'Cannot infer range from an empty list of tokens.', + ); + + const range = BaseToken.fullRange(tokens); + + return new Text(range, tokens); + } + + public override toString(): string { + return `text(${this.shortText()})${this.range}`; + } } diff --git a/src/vs/editor/common/codecs/frontMatterCodec/constants.ts b/src/vs/editor/common/codecs/frontMatterCodec/constants.ts new file mode 100644 index 00000000000..0df65a7b88d --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/constants.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { NewLine } from '../linesCodec/tokens/newLine.js'; +import { CarriageReturn } from '../linesCodec/tokens/carriageReturn.js'; +import { FormFeed, Space, Tab, VerticalTab } from '../simpleCodec/tokens/index.js'; + +/** + * List of valid "space" tokens that are valid between + * different entities of the Front Matter header. + */ +export const VALID_SPACE_TOKENS = Object.freeze([ + Space, Tab, CarriageReturn, NewLine, FormFeed, VerticalTab, +]); diff --git a/src/vs/editor/common/codecs/frontMatterCodec/frontMatterDecoder.ts b/src/vs/editor/common/codecs/frontMatterCodec/frontMatterDecoder.ts new file mode 100644 index 00000000000..02db7f7fca7 --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/frontMatterDecoder.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VALID_SPACE_TOKENS } from './constants.js'; +import { Word } from '../simpleCodec/tokens/index.js'; +import { TokenStream } from '../utils/tokenStream.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { ReadableStream } from '../../../../base/common/stream.js'; +import { FrontMatterToken, FrontMatterRecord } from './tokens/index.js'; +import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; +import { SimpleDecoder, type TSimpleDecoderToken } from '../simpleCodec/simpleDecoder.js'; +import { PartialFrontMatterRecord, PartialFrontMatterRecordName, PartialFrontMatterRecordNameWithDelimiter } from './parsers/frontMatterRecord.js'; + +/** + * Tokens produced by this decoder. + */ +export type TFrontMatterToken = FrontMatterRecord | TSimpleDecoderToken; + +/** + * Decoder capable of parsing Front Matter contents from a sequence of simple tokens. + */ +export class FrontMatterDecoder extends BaseDecoder { + /** + * Current parser reference responsible for parsing a specific sequence + * of tokens into a standalone token. + */ + private current?: PartialFrontMatterRecordName | PartialFrontMatterRecordNameWithDelimiter | PartialFrontMatterRecord; + + constructor( + stream: ReadableStream | TokenStream, + ) { + if (stream instanceof TokenStream) { + super(stream); + + return; + } + + super(new SimpleDecoder(stream)); + } + + protected override onStreamData(token: TSimpleDecoderToken): void { + if (this.current !== undefined) { + const acceptResult = this.current.accept(token); + const { result, wasTokenConsumed } = acceptResult; + + if (result === 'failure') { + this.reEmitCurrentTokens(); + + if (wasTokenConsumed === false) { + this._onData.fire(token); + } + + delete this.current; + return; + } + + const { nextParser } = acceptResult; + + if (nextParser instanceof FrontMatterToken) { + this._onData.fire(nextParser); + + if (wasTokenConsumed === false) { + this._onData.fire(token); + } + + delete this.current; + return; + } + + this.current = nextParser; + if (wasTokenConsumed === false) { + this._onData.fire(token); + } + + return; + } + + // a word token starts a new record + if (token instanceof Word) { + this.current = new PartialFrontMatterRecordName(token); + return; + } + + // re-emit all "space" tokens immediately as all of them + // are valid while we are not in the "record parsing" mode + for (const ValidToken of VALID_SPACE_TOKENS) { + if (token instanceof ValidToken) { + this._onData.fire(token); + return; + } + } + + // unexpected token type, re-emit existing tokens and continue + this.reEmitCurrentTokens(); + } + + protected override onStreamEnd(): void { + try { + if (this.current === undefined) { + return; + } + + this.reEmitCurrentTokens(); + } finally { + delete this.current; + super.onStreamEnd(); + } + } + + /** + * Re-emit tokens accumulated so far in the current parser object. + */ + protected reEmitCurrentTokens(): void { + if (this.current === undefined) { + return; + } + + for (const token of this.current.tokens) { + this._onData.fire(token); + } + delete this.current; + } +} diff --git a/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterArray.ts b/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterArray.ts new file mode 100644 index 00000000000..22b54f4517f --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterArray.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VALID_SPACE_TOKENS } from '../constants.js'; +import { assert } from '../../../../../base/common/assert.js'; +import { FrontMatterArray } from '../tokens/frontMatterArray.js'; +import { assertDefined } from '../../../../../base/common/types.js'; +import { FrontMatterValueToken } from '../tokens/frontMatterToken.js'; +import { TSimpleDecoderToken } from '../../simpleCodec/simpleDecoder.js'; +import { Comma, LeftBracket, RightBracket } from '../../simpleCodec/tokens/index.js'; +import { PartialFrontMatterValue, VALID_VALUE_START_TOKENS } from './frontMatterValue.js'; +import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; + +/** + * List of tokens that can go in-between array items + * and array brackets. + */ +const VALID_DELIMITER_TOKENS = Object.freeze([ + ...VALID_SPACE_TOKENS, + Comma, +]); + +/** + * Responsible for parsing an array syntax (or "inline sequence" + * in YAML terms), e.g. `[1, '2', true, 2.54]` + */ +export class PartialFrontMatterArray extends ParserBase { + /** + * Current parser reference responsible for parsing an array "value". + */ + private currentValueParser?: PartialFrontMatterValue; + + /** + * Whether an array item is allowed in the current position + * of the token sequence. E.g., items are allowed after + * a command or a open bracket, but not immediately after + * another item in the array. + */ + private arrayItemAllowed = true; + + constructor( + private readonly startToken: LeftBracket, + ) { + /** + * Sanity check - logic inside the {@link PartialFrontMatterArray.accept accept} method + * above assumes that the {@link VALID_DELIMITER_TOKENS} tokens list does not intersect + * with the {@link VALID_VALUE_START_TOKENS} tokens list. + * + * Note! the `as` type casting below is ok since we offload the type intersection check + * to the runtime, and is required to avoid compilation errors in Typescript. + */ + for (const DelimiterToken of VALID_DELIMITER_TOKENS) { + for (const ValueStartToken of VALID_VALUE_START_TOKENS as unknown[]) { + assert( + DelimiterToken !== ValueStartToken, + `Delimiter tokens list must not contain value start token '${ValueStartToken}'.`, + ); + } + } + + super([startToken]); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + if (this.currentValueParser !== undefined) { + const acceptResult = this.currentValueParser.accept(token); + const { result, wasTokenConsumed } = acceptResult; + + if (result === 'failure') { + this.isConsumed = true; + + return { + result: 'failure', + wasTokenConsumed, + }; + } + + const { nextParser } = acceptResult; + + if (nextParser instanceof FrontMatterValueToken) { + this.currentTokens.push(nextParser); + delete this.currentValueParser; + + return { + result: 'success', + nextParser: this, + wasTokenConsumed, + }; + } + + this.currentValueParser = nextParser; + return { + result: 'success', + nextParser: this, + wasTokenConsumed, + }; + } + + if (token instanceof RightBracket) { + // sanity check in case this block moves around + // to a different place in the code + assert( + this.currentValueParser === undefined, + `Unexpected end of array. Last value is not finished.`, + ); + + this.currentTokens.push(token); + + this.isConsumed = true; + return { + result: 'success', + nextParser: this.asArrayToken(), + wasTokenConsumed: true, + }; + } + + // iterate until a valid value start token is found + for (const ValidToken of VALID_DELIMITER_TOKENS) { + if (token instanceof ValidToken) { + this.currentTokens.push(token); + + if ((this.arrayItemAllowed === false) && token instanceof Comma) { + this.arrayItemAllowed = true; + } + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + } + + // once we found a valid start value token, create a new value parser + if ((this.arrayItemAllowed === true) && PartialFrontMatterValue.isValueStartToken(token)) { + this.currentValueParser = new PartialFrontMatterValue(); + this.arrayItemAllowed = false; + + return this.accept(token); + } + + // in all other cases fail because of the unexpected token type + this.isConsumed = true; + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + /** + * Convert current parser into a {@link FrontMatterArray} token, + * if possible. + * + * @throws if the last token in the accumulated token list + * is not a closing bracket ({@link RightBracket}). + */ + public asArrayToken(): FrontMatterArray { + this.isConsumed = true; + const endToken = this.currentTokens[this.currentTokens.length - 1]; + + assertDefined( + endToken, + `No tokens found.`, + ); + + assert( + endToken instanceof RightBracket, + 'Cannot find a closing bracket of the array.', + ); + + const valueTokens: FrontMatterValueToken[] = []; + for (const currentToken of this.currentTokens) { + if (currentToken instanceof FrontMatterValueToken) { + valueTokens.push(currentToken); + } + } + + return new FrontMatterArray([ + this.startToken, + ...valueTokens, + endToken, + ]); + } +} diff --git a/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterRecord.ts b/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterRecord.ts new file mode 100644 index 00000000000..6af49acbbce --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterRecord.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../../../../../base/common/assert.js'; +import { PartialFrontMatterValue } from './frontMatterValue.js'; +import { TSimpleDecoderToken } from '../../simpleCodec/simpleDecoder.js'; +import { Colon, Word, Dash, Space, Tab } from '../../simpleCodec/tokens/index.js'; +import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; +import { FrontMatterValueToken, FrontMatterRecordName, type TRecordNameToken, type TRecordSpaceToken, FrontMatterRecordDelimiter, FrontMatterRecord } from '../tokens/index.js'; + +/** + * Tokens that can be used inside a record name. + */ +const VALID_NAME_TOKENS = [ + Word, Dash, +]; + +/** + * List of a "space" tokens that are allowed in between + * record name, delimiter and value tokens inside a record. + * + * E.g. the following is a valid record with `\t` used as a "space" token: + * + * ``` + * \t\tname\t\t:\t\t'value'\t\t\n + * ``` + */ +const VALID_SPACE_TOKENS = [ + Space, Tab, +]; + +/** + * List of tokens that terminate a record name. + */ +const VALID_NAME_STOP_TOKENS = [ + ...VALID_SPACE_TOKENS, + Colon, +]; + +/** + * Parser for a `name` part of a Front Matter record. + * + * E.g., `'name'` in the example below: + * + * ``` + * name: 'value' + * ``` + */ +export class PartialFrontMatterRecordName extends ParserBase { + constructor( + startToken: Word, + ) { + super([startToken]); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + for (const ValidToken of VALID_NAME_TOKENS) { + if (token instanceof ValidToken) { + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + } + + // once name is followed by a "space" token or a "colon", we have the full + // record name hence can transition to the next parser + for (const SpaceOrDelimiterToken of VALID_NAME_STOP_TOKENS) { + if (token instanceof SpaceOrDelimiterToken) { + const recordName = new FrontMatterRecordName(this.currentTokens); + + this.isConsumed = true; + return { + result: 'success', + nextParser: new PartialFrontMatterRecordNameWithDelimiter([recordName, token]), + wasTokenConsumed: true, + }; + } + } + + // in all other cases fail due to the unexpected token type for a record name + this.isConsumed = true; + return { + result: 'failure', + wasTokenConsumed: false, + }; + } +} + +/** + * Parser for a record `name` with the `: ` delimiter. + * + * * E.g., `name:` in the example below: + * + * ``` + * name: 'value' + * ``` + */ +export class PartialFrontMatterRecordNameWithDelimiter extends ParserBase { + constructor( + tokens: readonly [FrontMatterRecordName, TRecordSpaceToken | Colon], + ) { + super([...tokens]); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + const previousToken = this.currentTokens[this.currentTokens.length - 1]; + + const isSpacingToken = (token instanceof Space) || (token instanceof Tab); + + // delimiter must always be a `:` followed by a "space" character + // once we encounter that sequence, we can transition to the next parser + if ((isSpacingToken === true) && (previousToken instanceof Colon)) { + const recordDelimiter = new FrontMatterRecordDelimiter([ + previousToken, + token, + ]); + + const recordName = this.currentTokens[0]; + + // sanity check + assert( + recordName instanceof FrontMatterRecordName, + `Expected a front matter record name, got '${recordName}'.`, + ); + + this.isConsumed = true; + return { + result: 'success', + nextParser: new PartialFrontMatterRecord( + [recordName, recordDelimiter], + ), + wasTokenConsumed: true, + }; + } + + // allow some spacing before the colon delimiter + for (const ValidToken of VALID_SPACE_TOKENS) { + if (token instanceof ValidToken) { + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + } + + // include the colon delimiter + if (token instanceof Colon) { + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + + // otherwise fail due to the unexpected token type between + // record name and record name delimiter tokens + this.isConsumed = true; + return { + result: 'failure', + wasTokenConsumed: false, + }; + } +} + +/** + * Parser for a `record` inside a Front Matter header. + * + * * E.g., `name: 'value'` in the example below: + * + * ``` + * --- + * name: 'value' + * isExample: true + * --- + * ``` + */ +export class PartialFrontMatterRecord extends ParserBase { + constructor( + tokens: [FrontMatterRecordName, FrontMatterRecordDelimiter], + ) { + super(tokens); + } + + /** + * Current parser reference responsible for parsing the "value" part of the record. + */ + private currentValueParser?: PartialFrontMatterValue; + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + if (this.currentValueParser !== undefined) { + const acceptResult = this.currentValueParser.accept(token); + const { result, wasTokenConsumed } = acceptResult; + + if (result === 'failure') { + this.isConsumed = true; + + return { + result: 'failure', + wasTokenConsumed, + }; + } + + const { nextParser } = acceptResult; + + if (nextParser instanceof FrontMatterValueToken) { + this.currentTokens.push(nextParser); + delete this.currentValueParser; + + this.isConsumed = true; + try { + return { + result: 'success', + nextParser: FrontMatterRecord.fromTokens([ + this.currentTokens[0], + this.currentTokens[1], + nextParser, + ]), + wasTokenConsumed, + }; + } catch (_error) { + return { + result: 'failure', + wasTokenConsumed, + }; + } + } + + this.currentValueParser = nextParser; + return { + result: 'success', + nextParser: this, + wasTokenConsumed, + }; + } + + // iterate until the first "value" token is found + for (const ValidToken of VALID_SPACE_TOKENS) { + if (token instanceof ValidToken) { + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + } + + // if token can start a "value" sequence, parse the value + if (PartialFrontMatterValue.isValueStartToken(token)) { + this.currentValueParser = new PartialFrontMatterValue(); + + return this.accept(token); + } + + // otherwise fail due to the unexpected token type for a record value + this.isConsumed = true; + return { + result: 'failure', + wasTokenConsumed: false, + }; + } +} diff --git a/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterString.ts b/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterString.ts new file mode 100644 index 00000000000..349c26d28cc --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterString.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../../../../../base/common/assert.js'; +import { SimpleToken } from '../../simpleCodec/tokens/index.js'; +import { assertDefined } from '../../../../../base/common/types.js'; +import { TSimpleDecoderToken } from '../../simpleCodec/simpleDecoder.js'; +import { FrontMatterString, TQuoteToken } from '../tokens/frontMatterString.js'; +import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; + +/** + * Parser responsible for parsing a string value. + */ +export class PartialFrontMatterString extends ParserBase> { + constructor( + private readonly startToken: TQuoteToken, + ) { + super([startToken]); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult> { + this.currentTokens.push(token); + + // iterate until a `matching end quote` is found + if ((token instanceof SimpleToken) && (this.startToken.sameType(token))) { + return { + result: 'success', + nextParser: this.asStringToken(), + wasTokenConsumed: true, + }; + } + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + + /** + * Convert the current parser into a {@link FrontMatterString} token, + * if possible. + * + * @throws if the first and last tokens are not quote tokens of the same type. + */ + public asStringToken(): FrontMatterString { + const endToken = this.currentTokens[this.currentTokens.length - 1]; + + assertDefined( + endToken, + `No matching end token found.`, + ); + + assert( + this.startToken.sameType(endToken), + `String starts with \`${this.startToken.text}\`, but ends with \`${endToken.text}\`.`, + ); + + return new FrontMatterString([ + this.startToken, + ...this.currentTokens + .slice(1, this.currentTokens.length - 1), + endToken, + ]); + } +} diff --git a/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterValue.ts b/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterValue.ts new file mode 100644 index 00000000000..8e1e32d032a --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/parsers/frontMatterValue.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { PartialFrontMatterArray } from './frontMatterArray.js'; +import { PartialFrontMatterString } from './frontMatterString.js'; +import { FrontMatterBoolean } from '../tokens/frontMatterBoolean.js'; +import { FrontMatterValueToken } from '../tokens/frontMatterToken.js'; +import { TSimpleDecoderToken } from '../../simpleCodec/simpleDecoder.js'; +import { Word, Quote, DoubleQuote, LeftBracket } from '../../simpleCodec/tokens/index.js'; +import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; + +/** + * List of tokens that can start a "value" sequence. + * + * - {@link Word} - can be a `boolean` value + * - {@link Quote}, {@link DoubleQuote} - can start a `string` value + * - {@link LeftBracket} - can start an `array` value + */ +export const VALID_VALUE_START_TOKENS = Object.freeze([ + Word, + Quote, + DoubleQuote, + LeftBracket, +]); + +/** + * Type alias for a token that can start a "value" sequence. + */ +type TValueStartToken = InstanceType; + +/** + * Parser responsible for parsing a "value" sequence in a Front Matter header. + */ +export class PartialFrontMatterValue extends ParserBase { + /** + * Current parser reference responsible for parsing + * a specific "value" sequence. + */ + private currentValueParser?: PartialFrontMatterString | PartialFrontMatterArray; + + /** + * Get the tokens that were accumulated so far. + */ + public override get tokens(): readonly TSimpleDecoderToken[] { + if (this.currentValueParser === undefined) { + return []; + } + + return this.currentValueParser.tokens; + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + if (this.currentValueParser !== undefined) { + const acceptResult = this.currentValueParser.accept(token); + const { result, wasTokenConsumed } = acceptResult; + + // current value parser is consumed with its child value parser + this.isConsumed = this.currentValueParser.consumed; + + if (result === 'success') { + const { nextParser } = acceptResult; + + if (nextParser instanceof FrontMatterValueToken) { + return { + result: 'success', + nextParser, + wasTokenConsumed, + }; + } + + this.currentValueParser = nextParser; + return { + result: 'success', + nextParser: this, + wasTokenConsumed, + }; + } + + return { + result: 'failure', + wasTokenConsumed, + }; + } + + // if the first token represents a `quote` character, try to parse a string value + if ((token instanceof Quote) || (token instanceof DoubleQuote)) { + this.currentValueParser = new PartialFrontMatterString(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + + // if the first token represents a `[` character, try to parse an array value + if (token instanceof LeftBracket) { + this.currentValueParser = new PartialFrontMatterArray(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + + // if the first token represents a `word` try to parse a boolean + if (token instanceof Word) { + // in either success or failure case, the parser is consumed + this.isConsumed = true; + + try { + return { + result: 'success', + nextParser: FrontMatterBoolean.fromToken(token), + wasTokenConsumed: true, + }; + } catch (_error) { + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + } + + // in all other cases fail due to unexpected value sequence + this.isConsumed = true; + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + /** + * Check if provided token can be a start of a "value" sequence. + * See {@link VALID_VALUE_START_TOKENS} for the list of valid tokens. + */ + public static isValueStartToken( + token: BaseToken, + ): token is TValueStartToken { + for (const ValidToken of VALID_VALUE_START_TOKENS) { + if (token instanceof ValidToken) { + return true; + } + } + + return false; + } +} diff --git a/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterArray.ts b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterArray.ts new file mode 100644 index 00000000000..b9e54253bcd --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterArray.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { FrontMatterValueToken, TValueTypeName } from './frontMatterToken.js'; +import { LeftBracket, RightBracket } from '../../simpleCodec/tokens/index.js'; + +/** + * Token that represents an `array` value in a Front Matter header. + */ +export class FrontMatterArray extends FrontMatterValueToken<'array'> { + /** + * Name of the `array` value type. + */ + public override readonly valueTypeName = 'array'; + + constructor( + /** + * List of tokens of the array value. Must start and end + * with square brackets, but tokens in the middle hold + * only the value tokens, omitting commas and spaces. + */ + public readonly tokens: readonly [ + LeftBracket, + ...FrontMatterValueToken[], + RightBracket, + ], + ) { + super( + BaseToken.fullRange(tokens), + ); + } + + /** + * List of the array items. + */ + public get items(): readonly FrontMatterValueToken[] { + const result = []; + + for (const token of this.tokens) { + if (token instanceof FrontMatterValueToken) { + result.push(token); + } + } + + return result; + } + + public override get text(): string { + return BaseToken.render(this.tokens); + } + public override toString(): string { + return `front-matter-array(${this.shortText()})${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterBoolean.ts b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterBoolean.ts new file mode 100644 index 00000000000..0203d3c9ab0 --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterBoolean.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../core/range.js'; +import { Word } from '../../simpleCodec/tokens/index.js'; +import { FrontMatterValueToken } from './frontMatterToken.js'; +import { assertDefined } from '../../../../../base/common/types.js'; + +/** + * Token that represents a `boolean` value in a Front Matter header. + */ +export class FrontMatterBoolean extends FrontMatterValueToken<'boolean'> { + /** + * Name of the `boolean` value type. + */ + public override readonly valueTypeName = 'boolean'; + + constructor( + range: Range, + public readonly value: boolean, + ) { + super(range); + } + + public static fromToken(token: Word): FrontMatterBoolean { + const value = asBoolean(token); + + assertDefined( + value, + `Cannot convert '${token}' to a boolean value.`, + ); + + return new FrontMatterBoolean(token.range, value); + } + + public override get text(): string { + return `${this.value}`; + } + + public override toString(): string { + return `front-matter-boolean(${this.shortText()})${this.range}`; + } +} + +/** + * Try to convert a {@link Word} token to a `boolean` value. + */ +const asBoolean = ( + token: Word, +): boolean | null => { + if (token.text.toLowerCase() === 'true') { + return true; + } + + if (token.text.toLowerCase() === 'false') { + return false; + } + + return null; +}; diff --git a/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterRecord.ts b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterRecord.ts new file mode 100644 index 00000000000..96ecdeabe43 --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterRecord.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { assert } from '../../../../../base/common/assert.js'; +import { Colon, Word, Dash, Space, Tab } from '../../simpleCodec/tokens/index.js'; +import { FrontMatterToken, FrontMatterValueToken, TValueTypeName } from '../tokens/frontMatterToken.js'; + +/** + * Type for tokens that can be used inside a record name. + */ +export type TNameToken = Word | Dash; + +/** + * Type for tokens that can be used as "space" in-between record + * name, delimiter and value. + */ +export type TSpaceToken = Space | Tab; + +/** + * Token representing a `record name` inside a Front Matter record. + * + * E.g., `name` in the example below: + * + * ``` + * --- + * name: 'value' + * --- + * ``` + */ +export class FrontMatterRecordName extends FrontMatterToken { + constructor( + public readonly tokens: readonly TNameToken[], + ) { + super(BaseToken.fullRange(tokens)); + } + + public override get text(): string { + return BaseToken.render(this.tokens); + } + + public override toString(): string { + return `front-matter-record-name(${this.shortText()})${this.range}`; + } +} + +/** + * Token representing a delimiter of a record inside a Front Matter header. + * + * E.g., `: ` in the example below: + * + * ``` + * --- + * name: 'value' + * --- + * ``` + */ +export class FrontMatterRecordDelimiter extends FrontMatterToken { + constructor( + public readonly tokens: readonly [Colon, TSpaceToken], + ) { + super( + BaseToken.fullRange(tokens), + ); + } + + public override get text(): string { + return BaseToken.render(this.tokens); + } + + public override toString(): string { + return `front-matter-delimiter(${this.shortText()})${this.range}`; + } +} + +/** + * Token representing a `record` inside a Front Matter header. + * + * E.g., `name: 'value'` in the example below: + * + * ``` + * --- + * name: 'value' + * --- + * ``` + */ +export class FrontMatterRecord extends FrontMatterToken { + constructor( + private readonly tokens: readonly [FrontMatterRecordName, FrontMatterRecordDelimiter, FrontMatterValueToken], + ) { + super( + BaseToken.fullRange(tokens), + ); + } + + /** + * Token that represent `name` of the record. + * + * E.g., `tools` in the example below: + * + * ``` + * --- + * tools: ['value'] + * --- + * ``` + */ + public get nameToken(): FrontMatterRecordName { + return this.tokens[0]; + } + + /** + * Token that represent `value` of the record. + * + * E.g., `['value']` in the example below: + * + * ``` + * --- + * tools: ['value'] + * --- + * ``` + */ + public get valueToken(): FrontMatterValueToken { + return this.tokens[2]; + } + + /** + * Create new instance from a list of tokens. + * + * @throws if: + * - the list of tokens is not exactly 3 tokens long + * - the first token in the list is not a `FrontMatterRecordName` + * - the second token in the list is not a `FrontMatterRecordDelimiter` + * - the third token in the list is not a `FrontMatterValueToken` + * + */ + public static fromTokens( + tokens: readonly FrontMatterToken[], + ): FrontMatterRecord { + assert( + tokens.length === 3, + `A front matter record must consist of exactly 3 tokens, got '${tokens.length}'.`, + ); + + const token1 = tokens[0]; + const token2 = tokens[1]; + const token3 = tokens[2]; + + assert( + token1 instanceof FrontMatterRecordName, + `Token #1 must be a front matter record name, got '${token1}'.`, + ); + assert( + token2 instanceof FrontMatterRecordDelimiter, + `Token #2 must be a front matter record delimiter, got '${token2}'.`, + ); + assert( + token3 instanceof FrontMatterValueToken, + `Token #3 must be a front matter value, got '${token3}'.`, + ); + + return new FrontMatterRecord([ + token1, token2, token3, + ]); + } + + public override get text(): string { + return BaseToken.render(this.tokens); + } + + public override toString(): string { + return `front-matter-record(${this.shortText()})${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterString.ts b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterString.ts new file mode 100644 index 00000000000..abd368a721d --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterString.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { FrontMatterValueToken } from './frontMatterToken.js'; +import { Quote, DoubleQuote } from '../../simpleCodec/tokens/index.js'; + +/** + * Type for any quote token that can be used to wrap a string. + */ +export type TQuoteToken = Quote | DoubleQuote; + +/** + * Token that represents a string value in a Front Matter header. + */ +export class FrontMatterString extends FrontMatterValueToken<'string'> { + /** + * Name of the `string` value type. + */ + public override readonly valueTypeName = 'string'; + + constructor( + public readonly tokens: readonly [TQuote, ...BaseToken[], TQuote], + ) { + super(BaseToken.fullRange(tokens)); + } + + /** + * Text of the string value without the wrapping quotes. + */ + public get cleanText(): string { + return BaseToken.render( + this.tokens.slice(1, this.tokens.length - 1), + ); + } + + public override get text(): string { + return BaseToken.render(this.tokens); + } + + public override toString(): string { + return `front-matter-string(${this.shortText()})${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterToken.ts b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterToken.ts new file mode 100644 index 00000000000..ce9eb28225c --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/tokens/frontMatterToken.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; + +/** + * Base class for all tokens inside a Front Matter header. + */ +export abstract class FrontMatterToken extends BaseToken { } + +/** + * List of all currently supported value types. + */ +export type TValueTypeName = 'string' | 'boolean' | 'array'; + +/** + * Base class for all tokens that represent a `value` inside a Front Matter header. + */ +export abstract class FrontMatterValueToken extends FrontMatterToken { + /** + * Type name of the `value` represented by this token. + */ + public abstract readonly valueTypeName: TTypeName; +} diff --git a/src/vs/editor/common/codecs/frontMatterCodec/tokens/index.ts b/src/vs/editor/common/codecs/frontMatterCodec/tokens/index.ts new file mode 100644 index 00000000000..a9959b44726 --- /dev/null +++ b/src/vs/editor/common/codecs/frontMatterCodec/tokens/index.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { FrontMatterArray } from './frontMatterArray.js'; +export { FrontMatterString } from './frontMatterString.js'; +export { FrontMatterBoolean } from './frontMatterBoolean.js'; +export { FrontMatterToken, FrontMatterValueToken } from './frontMatterToken.js'; +export { + FrontMatterRecordName, + FrontMatterRecordDelimiter, + FrontMatterRecord, + type TNameToken as TRecordNameToken, + type TSpaceToken as TRecordSpaceToken, +} from './frontMatterRecord.js'; diff --git a/src/vs/editor/common/codecs/linesCodec/linesDecoder.ts b/src/vs/editor/common/codecs/linesCodec/linesDecoder.ts index 3bd72e5bd73..67ce2286028 100644 --- a/src/vs/editor/common/codecs/linesCodec/linesDecoder.ts +++ b/src/vs/editor/common/codecs/linesCodec/linesDecoder.ts @@ -13,9 +13,14 @@ import { assertDefined } from '../../../../base/common/types.js'; import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; /** - * Tokens produced by the `LinesDecoder`. + * Any line break token type. */ -export type TLineToken = Line | CarriageReturn | NewLine; +export type TLineBreakToken = CarriageReturn | NewLine; + +/** + * Tokens produced by the {@link LinesDecoder}. + */ +export type TLineToken = Line | TLineBreakToken; /** * The `decoder` part of the `LinesCodec` and is able to transform @@ -53,7 +58,7 @@ export class LinesDecoder extends BaseDecoder { */ private processData( streamEnded: boolean, - ) { + ): void { // iterate over each line of the data buffer, emitting each line // as a `Line` token followed by a `NewLine` token, if applies while (this.buffer.byteLength > 0) { @@ -63,13 +68,17 @@ export class LinesDecoder extends BaseDecoder { : 1; // find the `\r`, `\n`, or `\r\n` tokens in the data - const endOfLineTokens = this.findEndOfLineTokens(lineNumber); - const firstToken = endOfLineTokens[0]; + const endOfLineTokens = this.findEndOfLineTokens( + lineNumber, + streamEnded, + ); + const firstToken: (NewLine | CarriageReturn | undefined) = endOfLineTokens[0]; - // if no end-of-the-line tokens found, stop processing because we - // either (1)need more data to arraive or (2)the stream has ended - // in the case (2) remaining data must be emitted as the last line - if (!firstToken) { + // if no end-of-the-line tokens found, stop the current processing + // attempt because we either (1) need more data to be received or + // (2) the stream has ended; in the case (2) remaining data must + // be emitted as the last line + if (firstToken === undefined) { // (2) if `streamEnded`, we need to emit the whole remaining // data as the last line immediately if (streamEnded) { @@ -88,15 +97,25 @@ export class LinesDecoder extends BaseDecoder { 'No last emitted line found.', ); + // Note! A standalone `\r` token case is not a well-defined case, and + // was primarily used by old Mac OSx systems which treated it as + // a line ending (same as `\n`). Hence for backward compatibility + // with those systems, we treat it as a new line token as well. + // We do that by replacing standalone `\r` token with `\n` one. + if ((endOfLineTokens.length === 1) && (firstToken instanceof CarriageReturn)) { + endOfLineTokens.splice(0, 1, new NewLine(firstToken.range)); + } + // emit the end-of-the-line tokens let startColumn = this.lastEmittedLine.range.endColumn; for (const token of endOfLineTokens) { - const endColumn = startColumn + token.byte.byteLength; + const byteLength = token.byte.byteLength; + const endColumn = startColumn + byteLength; // emit the token updating its column start/end numbers based on // the emitted line text length and previous end-of-the-line token this._onData.fire(token.withRange({ startColumn, endColumn })); // shorten the data buffer by the length of the token - this.buffer = this.buffer.slice(token.byte.byteLength); + this.buffer = this.buffer.slice(byteLength); // update the start column for the next token startColumn = endColumn; } @@ -122,6 +141,7 @@ export class LinesDecoder extends BaseDecoder { */ private findEndOfLineTokens( lineNumber: number, + streamEnded: boolean, ): (CarriageReturn | NewLine)[] { const result = []; @@ -130,7 +150,7 @@ export class LinesDecoder extends BaseDecoder { const newLineIndex = this.buffer.indexOf(NewLine.byte); // if the `\r` comes before the `\n`(if `\n` present at all) - if (carriageReturnIndex >= 0 && (carriageReturnIndex < newLineIndex || newLineIndex === -1)) { + if (carriageReturnIndex >= 0 && ((carriageReturnIndex < newLineIndex) || (newLineIndex === -1))) { // add the carriage return token first result.push( new CarriageReturn(new Range( @@ -154,11 +174,15 @@ export class LinesDecoder extends BaseDecoder { ); } - if (this.buffer.byteLength > carriageReturnIndex + 1) { - // either `\r` or `\r\n` cases found + // either `\r` or `\r\n` cases found; if we have the `\r` token, we can return + // the end-of-line tokens only, if the `\r` is followed by at least one more + // character (it could be a `\n` or any other character), or if the stream has + // ended (which means the `\r` is at the end of the line) + if ((this.buffer.byteLength > carriageReturnIndex + 1) || streamEnded) { return result; } + // in all other cases, return the empty array (no lend-of-line tokens found) return []; } diff --git a/src/vs/editor/common/codecs/linesCodec/tokens/carriageReturn.ts b/src/vs/editor/common/codecs/linesCodec/tokens/carriageReturn.ts index a509940bc4e..7ac432075b6 100644 --- a/src/vs/editor/common/codecs/linesCodec/tokens/carriageReturn.ts +++ b/src/vs/editor/common/codecs/linesCodec/tokens/carriageReturn.ts @@ -3,21 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Line } from './line.js'; -import { BaseToken } from '../../baseToken.js'; -import { Range } from '../../../core/range.js'; -import { Position } from '../../../core/position.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { SimpleToken } from '../../simpleCodec/tokens/simpleToken.js'; /** * Token that represent a `carriage return` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class CarriageReturn extends BaseToken { +export class CarriageReturn extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = '\r'; + public static override readonly symbol: '\r' = '\r'; /** * The byte representation of the {@link symbol}. @@ -34,33 +31,14 @@ export class CarriageReturn extends BaseToken { /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return CarriageReturn.symbol; } - /** - * Create new `CarriageReturn` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): CarriageReturn { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new CarriageReturn(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ public override toString(): string { - return `carriage-return${this.range}`; + return `CR${this.range}`; } } diff --git a/src/vs/editor/common/codecs/linesCodec/tokens/line.ts b/src/vs/editor/common/codecs/linesCodec/tokens/line.ts index 6669169967f..3159fd6b6f7 100644 --- a/src/vs/editor/common/codecs/linesCodec/tokens/line.ts +++ b/src/vs/editor/common/codecs/linesCodec/tokens/line.ts @@ -58,6 +58,6 @@ export class Line extends BaseToken { * Returns a string representation of the token. */ public override toString(): string { - return `line("${this.text}")${this.range}`; + return `line("${this.shortText()}")${this.range}`; } } diff --git a/src/vs/editor/common/codecs/linesCodec/tokens/newLine.ts b/src/vs/editor/common/codecs/linesCodec/tokens/newLine.ts index fb826b759ca..1211443272d 100644 --- a/src/vs/editor/common/codecs/linesCodec/tokens/newLine.ts +++ b/src/vs/editor/common/codecs/linesCodec/tokens/newLine.ts @@ -3,21 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Line } from './line.js'; -import { BaseToken } from '../../baseToken.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { Position } from '../../../../../editor/common/core/position.js'; +import { SimpleToken } from '../../simpleCodec/tokens/simpleToken.js'; /** * A token that represent a `new line` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class NewLine extends BaseToken { +export class NewLine extends SimpleToken { /** * The underlying symbol of the `NewLine` token. */ - public static readonly symbol: string = '\n'; + public static override readonly symbol: '\n' = '\n'; /** * The byte representation of the {@link symbol}. @@ -27,7 +24,7 @@ export class NewLine extends BaseToken { /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return NewLine.symbol; } @@ -38,24 +35,6 @@ export class NewLine extends BaseToken { return NewLine.byte; } - /** - * Create new `NewLine` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): NewLine { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new NewLine( - Range.fromPositions(startPosition, endPosition), - ); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts b/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts index 9da1394cf82..7825ab6ac88 100644 --- a/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts +++ b/src/vs/editor/common/codecs/markdownCodec/markdownDecoder.ts @@ -6,24 +6,25 @@ import { MarkdownToken } from './tokens/markdownToken.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { LeftBracket } from '../simpleCodec/tokens/brackets.js'; -import { ReadableStream } from '../../../../base/common/stream.js'; -import { LeftAngleBracket } from '../simpleCodec/tokens/angleBrackets.js'; -import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; -import { SimpleDecoder, TSimpleToken } from '../simpleCodec/simpleDecoder.js'; -import { MarkdownCommentStart, PartialMarkdownCommentStart } from './parsers/markdownComment.js'; -import { MarkdownLinkCaption, PartialMarkdownLink, PartialMarkdownLinkCaption } from './parsers/markdownLink.js'; -import { ExclamationMark } from '../simpleCodec/tokens/exclamationMark.js'; import { PartialMarkdownImage } from './parsers/markdownImage.js'; +import { ReadableStream } from '../../../../base/common/stream.js'; +import { TSimpleDecoderToken } from '../simpleCodec/simpleDecoder.js'; +import { LeftAngleBracket } from '../simpleCodec/tokens/angleBrackets.js'; +import { ExclamationMark } from '../simpleCodec/tokens/exclamationMark.js'; +import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; +import { MarkdownCommentStart, PartialMarkdownCommentStart } from './parsers/markdownComment.js'; +import { MarkdownExtensionsDecoder } from '../markdownExtensionsCodec/markdownExtensionsDecoder.js'; +import { MarkdownLinkCaption, PartialMarkdownLink, PartialMarkdownLinkCaption } from './parsers/markdownLink.js'; /** - * Tokens handled by this decoder. + * Tokens produced by this decoder. */ -export type TMarkdownToken = MarkdownToken | TSimpleToken; +export type TMarkdownToken = MarkdownToken | TSimpleDecoderToken; /** * Decoder capable of parsing markdown entities (e.g., links) from a sequence of simple tokens. */ -export class MarkdownDecoder extends BaseDecoder { +export class MarkdownDecoder extends BaseDecoder { /** * Current parser object that is responsible for parsing a sequence of tokens into * some markdown entity. Set to `undefined` when no parsing is in progress at the moment. @@ -36,10 +37,10 @@ export class MarkdownDecoder extends BaseDecoder { constructor( stream: ReadableStream, ) { - super(new SimpleDecoder(stream)); + super(new MarkdownExtensionsDecoder(stream)); } - protected override onStreamData(token: TSimpleToken): void { + protected override onStreamData(token: TSimpleDecoderToken): void { // `markdown links` start with `[` character, so here we can // initiate the process of parsing a markdown link if (token instanceof LeftBracket && !this.current) { @@ -92,8 +93,9 @@ export class MarkdownDecoder extends BaseDecoder { // then reset the current parser object for (const token of this.current.tokens) { this._onData.fire(token); - delete this.current; } + + delete this.current; } // if token was not consumed by the parser, call `onStreamData` again @@ -119,11 +121,12 @@ export class MarkdownDecoder extends BaseDecoder { // in all other cases, re-emit existing parser tokens const { tokens } = this.current; - delete this.current; for (const token of [...tokens]) { this._onData.fire(token); } + + delete this.current; } super.onStreamEnd(); diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts index df5f3f028ab..26e78d7f07d 100644 --- a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownComment.ts @@ -8,7 +8,7 @@ import { Dash } from '../../simpleCodec/tokens/dash.js'; import { pick } from '../../../../../base/common/arrays.js'; import { assert } from '../../../../../base/common/assert.js'; import { MarkdownComment } from '../tokens/markdownComment.js'; -import { TSimpleToken } from '../../simpleCodec/simpleDecoder.js'; +import { TSimpleDecoderToken } from '../../simpleCodec/simpleDecoder.js'; import { ExclamationMark } from '../../simpleCodec/tokens/exclamationMark.js'; import { LeftAngleBracket, RightAngleBracket } from '../../simpleCodec/tokens/angleBrackets.js'; import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; @@ -16,13 +16,13 @@ import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../simpleC /** * The parser responsible for parsing the ``. If it does, * then the parser transitions to the {@link MarkdownComment} token. */ -export class MarkdownCommentStart extends ParserBase { +export class MarkdownCommentStart extends ParserBase { constructor(tokens: [LeftAngleBracket, ExclamationMark, Dash, Dash]) { super(tokens); } @assertNotConsumed - public accept(token: TSimpleToken): TAcceptTokenResult { + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { // if received `>` while current token sequence ends with `--`, // then this is the end of the comment sequence if (token instanceof RightAngleBracket && this.endsWithDashes) { @@ -125,7 +125,7 @@ export class MarkdownCommentStart extends ParserBase { +export class PartialMarkdownImage extends ParserBase { /** * Current active parser instance, if in the mode of actively parsing the markdown link sequence. */ @@ -28,7 +28,7 @@ export class PartialMarkdownImage extends ParserBase { + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { // on the first call we expect a character that begins `markdown link` sequence // hence we initiate the markdown link parsing process, otherwise we fail if (!this.markdownLinkParser) { diff --git a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts index e8163286fd2..5ab1d06d5c9 100644 --- a/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts +++ b/src/vs/editor/common/codecs/markdownCodec/parsers/markdownLink.ts @@ -7,8 +7,8 @@ import { MarkdownLink } from '../tokens/markdownLink.js'; import { NewLine } from '../../linesCodec/tokens/newLine.js'; import { assert } from '../../../../../base/common/assert.js'; import { FormFeed } from '../../simpleCodec/tokens/formFeed.js'; -import { TSimpleToken } from '../../simpleCodec/simpleDecoder.js'; import { VerticalTab } from '../../simpleCodec/tokens/verticalTab.js'; +import { TSimpleDecoderToken } from '../../simpleCodec/simpleDecoder.js'; import { CarriageReturn } from '../../linesCodec/tokens/carriageReturn.js'; import { LeftBracket, RightBracket } from '../../simpleCodec/tokens/brackets.js'; import { ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; @@ -26,21 +26,21 @@ const MARKDOWN_LINK_STOP_CHARACTERS: readonly string[] = [CarriageReturn, NewLin * * The parsing process starts with single `[` token and collects all tokens until * the first `]` token is encountered. In this successful case, the parser transitions - * into the {@linkcode MarkdownLinkCaption} parser type which continues the general + * into the {@link MarkdownLinkCaption} parser type which continues the general * parsing process of the markdown link. * - * Otherwise, if one of the stop characters defined in the {@linkcode MARKDOWN_LINK_STOP_CHARACTERS} + * Otherwise, if one of the stop characters defined in the {@link MARKDOWN_LINK_STOP_CHARACTERS} * is encountered before the `]` token, the parsing process is aborted which is communicated to * the caller by returning a `failure` result. In this case, the caller is assumed to be responsible * for re-emitting the {@link tokens} accumulated so far as standalone entities since they are no * longer represent a coherent token entity of a larger size. */ -export class PartialMarkdownLinkCaption extends ParserBase { +export class PartialMarkdownLinkCaption extends ParserBase { constructor(token: LeftBracket) { super([token]); } - public accept(token: TSimpleToken): TAcceptTokenResult { + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { // any of stop characters is are breaking a markdown link caption sequence if (MARKDOWN_LINK_STOP_CHARACTERS.includes(token.text)) { return { @@ -70,7 +70,7 @@ export class PartialMarkdownLinkCaption extends ParserBase { - public accept(token: TSimpleToken): TAcceptTokenResult { +export class MarkdownLinkCaption extends ParserBase { + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { // the `(` character starts the link part of a markdown link // that is the only character that can follow the caption if (token instanceof LeftParenthesis) { @@ -108,10 +108,10 @@ export class MarkdownLinkCaption extends ParserBase { +export class PartialMarkdownLink extends ParserBase { /** * Number of open parenthesis in the sequence. - * See comment in the {@linkcode accept} method for more details. + * See comment in the {@link accept} method for more details. */ private openParensCount: number = 1; constructor( - protected readonly captionTokens: TSimpleToken[], + protected readonly captionTokens: TSimpleDecoderToken[], token: LeftParenthesis, ) { super([token]); } - public override get tokens(): readonly TSimpleToken[] { + public override get tokens(): readonly TSimpleDecoderToken[] { return [...this.captionTokens, ...this.currentTokens]; } - public accept(token: TSimpleToken): TAcceptTokenResult { + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { // markdown links allow for nested parenthesis inside the link reference part, but // the number of open parenthesis must match the number of closing parenthesis, e.g.: // - `[caption](/some/p()th/file.md)` is a valid markdown link diff --git a/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts index f7875d957a2..0181d808a96 100644 --- a/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts +++ b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownComment.ts @@ -51,6 +51,6 @@ export class MarkdownComment extends MarkdownToken { * Returns a string representation of the token. */ public override toString(): string { - return `md-comment("${this.text}")${this.range}`; + return `md-comment("${this.shortText()}")${this.range}`; } } diff --git a/src/vs/editor/common/codecs/markdownCodec/tokens/markdownImage.ts b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownImage.ts index 75e42e31aaf..5ef293ffaed 100644 --- a/src/vs/editor/common/codecs/markdownCodec/tokens/markdownImage.ts +++ b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownImage.ts @@ -136,6 +136,6 @@ export class MarkdownImage extends MarkdownToken { * Returns a string representation of the token. */ public override toString(): string { - return `md-image("${this.text}")${this.range}`; + return `md-image("${this.shortText()}")${this.range}`; } } diff --git a/src/vs/editor/common/codecs/markdownCodec/tokens/markdownLink.ts b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownLink.ts index a4b15718717..2862e11b358 100644 --- a/src/vs/editor/common/codecs/markdownCodec/tokens/markdownLink.ts +++ b/src/vs/editor/common/codecs/markdownCodec/tokens/markdownLink.ts @@ -115,7 +115,7 @@ export class MarkdownLink extends MarkdownToken { const { range } = this; - // note! '+1' for openning `(` of the link + // note! '+1' for opening `(` of the link const startColumn = range.startColumn + this.caption.length + 1; const endColumn = startColumn + this.path.length; @@ -131,6 +131,6 @@ export class MarkdownLink extends MarkdownToken { * Returns a string representation of the token. */ public override toString(): string { - return `md-link("${this.text}")${this.range}`; + return `md-link("${this.shortText()}")${this.range}`; } } diff --git a/src/vs/editor/common/codecs/markdownExtensionsCodec/markdownExtensionsDecoder.ts b/src/vs/editor/common/codecs/markdownExtensionsCodec/markdownExtensionsDecoder.ts new file mode 100644 index 00000000000..e4466e0ee13 --- /dev/null +++ b/src/vs/editor/common/codecs/markdownExtensionsCodec/markdownExtensionsDecoder.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { ReadableStream } from '../../../../base/common/stream.js'; +import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; +import { MarkdownExtensionsToken } from './tokens/markdownExtensionsToken.js'; +import { SimpleDecoder, TSimpleDecoderToken } from '../simpleCodec/simpleDecoder.js'; +import { PartialFrontMatterHeader, PartialFrontMatterStartMarker } from './parsers/frontMatterHeader.js'; + +/** + * Tokens produced by this decoder. + */ +export type TMarkdownExtensionsToken = MarkdownExtensionsToken | TSimpleDecoderToken; + +/** + * Decoder responsible for decoding extensions of markdown syntax, + * e.g., a `Front Matter` header, etc. + */ +export class MarkdownExtensionsDecoder extends BaseDecoder { + /** + * Current parser object that is responsible for parsing a sequence of tokens into + * some markdown entity. Set to `undefined` when no parsing is in progress at the moment. + */ + private current?: PartialFrontMatterStartMarker | PartialFrontMatterHeader; + + constructor( + stream: ReadableStream, + ) { + super(new SimpleDecoder(stream)); + } + + protected override onStreamData(token: TSimpleDecoderToken): void { + // front matter headers start with a `-` at the first column of the first line + if ((this.current === undefined) && PartialFrontMatterStartMarker.mayStartHeader(token)) { + this.current = new PartialFrontMatterStartMarker(token); + + return; + } + + // if current parser is not initiated, - we are not inside a sequence of tokens + // we care about, therefore re-emit the token immediately and continue + if (this.current === undefined) { + this._onData.fire(token); + return; + } + + // if there is a current parser object, submit the token to it + // so it can progress with parsing the tokens sequence + const parseResult = this.current.accept(token); + if (parseResult.result === 'success') { + const { nextParser } = parseResult; + + // if got a fully parsed out token back, emit it and reset + // the current parser object so a new parsing process can start + if (nextParser instanceof MarkdownExtensionsToken) { + this._onData.fire(nextParser); + delete this.current; + } else { + // otherwise, update the current parser object + this.current = nextParser; + } + } else { + // if failed to parse a sequence of a tokens as a single markdown + // entity (e.g., a link), re-emit the tokens accumulated so far + // then reset the currently initialized parser object + this.reEmitCurrentTokens(); + } + + // if token was not consumed by the parser, call `onStreamData` again + // so the token is properly handled by the decoder in the case when a + // new sequence starts with this token + if (!parseResult.wasTokenConsumed) { + this.onStreamData(token); + } + } + + protected override onStreamEnd(): void { + try { + if (this.current === undefined) { + return; + } + + // if current parser can be converted into a valid Front Matter + // header, then emit it and reset the current parser object + if (this.current instanceof PartialFrontMatterHeader) { + this._onData.fire( + this.current.asFrontMatterHeader(), + ); + delete this.current; + return; + } + + } catch (_error) { + // if failed to convert current parser object to a token, + // re-emit the tokens accumulated so far + this.reEmitCurrentTokens(); + } finally { + delete this.current; + super.onStreamEnd(); + } + } + + /** + * Re-emit tokens accumulated so far in the current parser object. + */ + protected reEmitCurrentTokens(): void { + if (this.current === undefined) { + return; + } + + for (const token of this.current.tokens) { + this._onData.fire(token); + } + delete this.current; + } +} diff --git a/src/vs/editor/common/codecs/markdownExtensionsCodec/parsers/frontMatterHeader.ts b/src/vs/editor/common/codecs/markdownExtensionsCodec/parsers/frontMatterHeader.ts new file mode 100644 index 00000000000..589aec9d63d --- /dev/null +++ b/src/vs/editor/common/codecs/markdownExtensionsCodec/parsers/frontMatterHeader.ts @@ -0,0 +1,345 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dash } from '../../simpleCodec/tokens/dash.js'; +import { NewLine } from '../../linesCodec/tokens/newLine.js'; +import { FrontMatterHeader } from '../tokens/frontMatterHeader.js'; +import { assertDefined } from '../../../../../base/common/types.js'; +import { TSimpleDecoderToken } from '../../simpleCodec/simpleDecoder.js'; +import { assert, assertNever } from '../../../../../base/common/assert.js'; +import { CarriageReturn } from '../../linesCodec/tokens/carriageReturn.js'; +import { FrontMatterMarker, TMarkerToken } from '../tokens/frontMatterMarker.js'; +import { assertNotConsumed, IAcceptTokenSuccess, ParserBase, TAcceptTokenResult } from '../../simpleCodec/parserBase.js'; + +/** + * Parses the start marker of a Front Matter header. + */ +export class PartialFrontMatterStartMarker extends ParserBase { + constructor(token: Dash) { + const { range } = token; + + assert( + range.startLineNumber === 1, + `Front Matter header must start at the first line, but it starts at line #${range.startLineNumber}.`, + ); + + assert( + range.startColumn === 1, + `Front Matter header must start at the beginning of the line, but it starts at ${range.startColumn}.`, + ); + + super([token]); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + const previousToken = this.currentTokens[this.currentTokens.length - 1]; + + // collect a sequence of dash tokens that may end with a CR token + if ((token instanceof Dash) || (token instanceof CarriageReturn)) { + // a dash or CR tokens can go only after another dash token + if ((previousToken instanceof Dash) === false) { + this.isConsumed = true; + + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + this.currentTokens.push(token); + + return { + result: 'success', + wasTokenConsumed: true, + nextParser: this, + }; + } + + // stop collecting dash tokens when a new line token is encountered + if (token instanceof NewLine) { + this.isConsumed = true; + + return { + result: 'success', + wasTokenConsumed: true, + nextParser: new PartialFrontMatterHeader( + FrontMatterMarker.fromTokens([ + ...this.currentTokens, + token, + ]), + ), + }; + } + + // any other token is invalid for the `start marker` + this.isConsumed = true; + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + /** + * Check if provided dash token can be a start of a Front Matter header. + */ + public static mayStartHeader(token: TSimpleDecoderToken): token is Dash { + return (token instanceof Dash) + && (token.range.startLineNumber === 1) + && (token.range.startColumn === 1); + } +} + +/** + * Parses a Front Matter header that already has a start marker + * and possibly some content that follows. + */ +export class PartialFrontMatterHeader extends ParserBase { + /** + * Parser instance for the end marker of the Front Matter header. + */ + private maybeEndMarker?: PartialFrontMatterEndMarker; + + constructor( + public readonly startMarker: FrontMatterMarker, + ) { + super([]); + } + + public override get tokens(): readonly TSimpleDecoderToken[] { + const endMarkerTokens = (this.maybeEndMarker !== undefined) + ? this.maybeEndMarker.tokens + : []; + + return [ + ...this.startMarker.tokens, + ...this.currentTokens, + ...endMarkerTokens, + ]; + } + + /** + * Convert the current token sequence into a {@link FrontMatterHeader} token. + * + * Note! that this method marks the current parser object as "consumed" + * hence it should not be used after this method is called. + */ + public asFrontMatterHeader(): FrontMatterHeader { + assertDefined( + this.maybeEndMarker, + 'Cannot convert to Front Matter header token without an end marker.', + ); + + assert( + this.maybeEndMarker.dashCount === this.startMarker.dashTokens.length, + [ + 'Start and end markers must have the same number of dashes', + `, got ${this.startMarker.dashTokens.length} / ${this.maybeEndMarker.dashCount}.`, + ].join(''), + ); + + this.isConsumed = true; + + return FrontMatterHeader.fromTokens( + this.startMarker.tokens, + this.currentTokens, + this.maybeEndMarker.tokens, + ); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + // if in the mode of parsing the end marker sequence, forward + // the token to the current end marker parser instance + if (this.maybeEndMarker !== undefined) { + return this.acceptEndMarkerToken(token); + } + + // collect all tokens until a `dash token at the beginning of a line` is found + if (((token instanceof Dash) === false) || (token.range.startColumn !== 1)) { + this.currentTokens.push(token); + + return { + result: 'success', + wasTokenConsumed: true, + nextParser: this, + }; + } + + // a dash token at the beginning of the line might be a start of the `end marker` + // sequence of the front matter header, hence initialize appropriate parser object + assert( + this.maybeEndMarker === undefined, + `End marker parser must not be present.`, + ); + this.maybeEndMarker = new PartialFrontMatterEndMarker(token); + + return { + result: 'success', + wasTokenConsumed: true, + nextParser: this, + }; + } + + /** + * When a end marker parser is present, we pass all tokens to it + * until it is completes the parsing process(either success or failure). + */ + private acceptEndMarkerToken( + token: TSimpleDecoderToken, + ): TAcceptTokenResult { + assertDefined( + this.maybeEndMarker, + `Partial end marker parser must be initialized.`, + ); + + // if we have a partial end marker, we are in the process of parsing + // the end marker, so just pass the token to it and return + const acceptResult = this.maybeEndMarker.accept(token); + const { result, wasTokenConsumed } = acceptResult; + + if (result === 'success') { + const { nextParser } = acceptResult; + const endMarkerParsingComplete = (nextParser instanceof FrontMatterMarker); + + if (endMarkerParsingComplete === false) { + return { + result: 'success', + wasTokenConsumed, + nextParser: this, + }; + } + + const endMarker = nextParser; + + // start and end markers must have the same number of dashes, hence + // if they don't match, we would like to continue parsing the header + // until we find an end marker with the same number of dashes + if (endMarker.dashTokens.length !== this.startMarker.dashTokens.length) { + return this.handleEndMarkerParsingFailure( + endMarker.tokens, + wasTokenConsumed, + ); + } + + this.isConsumed = true; + return { + result: 'success', + wasTokenConsumed: true, + nextParser: FrontMatterHeader.fromTokens( + this.startMarker.tokens, + this.currentTokens, + this.maybeEndMarker.tokens, + ), + }; + } + + // if failed to parse the end marker, we would like to continue parsing + // the header until we find a valid end marker + if (result === 'failure') { + return this.handleEndMarkerParsingFailure( + this.maybeEndMarker.tokens, + wasTokenConsumed, + ); + } + + assertNever( + result, + `Unexpected result '${result}' while parsing the end marker.`, + ); + } + + /** + * On failure to parse the end marker, we need to continue parsing + * the header because there might be another valid end marker in + * the stream of tokens. Therefore we copy over the end marker tokens + * into the list of "content" tokens and reset the end marker parser. + */ + private handleEndMarkerParsingFailure( + tokens: readonly TSimpleDecoderToken[], + wasTokenConsumed: boolean, + ): IAcceptTokenSuccess { + this.currentTokens.push(...tokens); + delete this.maybeEndMarker; + + return { + result: 'success', + wasTokenConsumed, + nextParser: this, + }; + } +} + +/** + * Parser the end marker sequence of a Front Matter header. + */ +class PartialFrontMatterEndMarker extends ParserBase { + constructor(token: Dash) { + const { range } = token; + + assert( + range.startColumn === 1, + `Front Matter header must start at the beginning of the line, but it starts at ${range.startColumn}.`, + ); + + super([token]); + } + + /** + * Number of dashes in the marker. + */ + public get dashCount(): number { + return this.tokens + .filter((token) => { return token instanceof Dash; }) + .length; + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + const previousToken = this.currentTokens[this.currentTokens.length - 1]; + + // collect a sequence of dash tokens that may end with a CR token + if ((token instanceof Dash) || (token instanceof CarriageReturn)) { + // a dash or CR tokens can go only after another dash token + if ((previousToken instanceof Dash) === false) { + this.isConsumed = true; + + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + this.currentTokens.push(token); + + return { + result: 'success', + wasTokenConsumed: true, + nextParser: this, + }; + } + + // stop collecting dash tokens when a new line token is encountered + if (token instanceof NewLine) { + this.isConsumed = true; + this.currentTokens.push(token); + + return { + result: 'success', + wasTokenConsumed: true, + nextParser: FrontMatterMarker.fromTokens([ + ...this.currentTokens, + ]), + }; + } + + // any other token is invalid for the `start marker` + this.isConsumed = true; + return { + result: 'failure', + wasTokenConsumed: false, + }; + } +} diff --git a/src/vs/editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterHeader.ts b/src/vs/editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterHeader.ts new file mode 100644 index 00000000000..67efa4d6996 --- /dev/null +++ b/src/vs/editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterHeader.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../core/range.js'; +import { BaseToken, Text } from '../../baseToken.js'; +import { MarkdownExtensionsToken } from './markdownExtensionsToken.js'; +import { TSimpleDecoderToken } from '../../simpleCodec/simpleDecoder.js'; +import { FrontMatterMarker, TMarkerToken } from './frontMatterMarker.js'; + +/** + * Token that represents a `Front Matter` header in a text. + */ +export class FrontMatterHeader extends MarkdownExtensionsToken { + constructor( + range: Range, + public readonly startMarker: FrontMatterMarker, + public readonly content: Text, + public readonly endMarker: FrontMatterMarker, + ) { + super(range); + } + + /** + * Return complete text representation of the token. + */ + public get text(): string { + const text: string[] = [ + this.startMarker.text, + this.content.text, + this.endMarker.text, + ]; + + return text.join(''); + } + + /** + * Range of the content of the Front Matter header. + */ + public get contentRange(): Range { + return this.content.range; + } + + /** + * Content token of the Front Matter header. + */ + public get contentToken(): Text { + return this.content; + } + + /** + * Check if this token is equal to another one. + */ + public override equals(other: T): boolean { + if (!super.sameRange(other.range)) { + return false; + } + + if (!(other instanceof FrontMatterHeader)) { + return false; + } + + if (this.text.length !== other.text.length) { + return false; + } + + return (this.text === other.text); + } + + /** + * Create new instance of the token from the given tokens. + */ + public static fromTokens( + startMarkerTokens: readonly TMarkerToken[], + contentTokens: readonly TSimpleDecoderToken[], + endMarkerTokens: readonly TMarkerToken[], + ): FrontMatterHeader { + const range = BaseToken.fullRange( + [...startMarkerTokens, ...endMarkerTokens], + ); + + return new FrontMatterHeader( + range, + FrontMatterMarker.fromTokens(startMarkerTokens), + Text.fromTokens(contentTokens), + FrontMatterMarker.fromTokens(endMarkerTokens), + ); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `frontmatter("${this.shortText()}")${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterMarker.ts b/src/vs/editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterMarker.ts new file mode 100644 index 00000000000..d7adb1bfc95 --- /dev/null +++ b/src/vs/editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterMarker.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../core/range.js'; +import { BaseToken } from '../../baseToken.js'; +import { Dash } from '../../simpleCodec/tokens/dash.js'; +import { NewLine } from '../../linesCodec/tokens/newLine.js'; +import { assert } from '../../../../../base/common/assert.js'; +import { MarkdownExtensionsToken } from './markdownExtensionsToken.js'; +import { CarriageReturn } from '../../linesCodec/tokens/carriageReturn.js'; + +/** + * Type for tokens inside a Front Matter header marker. + */ +export type TMarkerToken = Dash | CarriageReturn | NewLine; + +/** + * Marker for the start and end of a Front Matter header. + */ +export class FrontMatterMarker extends MarkdownExtensionsToken { + /** + * Returns complete text representation of the token. + */ + public get text(): string { + return BaseToken.render(this.tokens); + } + + /** + * List of {@link Dash} tokens in the marker. + */ + public get dashTokens(): readonly Dash[] { + return this.tokens + .filter((token) => { return token instanceof Dash; }); + } + + constructor( + range: Range, + public readonly tokens: readonly TMarkerToken[], + ) { + const lastToken = tokens[tokens.length - 1]; + + assert( + lastToken instanceof NewLine, + `Front Matter marker must end with a new line token, got '${lastToken}'.`, + ); + + super(range); + } + + /** + * Create new instance of the token from a provided + * list of tokens. + */ + public static fromTokens( + tokens: readonly TMarkerToken[], + ): FrontMatterMarker { + const range = BaseToken.fullRange(tokens); + + return new FrontMatterMarker(range, tokens); + } + + public toString(): string { + return `frontmatter-marker(${this.dashTokens.length}:${this.range})`; + } +} diff --git a/src/vs/editor/common/codecs/markdownExtensionsCodec/tokens/markdownExtensionsToken.ts b/src/vs/editor/common/codecs/markdownExtensionsCodec/tokens/markdownExtensionsToken.ts new file mode 100644 index 00000000000..82046eb2b4d --- /dev/null +++ b/src/vs/editor/common/codecs/markdownExtensionsCodec/tokens/markdownExtensionsToken.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MarkdownToken } from '../../markdownCodec/tokens/markdownToken.js'; + +/** + * Base class for all tokens produced by the `MarkdownExtensionsDecoder`. + */ +export abstract class MarkdownExtensionsToken extends MarkdownToken { } diff --git a/src/vs/editor/common/codecs/simpleCodec/parserBase.ts b/src/vs/editor/common/codecs/simpleCodec/parserBase.ts index e088a18f264..c9422f682b5 100644 --- a/src/vs/editor/common/codecs/simpleCodec/parserBase.ts +++ b/src/vs/editor/common/codecs/simpleCodec/parserBase.ts @@ -53,6 +53,13 @@ export abstract class ParserBase { */ protected isConsumed: boolean = false; + /** + * Whether the parser object was "consumed" hence must not be used anymore. + */ + public get consumed(): boolean { + return this.isConsumed; + } + /** * Number of tokens at the initialization of the current parser. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts index c32542f28da..6847f9e5671 100644 --- a/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts +++ b/src/vs/editor/common/codecs/simpleCodec/simpleDecoder.ts @@ -3,89 +3,113 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Hash } from './tokens/hash.js'; -import { Dash } from './tokens/dash.js'; -import { Colon } from './tokens/colon.js'; -import { FormFeed } from './tokens/formFeed.js'; -import { Tab } from '../simpleCodec/tokens/tab.js'; -import { Word } from '../simpleCodec/tokens/word.js'; -import { VerticalTab } from './tokens/verticalTab.js'; -import { Space } from '../simpleCodec/tokens/space.js'; +import { Line } from '../linesCodec/tokens/line.js'; import { NewLine } from '../linesCodec/tokens/newLine.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { ExclamationMark } from './tokens/exclamationMark.js'; import { ReadableStream } from '../../../../base/common/stream.js'; import { CarriageReturn } from '../linesCodec/tokens/carriageReturn.js'; -import { LinesDecoder, TLineToken } from '../linesCodec/linesDecoder.js'; -import { LeftBracket, RightBracket, TBracket } from './tokens/brackets.js'; import { BaseDecoder } from '../../../../base/common/codecs/baseDecoder.js'; -import { LeftParenthesis, RightParenthesis, TParenthesis } from './tokens/parentheses.js'; -import { LeftAngleBracket, RightAngleBracket, TAngleBracket } from './tokens/angleBrackets.js'; +import { LinesDecoder, TLineBreakToken, TLineToken } from '../linesCodec/linesDecoder.js'; +import { + At, + Tab, + Word, + Hash, + Dash, + Colon, + Slash, + Space, + Quote, + Comma, + FormFeed, + DollarSign, + DoubleQuote, + VerticalTab, + type TBracket, + LeftBracket, + RightBracket, + type TCurlyBrace, + LeftCurlyBrace, + RightCurlyBrace, + ExclamationMark, + type TParenthesis, + LeftParenthesis, + RightParenthesis, + type TAngleBracket, + LeftAngleBracket, + RightAngleBracket, +} from './tokens/index.js'; +import { pick } from '../../../../base/common/arrays.js'; +import { ISimpleTokenClass, SimpleToken } from './tokens/simpleToken.js'; /** - * A token type that this decoder can handle. + * Type for all simple tokens. */ -export type TSimpleToken = Word | Space | Tab | VerticalTab | NewLine | FormFeed - | CarriageReturn | TBracket | TAngleBracket | TParenthesis - | Colon | Hash | Dash | ExclamationMark; +export type TSimpleToken = Space | Tab | VerticalTab | At | Quote | DoubleQuote + | CarriageReturn | NewLine | FormFeed | TBracket | TAngleBracket | TCurlyBrace + | TParenthesis | Colon | Hash | Dash | ExclamationMark | Slash | DollarSign | Comma + | TLineBreakToken; + +/** +* Type of tokens emitted by this decoder. +*/ +export type TSimpleDecoderToken = TSimpleToken | Word; /** * List of well-known distinct tokens that this decoder emits (excluding * the word stop characters defined below). Everything else is considered - * an arbitrary "text" sequence and is emitted as a single `Word` token. + * an arbitrary "text" sequence and is emitted as a single {@link Word} token. */ -const WELL_KNOWN_TOKENS = Object.freeze([ - Space, Tab, VerticalTab, FormFeed, - LeftBracket, RightBracket, LeftAngleBracket, RightAngleBracket, - LeftParenthesis, RightParenthesis, Colon, Hash, Dash, ExclamationMark, +export const WELL_KNOWN_TOKENS: readonly ISimpleTokenClass[] = Object.freeze([ + LeftParenthesis, RightParenthesis, LeftBracket, RightBracket, LeftCurlyBrace, RightCurlyBrace, + LeftAngleBracket, RightAngleBracket, Space, Tab, VerticalTab, FormFeed, Colon, Hash, Dash, + ExclamationMark, At, Slash, DollarSign, Quote, DoubleQuote, Comma, ]); /** - * Characters that stop a "word" sequence. - * Note! the `\r` and `\n` are excluded from the list because this decoder based on `LinesDecoder` which - * already handles the `carriagereturn`/`newline` cases and emits lines that don't contain them. + * A {@link Word} sequence stops when one of the well-known tokens are encountered. + * Note! the `\r` and `\n` are excluded from the list because this decoder based on + * the {@link LinesDecoder} which emits {@link Line} tokens without them. */ -const WORD_STOP_CHARACTERS: readonly string[] = Object.freeze([ - Space.symbol, Tab.symbol, VerticalTab.symbol, FormFeed.symbol, - LeftBracket.symbol, RightBracket.symbol, LeftAngleBracket.symbol, RightAngleBracket.symbol, - LeftParenthesis.symbol, RightParenthesis.symbol, - Colon.symbol, Hash.symbol, Dash.symbol, ExclamationMark.symbol, -]); +const WORD_STOP_CHARACTERS: readonly string[] = Object.freeze( + WELL_KNOWN_TOKENS.map(pick('symbol')), +); /** * A decoder that can decode a stream of `Line`s into a stream * of simple token, - `Word`, `Space`, `Tab`, `NewLine`, etc. */ -export class SimpleDecoder extends BaseDecoder { +export class SimpleDecoder extends BaseDecoder { constructor( stream: ReadableStream, ) { super(new LinesDecoder(stream)); } - protected override onStreamData(token: TLineToken): void { + protected override onStreamData(line: TLineToken): void { // re-emit new line tokens immediately - if (token instanceof CarriageReturn || token instanceof NewLine) { - this._onData.fire(token); + if (line instanceof CarriageReturn || line instanceof NewLine) { + this._onData.fire(line); return; } - // loop through the text separating it into `Word` and `Space` tokens + // loop through the text separating it into `Word` and `well-known` tokens + const lineText = line.text.split(''); let i = 0; - while (i < token.text.length) { + while (i < lineText.length) { // index is 0-based, but column numbers are 1-based const columnNumber = i + 1; // check if the current character is a well-known token const tokenConstructor = WELL_KNOWN_TOKENS .find((wellKnownToken) => { - return wellKnownToken.symbol === token.text[i]; + return wellKnownToken.symbol === lineText[i]; }); // if it is a well-known token, emit it and continue to the next one if (tokenConstructor) { - this._onData.fire(tokenConstructor.newOnLine(token, columnNumber)); + this._onData.fire(SimpleToken.newOnLine(line, columnNumber, tokenConstructor)); i++; continue; @@ -95,14 +119,14 @@ export class SimpleDecoder extends BaseDecoder { // that needs to be collected into a single `Word` token, hence // read all the characters until a stop character is encountered let word = ''; - while (i < token.text.length && !(WORD_STOP_CHARACTERS.includes(token.text[i]))) { - word += token.text[i]; + while (i < lineText.length && !(WORD_STOP_CHARACTERS.includes(lineText[i]))) { + word += lineText[i]; i++; } // emit a "text" sequence of characters as a single `Word` token this._onData.fire( - Word.newOnLine(word, token, columnNumber), + Word.newOnLine(word, line, columnNumber), ); } } diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/angleBrackets.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/angleBrackets.ts index 70d264bdd99..52df3621492 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/angleBrackets.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/angleBrackets.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Range } from '../../../core/range.js'; -import { Position } from '../../../core/position.js'; -import { Line } from '../../linesCodec/tokens/line.js'; +import { SimpleToken } from './simpleToken.js'; /** * A token that represent a `<` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class LeftAngleBracket extends BaseToken { +export class LeftAngleBracket extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = '<'; + public static override readonly symbol: '<' = '<'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return LeftAngleBracket.symbol; } - /** - * Create new `LeftBracket` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): LeftAngleBracket { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new LeftAngleBracket(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ @@ -56,38 +34,19 @@ export class LeftAngleBracket extends BaseToken { * A token that represent a `>` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class RightAngleBracket extends BaseToken { +export class RightAngleBracket extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = '>'; + public static override readonly symbol: '>' = '>'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return RightAngleBracket.symbol; } - /** - * Create new `RightAngleBracket` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): RightAngleBracket { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new RightAngleBracket(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/at.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/at.ts new file mode 100644 index 00000000000..7fb98514cc3 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/at.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SimpleToken } from './simpleToken.js'; + +/** + * A token that represent a `@` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class At extends SimpleToken { + /** + * The underlying symbol of the token. + */ + public static override readonly symbol: '@' = '@'; + + /** + * Return text representation of the token. + */ + public override get text() { + return At.symbol; + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `at${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/brackets.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/brackets.ts index 16165cf64a7..f44f0e86b83 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/brackets.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/brackets.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Range } from '../../../core/range.js'; -import { Position } from '../../../core/position.js'; -import { Line } from '../../linesCodec/tokens/line.js'; +import { SimpleToken } from './simpleToken.js'; /** * A token that represent a `[` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class LeftBracket extends BaseToken { +export class LeftBracket extends SimpleToken { /** - * The underlying symbol of the `LeftBracket` token. + * The underlying symbol of the token. */ - public static readonly symbol: string = '['; + public static override readonly symbol: '[' = '['; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return LeftBracket.symbol; } - /** - * Create new `LeftBracket` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): LeftBracket { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new LeftBracket(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ @@ -56,38 +34,19 @@ export class LeftBracket extends BaseToken { * A token that represent a `]` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class RightBracket extends BaseToken { +export class RightBracket extends SimpleToken { /** - * The underlying symbol of the `RightBracket` token. + * The underlying symbol of the token. */ - public static readonly symbol: string = ']'; + public static override readonly symbol: ']' = ']'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return RightBracket.symbol; } - /** - * Create new `RightBracket` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): RightBracket { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new RightBracket(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/colon.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/colon.ts index 76e9f0cd2b4..6d8be7abad2 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/colon.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/colon.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Range } from '../../../core/range.js'; -import { Position } from '../../../core/position.js'; -import { Line } from '../../linesCodec/tokens/line.js'; +import { SimpleToken } from './simpleToken.js'; /** * A token that represent a `:` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class Colon extends BaseToken { +export class Colon extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = ':'; + public static override readonly symbol: ':' = ':'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return Colon.symbol; } - /** - * Create new token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): Colon { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new Colon(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/comma.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/comma.ts new file mode 100644 index 00000000000..c76ff95b096 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/comma.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SimpleToken } from './simpleToken.js'; + +/** + * A token that represent a `,` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class Comma extends SimpleToken { + /** + * The underlying symbol of the token. + */ + public static override readonly symbol: ',' = ','; + + /** + * Return text representation of the token. + */ + public override get text() { + return Comma.symbol; + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `comma${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/curlyBraces.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/curlyBraces.ts new file mode 100644 index 00000000000..854d1d2f362 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/curlyBraces.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SimpleToken } from './simpleToken.js'; + +/** + * A token that represent a `{` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class LeftCurlyBrace extends SimpleToken { + /** + * The underlying symbol of the token. + */ + public static override readonly symbol: '{' = '{'; + + /** + * Return text representation of the token. + */ + public override get text() { + return LeftCurlyBrace.symbol; + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `left-curly-brace${this.range}`; + } +} + +/** + * A token that represent a `}` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class RightCurlyBrace extends SimpleToken { + /** + * The underlying symbol of the token. + */ + public static override readonly symbol: '}' = '}'; + + /** + * Return text representation of the token. + */ + public override get text() { + return RightCurlyBrace.symbol; + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `right-curly-brace${this.range}`; + } +} + +/** + * General curly brace token type. + */ +export type TCurlyBrace = LeftCurlyBrace | RightCurlyBrace; diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/dash.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/dash.ts index ebc0179eeef..5987fb38cb8 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/dash.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/dash.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Range } from '../../../core/range.js'; -import { Position } from '../../../core/position.js'; -import { Line } from '../../linesCodec/tokens/line.js'; +import { SimpleToken } from './simpleToken.js'; /** * A token that represent a `-` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class Dash extends BaseToken { +export class Dash extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = '-'; + public static override readonly symbol: '-' = '-'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return Dash.symbol; } - /** - * Create new token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): Dash { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new Dash(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/dollarSign.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/dollarSign.ts new file mode 100644 index 00000000000..509bbae0b82 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/dollarSign.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SimpleToken } from './simpleToken.js'; + +/** + * A token that represent a `$` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class DollarSign extends SimpleToken { + /** + * The underlying symbol of the token. + */ + public static override readonly symbol: '$' = '$'; + + /** + * Return text representation of the token. + */ + public override get text() { + return DollarSign.symbol; + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `dollarSign${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/doubleQuote.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/doubleQuote.ts new file mode 100644 index 00000000000..5fc5b4595e8 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/doubleQuote.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { SimpleToken } from './simpleToken.js'; + +/** + * A token that represent a `"` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class DoubleQuote extends SimpleToken { + /** + * The underlying symbol of the token. + */ + public static override readonly symbol: '"' = '"'; + + /** + * Return text representation of the token. + */ + public override get text() { + return DoubleQuote.symbol; + } + + /** + * Checks if the provided token is of the same type + * as the current one. + */ + public sameType(other: BaseToken): other is typeof this { + return (other instanceof this.constructor); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `double-quote${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/exclamationMark.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/exclamationMark.ts index 025edf70291..8bf6eade73e 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/exclamationMark.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/exclamationMark.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Range } from '../../../core/range.js'; -import { Position } from '../../../core/position.js'; -import { Line } from '../../linesCodec/tokens/line.js'; +import { SimpleToken } from './simpleToken.js'; /** * A token that represent a `!` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class ExclamationMark extends BaseToken { +export class ExclamationMark extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = '!'; + public static override readonly symbol: '!' = '!'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return ExclamationMark.symbol; } - /** - * Create new token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): ExclamationMark { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new ExclamationMark(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/formFeed.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/formFeed.ts index 35f55dd8a2a..efdf6ec0048 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/formFeed.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/formFeed.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Line } from '../../linesCodec/tokens/line.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { Position } from '../../../../../editor/common/core/position.js'; +import { SimpleToken } from './simpleToken.js'; /** * Token that represent a `form feed` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class FormFeed extends BaseToken { +export class FormFeed extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = '\f'; + public static override readonly symbol: '\f' = '\f'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return FormFeed.symbol; } - /** - * Create new `FormFeed` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): FormFeed { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new FormFeed(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/hash.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/hash.ts index ddca12a2279..ac8cd96bb3c 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/hash.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/hash.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Range } from '../../../core/range.js'; -import { Position } from '../../../core/position.js'; -import { Line } from '../../linesCodec/tokens/line.js'; +import { SimpleToken } from './simpleToken.js'; /** * A token that represent a `#` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class Hash extends BaseToken { +export class Hash extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = '#'; + public static override readonly symbol: '#' = '#'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return Hash.symbol; } - /** - * Create new token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): Hash { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new Hash(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/index.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/index.ts new file mode 100644 index 00000000000..b8e38bc3325 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/index.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. + *--------------------------------------------------------------------------------------------*/ + +export { At } from './at.js'; +export { Tab } from './tab.js'; +export { Dash } from './dash.js'; +export { Hash } from './hash.js'; +export { Word } from './word.js'; +export { Colon } from './colon.js'; +export { Quote } from './quote.js'; +export { Slash } from './slash.js'; +export { Space } from './space.js'; +export { Comma } from './comma.js'; +export { FormFeed } from './formFeed.js'; +export { DollarSign } from './dollarSign.js'; +export { VerticalTab } from './verticalTab.js'; +export { SimpleToken } from './simpleToken.js'; +export { DoubleQuote } from './doubleQuote.js'; +export { ExclamationMark } from './exclamationMark.js'; +export { type TBracket, LeftBracket, RightBracket } from './brackets.js'; +export { type TCurlyBrace, LeftCurlyBrace, RightCurlyBrace } from './curlyBraces.js'; +export { type TParenthesis, LeftParenthesis, RightParenthesis } from './parentheses.js'; +export { type TAngleBracket, LeftAngleBracket, RightAngleBracket } from './angleBrackets.js'; diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/parentheses.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/parentheses.ts index d3509824f53..ec7b07d0e03 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/parentheses.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/parentheses.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Range } from '../../../core/range.js'; -import { Position } from '../../../core/position.js'; -import { Line } from '../../linesCodec/tokens/line.js'; +import { SimpleToken } from './simpleToken.js'; /** * A token that represent a `(` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class LeftParenthesis extends BaseToken { +export class LeftParenthesis extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = '('; + public static override readonly symbol: '(' = '('; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return LeftParenthesis.symbol; } - /** - * Create new `LeftParenthesis` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): LeftParenthesis { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new LeftParenthesis(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ @@ -56,38 +34,19 @@ export class LeftParenthesis extends BaseToken { * A token that represent a `)` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class RightParenthesis extends BaseToken { +export class RightParenthesis extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = ')'; + public static override readonly symbol: ')' = ')'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return RightParenthesis.symbol; } - /** - * Create new `RightParenthesis` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): RightParenthesis { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new RightParenthesis(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/quote.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/quote.ts new file mode 100644 index 00000000000..85c331b0be8 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/quote.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../../baseToken.js'; +import { SimpleToken } from './simpleToken.js'; + +/** + * A token that represent a `'` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class Quote extends SimpleToken { + /** + * The underlying symbol of the token. + */ + public static override readonly symbol: '\'' = '\''; + + /** + * Return text representation of the token. + */ + public override get text() { + return Quote.symbol; + } + + /** + * Checks if the provided token is of the same type + * as the current one. + */ + public sameType(other: BaseToken): other is Quote { + return (other instanceof this.constructor); + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `quote${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/simpleToken.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/simpleToken.ts new file mode 100644 index 00000000000..07a3f987d00 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/simpleToken.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../core/range.js'; +import { BaseToken } from '../../baseToken.js'; +import { Line } from '../../linesCodec/tokens/line.js'; + +/** + * Interface for a class that can be instantiated into a {@link SimpleToken}. + */ +export interface ISimpleTokenClass { + /** + * Character representing the token. + */ + readonly symbol: string; + + /** + * Constructor for the token. + */ + new(...args: any[]): TSimpleToken; +} + +/** + * Base class for all "simple" tokens with a `range`. + * A simple token is the one that represents a single character. + */ +export abstract class SimpleToken extends BaseToken { + /** + * The underlying symbol of the token. + */ + public static readonly symbol: string; + + /** + * Return text representation of the token. + */ + public abstract override get text(): string; + + /** + * Create new token instance with range inside + * the given `Line` at the given `column number`. + */ + public static newOnLine( + line: Line, + atColumnNumber: number, + Constructor: ISimpleTokenClass, + ): SimpleToken { + const { range } = line; + + return new Constructor(new Range( + range.startLineNumber, + atColumnNumber, + range.startLineNumber, + atColumnNumber + Constructor.symbol.length, + )); + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/slash.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/slash.ts new file mode 100644 index 00000000000..a0c6dee88a0 --- /dev/null +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/slash.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SimpleToken } from './simpleToken.js'; + +/** + * A token that represent a `/` with a `range`. The `range` + * value reflects the position of the token in the original data. + */ +export class Slash extends SimpleToken { + /** + * The underlying symbol of the token. + */ + public static override readonly symbol: '/' = '/'; + + /** + * Return text representation of the token. + */ + public override get text() { + return Slash.symbol; + } + + /** + * Returns a string representation of the token. + */ + public override toString(): string { + return `slash${this.range}`; + } +} diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/space.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/space.ts index 18a5dff4a0a..66f17e49a25 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/space.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/space.ts @@ -3,47 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Line } from '../../linesCodec/tokens/line.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { Position } from '../../../../../editor/common/core/position.js'; +import { SimpleToken } from './simpleToken.js'; /** * A token that represent a `space` with a `range`. The `range` * value reflects the position of the token in the original data. - */ -export class Space extends BaseToken { + */export class Space extends SimpleToken { /** * The underlying symbol of the `Space` token. */ - public static readonly symbol: string = ' '; + public static override readonly symbol: ' ' = ' '; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return Space.symbol; } - /** - * Create new `Space` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): Space { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new Space(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts index c0d775ff8cd..58d72627db6 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/tab.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Line } from '../../linesCodec/tokens/line.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { Position } from '../../../../../editor/common/core/position.js'; +import { SimpleToken } from './simpleToken.js'; /** * A token that represent a `tab` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class Tab extends BaseToken { +export class Tab extends SimpleToken { /** * The underlying symbol of the token. */ - public static readonly symbol: string = '\t'; + public static override readonly symbol: '\t' = '\t'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return Tab.symbol; } - /** - * Create new token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): Tab { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new Tab(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/verticalTab.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/verticalTab.ts index c6b87db0e37..7afd67db343 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/verticalTab.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/verticalTab.ts @@ -3,47 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseToken } from '../../baseToken.js'; -import { Line } from '../../linesCodec/tokens/line.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { Position } from '../../../../../editor/common/core/position.js'; +import { SimpleToken } from './simpleToken.js'; /** * Token that represent a `vertical tab` with a `range`. The `range` * value reflects the position of the token in the original data. */ -export class VerticalTab extends BaseToken { +export class VerticalTab extends SimpleToken { /** * The underlying symbol of the `VerticalTab` token. */ - public static readonly symbol: string = '\v'; + public static override readonly symbol: '\v' = '\v'; /** * Return text representation of the token. */ - public get text(): string { + public override get text() { return VerticalTab.symbol; } - /** - * Create new `VerticalTab` token with range inside - * the given `Line` at the given `column number`. - */ - public static newOnLine( - line: Line, - atColumnNumber: number, - ): VerticalTab { - const { range } = line; - - const startPosition = new Position(range.startLineNumber, atColumnNumber); - const endPosition = new Position(range.startLineNumber, atColumnNumber + this.symbol.length); - - return new VerticalTab(Range.fromPositions( - startPosition, - endPosition, - )); - } - /** * Returns a string representation of the token. */ diff --git a/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts b/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts index 2ca5598ac4b..c7fd146103a 100644 --- a/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts +++ b/src/vs/editor/common/codecs/simpleCodec/tokens/word.ts @@ -67,6 +67,6 @@ export class Word extends BaseToken { * Returns a string representation of the token. */ public override toString(): string { - return `word("${this.text}")${this.range}`; + return `word("${this.shortText()}")${this.range}`; } } diff --git a/src/vs/editor/common/codecs/utils/tokenStream.ts b/src/vs/editor/common/codecs/utils/tokenStream.ts new file mode 100644 index 00000000000..d5d0c5f0967 --- /dev/null +++ b/src/vs/editor/common/codecs/utils/tokenStream.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseToken } from '../baseToken.js'; +import { assert, assertNever } from '../../../../base/common/assert.js'; +import { ObservableDisposable } from '../../../../base/common/observableDisposable.js'; +import { newWriteableStream, WriteableStream, ReadableStream } from '../../../../base/common/stream.js'; + +/** + * A readable stream of provided tokens. + */ +export class TokenStream extends ObservableDisposable implements ReadableStream { + /** + * Underlying writable stream instance. + */ + private readonly stream: WriteableStream; + + /** + * Index of the next token to be sent. + */ + private index: number; + + /** + * Interval reference that is used to periodically send + * tokens to the stream in the background. + */ + private interval: ReturnType | undefined; + + /** + * Number of tokens left to be sent. + */ + private get tokensLeft(): number { + return this.tokens.length - this.index; + } + + constructor( + private readonly tokens: readonly T[], + ) { + super(); + + this.stream = newWriteableStream(null); + this.index = 0; + + // send couple of tokens immediately + this.sendTokens(); + } + + /** + * Start periodically sending tokens to the stream + * asynchronously in the background. + */ + public startStream(): this { + // already running, noop + if (this.interval !== undefined) { + return this; + } + + // no tokens to send, end the stream immediately + if (this.tokens.length === 0) { + this.stream.end(); + return this; + } + + // periodically send tokens to the stream + this.interval = setInterval(() => { + if (this.tokensLeft === 0) { + clearInterval(this.interval); + delete this.interval; + + return; + } + + this.sendTokens(); + }, 1); + + return this; + } + + /** + * Stop tokens sending interval. + */ + public stopStream(): this { + if (this.interval === undefined) { + return this; + } + + clearInterval(this.interval); + delete this.interval; + + return this; + } + + /** + * Sends a provided number of tokens to the stream. + */ + private sendTokens( + tokensCount: number = 25, + ): void { + if (this.tokensLeft <= 0) { + return; + } + + // send up to 10 tokens at a time + let tokensToSend = Math.min(this.tokensLeft, tokensCount); + while (tokensToSend > 0) { + assert( + this.index < this.tokens.length, + `Token index '${this.index}' is out of bounds.`, + ); + + this.stream.write(this.tokens[this.index]); + this.index++; + tokensToSend--; + } + + // if sent all tokens, end the stream immediately + if (this.tokensLeft === 0) { + this.stream.end(); + } + } + + public pause(): void { + this.stopStream(); + + return this.stream.pause(); + } + + public resume(): void { + this.startStream(); + + return this.stream.resume(); + } + + public destroy(): void { + this.dispose(); + } + + public removeListener(event: string, callback: Function): void { + return this.stream.removeListener(event, callback); + } + + public on(event: 'data', callback: (data: T) => void): void; + public on(event: 'error', callback: (err: Error) => void): void; + public on(event: 'end', callback: () => void): void; + public on(event: 'data' | 'error' | 'end', callback: (arg?: any) => void): void { + if (event === 'data') { + this.stream.on(event, callback); + // this is the convention of the readable stream, - when + // the `data` event is registered, the stream is started + this.startStream(); + + return; + } + + if (event === 'error') { + return this.stream.on(event, callback); + } + + if (event === 'end') { + return this.stream.on(event, callback); + } + + assertNever( + event, + `Unexpected event name '${event}'.`, + ); + } + + /** + * Cleanup send interval and destroy the stream. + */ + public override dispose(): void { + this.stopStream(); + this.stream.destroy(); + + super.dispose(); + } +} diff --git a/src/vs/editor/common/config/editorConfigurationSchema.ts b/src/vs/editor/common/config/editorConfigurationSchema.ts index c3613ee9725..8b8d099149a 100644 --- a/src/vs/editor/common/config/editorConfigurationSchema.ts +++ b/src/vs/editor/common/config/editorConfigurationSchema.ts @@ -117,6 +117,12 @@ const editorConfiguration: IConfigurationNode = { markdownDescription: nls.localize('editor.experimental.treeSitterTelemetry', "Controls whether tree sitter parsing should be turned on and telemetry collected. Setting `editor.experimental.preferTreeSitter` for specific languages will take precedence."), tags: ['experimental', 'onExP'] }, + 'editor.experimental.preferTreeSitter.css': { + type: 'boolean', + default: false, + markdownDescription: nls.localize('editor.experimental.preferTreeSitter.css', "Controls whether tree sitter parsing should be turned on for css. This will take precedence over `editor.experimental.treeSitterTelemetry` for css."), + tags: ['experimental', 'onExP'] + }, 'editor.experimental.preferTreeSitter.typescript': { type: 'boolean', default: false, @@ -129,6 +135,12 @@ const editorConfiguration: IConfigurationNode = { markdownDescription: nls.localize('editor.experimental.preferTreeSitter.ini', "Controls whether tree sitter parsing should be turned on for ini. This will take precedence over `editor.experimental.treeSitterTelemetry` for ini."), tags: ['experimental', 'onExP'] }, + 'editor.experimental.preferTreeSitter.regex': { + type: 'boolean', + default: false, + markdownDescription: nls.localize('editor.experimental.preferTreeSitter.regex', "Controls whether tree sitter parsing should be turned on for regex. This will take precedence over `editor.experimental.treeSitterTelemetry` for regex."), + tags: ['experimental', 'onExP'] + }, 'editor.language.brackets': { type: ['array', 'null'], default: null, // We want to distinguish the empty array from not configured. diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index b887b7ac57c..bb4eee89030 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -986,6 +986,7 @@ export interface IEnvironmentalOptions { readonly inputMode: 'insert' | 'overtype'; readonly accessibilitySupport: AccessibilitySupport; readonly glyphMarginDecorationLaneCount: number; + readonly editContextSupported: boolean; } /** @@ -1943,8 +1944,7 @@ class EffectiveExperimentalEditContextEnabled extends ComputedEditorOption endColumnExclusive) { + throw new BugIndicatingError(`startColumn ${startColumn} cannot be after endColumnExclusive ${endColumnExclusive}`); + } + } + + toRange(lineNumber: number): Range { + return new Range(lineNumber, this.startColumn, lineNumber, this.endColumnExclusive); + } + + equals(other: ColumnRange): boolean { + return this.startColumn === other.startColumn + && this.endColumnExclusive === other.endColumnExclusive; + } + + toZeroBasedOffsetRange(): OffsetRange { + return new OffsetRange(this.startColumn - 1, this.endColumnExclusive - 1); + } +} diff --git a/src/vs/editor/common/core/offsetEdit.ts b/src/vs/editor/common/core/offsetEdit.ts index f35eb12d5b4..adcbe4da86d 100644 --- a/src/vs/editor/common/core/offsetEdit.ts +++ b/src/vs/editor/common/core/offsetEdit.ts @@ -11,6 +11,17 @@ import { OffsetRange } from './offsetRange.js'; * Use `TextEdit` to describe edits for a 1-based line/column text. */ export class OffsetEdit { + public static join(edits: readonly OffsetEdit[]): OffsetEdit { + if (edits.length === 0) { + return OffsetEdit.empty; + } + let result = edits[0]; + for (let i = 1; i < edits.length; i++) { + result = result.compose(edits[i]); + } + return result; + } + public static readonly empty = new OffsetEdit([]); public static fromJson(data: IOffsetEdit): OffsetEdit { diff --git a/src/vs/editor/common/core/offsetRange.ts b/src/vs/editor/common/core/offsetRange.ts index 7e1aa41b29e..4786748450d 100644 --- a/src/vs/editor/common/core/offsetRange.ts +++ b/src/vs/editor/common/core/offsetRange.ts @@ -202,6 +202,10 @@ export class OffsetRange implements IOffsetRange { export class OffsetRangeSet { private readonly _sortedRanges: OffsetRange[] = []; + public get ranges(): OffsetRange[] { + return [...this._sortedRanges]; + } + public addRange(range: OffsetRange): void { let i = 0; while (i < this._sortedRanges.length && this._sortedRanges[i].endExclusive < range.start) { diff --git a/src/vs/editor/common/core/positionToOffset.ts b/src/vs/editor/common/core/positionToOffset.ts index 3548c8d0e89..7fa4e2d59a4 100644 --- a/src/vs/editor/common/core/positionToOffset.ts +++ b/src/vs/editor/common/core/positionToOffset.ts @@ -4,11 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import { findLastIdxMonotonous } from '../../../base/common/arraysFind.js'; +import { ITextModel } from '../model.js'; +import { OffsetEdit, SingleOffsetEdit } from './offsetEdit.js'; import { OffsetRange } from './offsetRange.js'; import { Position } from './position.js'; import { Range } from './range.js'; +import { SingleTextEdit, TextEdit } from './textEdit.js'; import { TextLength } from './textLength.js'; +export function getPositionOffsetTransformerFromTextModel(textModel: ITextModel): PositionOffsetTransformer { + const text = textModel.getValue(); + return new PositionOffsetTransformer(text); +} + export class PositionOffsetTransformer { private readonly lineStartOffsetByLineIdx: number[]; private readonly lineEndOffsetByLineIdx: number[]; @@ -88,4 +96,22 @@ export class PositionOffsetTransformer { getLineLength(lineNumber: number): number { return this.lineEndOffsetByLineIdx[lineNumber - 1] - this.lineStartOffsetByLineIdx[lineNumber - 1]; } + + getOffsetEdit(edit: TextEdit): OffsetEdit { + const edits = edit.edits.map(e => this.getSingleOffsetEdit(e)); + return new OffsetEdit(edits); + } + + getSingleOffsetEdit(edit: SingleTextEdit): SingleOffsetEdit { + return new SingleOffsetEdit(this.getOffsetRange(edit.range), edit.text); + } + + getSingleTextEdit(edit: SingleOffsetEdit): SingleTextEdit { + return new SingleTextEdit(this.getRange(edit.replaceRange), edit.newText); + } + + getTextEdit(edit: OffsetEdit): TextEdit { + const edits = edit.edits.map(e => this.getSingleTextEdit(e)); + return new TextEdit(edits); + } } diff --git a/src/vs/editor/common/core/rangeSingleLine.ts b/src/vs/editor/common/core/rangeSingleLine.ts new file mode 100644 index 00000000000..d16cf24b0dd --- /dev/null +++ b/src/vs/editor/common/core/rangeSingleLine.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ColumnRange } from './columnRange.js'; +import { Range } from './range.js'; + +export class RangeSingleLine { + public static fromRange(range: Range): RangeSingleLine | undefined { + if (range.endLineNumber !== range.startLineNumber) { + return undefined; + } + return new RangeSingleLine(range.startLineNumber, new ColumnRange(range.startColumn, range.endColumn)); + } + + constructor( + /** 1-based */ + public readonly lineNumber: number, + public readonly columnRange: ColumnRange, + ) { } + + toRange(): Range { + return new Range(this.lineNumber, this.columnRange.startColumn, this.lineNumber, this.columnRange.endColumnExclusive); + } +} diff --git a/src/vs/editor/common/core/textEdit.ts b/src/vs/editor/common/core/textEdit.ts index 83baa3bff43..80d03aa1e34 100644 --- a/src/vs/editor/common/core/textEdit.ts +++ b/src/vs/editor/common/core/textEdit.ts @@ -193,6 +193,68 @@ export class TextEdit { equals(other: TextEdit): boolean { return equals(this.edits, other.edits, (a, b) => a.equals(b)); } + + toString(text: AbstractText | string | undefined): string { + if (text === undefined) { + return this.edits.map(edit => edit.toString()).join('\n'); + } + + if (typeof text === 'string') { + return this.toString(new StringText(text)); + } + + if (this.edits.length === 0) { + return ''; + } + + return this.edits.map(edit => { + const maxLength = 10; + const originalText = text.getValueOfRange(edit.range); + + // Get text before the edit + const beforeRange = Range.fromPositions( + new Position(Math.max(1, edit.range.startLineNumber - 1), 1), + edit.range.getStartPosition() + ); + let beforeText = text.getValueOfRange(beforeRange); + if (beforeText.length > maxLength) { + beforeText = '...' + beforeText.substring(beforeText.length - maxLength); + } + + // Get text after the edit + const afterRange = Range.fromPositions( + edit.range.getEndPosition(), + new Position(edit.range.endLineNumber + 1, 1) + ); + let afterText = text.getValueOfRange(afterRange); + if (afterText.length > maxLength) { + afterText = afterText.substring(0, maxLength) + '...'; + } + + // Format the replaced text + let replacedText = originalText; + if (replacedText.length > maxLength) { + const halfMax = Math.floor(maxLength / 2); + replacedText = replacedText.substring(0, halfMax) + '...' + + replacedText.substring(replacedText.length - halfMax); + } + + // Format the new text + let newText = edit.text; + if (newText.length > maxLength) { + const halfMax = Math.floor(maxLength / 2); + newText = newText.substring(0, halfMax) + '...' + + newText.substring(newText.length - halfMax); + } + + if (replacedText.length === 0) { + // allow-any-unicode-next-line + return `${beforeText}❰${newText}❱${afterText}`; + } + // allow-any-unicode-next-line + return `${beforeText}❰${replacedText}↦${newText}❱${afterText}`; + }).join('\n'); + } } export class SingleTextEdit { diff --git a/src/vs/editor/common/editorCommon.ts b/src/vs/editor/common/editorCommon.ts index e25edb4fb50..9cf2cd59a83 100644 --- a/src/vs/editor/common/editorCommon.ts +++ b/src/vs/editor/common/editorCommon.ts @@ -634,6 +634,7 @@ export interface IThemeDecorationRenderOptions { fontStyle?: string; fontWeight?: string; fontSize?: string; + lineHeight?: number; textDecoration?: string; cursor?: string; color?: string | ThemeColor; diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 6ce3c242173..39ffd5dd032 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -27,9 +27,10 @@ import { localize } from '../../nls.js'; import { ExtensionIdentifier } from '../../platform/extensions/common/extensions.js'; import { IMarkerData } from '../../platform/markers/common/markers.js'; import { IModelTokensChangedEvent } from './textModelEvents.js'; -import type * as Parser from '@vscode/tree-sitter-wasm'; import { ITextModel } from './model.js'; import { TokenUpdate } from './model/tokenStore.js'; +import { ITextModelTreeSitter } from './services/treeSitterParserService.js'; +import type * as Parser from '@vscode/tree-sitter-wasm'; /** * @internal @@ -89,12 +90,15 @@ export class EncodedTokenizationResult { export interface SyntaxNode { startIndex: number; endIndex: number; + startPosition: IPosition; + endPosition: IPosition; } export interface QueryCapture { name: string; text?: string; node: SyntaxNode; + encodedLanguageId: number; } /** @@ -108,10 +112,11 @@ export interface ITreeSitterTokenizationSupport { getTokensInRange(textModel: ITextModel, range: Range, rangeStartOffset: number, rangeEndOffset: number): TokenUpdate[] | undefined; tokenizeEncoded(lineNumber: number, textModel: model.ITextModel): void; captureAtPosition(lineNumber: number, column: number, textModel: model.ITextModel): QueryCapture[]; - captureAtPositionTree(lineNumber: number, column: number, tree: Parser.Tree): QueryCapture[]; + captureAtRangeTree(range: Range, tree: Parser.Tree, textModelTreeSitter: ITextModelTreeSitter): QueryCapture[]; onDidChangeTokens: Event<{ textModel: model.ITextModel; changes: IModelTokensChangedEvent }>; onDidChangeBackgroundTokenization: Event<{ textModel: model.ITextModel }>; tokenizeEncodedInstrumented(lineNumber: number, textModel: model.ITextModel): { result: Uint32Array; captureTime: number; metadataTime: number } | undefined; + guessTokensForLinesContent(lineNumber: number, textModel: model.ITextModel, lines: string[]): Uint32Array[] | undefined; } /** @@ -842,6 +847,8 @@ export interface InlineCompletion { readonly showRange?: IRange; readonly warning?: InlineCompletionWarning; + + readonly displayLocation?: InlineCompletionDisplayLocation; } export interface InlineCompletionWarning { @@ -849,6 +856,11 @@ export interface InlineCompletionWarning { icon?: IconPath; } +export interface InlineCompletionDisplayLocation { + range: IRange; + label: string; +} + /** * TODO: add `| URI | { light: URI; dark: URI }`. */ @@ -892,13 +904,24 @@ export interface InlineCompletionsProvider): void; + /** * Will be called when a completions list is no longer in use and can be garbage-collected. */ freeInlineCompletions(completions: T): void; + onDidChangeInlineCompletions?: Event; + /** * Only used for {@link yieldsToGroupIds}. * Multiple providers can have the same group id. @@ -918,6 +941,22 @@ export interface InlineCompletionsProvider = { + kind: InlineCompletionEndOfLifeReasonKind.Accepted; // User did an explicit action to accept +} | { + kind: InlineCompletionEndOfLifeReasonKind.Rejected; // User did an explicit action to reject +} | { + kind: InlineCompletionEndOfLifeReasonKind.Ignored; + supersededBy?: TInlineCompletion; + userTypingDisagreed: boolean; +}; + export interface CodeAction { title: string; command?: Command; @@ -2054,7 +2093,7 @@ export interface CommentThread { onDidChangeInitialCollapsibleState: Event; state?: CommentThreadState; applicability?: CommentThreadApplicability; - canReply: boolean; + canReply: boolean | CommentAuthorInformation; input?: CommentInput; onDidChangeInput: Event; onDidChangeLabel: Event; diff --git a/src/vs/editor/common/languages/highlights/css.scm b/src/vs/editor/common/languages/highlights/css.scm new file mode 100644 index 00000000000..1dccbce2796 --- /dev/null +++ b/src/vs/editor/common/languages/highlights/css.scm @@ -0,0 +1,114 @@ +; Order matters! Place lower precedence first. + +[ + "{" + "}" + "(" + ")" + "[" + "]" +] @punctuation.css + +[ + "*=" +] @keyword.operator.css + +[ + "+" + ">" +] @keyword.operator.combinator.css + +(comment) @comment.block.css + +; Selectors + +(selectors) @meta.selector.css + +(class_selector) @entity.other.attribute-name.class.css + +(id_selector) @entity.other.attribute-name.id.css + +(tag_name) @entity.name.tag.css + +(universal_selector) @entity.name.tag.wildcard.css + +(pseudo_class_selector) @entity.other.attribute-name.pseudo-class.css + +(pseudo_element_selector + "::" @entity.other.attribute-name.pseudo-element.css + . + (tag_name) @entity.other.attribute-name.pseudo-element.css) + +(attribute_name) @entity.other.attribute-name.css + +; @ Rules + +[ + ("@import") + ("@charset") + ("@namespace") + ("@media") + ("@supports") + ("@keyframes") + (at_keyword) +] @keyword.control.at-rule.css + +(keyword_query) @support.constant.media.css + +(keyframes_name) @variable.parameter.keyframe-list.css + +; Functions + +(function_name) @support.function.css + +; Properties + +(property_name) @support.type.property-name.css + +; Other values + +(plain_value) @support.constant.property-value.css + +; Strings + +((string_value) @string.quoted.single.css + (#match? @string.quoted.single.css "^'.*'$")) + +((string_value) @string.quoted.double.css + (#match? @string.quoted.double.css "^\".*\"$")) + +; Numbers + +([ + (integer_value) + (float_value) +]) @constant.numeric.css + +(unit) @keyword.other.unit.css + +; Special values + +(declaration + ((property_name) @support.type.property-name.css + (#eq? @support.type.property-name.css "font")) + (plain_value) @support.constant.font-name.css) + +((color_value) @constant.other.color.rgb-value.hex.css + (#match? @constant.other.color.rgb-value.hex.css "^#.*")) + +(call_expression + (function_name) @meta.function.variable.css (#eq? @meta.function.variable.css "var") + (arguments + (plain_value) @variable.argument.css)) + +; Special Functions + +(call_expression + ((function_name) @support.function.url.css + (#eq? @support.function.url.css "url")) + (arguments + (plain_value) @variable.parameter.url.css)) + +; Keywords + +(important) @keyword.other.important.css diff --git a/src/vs/editor/common/languages/highlights/regex.scm b/src/vs/editor/common/languages/highlights/regex.scm new file mode 100644 index 00000000000..af098dba6c3 --- /dev/null +++ b/src/vs/editor/common/languages/highlights/regex.scm @@ -0,0 +1,126 @@ +; Order matters! Place lower precedence first. +[ + "?" + "=" + "!" +] @keyword.operator.regexp + +[ + "(" + ")" +] @punctuation.definition.group.regexp + +[ + ">" + "{" + "}" +] @punctuation.regexp + +[ + "[" + "]" +] @punctuation.definition.character-class.regexp + +( + ([ + "(?<" + ] @punctuation.definition.group.assertion.regexp) + . + [ + "=" + "!" + ] @punctuation.definition.group.assertion.regexp +) @meta.assertion.look-behind.regexp + +( + ([ + "(?" + ] @punctuation.definition.group.assertion.regexp) + . + [ + "=" + "!" + ] @punctuation.definition.group.assertion.regexp +) @meta.assertion.look-ahead.regexp + +"(?:" @punctuation.definition.group.regexp @punctuation.definition.group.no-capture.regexp + +(lookaround_assertion ("!") @punctuation.definition.group.assertion.regexp) + +(named_capturing_group) @punctuation.definition.group.regexp + +(group_name) @variable.other.regexp + +[ + (control_letter_escape) + (non_boundary_assertion) +] @string.escape.regexp + +[ + (start_assertion) + (end_assertion) + (boundary_assertion) +] @keyword.control.anchor.regexp + +(class_character) @constant.character-class.regexp + +(identity_escape) @constant.character.escape.regexp + +[ + ((identity_escape) @internal.regexp (#match? @internal.regexp "\\[^ux]")) +] @constant.character.escape.regexp + +( + ((identity_escape) @internal.regexp (#eq? @internal.regexp "\\u")) + . + (pattern_character) @constant.character.numeric.regexp + . + (pattern_character) @constant.character.numeric.regexp + . + (pattern_character) @constant.character.numeric.regexp + . + (pattern_character) @constant.character.numeric.regexp +) @constant.character.numeric.regexp + +( + ((identity_escape) @internal.regexp (#eq? @internal.regexp "\\x")) + . + (pattern_character) @constant.character.numeric.regexp + . + (pattern_character) @constant.character.numeric.regexp +) @constant.character.numeric.regexp + +( + ((identity_escape) @internal.regexp (#eq? @internal.regexp "\\x")) + . + (class_character) @constant.character.numeric.regexp + . + (class_character) @constant.character.numeric.regexp +) @constant.character.numeric.regexp + +(control_escape) @constant.other.character-class.regexp + +(character_class_escape) @constant.character.escape.regexp + +(decimal_escape) @keyword.other.back-reference.regexp + +("|") @keyword.operator.or.regexp + +[ + "*" + "+" +] @keyword.operator.quantifier.regexp + +(count_quantifier) @keyword.operator.quantifier.regexp + +[ + (lazy) +] @keyword.operator.quantifier.regexp + +(optional ("?") @keyword.operator.quantifier.regexp) + +(character_class + [ + "^" @keyword.operator.negation.regexp + (class_range "-" @constant.other.character-class.range.regexp) + ]) diff --git a/src/vs/editor/common/languages/highlights/typescript.scm b/src/vs/editor/common/languages/highlights/typescript.scm index 813ce090dcc..c1c7d8f0df1 100644 --- a/src/vs/editor/common/languages/highlights/typescript.scm +++ b/src/vs/editor/common/languages/highlights/typescript.scm @@ -1,5 +1,4 @@ ; Order matters! Place lower precedence first. -; Adapted from https://github.com/zed-industries/zed/blob/main/crates/languages/src/typescript/highlights.scm ; Variables @@ -29,6 +28,8 @@ (template_literal_type) ] @string.template.ts) +(template_substitution) @meta.template.expression.ts + (string . ([ "\"" @@ -131,6 +132,9 @@ (arrow_function parameter: (identifier) @variable.parameter.ts) +(type_predicate + name: (identifier) @variable.parameter.ts) + ; Function and method calls (call_expression @@ -239,6 +243,7 @@ (unary_expression ([ "-" + "+" ]) @keyword.operator.arithmetic.ts) [ diff --git a/src/vs/editor/common/languages/injections/typescript.scm b/src/vs/editor/common/languages/injections/typescript.scm new file mode 100644 index 00000000000..5794b7c0741 --- /dev/null +++ b/src/vs/editor/common/languages/injections/typescript.scm @@ -0,0 +1,2 @@ +((regex) @injection.content + (#set! injection.language "regex")) diff --git a/src/vs/editor/common/languages/modesRegistry.ts b/src/vs/editor/common/languages/modesRegistry.ts index 28fdbf80051..a61af971493 100644 --- a/src/vs/editor/common/languages/modesRegistry.ts +++ b/src/vs/editor/common/languages/modesRegistry.ts @@ -7,7 +7,7 @@ import * as nls from '../../../nls.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { ILanguageExtensionPoint } from './language.js'; import { Registry } from '../../../platform/registry/common/platform.js'; -import { IDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; import { Mimes } from '../../../base/common/mime.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../platform/configuration/common/configurationRegistry.js'; @@ -16,14 +16,15 @@ export const Extensions = { ModesRegistry: 'editor.modesRegistry' }; -export class EditorModesRegistry { +export class EditorModesRegistry extends Disposable { private readonly _languages: ILanguageExtensionPoint[]; - private readonly _onDidChangeLanguages = new Emitter(); + private readonly _onDidChangeLanguages = this._register(new Emitter()); public readonly onDidChangeLanguages: Event = this._onDidChangeLanguages.event; constructor() { + super(); this._languages = []; } diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index 146c9830e04..5134e5e4d93 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -19,7 +19,7 @@ import { IWordAtPosition } from './core/wordHelper.js'; import { FormattingOptions } from './languages.js'; import { ILanguageSelection } from './languages/language.js'; import { IBracketPairsTextModelPart } from './textModelBracketPairs.js'; -import { IModelContentChange, IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent, InternalModelContentChangeEvent, ModelInjectedTextChangedEvent } from './textModelEvents.js'; +import { IModelContentChange, IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent, InternalModelContentChangeEvent, ModelInjectedTextChangedEvent, ModelLineHeightChangedEvent } from './textModelEvents.js'; import { IGuidesTextModelPart } from './textModelGuides.js'; import { ITokenizationTextModelPart } from './tokenizationTextModelPart.js'; import { UndoRedoGroup } from '../../platform/undoRedo/common/undoRedo.js'; @@ -218,6 +218,10 @@ export interface IModelDecorationOptions { * with the specified {@link IModelDecorationGlyphMarginOptions} in the glyph margin. */ glyphMargin?: IModelDecorationGlyphMarginOptions | null; + /** + * If set, the decoration will override the line height of the lines it spans. + */ + lineHeight?: number | null; /** * If set, the decoration will be rendered in the lines decorations with this CSS class name. */ @@ -1108,6 +1112,12 @@ export interface ITextModel { */ getInjectedTextDecorations(ownerId?: number): IModelDecoration[]; + /** + * Gets all the decorations that contain custom line heights. + * @param ownerId If set, it will ignore decorations belonging to other owners. + */ + getCustomLineHeightsDecorations(ownerId?: number): IModelDecoration[]; + /** * @internal */ @@ -1238,6 +1248,14 @@ export interface ITextModel { * @event */ readonly onDidChangeDecorations: Event; + /** + * An event emitted when line heights from decorations changes. + * This event is emitted only when adding, removing or changing a decoration + * and not when doing edits in the model (i.e. when decoration ranges change) + * @internal + * @event + */ + readonly onDidChangeLineHeight: Event; /** * An event emitted when the model options have changed. * @event diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index c17724ca9ad..0ea09949e81 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -40,13 +40,14 @@ import { SearchParams, TextModelSearch } from './textModelSearch.js'; import { TokenizationTextModelPart } from './tokenizationTextModelPart.js'; import { AttachedViews } from './tokens.js'; import { IBracketPairsTextModelPart } from '../textModelBracketPairs.js'; -import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelOptionsChangedEvent, InternalModelContentChangeEvent, LineInjectedText, ModelInjectedTextChangedEvent, ModelRawChange, ModelRawContentChangedEvent, ModelRawEOLChanged, ModelRawFlush, ModelRawLineChanged, ModelRawLinesDeleted, ModelRawLinesInserted } from '../textModelEvents.js'; +import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelOptionsChangedEvent, InternalModelContentChangeEvent, LineInjectedText, ModelInjectedTextChangedEvent, ModelRawChange, ModelRawContentChangedEvent, ModelRawEOLChanged, ModelRawFlush, ModelRawLineChanged, ModelRawLinesDeleted, ModelRawLinesInserted, ModelLineHeightChangedEvent, ModelLineHeightChanged } from '../textModelEvents.js'; import { IGuidesTextModelPart } from '../textModelGuides.js'; import { ITokenizationTextModelPart } from '../tokenizationTextModelPart.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { IColorTheme } from '../../../platform/theme/common/themeService.js'; import { IUndoRedoService, ResourceEditStackSnapshot, UndoRedoGroup } from '../../../platform/undoRedo/common/undoRedo.js'; import { TokenArray } from '../tokens/tokenArray.js'; +import { SetWithKey } from '../../../base/common/collections.js'; export function createTextBufferFactory(text: string): model.ITextBufferFactory { const builder = new PieceTreeTextBufferBuilder(); @@ -213,7 +214,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati private readonly _onWillDispose: Emitter = this._register(new Emitter()); public readonly onWillDispose: Event = this._onWillDispose.event; - private readonly _onDidChangeDecorations: DidChangeDecorationsEmitter = this._register(new DidChangeDecorationsEmitter(affectedInjectedTextLines => this.handleBeforeFireDecorationsChangedEvent(affectedInjectedTextLines))); + private readonly _onDidChangeDecorations: DidChangeDecorationsEmitter = this._register(new DidChangeDecorationsEmitter((affectedInjectedTextLines, affectedLineHeights) => this.handleBeforeFireDecorationsChangedEvent(affectedInjectedTextLines, affectedLineHeights))); public readonly onDidChangeDecorations: Event = this._onDidChangeDecorations.event; public get onDidChangeLanguage() { return this._tokenizationTextModelPart.onDidChangeLanguage; } @@ -228,6 +229,9 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati private readonly _onDidChangeInjectedText: Emitter = this._register(new Emitter()); + private readonly _onDidChangeLineHeight: Emitter = this._register(new Emitter()); + public readonly onDidChangeLineHeight: Event = this._onDidChangeLineHeight.event; + private readonly _eventEmitter: DidChangeContentEmitter = this._register(new DidChangeContentEmitter()); public onDidChangeContent(listener: (e: IModelContentChangedEvent) => void): IDisposable { return this._eventEmitter.slowEvent((e: InternalModelContentChangeEvent) => listener(e.contentChangedEvent)); @@ -413,6 +417,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati || this._onDidChangeOptions.hasListeners() || this._onDidChangeAttached.hasListeners() || this._onDidChangeInjectedText.hasListeners() + || this._onDidChangeLineHeight.hasListeners() || this._eventEmitter.hasListeners() ); } @@ -1576,17 +1581,19 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati //#region Decorations - private handleBeforeFireDecorationsChangedEvent(affectedInjectedTextLines: Set | null): void { + private handleBeforeFireDecorationsChangedEvent(affectedInjectedTextLines: Set | null, affectedLineHeights: Set | null): void { // This is called before the decoration changed event is fired. - if (affectedInjectedTextLines === null || affectedInjectedTextLines.size === 0) { - return; + if (affectedInjectedTextLines && affectedInjectedTextLines.size > 0) { + const affectedLines = Array.from(affectedInjectedTextLines); + const lineChangeEvents = affectedLines.map(lineNumber => new ModelRawLineChanged(lineNumber, this.getLineContent(lineNumber), this._getInjectedTextInLine(lineNumber))); + this._onDidChangeInjectedText.fire(new ModelInjectedTextChangedEvent(lineChangeEvents)); + } + if (affectedLineHeights && affectedLineHeights.size > 0) { + const affectedLines = Array.from(affectedLineHeights); + const lineHeightChangeEvent = affectedLines.map(specialLineHeightChange => new ModelLineHeightChanged(specialLineHeightChange.ownerId, specialLineHeightChange.decorationId, specialLineHeightChange.lineNumber, specialLineHeightChange.lineHeight)); + this._onDidChangeLineHeight.fire(new ModelLineHeightChangedEvent(lineHeightChangeEvent)); } - - const affectedLines = Array.from(affectedInjectedTextLines); - const lineChangeEvents = affectedLines.map(lineNumber => new ModelRawLineChanged(lineNumber, this.getLineContent(lineNumber), this._getInjectedTextInLine(lineNumber))); - - this._onDidChangeInjectedText.fire(new ModelInjectedTextChangedEvent(lineChangeEvents)); } public changeDecorations(callback: (changeAccessor: model.IModelDecorationsChangeAccessor) => T, ownerId: number = 0): T | null { @@ -1606,10 +1613,10 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati return this._deltaDecorationsImpl(ownerId, [], [{ range: range, options: options }])[0]; }, changeDecoration: (id: string, newRange: IRange): void => { - this._changeDecorationImpl(id, newRange); + this._changeDecorationImpl(ownerId, id, newRange); }, changeDecorationOptions: (id: string, options: model.IModelDecorationOptions) => { - this._changeDecorationOptionsImpl(id, _normalizeOptions(options)); + this._changeDecorationOptionsImpl(ownerId, id, _normalizeOptions(options)); }, removeDecoration: (id: string): void => { this._deltaDecorationsImpl(ownerId, [id], []); @@ -1761,6 +1768,10 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati return this._decorationsTree.getAllInjectedText(this, ownerId); } + public getCustomLineHeightsDecorations(ownerId: number = 0): model.IModelDecoration[] { + return this._decorationsTree.getAllCustomLineHeights(this, ownerId); + } + private _getInjectedTextInLine(lineNumber: number): LineInjectedText[] { const startOffset = this._buffer.getOffsetAt(lineNumber, 1); const endOffset = startOffset + this._buffer.getLineLength(lineNumber); @@ -1789,7 +1800,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati return this._buffer.getRangeAt(start, end - start); } - private _changeDecorationImpl(decorationId: string, _range: IRange): void { + private _changeDecorationImpl(ownerId: number, decorationId: string, _range: IRange): void { const node = this._decorations[decorationId]; if (!node) { return; @@ -1803,6 +1814,10 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati const oldRange = this.getDecorationRange(decorationId); this._onDidChangeDecorations.recordLineAffectedByInjectedText(oldRange!.startLineNumber); } + if (node.options.lineHeight !== null) { + const oldRange = this.getDecorationRange(decorationId); + this._onDidChangeDecorations.recordLineAffectedByLineHeightChange(ownerId, decorationId, oldRange!.startLineNumber, null); + } const range = this._validateRangeRelaxedNoAllocations(_range); const startOffset = this._buffer.getOffsetAt(range.startLineNumber, range.startColumn); @@ -1819,9 +1834,12 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati if (node.options.before) { this._onDidChangeDecorations.recordLineAffectedByInjectedText(range.startLineNumber); } + if (node.options.lineHeight !== null) { + this._onDidChangeDecorations.recordLineAffectedByLineHeightChange(ownerId, decorationId, range.startLineNumber, node.options.lineHeight); + } } - private _changeDecorationOptionsImpl(decorationId: string, options: ModelDecorationOptions): void { + private _changeDecorationOptionsImpl(ownerId: number, decorationId: string, options: ModelDecorationOptions): void { const node = this._decorations[decorationId]; if (!node) { return; @@ -1841,6 +1859,10 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati const nodeRange = this._decorationsTree.getNodeRange(this, node); this._onDidChangeDecorations.recordLineAffectedByInjectedText(nodeRange.startLineNumber); } + if (node.options.lineHeight !== null || options.lineHeight !== null) { + const nodeRange = this._decorationsTree.getNodeRange(this, node); + this._onDidChangeDecorations.recordLineAffectedByLineHeightChange(ownerId, decorationId, nodeRange.startLineNumber, options.lineHeight); + } const movedInOverviewRuler = nodeWasInOverviewRuler !== nodeIsInOverviewRuler; const changedWhetherInjectedText = isOptionsInjectedText(options) !== isNodeInjectedText(node); @@ -1871,8 +1893,10 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati if (oldDecorationIndex < oldDecorationsLen) { // (1) get ourselves an old node + let decorationId: string; do { - node = this._decorations[oldDecorationsIds[oldDecorationIndex++]]; + decorationId = oldDecorationsIds[oldDecorationIndex++]; + node = this._decorations[decorationId]; } while (!node && oldDecorationIndex < oldDecorationsLen); // (2) remove the node from the tree (if it exists) @@ -1885,7 +1909,10 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati const nodeRange = this._decorationsTree.getNodeRange(this, node); this._onDidChangeDecorations.recordLineAffectedByInjectedText(nodeRange.startLineNumber); } - + if (node.options.lineHeight !== null) { + const nodeRange = this._decorationsTree.getNodeRange(this, node); + this._onDidChangeDecorations.recordLineAffectedByLineHeightChange(ownerId, decorationId, nodeRange.startLineNumber, null); + } this._decorationsTree.delete(node); if (!suppressEvents) { @@ -1920,7 +1947,9 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati if (node.options.before) { this._onDidChangeDecorations.recordLineAffectedByInjectedText(range.startLineNumber); } - + if (node.options.lineHeight !== null) { + this._onDidChangeDecorations.recordLineAffectedByLineHeightChange(ownerId, node.id, range.startLineNumber, node.options.lineHeight); + } if (!suppressEvents) { this._onDidChangeDecorations.checkAffectedAndFire(options); } @@ -2090,6 +2119,12 @@ class DecorationsTrees { return this._ensureNodesHaveRanges(host, result).filter((i) => i.options.showIfCollapsed || !i.range.isEmpty()); } + public getAllCustomLineHeights(host: IDecorationsTreesHost, filterOwnerId: number): model.IModelDecoration[] { + const versionId = host.getVersionId(); + const result = this._search(filterOwnerId, false, false, versionId, false); + return this._ensureNodesHaveRanges(host, result).filter((i) => typeof i.options.lineHeight === 'number'); + } + public getAll(host: IDecorationsTreesHost, filterOwnerId: number, filterOutValidation: boolean, overviewRulerOnly: boolean, onlyMarginDecorations: boolean): model.IModelDecoration[] { const versionId = host.getVersionId(); const result = this._search(filterOwnerId, filterOutValidation, overviewRulerOnly, versionId, onlyMarginDecorations); @@ -2316,6 +2351,7 @@ export class ModelDecorationOptions implements model.IModelDecorationOptions { readonly hoverMessage: IMarkdownString | IMarkdownString[] | null; readonly glyphMarginHoverMessage: IMarkdownString | IMarkdownString[] | null; readonly isWholeLine: boolean; + readonly lineHeight: number | null; readonly showIfCollapsed: boolean; readonly collapseOnReplaceEdit: boolean; readonly overviewRuler: ModelDecorationOverviewRulerOptions | null; @@ -2351,6 +2387,7 @@ export class ModelDecorationOptions implements model.IModelDecorationOptions { this.glyphMarginHoverMessage = options.glyphMarginHoverMessage || null; this.lineNumberHoverMessage = options.lineNumberHoverMessage || null; this.isWholeLine = options.isWholeLine || false; + this.lineHeight = options.lineHeight ?? null; this.showIfCollapsed = options.showIfCollapsed || false; this.collapseOnReplaceEdit = options.collapseOnReplaceEdit || false; this.overviewRuler = options.overviewRuler ? new ModelDecorationOverviewRulerOptions(options.overviewRuler) : null; @@ -2391,6 +2428,20 @@ function _normalizeOptions(options: model.IModelDecorationOptions): ModelDecorat return ModelDecorationOptions.createDynamic(options); } +class LineHeightChangingDecoration { + + public static toKey(obj: LineHeightChangingDecoration): string { + return `${obj.ownerId};${obj.decorationId};${obj.lineNumber}`; + } + + constructor( + public readonly ownerId: number, + public readonly decorationId: string, + public readonly lineNumber: number, + public readonly lineHeight: number | null + ) { } +} + class DidChangeDecorationsEmitter extends Disposable { private readonly _actual: Emitter = this._register(new Emitter()); @@ -2401,10 +2452,11 @@ class DidChangeDecorationsEmitter extends Disposable { private _affectsMinimap: boolean; private _affectsOverviewRuler: boolean; private _affectedInjectedTextLines: Set | null = null; + private _affectedLineHeights: SetWithKey | null = null; private _affectsGlyphMargin: boolean; private _affectsLineNumber: boolean; - constructor(private readonly handleBeforeFire: (affectedInjectedTextLines: Set | null) => void) { + constructor(private readonly handleBeforeFire: (affectedInjectedTextLines: Set | null, affectedLineHeights: SetWithKey | null) => void) { super(); this._deferredCnt = 0; this._shouldFireDeferred = false; @@ -2431,6 +2483,8 @@ class DidChangeDecorationsEmitter extends Disposable { this._affectedInjectedTextLines?.clear(); this._affectedInjectedTextLines = null; + this._affectedLineHeights?.clear(); + this._affectedLineHeights = null; } } @@ -2441,6 +2495,13 @@ class DidChangeDecorationsEmitter extends Disposable { this._affectedInjectedTextLines.add(lineNumber); } + public recordLineAffectedByLineHeightChange(ownerId: number, decorationId: string, lineNumber: number, lineHeight: number | null): void { + if (!this._affectedLineHeights) { + this._affectedLineHeights = new SetWithKey([], LineHeightChangingDecoration.toKey); + } + this._affectedLineHeights.add(new LineHeightChangingDecoration(ownerId, decorationId, lineNumber, lineHeight)); + } + public checkAffectedAndFire(options: ModelDecorationOptions): void { this._affectsMinimap ||= !!options.minimap?.position; this._affectsOverviewRuler ||= !!options.overviewRuler?.color; @@ -2465,7 +2526,7 @@ class DidChangeDecorationsEmitter extends Disposable { } private doFire() { - this.handleBeforeFire(this._affectedInjectedTextLines); + this.handleBeforeFire(this._affectedInjectedTextLines, this._affectedLineHeights); const event: IModelDecorationsChangedEvent = { affectsMinimap: this._affectsMinimap, diff --git a/src/vs/editor/common/model/tokenStore.ts b/src/vs/editor/common/model/tokenStore.ts index c180e1b15a3..4582e09703e 100644 --- a/src/vs/editor/common/model/tokenStore.ts +++ b/src/vs/editor/common/model/tokenStore.ts @@ -294,7 +294,7 @@ export class TokenStore implements IDisposable { } postcedingNodes.push(node.node); continue; - } else if (isLeaf(node.node) && (currentOffset + node.node.length >= firstUnchangedOffsetAfterUpdate)) { + } else if (isLeaf(node.node) && (currentOffset + node.node.length > firstUnchangedOffsetAfterUpdate)) { // we have a partial postceeding node postcedingNodes.push({ length: currentOffset + node.node.length - firstUnchangedOffsetAfterUpdate, token: node.node.token, height: 0, tokenQuality: node.node.tokenQuality }); continue; diff --git a/src/vs/editor/common/model/tokenizationTextModelPart.ts b/src/vs/editor/common/model/tokenizationTextModelPart.ts index 84dea7fdba6..8b1ea211b93 100644 --- a/src/vs/editor/common/model/tokenizationTextModelPart.ts +++ b/src/vs/editor/common/model/tokenizationTextModelPart.ts @@ -211,7 +211,7 @@ export class TokenizationTextModelPart extends TextModelPart implements ITokeniz // #region Semantic Tokens public setSemanticTokens(tokens: SparseMultilineTokens[] | null, isComplete: boolean): void { - this._semanticTokens.set(tokens, isComplete); + this._semanticTokens.set(tokens, isComplete, this._textModel); this._emitModelTokensChangedEvent({ semanticTokensApplied: tokens !== null, diff --git a/src/vs/editor/common/model/treeSitterTokenStoreService.ts b/src/vs/editor/common/model/treeSitterTokenStoreService.ts index c304ad9309c..b2ae5b3bd0d 100644 --- a/src/vs/editor/common/model/treeSitterTokenStoreService.ts +++ b/src/vs/editor/common/model/treeSitterTokenStoreService.ts @@ -70,6 +70,8 @@ class TreeSitterTokenizationStoreService implements ITreeSitterTokenizationStore if (oldToken) { // Insert. Just grow the token at this position to include the insert. newToken = { startOffsetInclusive: oldToken.startOffsetInclusive, length: oldToken.length + change.text.length - change.rangeLength, token: oldToken.token }; + // Also mark tokens that are in the range of the change as needing a refresh. + storeInfo.store.markForRefresh(offset, change.rangeOffset + (change.text.length > change.rangeLength ? change.text.length : change.rangeLength)); } else { // The document got larger and the change is at the end of the document. newToken = { startOffsetInclusive: offset, length: change.text.length, token: 0 }; @@ -80,10 +82,7 @@ class TreeSitterTokenizationStoreService implements ITreeSitterTokenizationStore const deletedCharCount = change.rangeLength - change.text.length; storeInfo.store.delete(deletedCharCount, change.rangeOffset); } - const refreshLength = change.rangeLength > change.text.length ? change.rangeLength : change.text.length; - storeInfo.store.markForRefresh(change.rangeOffset, change.rangeOffset + refreshLength); } - } rangeHasTokens(model: ITextModel, range: Range, minimumTokenQuality: TokenQuality): boolean { diff --git a/src/vs/editor/common/model/treeSitterTokens.ts b/src/vs/editor/common/model/treeSitterTokens.ts index 566de4466fb..55c0d9bf196 100644 --- a/src/vs/editor/common/model/treeSitterTokens.ts +++ b/src/vs/editor/common/model/treeSitterTokens.ts @@ -58,7 +58,7 @@ export class TreeSitterTokens extends AbstractTokens { const content = this._textModel.getLineContent(lineNumber); if (this._tokenizationSupport && content.length > 0) { const rawTokens = this._tokenStore.getTokens(this._textModel, lineNumber); - if (rawTokens) { + if (rawTokens && rawTokens.length > 0) { return new LineTokens(rawTokens, content, this._languageIdCodec); } } @@ -94,7 +94,7 @@ export class TreeSitterTokens extends AbstractTokens { } public override forceTokenization(lineNumber: number): void { - if (this._tokenizationSupport) { + if (this._tokenizationSupport && !this.hasAccurateTokensForLine(lineNumber)) { this._tokenizationSupport.tokenizeEncoded(lineNumber, this._textModel); } } @@ -112,10 +112,21 @@ export class TreeSitterTokens extends AbstractTokens { // TODO @alexr00 implement once we have custom parsing and don't just feed in the whole text model value return StandardTokenType.Other; } + public override tokenizeLinesAt(lineNumber: number, lines: string[]): LineTokens[] | null { - // TODO @alexr00 understand what this is for and implement + if (this._tokenizationSupport) { + const rawLineTokens = this._tokenizationSupport.guessTokensForLinesContent(lineNumber, this._textModel, lines); + const lineTokens: LineTokens[] = []; + if (rawLineTokens) { + for (let i = 0; i < rawLineTokens.length; i++) { + lineTokens.push(new LineTokens(rawLineTokens[i], lines[i], this._languageIdCodec)); + } + return lineTokens; + } + } return null; } + public override get hasTokens(): boolean { return this._tokenStore.hasTokens(this._textModel); } diff --git a/src/vs/editor/common/services/editorSimpleWorker.ts b/src/vs/editor/common/services/editorWebWorker.ts similarity index 87% rename from src/vs/editor/common/services/editorSimpleWorker.ts rename to src/vs/editor/common/services/editorWebWorker.ts index 2432a8f657a..c5e8ea184f8 100644 --- a/src/vs/editor/common/services/editorSimpleWorker.ts +++ b/src/vs/editor/common/services/editorWebWorker.ts @@ -6,7 +6,7 @@ import { stringDiff } from '../../../base/common/diff/diff.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; -import { IRequestHandler, IWorkerServer } from '../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerServerRequestHandler } from '../../../base/common/worker/webWorker.js'; import { Position } from '../core/position.js'; import { IRange, Range } from '../core/range.js'; import { EndOfLineSequence, ITextModel } from '../model.js'; @@ -16,16 +16,13 @@ import { computeLinks } from '../languages/linkComputer.js'; import { BasicInplaceReplace } from '../languages/supports/inplaceReplaceSupport.js'; import { DiffAlgorithmName, IDiffComputationResult, ILineChange, IUnicodeHighlightsResult } from './editorWorker.js'; import { createMonacoBaseAPI } from './editorBaseApi.js'; -import { EditorWorkerHost } from './editorWorkerHost.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import { UnicodeTextModelHighlighter, UnicodeHighlighterOptions } from './unicodeTextModelHighlighter.js'; import { DiffComputer, IChange } from '../diff/legacyLinesDiffComputer.js'; import { ILinesDiffComputer, ILinesDiffComputerOptions } from '../diff/linesDiffComputer.js'; import { DetailedLineRangeMapping } from '../diff/rangeMapping.js'; import { linesDiffComputers } from '../diff/linesDiffComputers.js'; -import { createProxyObject, getAllMethodNames } from '../../../base/common/objects.js'; import { IDocumentDiffProviderOptions } from '../diff/documentDiffProvider.js'; -import { AppResourcePath, FileAccess } from '../../../base/common/network.js'; import { BugIndicatingError } from '../../../base/common/errors.js'; import { computeDefaultDocumentColors } from '../languages/defaultDocumentColorsComputer.js'; import { FindSectionHeaderOptions, SectionHeader, findSectionHeaders } from './findSectionHeaders.js'; @@ -67,31 +64,27 @@ export interface IWordRange { /** * @internal */ -export interface IForeignModuleFactory { - (ctx: IWorkerContext, createData: any): any; -} - -declare const require: any; - -/** - * @internal - */ -export class BaseEditorSimpleWorker implements IDisposable, IWorkerTextModelSyncChannelServer, IRequestHandler { +export class EditorWorker implements IDisposable, IWorkerTextModelSyncChannelServer, IWebWorkerServerRequestHandler { _requestHandlerBrand: any; private readonly _workerTextModelSyncServer = new WorkerTextModelSyncServer(); - constructor() { - } + constructor( + private readonly _foreignModule: any | null = null + ) { } dispose(): void { } + public async $ping() { + return 'pong'; + } + protected _getModel(uri: string): ICommonModel | undefined { return this._workerTextModelSyncServer.getModel(uri); } - protected _getModels(): ICommonModel[] { + public getModels(): ICommonModel[] { return this._workerTextModelSyncServer.getModels(); } @@ -132,7 +125,7 @@ export class BaseEditorSimpleWorker implements IDisposable, IWorkerTextModelSync return null; } - const result = EditorSimpleWorker.computeDiff(original, modified, options, algorithm); + const result = EditorWorker.computeDiff(original, modified, options, algorithm); return result; } @@ -267,7 +260,7 @@ export class BaseEditorSimpleWorker implements IDisposable, IWorkerTextModelSync } // make sure diff won't take too long - if (Math.max(text.length, original.length) > EditorSimpleWorker._diffLimit) { + if (Math.max(text.length, original.length) > EditorWorker._diffLimit) { result.push({ range, text }); continue; } @@ -336,7 +329,7 @@ export class BaseEditorSimpleWorker implements IDisposable, IWorkerTextModelSync } // make sure diff won't take too long - if (Math.max(text.length, original.length) > EditorSimpleWorker._diffLimit) { + if (Math.max(text.length, original.length) > EditorWorker._diffLimit) { result.push({ range, text }); continue; } @@ -437,7 +430,7 @@ export class BaseEditorSimpleWorker implements IDisposable, IWorkerTextModelSync continue; } seen.add(word); - if (seen.size > EditorSimpleWorker._suggestionsLimit) { + if (seen.size > EditorWorker._suggestionsLimit) { break outer; } } @@ -509,60 +502,9 @@ export class BaseEditorSimpleWorker implements IDisposable, IWorkerTextModelSync const result = BasicInplaceReplace.INSTANCE.navigateValueSet(range, selectionText, wordRange, word, up); return result; } -} - -/** - * @internal - */ -export class EditorSimpleWorker extends BaseEditorSimpleWorker { - - private _foreignModule: any = null; - - constructor( - private readonly _host: EditorWorkerHost, - private readonly _foreignModuleFactory: IForeignModuleFactory | null - ) { - super(); - } - - public async $ping() { - return 'pong'; - } // ---- BEGIN foreign module support -------------------------------------------------------------------------- - public $loadForeignModule(moduleId: string, createData: any, foreignHostMethods: string[]): Promise { - const proxyMethodRequest = (method: string, args: any[]): Promise => { - return this._host.$fhr(method, args); - }; - - const foreignHost = createProxyObject(foreignHostMethods, proxyMethodRequest); - - const ctx: IWorkerContext = { - host: foreignHost, - getMirrorModels: (): IMirrorModel[] => { - return this._getModels(); - } - }; - - if (this._foreignModuleFactory) { - this._foreignModule = this._foreignModuleFactory(ctx, createData); - // static foreing module - return Promise.resolve(getAllMethodNames(this._foreignModule)); - } - - return new Promise((resolve, reject) => { - - const onModuleCallback = (foreignModule: { create: IForeignModuleFactory }) => { - this._foreignModule = foreignModule.create(ctx, createData); - resolve(getAllMethodNames(this._foreignModule)); - }; - - const url = FileAccess.asBrowserUri(`${moduleId}.js` as AppResourcePath).toString(true); - import(`${url}`).then(onModuleCallback).catch(reject); - }); - } - // foreign method request public $fmr(method: string, args: any[]): Promise { if (!this._foreignModule || typeof this._foreignModule[method] !== 'function') { @@ -579,15 +521,6 @@ export class EditorSimpleWorker extends BaseEditorSimpleWorker { // ---- END foreign module support -------------------------------------------------------------------------- } -/** - * Defines the worker entry point. Must be exported and named `create`. - * @skipMangle - * @internal - */ -export function create(workerServer: IWorkerServer): IRequestHandler { - return new EditorSimpleWorker(EditorWorkerHost.getChannel(workerServer), null); -} - // This is only available in a Web Worker declare function importScripts(...urls: string[]): void; diff --git a/src/vs/editor/common/services/editorWebWorkerMain.ts b/src/vs/editor/common/services/editorWebWorkerMain.ts new file mode 100644 index 00000000000..2094b0f73b5 --- /dev/null +++ b/src/vs/editor/common/services/editorWebWorkerMain.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { bootstrapWebWorker } from '../../../base/common/worker/webWorkerBootstrap.js'; +import { EditorWorker } from './editorWebWorker.js'; + +bootstrapWebWorker(() => new EditorWorker(null)); diff --git a/src/vs/editor/common/services/editorWorker.ts b/src/vs/editor/common/services/editorWorker.ts index c7b03cd784c..fc0f44fa458 100644 --- a/src/vs/editor/common/services/editorWorker.ts +++ b/src/vs/editor/common/services/editorWorker.ts @@ -10,7 +10,7 @@ import { IChange } from '../diff/legacyLinesDiffComputer.js'; import { IColorInformation, IInplaceReplaceSupportResult, TextEdit } from '../languages.js'; import { UnicodeHighlighterOptions } from './unicodeTextModelHighlighter.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; -import type { BaseEditorSimpleWorker } from './editorSimpleWorker.js'; +import type { EditorWorker } from './editorWebWorker.js'; import { SectionHeader, FindSectionHeaderOptions } from './findSectionHeaders.js'; export const IEditorWorkerService = createDecorator('editorWorkerService'); @@ -23,7 +23,7 @@ export interface IEditorWorkerService { canComputeUnicodeHighlights(uri: URI): boolean; computedUnicodeHighlights(uri: URI, options: UnicodeHighlighterOptions, range?: IRange): Promise; - /** Implementation in {@link BaseEditorSimpleWorker.computeDiff} */ + /** Implementation in {@link EditorWorker.computeDiff} */ computeDiff(original: URI, modified: URI, options: IDocumentDiffProviderOptions, algorithm: DiffAlgorithmName): Promise; canComputeDirtyDiff(original: URI, modified: URI): boolean; diff --git a/src/vs/editor/common/services/editorWorkerBootstrap.ts b/src/vs/editor/common/services/editorWorkerBootstrap.ts deleted file mode 100644 index f754fe8fd4a..00000000000 --- a/src/vs/editor/common/services/editorWorkerBootstrap.ts +++ /dev/null @@ -1,51 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IWorkerServer, SimpleWorkerServer } from '../../../base/common/worker/simpleWorker.js'; -import { EditorSimpleWorker } from './editorSimpleWorker.js'; -import { EditorWorkerHost } from './editorWorkerHost.js'; - -type MessageEvent = { - data: any; -}; - -declare const globalThis: { - postMessage: (message: any) => void; - onmessage: (event: MessageEvent) => void; -}; - -let initialized = false; - -export function initialize(factory: any) { - if (initialized) { - return; - } - initialized = true; - - const simpleWorker = new SimpleWorkerServer((msg) => { - globalThis.postMessage(msg); - }, (workerServer: IWorkerServer) => new EditorSimpleWorker(EditorWorkerHost.getChannel(workerServer), null)); - - globalThis.onmessage = (e: MessageEvent) => { - simpleWorker.onmessage(e.data); - }; -} - -globalThis.onmessage = (e: MessageEvent) => { - // Ignore first message in this case and initialize if not yet initialized - if (!initialized) { - initialize(null); - } -}; - -type CreateFunction = (ctx: C, data: D) => R; - -export function bootstrapSimpleEditorWorker(createFn: CreateFunction) { - globalThis.onmessage = () => { - initialize((ctx: C, createData: D) => { - return createFn.call(self, ctx, createData); - }); - }; -} diff --git a/src/vs/editor/common/services/editorWorkerHost.ts b/src/vs/editor/common/services/editorWorkerHost.ts index 54751e1cebf..6223c6b7464 100644 --- a/src/vs/editor/common/services/editorWorkerHost.ts +++ b/src/vs/editor/common/services/editorWorkerHost.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IWorkerServer, IWorkerClient } from '../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerServer, IWebWorkerClient } from '../../../base/common/worker/webWorker.js'; export abstract class EditorWorkerHost { public static CHANNEL_NAME = 'editorWorkerHost'; - public static getChannel(workerServer: IWorkerServer): EditorWorkerHost { + public static getChannel(workerServer: IWebWorkerServer): EditorWorkerHost { return workerServer.getChannel(EditorWorkerHost.CHANNEL_NAME); } - public static setChannel(workerClient: IWorkerClient, obj: EditorWorkerHost): void { + public static setChannel(workerClient: IWebWorkerClient, obj: EditorWorkerHost): void { workerClient.setChannel(EditorWorkerHost.CHANNEL_NAME, obj); } diff --git a/src/vs/editor/common/services/textModelSync/textModelSync.impl.ts b/src/vs/editor/common/services/textModelSync/textModelSync.impl.ts index 77bb17bec86..ccab956b331 100644 --- a/src/vs/editor/common/services/textModelSync/textModelSync.impl.ts +++ b/src/vs/editor/common/services/textModelSync/textModelSync.impl.ts @@ -6,14 +6,14 @@ import { IntervalTimer } from '../../../../base/common/async.js'; import { Disposable, DisposableStore, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { IWorkerClient, IWorkerServer } from '../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerClient, IWebWorkerServer } from '../../../../base/common/worker/webWorker.js'; import { IPosition, Position } from '../../core/position.js'; import { IRange, Range } from '../../core/range.js'; import { ensureValidWordDefinition, getWordAtText, IWordAtPosition } from '../../core/wordHelper.js'; import { IDocumentColorComputerTarget } from '../../languages/defaultDocumentColorsComputer.js'; import { ILinkComputerTarget } from '../../languages/linkComputer.js'; import { MirrorTextModel as BaseMirrorModel, IModelChangedEvent } from '../../model/mirrorTextModel.js'; -import { IMirrorModel, IWordRange } from '../editorSimpleWorker.js'; +import { IMirrorModel, IWordRange } from '../editorWebWorker.js'; import { IModelService } from '../model.js'; import { IRawModelData, IWorkerTextModelSyncChannelServer } from './textModelSync.protocol.js'; @@ -26,7 +26,7 @@ export const WORKER_TEXT_MODEL_SYNC_CHANNEL = 'workerTextModelSync'; export class WorkerTextModelSyncClient extends Disposable { - public static create(workerClient: IWorkerClient, modelService: IModelService): WorkerTextModelSyncClient { + public static create(workerClient: IWebWorkerClient, modelService: IModelService): WorkerTextModelSyncClient { return new WorkerTextModelSyncClient( workerClient.getChannel(WORKER_TEXT_MODEL_SYNC_CHANNEL), modelService @@ -136,7 +136,7 @@ export class WorkerTextModelSyncServer implements IWorkerTextModelSyncChannelSer this._models = Object.create(null); } - public bindToServer(workerServer: IWorkerServer): void { + public bindToServer(workerServer: IWebWorkerServer): void { workerServer.setChannel(WORKER_TEXT_MODEL_SYNC_CHANNEL, this); } diff --git a/src/vs/editor/common/services/textResourceConfiguration.ts b/src/vs/editor/common/services/textResourceConfiguration.ts index f2edf685f2b..da2c0a8b653 100644 --- a/src/vs/editor/common/services/textResourceConfiguration.ts +++ b/src/vs/editor/common/services/textResourceConfiguration.ts @@ -72,7 +72,7 @@ export interface ITextResourceConfigurationService { * @param configurationTarget Optional target into which the configuration has to be updated. * If not specified, target will be derived by checking where the configuration is defined. */ - updateValue(resource: URI, key: string, value: any, configurationTarget?: ConfigurationTarget): Promise; + updateValue(resource: URI | undefined, key: string, value: any, configurationTarget?: ConfigurationTarget): Promise; } diff --git a/src/vs/editor/common/services/textResourceConfigurationService.ts b/src/vs/editor/common/services/textResourceConfigurationService.ts index b8534a81e6c..85ef41882c4 100644 --- a/src/vs/editor/common/services/textResourceConfigurationService.ts +++ b/src/vs/editor/common/services/textResourceConfigurationService.ts @@ -37,8 +37,8 @@ export class TextResourceConfigurationService extends Disposable implements ITex return this._getValue(resource, null, typeof arg2 === 'string' ? arg2 : undefined); } - updateValue(resource: URI, key: string, value: any, configurationTarget?: ConfigurationTarget): Promise { - const language = this.getLanguage(resource, null); + updateValue(resource: URI | undefined, key: string, value: any, configurationTarget?: ConfigurationTarget): Promise { + const language = resource ? this.getLanguage(resource, null) : null; const configurationValue = this.configurationService.inspect(key, { resource, overrideIdentifier: language }); if (configurationTarget === undefined) { configurationTarget = this.deriveConfigurationTarget(configurationValue, language); @@ -110,7 +110,7 @@ export class TextResourceConfigurationService extends Disposable implements ITex return true; } if (overrideIdentifier) { - //TODO@bpasero workaround for https://github.com/microsoft/vscode/issues/240410 + //TODO@sandy081 workaround for https://github.com/microsoft/vscode/issues/240410 return configurationChangeEvent.affectedKeys.has(`[${overrideIdentifier}]`); } return false; diff --git a/src/vs/editor/common/services/treeSitter/textModelTreeSitter.ts b/src/vs/editor/common/services/treeSitter/textModelTreeSitter.ts new file mode 100644 index 00000000000..bf8744a3878 --- /dev/null +++ b/src/vs/editor/common/services/treeSitter/textModelTreeSitter.ts @@ -0,0 +1,791 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as Parser from '@vscode/tree-sitter-wasm'; +import { ITreeSitterParseResult, ITextModelTreeSitter, RangeChange, TreeParseUpdateEvent, ITreeSitterImporter, ModelTreeUpdateEvent } from '../treeSitterParserService.js'; +import { Disposable, DisposableMap, DisposableStore, dispose, IDisposable } from '../../../../base/common/lifecycle.js'; +import { ITextModel } from '../../model.js'; +import { IModelContentChange, IModelContentChangedEvent } from '../../textModelEvents.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { setTimeout0 } from '../../../../base/common/platform.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { CancellationToken, cancelOnDispose } from '../../../../base/common/cancellation.js'; +import { Range } from '../../core/range.js'; +import { LimitedQueue } from '../../../../base/common/async.js'; +import { TextLength } from '../../core/textLength.js'; +import { TreeSitterLanguages } from './treeSitterLanguages.js'; +import { AppResourcePath, FileAccess } from '../../../../base/common/network.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; +import { getClosestPreviousNodes, gotoNthChild, gotoParent, nextSiblingOrParentSibling } from './cursorUtils.js'; + +export interface TextModelTreeSitterItem { + dispose(): void; + textModelTreeSitter: TextModelTreeSitter; + disposables: DisposableStore; +} + +const enum TelemetryParseType { + Full = 'fullParse', + Incremental = 'incrementalParse' +} + +export class TextModelTreeSitter extends Disposable implements ITextModelTreeSitter { + private _onDidChangeParseResult: Emitter = this._register(new Emitter()); + public readonly onDidChangeParseResult: Event = this._onDidChangeParseResult.event; + private _rootTreeSitterTree: TreeSitterParseResult | undefined; + + private _query: Parser.Query | undefined; + // TODO: @alexr00 use a better data structure for this + private readonly _injectionTreeSitterTrees: DisposableMap = this._register(new DisposableMap()); + private _versionId: number = 0; + + get parseResult(): ITreeSitterParseResult | undefined { return this._rootTreeSitterTree; } + + constructor( + readonly textModel: ITextModel, + private readonly _treeSitterLanguages: TreeSitterLanguages, + parseImmediately: boolean = true, + @ITreeSitterImporter private readonly _treeSitterImporter: ITreeSitterImporter, + @ILogService private readonly _logService: ILogService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IFileService private readonly _fileService: IFileService + ) { + super(); + if (parseImmediately) { + this._register(Event.runAndSubscribe(this.textModel.onDidChangeLanguage, (e => this._onDidChangeLanguage(e ? e.newLanguage : this.textModel.getLanguageId())))); + } else { + this._register(this.textModel.onDidChangeLanguage(e => this._onDidChangeLanguage(e ? e.newLanguage : this.textModel.getLanguageId()))); + } + } + + private readonly _parseSessionDisposables = this._register(new DisposableStore()); + private async _onDidChangeLanguage(languageId: string) { + this.parse(languageId); + } + + /** + * Be very careful when making changes to this method as it is easy to introduce race conditions. + */ + public async parse(languageId: string = this.textModel.getLanguageId()): Promise { + this._parseSessionDisposables.clear(); + this._rootTreeSitterTree = undefined; + + const token = cancelOnDispose(this._parseSessionDisposables); + let language: Parser.Language | undefined; + try { + language = await this._getLanguage(languageId, token); + } catch (e) { + if (isCancellationError(e)) { + return; + } + throw e; + } + + const Parser = await this._treeSitterImporter.getParserClass(); + if (token.isCancellationRequested) { + return; + } + + const treeSitterTree = this._parseSessionDisposables.add(new TreeSitterParseResult(new Parser(), languageId, language, this._logService, this._telemetryService)); + this._rootTreeSitterTree = treeSitterTree; + this._parseSessionDisposables.add(treeSitterTree.onDidUpdate(e => this._handleTreeUpdate(e))); + this._parseSessionDisposables.add(this.textModel.onDidChangeContent(e => this._onDidChangeContent(treeSitterTree, [e]))); + this._onDidChangeContent(treeSitterTree, undefined); + if (token.isCancellationRequested) { + return; + } + + return this._rootTreeSitterTree; + } + + private _getLanguage(languageId: string, token: CancellationToken): Promise { + const language = this._treeSitterLanguages.getOrInitLanguage(languageId); + if (language) { + return Promise.resolve(language); + } + const disposables: IDisposable[] = []; + + return new Promise((resolve, reject) => { + disposables.push(this._treeSitterLanguages.onDidAddLanguage(e => { + if (e.id === languageId) { + dispose(disposables); + resolve(e.language); + } + })); + token.onCancellationRequested(() => { + dispose(disposables); + reject(new CancellationError()); + }, undefined, disposables); + }); + } + + private async _handleTreeUpdate(e: TreeParseUpdateEvent, parentTreeResult?: ITreeSitterParseResult, parentLanguage?: string) { + if (e.ranges && (e.versionId >= this._versionId)) { + this._versionId = e.versionId; + const tree = parentTreeResult ?? this._rootTreeSitterTree!; + let injections: Map | undefined; + if (tree.tree) { + injections = await this._collectInjections(tree.tree); + // kick off check for injected languages + if (injections) { + this._processInjections(injections, tree, parentLanguage ?? this.textModel.getLanguageId(), e.includedModelChanges); + } + } + + this._onDidChangeParseResult.fire({ ranges: e.ranges, versionId: e.versionId, tree: this, languageId: this.textModel.getLanguageId(), hasInjections: !!injections && injections.size > 0 }); + } + } + + private _queries: string | undefined; + private async _ensureInjectionQueries() { + if (!this._queries) { + const injectionsQueriesLocation: AppResourcePath = `vs/editor/common/languages/injections/${this.textModel.getLanguageId()}.scm`; + const uri = FileAccess.asFileUri(injectionsQueriesLocation); + if (!(await this._fileService.exists(uri))) { + this._queries = ''; + } else if (this._fileService.hasProvider(uri)) { + const query = await this._fileService.readFile(uri); + this._queries = query.value.toString(); + } else { + this._queries = ''; + } + } + return this._queries; + } + + private async _getQuery() { + if (!this._query) { + const language = await this._treeSitterLanguages.getLanguage(this.textModel.getLanguageId()); + if (!language) { + return; + } + const queries = await this._ensureInjectionQueries(); + if (queries === '') { + return; + } + const Query = await this._treeSitterImporter.getQueryClass(); + this._query = new Query(language, queries); + } + return this._query; + } + + private async _collectInjections(tree: Parser.Tree): Promise | undefined> { + const query = await this._getQuery(); + if (!query) { + return; + } + + if (!tree?.rootNode) { + // need to check the root node here as `walk` will throw if not defined. + return; + } + + const cursor = tree.walk(); + const injections: Map = new Map(); + let hasNext = true; + + while (hasNext) { + hasNext = await this._processNode(cursor, query, injections); + // Yield periodically + await new Promise(resolve => setTimeout0(resolve)); + } + + return this._mergeAdjacentRanges(injections); + } + + private _processNode(cursor: Parser.TreeCursor, query: Parser.Query, injections: Map): boolean { + const node = cursor.currentNode; + const nodeLineCount = node.endPosition.row - node.startPosition.row; + + // We check the node line count to avoid processing large nodes in one go as that can cause performance issues. + if (nodeLineCount <= 1000) { + this._processCaptures(query, node, injections); + // Move to next sibling or up and over + return cursor.gotoNextSibling() || this.gotoNextSiblingOfAncestor(cursor); + } else { + // Node is too large, go to first child or next sibling + return cursor.gotoFirstChild() || cursor.gotoNextSibling() || this.gotoNextSiblingOfAncestor(cursor); + } + } + + private _processCaptures(query: Parser.Query, node: Parser.Node, injections: Map): void { + const captures = query.captures(node); + for (const capture of captures) { + const injectionLanguage = capture.setProperties?.['injection.language']; + if (injectionLanguage) { + const range = this._createRangeFromNode(capture.node); + if (!injections.has(injectionLanguage)) { + injections.set(injectionLanguage, []); + } + injections.get(injectionLanguage)?.push(range); + } + } + } + + private _createRangeFromNode(node: Parser.Node): Parser.Range { + return { + startIndex: node.startIndex, + endIndex: node.endIndex, + startPosition: { row: node.startPosition.row, column: node.startPosition.column }, + endPosition: { row: node.endPosition.row, column: node.endPosition.column } + }; + } + + private _mergeAdjacentRanges(injections: Map): Map { + for (const [languageId, ranges] of injections) { + if (ranges.length <= 1) { + continue; + } + + const mergedRanges: Parser.Range[] = []; + let current = ranges[0]; + + for (let i = 1; i < ranges.length; i++) { + const next = ranges[i]; + if (next.startIndex <= current.endIndex) { + current = this._mergeRanges(current, next); + } else { + mergedRanges.push(current); + current = next; + } + } + mergedRanges.push(current); + + injections.set(languageId, mergedRanges); + } + + return injections; + } + + private _mergeRanges(current: Parser.Range, next: Parser.Range): Parser.Range { + return { + startIndex: current.startIndex, + endIndex: Math.max(current.endIndex, next.endIndex), + startPosition: current.startPosition, + endPosition: next.endPosition.row > current.endPosition.row ? + next.endPosition : + current.endPosition + }; + } + + private async _processInjections( + injections: Map, + parentTree: ITreeSitterParseResult, + parentLanguage: string, + modelChanges: IModelContentChangedEvent[] | undefined + ): Promise { + if (injections.size === 0) { + this._injectionTreeSitterTrees.clearAndDisposeAll(); + return; + } + + const unseenInjections: Set = new Set(this._injectionTreeSitterTrees.keys()); + for (const [languageId, ranges] of injections) { + const language = await this._treeSitterLanguages.getLanguage(languageId); + if (!language) { + continue; + } + + const treeSitterTree = await this._getOrCreateInjectedTree(languageId, language, parentTree, parentLanguage); + if (treeSitterTree) { + unseenInjections.delete(languageId); + this._onDidChangeContent(treeSitterTree, modelChanges, ranges); + } + } + for (const unseenInjection of unseenInjections) { + this._injectionTreeSitterTrees.deleteAndDispose(unseenInjection); + } + } + + private async _getOrCreateInjectedTree( + languageId: string, + language: Parser.Language, + parentTree: ITreeSitterParseResult, + parentLanguage: string + ): Promise { + let treeSitterTree = this._injectionTreeSitterTrees.get(languageId); + if (!treeSitterTree) { + const Parser = await this._treeSitterImporter.getParserClass(); + treeSitterTree = new TreeSitterParseResult(new Parser(), languageId, language, this._logService, this._telemetryService); + this._parseSessionDisposables.add(treeSitterTree.onDidUpdate(e => this._handleTreeUpdate(e, parentTree, parentLanguage))); + this._injectionTreeSitterTrees.set(languageId, treeSitterTree); + } + return treeSitterTree; + } + + private gotoNextSiblingOfAncestor(cursor: Parser.TreeCursor): boolean { + while (cursor.gotoParent()) { + if (cursor.gotoNextSibling()) { + return true; + } + } + return false; + } + + getInjection(offset: number, parentLanguage: string): ITreeSitterParseResult | undefined { + if (this._injectionTreeSitterTrees.size === 0) { + return undefined; + } + let hasFoundParentLanguage = parentLanguage === this.textModel.getLanguageId(); + + for (const [_, treeSitterTree] of this._injectionTreeSitterTrees) { + if (treeSitterTree.tree) { + if (hasFoundParentLanguage && treeSitterTree.ranges?.find(r => r.startIndex <= offset && r.endIndex >= offset)) { + return treeSitterTree; + } + if (!hasFoundParentLanguage && treeSitterTree.languageId === parentLanguage) { + hasFoundParentLanguage = true; + } + } + } + return undefined; + } + + private _onDidChangeContent(treeSitterTree: TreeSitterParseResult, change: IModelContentChangedEvent[] | undefined, ranges?: Parser.Range[]) { + treeSitterTree.onDidChangeContent(this.textModel, change, ranges); + } +} + +export class TreeSitterParseResult implements IDisposable, ITreeSitterParseResult { + private _tree: Parser.Tree | undefined; + private _lastFullyParsed: Parser.Tree | undefined; + private _lastFullyParsedWithEdits: Parser.Tree | undefined; + private readonly _onDidUpdate: Emitter = new Emitter(); + public readonly onDidUpdate: Event = this._onDidUpdate.event; + private _versionId: number = 0; + private _editVersion: number = 0; + get versionId() { + return this._versionId; + } + private _isDisposed: boolean = false; + constructor(public readonly parser: Parser.Parser, + public readonly languageId: string, + public /** exposed for tests **/ readonly language: Parser.Language, + private readonly _logService: ILogService, + private readonly _telemetryService: ITelemetryService) { + this.parser.setLanguage(language); + } + dispose(): void { + this._isDisposed = true; + this._onDidUpdate.dispose(); + this._tree?.delete(); + this._lastFullyParsed?.delete(); + this._lastFullyParsedWithEdits?.delete(); + this.parser?.delete(); + } + get tree() { return this._lastFullyParsed; } + get isDisposed() { return this._isDisposed; } + + private findChangedNodes(newTree: Parser.Tree, oldTree: Parser.Tree): Parser.Range[] { + const newCursor = newTree.walk(); + const oldCursor = oldTree.walk(); + + const nodes: Parser.Range[] = []; + let next = true; + + do { + if (newCursor.currentNode.hasChanges) { + // Check if only one of the children has changes. + // If it's only one, then we go to that child. + // If it's more then, we need to go to each child + // If it's none, then we've found one of our ranges + const newChildren = newCursor.currentNode.children; + const indexChangedChildren: number[] = []; + const changedChildren = newChildren.filter((c, index) => { + if (c?.hasChanges || (oldCursor.currentNode.children.length <= index)) { + indexChangedChildren.push(index); + return true; + } + return false; + }); + // If we have changes and we *had* an error, the whole node should be refreshed. + if ((changedChildren.length === 0) || (newCursor.currentNode.hasError !== oldCursor.currentNode.hasError)) { + // walk up again until we get to the first one that's named as unnamed nodes can be too granular + while (newCursor.currentNode.parent && next && !newCursor.currentNode.isNamed) { + next = gotoParent(newCursor, oldCursor); + } + // Use the end position of the previous node and the start position of the current node + const newNode = newCursor.currentNode; + const closestPreviousNode = getClosestPreviousNodes(newCursor, newTree) ?? newNode; + nodes.push({ + startIndex: closestPreviousNode.startIndex, + endIndex: newNode.endIndex, + startPosition: closestPreviousNode.startPosition, + endPosition: newNode.endPosition + }); + next = nextSiblingOrParentSibling(newCursor, oldCursor); + } else if (changedChildren.length >= 1) { + next = gotoNthChild(newCursor, oldCursor, indexChangedChildren[0]); + } + } else { + next = nextSiblingOrParentSibling(newCursor, oldCursor); + } + } while (next); + + return nodes; + } + + private findTreeChanges(newTree: Parser.Tree, changedNodes: Parser.Range[], newRanges: Parser.Range[]): RangeChange[] { + let newRangeIndex = 0; + const mergedChanges: RangeChange[] = []; + + // Find the parent in the new tree of the changed node + for (let nodeIndex = 0; nodeIndex < changedNodes.length; nodeIndex++) { + const node = changedNodes[nodeIndex]; + + if (mergedChanges.length > 0) { + if ((node.startIndex >= mergedChanges[mergedChanges.length - 1].newRangeStartOffset) && (node.endIndex <= mergedChanges[mergedChanges.length - 1].newRangeEndOffset)) { + // This node is within the previous range, skip it + continue; + } + } + + const cursor = newTree.walk(); + const cursorContainersNode = () => cursor.startIndex < node.startIndex && cursor.endIndex > node.endIndex; + + while (cursorContainersNode()) { + // See if we can go to a child + let child = cursor.gotoFirstChild(); + let foundChild = false; + while (child) { + if (cursorContainersNode() && cursor.currentNode.isNamed) { + foundChild = true; + break; + } else { + child = cursor.gotoNextSibling(); + } + } + if (!foundChild) { + cursor.gotoParent(); + break; + } + if (cursor.currentNode.childCount === 0) { + break; + } + } + + let nodesInRange: Parser.Node[]; + // It's possible we end up with a really large range if the parent node is big + // Try to avoid this large range by finding several smaller nodes that together encompass the range of the changed node. + const foundNodeSize = cursor.endIndex - cursor.startIndex; + if (foundNodeSize > 5000) { + // Try to find 3 consecutive nodes that together encompass the changed node. + let child = cursor.gotoFirstChild(); + nodesInRange = []; + while (child) { + if (cursor.endIndex > node.startIndex) { + // Found the starting point of our nodes + nodesInRange.push(cursor.currentNode); + do { + child = cursor.gotoNextSibling(); + } while (child && (cursor.endIndex < node.endIndex)); + + nodesInRange.push(cursor.currentNode); + break; + } + child = cursor.gotoNextSibling(); + } + } else { + nodesInRange = [cursor.currentNode]; + } + + // Fill in gaps between nodes + // Reset the cursor to the first node in the range; + while (cursor.currentNode.id !== nodesInRange[0].id) { + cursor.gotoPreviousSibling(); + } + const previousNode = getClosestPreviousNodes(cursor, newTree); + const startPosition = previousNode ? previousNode.endPosition : nodesInRange[0].startPosition; + const startIndex = previousNode ? previousNode.endIndex : nodesInRange[0].startIndex; + const endPosition = nodesInRange[nodesInRange.length - 1].endPosition; + const endIndex = nodesInRange[nodesInRange.length - 1].endIndex; + + const newChange = { newRange: new Range(startPosition.row + 1, startPosition.column + 1, endPosition.row + 1, endPosition.column + 1), newRangeStartOffset: startIndex, newRangeEndOffset: endIndex }; + if ((newRangeIndex < newRanges.length) && rangesIntersect(newRanges[newRangeIndex], { startIndex, endIndex, startPosition, endPosition })) { + // combine the new change with the range + if (newRanges[newRangeIndex].startIndex < newChange.newRangeStartOffset) { + newChange.newRange = newChange.newRange.setStartPosition(newRanges[newRangeIndex].startPosition.row + 1, newRanges[newRangeIndex].startPosition.column + 1); + newChange.newRangeStartOffset = newRanges[newRangeIndex].startIndex; + } + if (newRanges[newRangeIndex].endIndex > newChange.newRangeEndOffset) { + newChange.newRange = newChange.newRange.setEndPosition(newRanges[newRangeIndex].endPosition.row + 1, newRanges[newRangeIndex].endPosition.column + 1); + newChange.newRangeEndOffset = newRanges[newRangeIndex].endIndex; + } + newRangeIndex++; + } else if (newRangeIndex < newRanges.length && newRanges[newRangeIndex].endIndex < newChange.newRangeStartOffset) { + // add the full range to the merged changes + mergedChanges.push({ + newRange: new Range(newRanges[newRangeIndex].startPosition.row + 1, newRanges[newRangeIndex].startPosition.column + 1, newRanges[newRangeIndex].endPosition.row + 1, newRanges[newRangeIndex].endPosition.column + 1), + newRangeStartOffset: newRanges[newRangeIndex].startIndex, + newRangeEndOffset: newRanges[newRangeIndex].endIndex + }); + } + + if ((mergedChanges.length > 0) && (mergedChanges[mergedChanges.length - 1].newRangeEndOffset >= newChange.newRangeStartOffset)) { + // Merge the changes + mergedChanges[mergedChanges.length - 1].newRange = Range.fromPositions(mergedChanges[mergedChanges.length - 1].newRange.getStartPosition(), newChange.newRange.getEndPosition()); + mergedChanges[mergedChanges.length - 1].newRangeEndOffset = newChange.newRangeEndOffset; + } else { + mergedChanges.push(newChange); + } + } + return this._constrainRanges(mergedChanges); + } + + private _constrainRanges(changes: RangeChange[]): RangeChange[] { + if (!this.ranges) { + return changes; + } + + const constrainedChanges: RangeChange[] = []; + let changesIndex = 0; + let rangesIndex = 0; + while (changesIndex < changes.length && rangesIndex < this.ranges.length) { + const change = changes[changesIndex]; + const range = this.ranges[rangesIndex]; + if (change.newRangeEndOffset < range.startIndex) { + // Change is before the range, move to the next change + changesIndex++; + } else if (change.newRangeStartOffset > range.endIndex) { + // Change is after the range, move to the next range + rangesIndex++; + } else { + // Change is within the range, constrain it + const newRangeStartOffset = Math.max(change.newRangeStartOffset, range.startIndex); + const newRangeEndOffset = Math.min(change.newRangeEndOffset, range.endIndex); + const newRange = change.newRange.intersectRanges(new Range(range.startPosition.row + 1, range.startPosition.column + 1, range.endPosition.row + 1, range.endPosition.column + 1))!; + constrainedChanges.push({ + newRange, + newRangeEndOffset, + newRangeStartOffset + }); + // Remove the intersected range from the current change + if (newRangeEndOffset < change.newRangeEndOffset) { + change.newRange = Range.fromPositions(newRange.getEndPosition(), change.newRange.getEndPosition()); + change.newRangeStartOffset = newRangeEndOffset + 1; + } else { + // Move to the next change + changesIndex++; + } + } + } + + return constrainedChanges; + } + + private _unfiredChanges: IModelContentChangedEvent[] | undefined; + private _onDidChangeContentQueue: LimitedQueue = new LimitedQueue(); + public onDidChangeContent(model: ITextModel, changes: IModelContentChangedEvent[] | undefined, ranges?: Parser.Range[]): void { + const version = model.getVersionId(); + if (version === this._editVersion) { + return; + } + + let newRanges: Parser.Range[] = []; + if (ranges) { + newRanges = this._setRanges(ranges); + } + + if (changes && changes.length > 0) { + if (this._unfiredChanges) { + this._unfiredChanges.push(...changes); + } else { + this._unfiredChanges = changes; + } + for (const change of changes) { + this._applyEdits(change.changes, version); + } + } else { + this._applyEdits([], version); + } + + this._onDidChangeContentQueue.queue(async () => { + if (this.isDisposed) { + // No need to continue the queue if we are disposed + return; + } + + const oldTree = this._lastFullyParsed; + let changedNodes: Parser.Range[] | undefined; + if (this._lastFullyParsedWithEdits && this._lastFullyParsed) { + changedNodes = this.findChangedNodes(this._lastFullyParsedWithEdits, this._lastFullyParsed); + } + + const completed = await this._parseAndUpdateTree(model, version); + if (completed) { + let ranges: RangeChange[] | undefined; + if (!changedNodes) { + if (this._ranges) { + ranges = this._ranges.map(r => ({ newRange: new Range(r.startPosition.row + 1, r.startPosition.column + 1, r.endPosition.row + 1, r.endPosition.column + 1), oldRangeLength: r.endIndex - r.startIndex, newRangeStartOffset: r.startIndex, newRangeEndOffset: r.endIndex })); + } else { + ranges = [{ newRange: model.getFullModelRange(), newRangeStartOffset: 0, newRangeEndOffset: model.getValueLength() }]; + } + } else if (oldTree && changedNodes) { + ranges = this.findTreeChanges(completed, changedNodes, newRanges); + } + const changes = this._unfiredChanges ?? []; + this._unfiredChanges = undefined; + this._onDidUpdate.fire({ language: this.languageId, ranges, versionId: version, tree: completed, includedModelChanges: changes }); + } + }); + } + + private _applyEdits(changes: IModelContentChange[], version: number) { + for (const change of changes) { + const originalTextLength = TextLength.ofRange(Range.lift(change.range)); + const newTextLength = TextLength.ofText(change.text); + const summedTextLengths = change.text.length === 0 ? newTextLength : originalTextLength.add(newTextLength); + const edit = { + startIndex: change.rangeOffset, + oldEndIndex: change.rangeOffset + change.rangeLength, + newEndIndex: change.rangeOffset + change.text.length, + startPosition: { row: change.range.startLineNumber - 1, column: change.range.startColumn - 1 }, + oldEndPosition: { row: change.range.endLineNumber - 1, column: change.range.endColumn - 1 }, + newEndPosition: { row: change.range.startLineNumber + summedTextLengths.lineCount - 1, column: summedTextLengths.lineCount ? summedTextLengths.columnCount : (change.range.endColumn + summedTextLengths.columnCount) } + }; + this._tree?.edit(edit); + this._lastFullyParsedWithEdits?.edit(edit); + } + this._editVersion = version; + } + + private async _parseAndUpdateTree(model: ITextModel, version: number): Promise { + const tree = await this._parse(model); + if (tree) { + this._tree?.delete(); + this._tree = tree; + this._lastFullyParsed?.delete(); + this._lastFullyParsed = tree.copy(); + this._lastFullyParsedWithEdits?.delete(); + this._lastFullyParsedWithEdits = tree.copy(); + this._versionId = version; + return tree; + } else if (!this._tree) { + // No tree means this is the initial parse and there were edits + // parse function doesn't handle this well and we can end up with an incorrect tree, so we reset + this.parser.reset(); + } + return undefined; + } + + private _parse(model: ITextModel): Promise { + let parseType: TelemetryParseType = TelemetryParseType.Full; + if (this.tree) { + parseType = TelemetryParseType.Incremental; + } + return this._parseAndYield(model, parseType); + } + + private async _parseAndYield(model: ITextModel, parseType: TelemetryParseType): Promise { + let time: number = 0; + let passes: number = 0; + const inProgressVersion = this._editVersion; + let newTree: Parser.Tree | null | undefined; + this._lastYieldTime = performance.now(); + + do { + const timer = performance.now(); + try { + newTree = this.parser.parse((index: number, position?: Parser.Point) => this._parseCallback(model, index), this._tree, { progressCallback: this._parseProgressCallback.bind(this), includedRanges: this._ranges }); + } catch (e) { + // parsing can fail when the timeout is reached, will resume upon next loop + } finally { + time += performance.now() - timer; + passes++; + } + + // So long as this isn't the initial parse, even if the model changes and edits are applied, the tree parsing will continue correctly after the await. + await new Promise(resolve => setTimeout0(resolve)); + + } while (!model.isDisposed() && !this.isDisposed && !newTree && inProgressVersion === model.getVersionId()); + this.sendParseTimeTelemetry(parseType, time, passes); + return (newTree && (inProgressVersion === model.getVersionId())) ? newTree : undefined; + } + + private _lastYieldTime: number = 0; + private _parseProgressCallback(state: Parser.ParseState) { + const now = performance.now(); + if (now - this._lastYieldTime > 50) { + this._lastYieldTime = now; + return true; + } + return false; + } + + private _parseCallback(textModel: ITextModel, index: number): string | undefined { + try { + return textModel.getTextBuffer().getNearestChunk(index); + } catch (e) { + this._logService.debug('Error getting chunk for tree-sitter parsing', e); + } + return undefined; + } + + private _ranges: Parser.Range[] | undefined; + private _setRanges(newRanges: Parser.Range[]): Parser.Range[] { + const unKnownRanges: Parser.Range[] = []; + // If we have existing ranges, find the parts of the new ranges that are not included in the existing ones + if (this._ranges) { + for (const newRange of newRanges) { + let isFullyIncluded = false; + + for (let i = 0; i < this._ranges.length; i++) { + const existingRange = this._ranges[i]; + + if (rangesEqual(existingRange, newRange) || rangesIntersect(existingRange, newRange)) { + isFullyIncluded = true; + break; + } + } + + if (!isFullyIncluded) { + unKnownRanges.push(newRange); + } + } + } else { + // No existing ranges, all new ranges are unknown + unKnownRanges.push(...newRanges); + } + + this._ranges = newRanges; + return unKnownRanges; + } + + get ranges(): Parser.Range[] | undefined { + return this._ranges; + } + + private sendParseTimeTelemetry(parseType: TelemetryParseType, time: number, passes: number): void { + this._logService.debug(`Tree parsing (${parseType}) took ${time} ms and ${passes} passes.`); + type ParseTimeClassification = { + owner: 'alexr00'; + comment: 'Used to understand how long it takes to parse a tree-sitter tree'; + languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The programming language ID.' }; + time: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The ms it took to parse' }; + passes: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of passes it took to parse' }; + }; + if (parseType === TelemetryParseType.Full) { + this._telemetryService.publicLog2<{ languageId: string; time: number; passes: number }, ParseTimeClassification>(`treeSitter.fullParse`, { languageId: this.languageId, time, passes }); + } else { + this._telemetryService.publicLog2<{ languageId: string; time: number; passes: number }, ParseTimeClassification>(`treeSitter.incrementalParse`, { languageId: this.languageId, time, passes }); + } + } +} + +function rangesEqual(a: Parser.Range, b: Parser.Range) { + return (a.startPosition.row === b.startPosition.row) + && (a.startPosition.column === b.startPosition.column) + && (a.endPosition.row === b.endPosition.row) + && (a.endPosition.column === b.endPosition.column) + && (a.startIndex === b.startIndex) + && (a.endIndex === b.endIndex); +} + +function rangesIntersect(a: Parser.Range, b: Parser.Range) { + return (a.startIndex <= b.startIndex && a.endIndex >= b.startIndex) || + (b.startIndex <= a.startIndex && b.endIndex >= a.startIndex); +} diff --git a/src/vs/editor/common/services/treeSitter/treeSitterLanguages.ts b/src/vs/editor/common/services/treeSitter/treeSitterLanguages.ts new file mode 100644 index 00000000000..d7ec9cff2d5 --- /dev/null +++ b/src/vs/editor/common/services/treeSitter/treeSitterLanguages.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import type * as Parser from '@vscode/tree-sitter-wasm'; +import { AppResourcePath, FileAccess, nodeModulesAsarUnpackedPath, nodeModulesPath } from '../../../../base/common/network.js'; +import { EDITOR_EXPERIMENTAL_PREFER_TREESITTER, ITreeSitterImporter } from '../treeSitterParserService.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { canASAR } from '../../../../amdX.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { PromiseResult } from '../../../../base/common/observable.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; + +export const MODULE_LOCATION_SUBPATH = `@vscode/tree-sitter-wasm/wasm`; + +export function getModuleLocation(environmentService: IEnvironmentService): AppResourcePath { + return `${(canASAR && environmentService.isBuilt) ? nodeModulesAsarUnpackedPath : nodeModulesPath}/${MODULE_LOCATION_SUBPATH}`; +} + +export class TreeSitterLanguages extends Disposable { + private _languages: AsyncCache = new AsyncCache(); + public /*exposed for tests*/ readonly _onDidAddLanguage: Emitter<{ id: string; language: Parser.Language }> = this._register(new Emitter()); + /** + * If you're looking for a specific language, make sure to check if it already exists with `getLanguage` as it will kick off the process to add it if it doesn't exist. + */ + public readonly onDidAddLanguage: Event<{ id: string; language: Parser.Language }> = this._onDidAddLanguage.event; + + constructor(private readonly _treeSitterImporter: ITreeSitterImporter, + private readonly _fileService: IFileService, + private readonly _environmentService: IEnvironmentService, + configurationService: IConfigurationService, + private readonly _registeredLanguages: Map, + ) { + super(); + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(EDITOR_EXPERIMENTAL_PREFER_TREESITTER)) { + for (const language of this._languages.keys()) { + if (e.affectsConfiguration(`${EDITOR_EXPERIMENTAL_PREFER_TREESITTER}.${language}`)) { + if (this._languages.getSyncIfCached(language) === undefined) { + this._languages.delete(language); + } + } + } + } + })); + } + + public getOrInitLanguage(languageId: string): Parser.Language | undefined { + if (this._languages.isCached(languageId)) { + return this._languages.getSyncIfCached(languageId); + } else { + // kick off adding the language, but don't wait + this._addLanguage(languageId); + return undefined; + } + } + + public async getLanguage(languageId: string): Promise { + if (this._languages.isCached(languageId)) { + return this._languages.getSyncIfCached(languageId); + } else { + await this._addLanguage(languageId); + return this._languages.get(languageId); + } + } + + private async _addLanguage(languageId: string): Promise { + const languagePromise = this._languages.get(languageId); + if (!languagePromise) { + this._languages.set(languageId, this._fetchLanguage(languageId)); + const language = await this._languages.get(languageId); + if (!language) { + return undefined; + } + this._onDidAddLanguage.fire({ id: languageId, language }); + } + } + + private async _fetchLanguage(languageId: string): Promise { + const grammarName = this._registeredLanguages.get(languageId); + const languageLocation = this._getLanguageLocation(languageId); + if (!grammarName || !languageLocation) { + return undefined; + } + const wasmPath: AppResourcePath = `${languageLocation}/${grammarName}.wasm`; + const languageFile = await (this._fileService.readFile(FileAccess.asFileUri(wasmPath))); + const Language = await this._treeSitterImporter.getLanguageClass(); + return Language.load(languageFile.value.buffer); + } + + private _getLanguageLocation(languageId: string): AppResourcePath | undefined { + const grammarName = this._registeredLanguages.get(languageId); + if (!grammarName) { + return undefined; + } + return getModuleLocation(this._environmentService); + } +} + +class AsyncCache { + private readonly _values = new Map>(); + + set(key: TKey, promise: Promise) { + this._values.set(key, new PromiseWithSyncAccess(promise)); + } + + get(key: TKey): Promise | undefined { + return this._values.get(key)?.promise; + } + + getSyncIfCached(key: TKey): T | undefined { + return this._values.get(key)?.result?.data; + } + + isCached(key: TKey): boolean { + return this._values.get(key)?.result !== undefined; + } + + delete(key: TKey) { + return this._values.delete(key); + } + + keys() { + return this._values.keys(); + } +} + +class PromiseWithSyncAccess { + private _result: PromiseResult | undefined; + /** + * Returns undefined if the promise did not resolve yet. + */ + get result(): PromiseResult | undefined { + return this._result; + } + + constructor(public readonly promise: Promise) { + promise.then(result => { + this._result = new PromiseResult(result, undefined); + }).catch(e => { + this._result = new PromiseResult(undefined, e); + }); + } +} diff --git a/src/vs/editor/common/services/treeSitter/treeSitterParserService.ts b/src/vs/editor/common/services/treeSitter/treeSitterParserService.ts index 985207918cd..b46816ec5e8 100644 --- a/src/vs/editor/common/services/treeSitter/treeSitterParserService.ts +++ b/src/vs/editor/common/services/treeSitter/treeSitterParserService.ts @@ -4,509 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import type * as Parser from '@vscode/tree-sitter-wasm'; -import { AppResourcePath, FileAccess, nodeModulesAsarUnpackedPath, nodeModulesPath } from '../../../../base/common/network.js'; -import { EDITOR_EXPERIMENTAL_PREFER_TREESITTER, ITreeSitterParserService, ITreeSitterParseResult, ITextModelTreeSitter, RangeChange, TreeUpdateEvent, TreeParseUpdateEvent, ITreeSitterImporter, TREESITTER_ALLOWED_SUPPORT } from '../treeSitterParserService.js'; +import { AppResourcePath, FileAccess } from '../../../../base/common/network.js'; +import { EDITOR_EXPERIMENTAL_PREFER_TREESITTER, ITreeSitterParserService, ITextModelTreeSitter, TreeUpdateEvent, ITreeSitterImporter, TREESITTER_ALLOWED_SUPPORT, ModelTreeUpdateEvent } from '../treeSitterParserService.js'; import { IModelService } from '../model.js'; -import { Disposable, DisposableMap, DisposableStore, dispose, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; import { ITextModel } from '../../model.js'; import { IFileService } from '../../../../platform/files/common/files.js'; -import { IModelContentChange, IModelContentChangedEvent } from '../../textModelEvents.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { setTimeout0 } from '../../../../base/common/platform.js'; -import { canASAR } from '../../../../amdX.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { CancellationToken, cancelOnDispose } from '../../../../base/common/cancellation.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; -import { PromiseResult } from '../../../../base/common/observable.js'; -import { Range } from '../../core/range.js'; -import { LimitedQueue } from '../../../../base/common/async.js'; -import { TextLength } from '../../core/textLength.js'; -import { getClosestPreviousNodes, gotoNthChild, gotoParent, nextSiblingOrParentSibling } from './cursorUtils.js'; +import { TextModelTreeSitter, TextModelTreeSitterItem } from './textModelTreeSitter.js'; +import { getModuleLocation, TreeSitterLanguages } from './treeSitterLanguages.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; const EDITOR_TREESITTER_TELEMETRY = 'editor.experimental.treeSitterTelemetry'; -const MODULE_LOCATION_SUBPATH = `@vscode/tree-sitter-wasm/wasm`; const FILENAME_TREESITTER_WASM = `tree-sitter.wasm`; -function getModuleLocation(environmentService: IEnvironmentService): AppResourcePath { - return `${(canASAR && environmentService.isBuilt) ? nodeModulesAsarUnpackedPath : nodeModulesPath}/${MODULE_LOCATION_SUBPATH}`; -} - -export class TextModelTreeSitter extends Disposable implements ITextModelTreeSitter { - private _onDidChangeParseResult: Emitter = this._register(new Emitter()); - public readonly onDidChangeParseResult: Event = this._onDidChangeParseResult.event; - private _parseResult: TreeSitterParseResult | undefined; - private _versionId: number = 0; - - get parseResult(): ITreeSitterParseResult | undefined { return this._parseResult; } - - constructor(readonly model: ITextModel, - private readonly _treeSitterLanguages: TreeSitterLanguages, - private readonly _treeSitterImporter: ITreeSitterImporter, - private readonly _logService: ILogService, - private readonly _telemetryService: ITelemetryService, - parseImmediately: boolean = true - ) { - super(); - if (parseImmediately) { - this._register(Event.runAndSubscribe(this.model.onDidChangeLanguage, (e => this._onDidChangeLanguage(e ? e.newLanguage : this.model.getLanguageId())))); - } else { - this._register(this.model.onDidChangeLanguage(e => this._onDidChangeLanguage(e ? e.newLanguage : this.model.getLanguageId()))); - } - } - - private readonly _parseSessionDisposables = this._register(new DisposableStore()); - /** - * Be very careful when making changes to this method as it is easy to introduce race conditions. - */ - private async _onDidChangeLanguage(languageId: string) { - this.parse(languageId); - } - - public async parse(languageId: string = this.model.getLanguageId()): Promise { - this._parseSessionDisposables.clear(); - this._parseResult = undefined; - - const token = cancelOnDispose(this._parseSessionDisposables); - let language: Parser.Language | undefined; - try { - language = await this._getLanguage(languageId, token); - } catch (e) { - if (isCancellationError(e)) { - return; - } - throw e; - } - - const Parser = await this._treeSitterImporter.getParserClass(); - if (token.isCancellationRequested) { - return; - } - - const treeSitterTree = this._parseSessionDisposables.add(new TreeSitterParseResult(new Parser(), language, this._logService, this._telemetryService)); - this._parseResult = treeSitterTree; - this._parseSessionDisposables.add(treeSitterTree.onDidUpdate(e => { - if (e.ranges && (e.versionId >= this._versionId)) { - this._versionId = e.versionId; - this._onDidChangeParseResult.fire({ ranges: e.ranges, versionId: e.versionId }); - } - })); - this._parseSessionDisposables.add(this.model.onDidChangeContent(e => this._onDidChangeContent(treeSitterTree, e))); - this._onDidChangeContent(treeSitterTree, undefined); - if (token.isCancellationRequested) { - return; - } - - return this._parseResult; - } - - private _getLanguage(languageId: string, token: CancellationToken): Promise { - const language = this._treeSitterLanguages.getOrInitLanguage(languageId); - if (language) { - return Promise.resolve(language); - } - const disposables: IDisposable[] = []; - - return new Promise((resolve, reject) => { - disposables.push(this._treeSitterLanguages.onDidAddLanguage(e => { - if (e.id === languageId) { - dispose(disposables); - resolve(e.language); - } - })); - token.onCancellationRequested(() => { - dispose(disposables); - reject(new CancellationError()); - }, undefined, disposables); - }); - } - - private _onDidChangeContent(treeSitterTree: TreeSitterParseResult, change: IModelContentChangedEvent | undefined) { - return treeSitterTree.onDidChangeContent(this.model, change); - } -} - -const enum TelemetryParseType { - Full = 'fullParse', - Incremental = 'incrementalParse' -} - -export class TreeSitterParseResult implements IDisposable, ITreeSitterParseResult { - private _tree: Parser.Tree | undefined; - private _lastFullyParsed: Parser.Tree | undefined; - private _lastFullyParsedWithEdits: Parser.Tree | undefined; - private readonly _onDidUpdate: Emitter = new Emitter(); - public readonly onDidUpdate: Event = this._onDidUpdate.event; - private _versionId: number = 0; - private _editVersion: number = 0; - get versionId() { - return this._versionId; - } - private _isDisposed: boolean = false; - constructor(public readonly parser: Parser.Parser, - public /** exposed for tests **/ readonly language: Parser.Language, - private readonly _logService: ILogService, - private readonly _telemetryService: ITelemetryService) { - this.parser.setLanguage(language); - } - dispose(): void { - this._isDisposed = true; - this._onDidUpdate.dispose(); - this._tree?.delete(); - this._lastFullyParsed?.delete(); - this._lastFullyParsedWithEdits?.delete(); - this.parser?.delete(); - } - get tree() { return this._lastFullyParsed; } - get isDisposed() { return this._isDisposed; } - - private findChangedNodes(newTree: Parser.Tree, oldTree: Parser.Tree): Parser.Node[] { - const newCursor = newTree.walk(); - const oldCursor = oldTree.walk(); - - const nodes: Parser.Node[] = []; - let next = true; - - do { - if (newCursor.currentNode.hasChanges) { - // Check if only one of the children has changes. - // If it's only one, then we go to that child. - // If it's more then, we need to go to each child - // If it's none, then we've found one of our ranges - const newChildren = newCursor.currentNode.children; - const indexChangedChildren: number[] = []; - const changedChildren = newChildren.filter((c, index) => { - if (c?.hasChanges) { - indexChangedChildren.push(index); - } - return c?.hasChanges; - }); - // If we have changes and we *had* an error, the whole node should be refreshed. - if ((changedChildren.length === 0)) { - // walk up again until we get to the first one that's named as unnamed nodes can be too granular - while (newCursor.currentNode.parent && !newCursor.currentNode.isNamed && next) { - next = gotoParent(newCursor, oldCursor); - } - - const newNode = newCursor.currentNode; - nodes.push(newNode); - next = nextSiblingOrParentSibling(newCursor, oldCursor); - } else if (changedChildren.length >= 1) { - next = gotoNthChild(newCursor, oldCursor, indexChangedChildren[0]); - } - } else { - next = nextSiblingOrParentSibling(newCursor, oldCursor); - } - } while (next); - - return nodes; - } - - private findTreeChanges(newTree: Parser.Tree, changedNodes: Parser.Node[]): RangeChange[] { - const mergedChanges: RangeChange[] = []; - - // Find the parent in the new tree of the changed node - for (let nodeIndex = 0; nodeIndex < changedNodes.length; nodeIndex++) { - const node = changedNodes[nodeIndex]; - - if (mergedChanges.length > 0) { - if ((node.startIndex > mergedChanges[mergedChanges.length - 1].newRangeStartOffset) && (node.endIndex < mergedChanges[mergedChanges.length - 1].newRangeEndOffset)) { - // This node is within the previous range, skip it - continue; - } - } - - const cursor = newTree.walk(); - const cursorContainersNode = () => cursor.startIndex <= node.startIndex && cursor.endIndex >= node.endIndex; - - while (cursorContainersNode()) { - // See if we can go to a child - let child = cursor.gotoFirstChild(); - let foundChild = false; - while (child) { - if (cursorContainersNode()) { - foundChild = true; - break; - } else { - child = cursor.gotoNextSibling(); - } - } - if (!foundChild) { - cursor.gotoParent(); - break; - } - if (cursor.currentNode.childCount === 0) { - break; - } - } - - let nodesInRange: Parser.Node[]; - // It's possible we end up with a really large range if the parent node is big - // Try to avoid this large range by finding several smaller nodes that together encompass the range of the changed node. - const foundNodeSize = cursor.endIndex - cursor.startIndex; - if (foundNodeSize > 5000) { - // Try to find 3 consecutive nodes that together encompass the changed node. - let child = cursor.gotoFirstChild(); - nodesInRange = []; - while (child) { - if (cursor.startIndex <= node.startIndex && cursor.endIndex > node.startIndex) { - // Found the starting point of our nodes - nodesInRange.push(cursor.currentNode); - do { - child = cursor.gotoNextSibling(); - } while (child && (cursor.endIndex < node.endIndex)); - - nodesInRange.push(cursor.currentNode); - break; - } - child = cursor.gotoNextSibling(); - } - } else { - nodesInRange = [cursor.currentNode]; - } - - // Fill in gaps between nodes - // Reset the cursor to the first node in the range; - while (cursor.currentNode.id !== nodesInRange[0].id) { - cursor.gotoPreviousSibling(); - } - const previousNode = getClosestPreviousNodes(cursor, newTree); - const startingPosition = previousNode ? previousNode.endPosition : nodesInRange[0].startPosition; - const startingIndex = previousNode ? previousNode.endIndex : nodesInRange[0].startIndex; - const endingPosition = nodesInRange[nodesInRange.length - 1].endPosition; - const endingIndex = nodesInRange[nodesInRange.length - 1].endIndex; - - const newChange = { newRange: new Range(startingPosition.row + 1, startingPosition.column + 1, endingPosition.row + 1, endingPosition.column + 1), newRangeStartOffset: startingIndex, newRangeEndOffset: endingIndex }; - if ((mergedChanges.length > 0) && (mergedChanges[mergedChanges.length - 1].newRangeEndOffset >= newChange.newRangeStartOffset)) { - // Merge the changes - mergedChanges[mergedChanges.length - 1].newRange = Range.fromPositions(mergedChanges[mergedChanges.length - 1].newRange.getStartPosition(), newChange.newRange.getEndPosition()); - mergedChanges[mergedChanges.length - 1].newRangeEndOffset = newChange.newRangeEndOffset; - } else { - mergedChanges.push(newChange); - } - } - return mergedChanges; - } - - private _onDidChangeContentQueue: LimitedQueue = new LimitedQueue(); - public onDidChangeContent(model: ITextModel, changes: IModelContentChangedEvent | undefined): void { - const version = model.getVersionId(); - if (version === this._editVersion) { - return; - } - - this._applyEdits(changes?.changes ?? [], version); - - this._onDidChangeContentQueue.queue(async () => { - if (this.isDisposed) { - // No need to continue the queue if we are disposed - return; - } - - const oldTree = this._lastFullyParsed; - let changedNodes: Parser.Node[] | undefined; - if (this._lastFullyParsedWithEdits && this._lastFullyParsed) { - changedNodes = this.findChangedNodes(this._lastFullyParsedWithEdits, this._lastFullyParsed); - } - - const completed = await this._parseAndUpdateTree(model, version); - if (completed) { - let ranges: RangeChange[] | undefined; - if (!changedNodes) { - ranges = [{ newRange: model.getFullModelRange(), newRangeStartOffset: 0, newRangeEndOffset: model.getValueLength() }]; - } else if (oldTree && changedNodes) { - ranges = this.findTreeChanges(completed, changedNodes); - } - this._onDidUpdate.fire({ ranges, versionId: version }); - } - }); - } - - private _applyEdits(changes: IModelContentChange[], version: number) { - for (const change of changes) { - const originalTextLength = TextLength.ofRange(Range.lift(change.range)); - const newTextLength = TextLength.ofText(change.text); - const summedTextLengths = change.text.length === 0 ? newTextLength : originalTextLength.add(newTextLength); - const edit = { - startIndex: change.rangeOffset, - oldEndIndex: change.rangeOffset + change.rangeLength, - newEndIndex: change.rangeOffset + change.text.length, - startPosition: { row: change.range.startLineNumber - 1, column: change.range.startColumn - 1 }, - oldEndPosition: { row: change.range.endLineNumber - 1, column: change.range.endColumn - 1 }, - newEndPosition: { row: change.range.startLineNumber + summedTextLengths.lineCount - 1, column: summedTextLengths.lineCount ? summedTextLengths.columnCount : (change.range.endColumn + summedTextLengths.columnCount) } - }; - this._tree?.edit(edit); - this._lastFullyParsedWithEdits?.edit(edit); - } - this._editVersion = version; - } - - private async _parseAndUpdateTree(model: ITextModel, version: number): Promise { - const tree = await this._parse(model); - if (tree) { - this._tree?.delete(); - this._tree = tree; - this._lastFullyParsed?.delete(); - this._lastFullyParsed = tree.copy(); - this._lastFullyParsedWithEdits?.delete(); - this._lastFullyParsedWithEdits = tree.copy(); - this._versionId = version; - return tree; - } else if (!this._tree) { - // No tree means this is the inial parse and there were edits - // parse function doesn't handle this well and we can end up with an incorrect tree, so we reset - this.parser.reset(); - } - return undefined; - } - - private _parse(model: ITextModel): Promise { - let parseType: TelemetryParseType = TelemetryParseType.Full; - if (this.tree) { - parseType = TelemetryParseType.Incremental; - } - return this._parseAndYield(model, parseType); - } - - private async _parseAndYield(model: ITextModel, parseType: TelemetryParseType): Promise { - const language = model.getLanguageId(); - let time: number = 0; - let passes: number = 0; - const inProgressVersion = this._editVersion; - let newTree: Parser.Tree | null | undefined; - this._lastYieldTime = performance.now(); - - do { - const timer = performance.now(); - try { - newTree = this.parser.parse((index: number, position?: Parser.Point) => this._parseCallback(model, index), this._tree, { progressCallback: this._parseProgressCallback.bind(this) }); - } catch (e) { - // parsing can fail when the timeout is reached, will resume upon next loop - } finally { - time += performance.now() - timer; - passes++; - } - - // So long as this isn't the initial parse, even if the model changes and edits are applied, the tree parsing will continue correctly after the await. - await new Promise(resolve => setTimeout0(resolve)); - - } while (!model.isDisposed() && !this.isDisposed && !newTree && inProgressVersion === model.getVersionId()); - this.sendParseTimeTelemetry(parseType, language, time, passes); - return (newTree && (inProgressVersion === model.getVersionId())) ? newTree : undefined; - } - - private _lastYieldTime: number = 0; - private _parseProgressCallback(state: Parser.ParseState) { - const now = performance.now(); - if (now - this._lastYieldTime > 50) { - this._lastYieldTime = now; - return true; - } - return false; - } - - private _parseCallback(textModel: ITextModel, index: number): string | undefined { - try { - return textModel.getTextBuffer().getNearestChunk(index); - } catch (e) { - this._logService.debug('Error getting chunk for tree-sitter parsing', e); - } - return undefined; - } - - private sendParseTimeTelemetry(parseType: TelemetryParseType, languageId: string, time: number, passes: number): void { - this._logService.debug(`Tree parsing (${parseType}) took ${time} ms and ${passes} passes.`); - type ParseTimeClassification = { - owner: 'alexr00'; - comment: 'Used to understand how long it takes to parse a tree-sitter tree'; - languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The programming language ID.' }; - time: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The ms it took to parse' }; - passes: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of passes it took to parse' }; - }; - if (parseType === TelemetryParseType.Full) { - this._telemetryService.publicLog2<{ languageId: string; time: number; passes: number }, ParseTimeClassification>(`treeSitter.fullParse`, { languageId, time, passes }); - } else { - this._telemetryService.publicLog2<{ languageId: string; time: number; passes: number }, ParseTimeClassification>(`treeSitter.incrementalParse`, { languageId, time, passes }); - } - } -} - -export class TreeSitterLanguages extends Disposable { - private _languages: AsyncCache = new AsyncCache(); - public /*exposed for tests*/ readonly _onDidAddLanguage: Emitter<{ id: string; language: Parser.Language }> = this._register(new Emitter()); - /** - * If you're looking for a specific language, make sure to check if it already exists with `getLanguage` as it will kick off the process to add it if it doesn't exist. - */ - public readonly onDidAddLanguage: Event<{ id: string; language: Parser.Language }> = this._onDidAddLanguage.event; - - constructor(private readonly _treeSitterImporter: ITreeSitterImporter, - private readonly _fileService: IFileService, - private readonly _environmentService: IEnvironmentService, - private readonly _registeredLanguages: Map, - ) { - super(); - } - - public getOrInitLanguage(languageId: string): Parser.Language | undefined { - if (this._languages.isCached(languageId)) { - return this._languages.getSyncIfCached(languageId); - } else { - // kick off adding the language, but don't wait - this._addLanguage(languageId); - return undefined; - } - } - - public async getLanguage(languageId: string): Promise { - if (this._languages.isCached(languageId)) { - return this._languages.getSyncIfCached(languageId); - } else { - await this._addLanguage(languageId); - return this._languages.get(languageId); - } - } - - private async _addLanguage(languageId: string): Promise { - const languagePromise = this._languages.get(languageId); - if (!languagePromise) { - this._languages.set(languageId, this._fetchLanguage(languageId)); - const language = await this._languages.get(languageId); - if (!language) { - return undefined; - } - this._onDidAddLanguage.fire({ id: languageId, language }); - } - } - - private async _fetchLanguage(languageId: string): Promise { - const grammarName = this._registeredLanguages.get(languageId); - const languageLocation = this._getLanguageLocation(languageId); - if (!grammarName || !languageLocation) { - return undefined; - } - const wasmPath: AppResourcePath = `${languageLocation}/${grammarName}.wasm`; - const languageFile = await (this._fileService.readFile(FileAccess.asFileUri(wasmPath))); - const Language = await this._treeSitterImporter.getLanguageClass(); - return Language.load(languageFile.value.buffer); - } - - private _getLanguageLocation(languageId: string): AppResourcePath | undefined { - const grammarName = this._registeredLanguages.get(languageId); - if (!grammarName) { - return undefined; - } - return getModuleLocation(this._environmentService); - } -} - -interface TextModelTreeSitterItem { - dispose(): void; - textModelTreeSitter: TextModelTreeSitter; - disposables: DisposableStore; -} - export class TreeSitterTextModelService extends Disposable implements ITreeSitterParserService { readonly _serviceBrand: undefined; private _init!: Promise; @@ -522,14 +35,13 @@ export class TreeSitterTextModelService extends Disposable implements ITreeSitte constructor(@IModelService private readonly _modelService: IModelService, @IFileService fileService: IFileService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, - @ILogService private readonly _logService: ILogService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentService private readonly _environmentService: IEnvironmentService, - @ITreeSitterImporter private readonly _treeSitterImporter: ITreeSitterImporter + @ITreeSitterImporter private readonly _treeSitterImporter: ITreeSitterImporter, + @IInstantiationService private readonly _instantiationService: IInstantiationService ) { super(); - this._treeSitterLanguages = this._register(new TreeSitterLanguages(this._treeSitterImporter, fileService, this._environmentService, this._registeredLanguages)); + this._treeSitterLanguages = this._register(new TreeSitterLanguages(this._treeSitterImporter, fileService, this._environmentService, this._configurationService, this._registeredLanguages)); this.onDidAddLanguage = this._treeSitterLanguages.onDidAddLanguage; this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(EDITOR_EXPERIMENTAL_PREFER_TREESITTER)) { @@ -543,9 +55,9 @@ export class TreeSitterTextModelService extends Disposable implements ITreeSitte return this._treeSitterLanguages.getOrInitLanguage(languageId); } - getParseResult(textModel: ITextModel): ITreeSitterParseResult | undefined { + getParseResult(textModel: ITextModel): ITextModelTreeSitter | undefined { const textModelTreeSitter = this._textModelTreeSitters.get(textModel); - return textModelTreeSitter?.textModelTreeSitter.parseResult; + return textModelTreeSitter?.textModelTreeSitter; } /** @@ -657,10 +169,10 @@ export class TreeSitterTextModelService extends Disposable implements ITreeSitte } private _createTextModelTreeSitter(model: ITextModel, parseImmediately: boolean = true): ITextModelTreeSitter { - const textModelTreeSitter = new TextModelTreeSitter(model, this._treeSitterLanguages, this._treeSitterImporter, this._logService, this._telemetryService, parseImmediately); + const textModelTreeSitter = this._instantiationService.createInstance(TextModelTreeSitter, model, this._treeSitterLanguages, parseImmediately); const disposables = new DisposableStore(); disposables.add(textModelTreeSitter); - disposables.add(textModelTreeSitter.onDidChangeParseResult(change => this._onDidUpdateTree.fire({ textModel: model, ranges: change.ranges ?? [], versionId: change.versionId }))); + disposables.add(textModelTreeSitter.onDidChangeParseResult((e) => this._handleOnDidChangeParseResult(e, model))); this._textModelTreeSitters.set(model, { textModelTreeSitter, disposables, @@ -669,6 +181,10 @@ export class TreeSitterTextModelService extends Disposable implements ITreeSitte return textModelTreeSitter; } + private _handleOnDidChangeParseResult(change: ModelTreeUpdateEvent, model: ITextModel) { + this._onDidUpdateTree.fire({ textModel: model, ranges: change.ranges, versionId: change.versionId, tree: change.tree, languageId: change.languageId, hasInjections: change.hasInjections }); + } + private _addGrammar(languageId: string, grammarName: string) { if (!this._registeredLanguages.has(languageId)) { this._registeredLanguages.set(languageId, grammarName); @@ -677,45 +193,9 @@ export class TreeSitterTextModelService extends Disposable implements ITreeSitte private _removeGrammar(languageId: string) { if (this._registeredLanguages.has(languageId)) { - this._registeredLanguages.delete('typescript'); + this._registeredLanguages.delete(languageId); } } } -class PromiseWithSyncAccess { - private _result: PromiseResult | undefined; - /** - * Returns undefined if the promise did not resolve yet. - */ - get result(): PromiseResult | undefined { - return this._result; - } - constructor(public readonly promise: Promise) { - promise.then(result => { - this._result = new PromiseResult(result, undefined); - }).catch(e => { - this._result = new PromiseResult(undefined, e); - }); - } -} - -class AsyncCache { - private readonly _values = new Map>(); - - set(key: TKey, promise: Promise) { - this._values.set(key, new PromiseWithSyncAccess(promise)); - } - - get(key: TKey): Promise | undefined { - return this._values.get(key)?.promise; - } - - getSyncIfCached(key: TKey): T | undefined { - return this._values.get(key)?.result?.data; - } - - isCached(key: TKey): boolean { - return this._values.get(key)?.result !== undefined; - } -} diff --git a/src/vs/editor/common/services/treeSitterParserService.ts b/src/vs/editor/common/services/treeSitterParserService.ts index 64e5a870d8f..a362e68f809 100644 --- a/src/vs/editor/common/services/treeSitterParserService.ts +++ b/src/vs/editor/common/services/treeSitterParserService.ts @@ -9,9 +9,10 @@ import { ITextModel } from '../model.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { Range } from '../core/range.js'; import { importAMDNodeModule } from '../../../amdX.js'; +import { IModelContentChangedEvent } from '../textModelEvents.js'; export const EDITOR_EXPERIMENTAL_PREFER_TREESITTER = 'editor.experimental.preferTreeSitter'; -export const TREESITTER_ALLOWED_SUPPORT = ['typescript', 'ini']; +export const TREESITTER_ALLOWED_SUPPORT = ['css', 'typescript', 'ini', 'regex']; export const ITreeSitterParserService = createDecorator('treeSitterParserService'); @@ -29,13 +30,22 @@ export interface RangeChange { export interface TreeParseUpdateEvent { ranges: RangeChange[] | undefined; + language: string; versionId: number; + tree: Parser.Tree; + includedModelChanges: IModelContentChangedEvent[]; } -export interface TreeUpdateEvent { - textModel: ITextModel; +export interface ModelTreeUpdateEvent { ranges: RangeChange[]; versionId: number; + tree: ITextModelTreeSitter; + languageId: string; + hasInjections: boolean; +} + +export interface TreeUpdateEvent extends ModelTreeUpdateEvent { + textModel: ITextModel; } export interface ITreeSitterParserService { @@ -43,7 +53,7 @@ export interface ITreeSitterParserService { onDidAddLanguage: Event<{ id: string; language: Parser.Language }>; getOrInitLanguage(languageId: string): Parser.Language | undefined; getLanguage(languageId: string): Promise; - getParseResult(textModel: ITextModel): ITreeSitterParseResult | undefined; + getParseResult(textModel: ITextModel): ITextModelTreeSitter | undefined; getTree(content: string, languageId: string): Promise; getTreeSync(content: string, languageId: string): Parser.Tree | undefined; onDidUpdateTree: Event; @@ -56,6 +66,8 @@ export interface ITreeSitterParserService { export interface ITreeSitterParseResult { readonly tree: Parser.Tree | undefined; readonly language: Parser.Language; + readonly languageId: string; + readonly ranges: Parser.Range[] | undefined; versionId: number; } @@ -64,6 +76,9 @@ export interface ITextModelTreeSitter { * For testing purposes so that the time to parse can be measured. */ parse(languageId?: string): Promise; + textModel: ITextModel; + parseResult: ITreeSitterParseResult | undefined; + getInjection(offset: number, parentLanguage: string): ITreeSitterParseResult | undefined; dispose(): void; } diff --git a/src/vs/editor/common/standalone/standaloneEnums.ts b/src/vs/editor/common/standalone/standaloneEnums.ts index 481bae63854..f44bee76294 100644 --- a/src/vs/editor/common/standalone/standaloneEnums.ts +++ b/src/vs/editor/common/standalone/standaloneEnums.ts @@ -420,6 +420,12 @@ export enum InlayHintKind { Parameter = 2 } +export enum InlineCompletionEndOfLifeReasonKind { + Accepted = 0, + Rejected = 1, + Ignored = 2 +} + /** * How an {@link InlineCompletionsProvider inline completion provider} was triggered. */ diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index c35d0472106..3123b120b28 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -234,6 +234,37 @@ export class ModelRawLineChanged { } } + +/** + * An event describing that a line height has changed in the model. + * @internal + */ +export class ModelLineHeightChanged { + /** + * Editor owner ID + */ + public readonly ownerId: number; + /** + * The decoration ID that has changed. + */ + public readonly decorationId: string; + /** + * The line that has changed. + */ + public readonly lineNumber: number; + /** + * The line height on the line. + */ + public readonly lineHeight: number | null; + + constructor(ownerId: number, decorationId: string, lineNumber: number, lineHeight: number | null) { + this.ownerId = ownerId; + this.decorationId = decorationId; + this.lineNumber = lineNumber; + this.lineHeight = lineHeight; + } +} + /** * An event describing that line(s) have been deleted in a model. * @internal @@ -361,6 +392,19 @@ export class ModelInjectedTextChangedEvent { } } +/** + * An event describing a change of a line height. + * @internal + */ +export class ModelLineHeightChangedEvent { + + public readonly changes: ModelLineHeightChanged[]; + + constructor(changes: ModelLineHeightChanged[]) { + this.changes = changes; + } +} + /** * @internal */ diff --git a/src/vs/editor/common/tokens/common.ts b/src/vs/editor/common/tokens/common.ts new file mode 100644 index 00000000000..d8b3a9c76b4 --- /dev/null +++ b/src/vs/editor/common/tokens/common.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export class RateLimiter { + private _lastRun: number; + private readonly _minimumTimeBetweenRuns: number; + + constructor(public readonly timesPerSecond: number = 5) { + this._lastRun = 0; + this._minimumTimeBetweenRuns = 1000 / timesPerSecond; + } + + public runIfNotLimited(callback: () => void): void { + const now = Date.now(); + if (now - this._lastRun >= this._minimumTimeBetweenRuns) { + this._lastRun = now; + callback(); + } + } +} diff --git a/src/vs/editor/common/tokens/contiguousTokensEditing.ts b/src/vs/editor/common/tokens/contiguousTokensEditing.ts index 28d444a2df0..7da48f7527b 100644 --- a/src/vs/editor/common/tokens/contiguousTokensEditing.ts +++ b/src/vs/editor/common/tokens/contiguousTokensEditing.ts @@ -135,10 +135,10 @@ export class ContiguousTokensEditing { } } -export function toUint32Array(arr: Uint32Array | ArrayBuffer): Uint32Array { +export function toUint32Array(arr: Uint32Array | ArrayBuffer): Uint32Array { if (arr instanceof Uint32Array) { - return arr; + return arr as Uint32Array; } else { - return new Uint32Array(arr); + return new Uint32Array(arr); } } diff --git a/src/vs/editor/common/tokens/lineTokens.ts b/src/vs/editor/common/tokens/lineTokens.ts index 5546834b94d..1ebab1927d7 100644 --- a/src/vs/editor/common/tokens/lineTokens.ts +++ b/src/vs/editor/common/tokens/lineTokens.ts @@ -9,7 +9,7 @@ import { IPosition } from '../core/position.js'; import { ITextModel } from '../model.js'; import { OffsetRange } from '../core/offsetRange.js'; import { TokenArray, TokenArrayBuilder } from './tokenArray.js'; -import { BugIndicatingError } from '../../../base/common/errors.js'; +import { onUnexpectedError } from '../../../base/common/errors.js'; export interface IViewLineTokens { @@ -104,7 +104,7 @@ export class LineTokens implements IViewLineTokens { constructor(tokens: Uint32Array, text: string, decoder: ILanguageIdCodec) { const tokensLength = tokens.length > 1 ? tokens[tokens.length - 2] : 0; if (tokensLength !== text.length) { - throw new BugIndicatingError('Token length and text length do not match!'); + onUnexpectedError(new Error('Token length and text length do not match!')); } this._tokens = tokens; this._tokensCount = (this._tokens.length >>> 1); @@ -112,6 +112,10 @@ export class LineTokens implements IViewLineTokens { this.languageIdCodec = decoder; } + public getTextLength(): number { + return this._text.length; + } + public equals(other: IViewLineTokens): boolean { if (other instanceof LineTokens) { return this.slicedEquals(other, 0, this._tokensCount); diff --git a/src/vs/editor/common/tokens/sparseMultilineTokens.ts b/src/vs/editor/common/tokens/sparseMultilineTokens.ts index 50887a06af6..931e55eb3cc 100644 --- a/src/vs/editor/common/tokens/sparseMultilineTokens.ts +++ b/src/vs/editor/common/tokens/sparseMultilineTokens.ts @@ -7,6 +7,8 @@ import { CharCode } from '../../../base/common/charCode.js'; import { Position } from '../core/position.js'; import { IRange, Range } from '../core/range.js'; import { countEOL } from '../core/eolCounter.js'; +import { ITextModel } from '../model.js'; +import { RateLimiter } from './common.js'; /** * Represents sparse tokens over a contiguous range of lines. @@ -162,6 +164,10 @@ export class SparseMultilineTokens { this._tokens.acceptInsertText(lineIndex, position.column - 1, eolCount, firstLineLength, lastLineLength, firstCharCode); } + + public reportIfInvalid(model: ITextModel): void { + this._tokens.reportIfInvalid(model, this._startLineNumber); + } } class SparseMultilineTokensStorage { @@ -558,6 +564,26 @@ class SparseMultilineTokensStorage { tokens[offset + 2] = tokenEndCharacter; } } + + private static _rateLimiter = new RateLimiter(10 / 60); // limit to 10 times per minute + + public reportIfInvalid(model: ITextModel, startLineNumber: number): void { + for (let i = 0; i < this._tokenCount; i++) { + const lineNumber = this._getDeltaLine(i) + startLineNumber; + + if (lineNumber > model.getLineCount()) { + SparseMultilineTokensStorage._rateLimiter.runIfNotLimited(() => { + console.error('Invalid Semantic Tokens Data From Extension: lineNumber > model.getLineCount()'); + }); + } + + if (this._getEndCharacter(i) > model.getLineLength(lineNumber)) { + SparseMultilineTokensStorage._rateLimiter.runIfNotLimited(() => { + console.error('Invalid Semantic Tokens Data From Extension: end character > model.getLineLength(lineNumber)'); + }); + } + } + } } export class SparseLineTokens { diff --git a/src/vs/editor/common/tokens/sparseTokensStore.ts b/src/vs/editor/common/tokens/sparseTokensStore.ts index dd89936c989..69d217f6036 100644 --- a/src/vs/editor/common/tokens/sparseTokensStore.ts +++ b/src/vs/editor/common/tokens/sparseTokensStore.ts @@ -9,6 +9,7 @@ import { LineTokens } from './lineTokens.js'; import { SparseMultilineTokens } from './sparseMultilineTokens.js'; import { ILanguageIdCodec } from '../languages.js'; import { MetadataConsts } from '../encodedTokenAttributes.js'; +import { ITextModel } from '../model.js'; /** * Represents sparse tokens in a text model. @@ -34,9 +35,15 @@ export class SparseTokensStore { return (this._pieces.length === 0); } - public set(pieces: SparseMultilineTokens[] | null, isComplete: boolean): void { + public set(pieces: SparseMultilineTokens[] | null, isComplete: boolean, textModel: ITextModel | undefined = undefined): void { this._pieces = pieces || []; this._isComplete = isComplete; + + if (textModel) { + for (const p of this._pieces) { + p.reportIfInvalid(textModel); + } + } } public setPartial(_range: Range, pieces: SparseMultilineTokens[]): Range { @@ -124,7 +131,7 @@ export class SparseTokensStore { } public addSparseTokens(lineNumber: number, aTokens: LineTokens): LineTokens { - if (aTokens.getLineContent().length === 0) { + if (aTokens.getTextLength() === 0) { // Don't do anything for empty lines return aTokens; } @@ -160,8 +167,10 @@ export class SparseTokensStore { }; for (let bIndex = 0; bIndex < bLen; bIndex++) { - const bStartCharacter = bTokens.getStartCharacter(bIndex); - const bEndCharacter = bTokens.getEndCharacter(bIndex); + // bTokens is not validated yet, but aTokens is. We want to make sure that the LineTokens we return + // are valid, so we clamp the ranges to ensure that. + const bStartCharacter = Math.min(bTokens.getStartCharacter(bIndex), aTokens.getTextLength()); + const bEndCharacter = Math.min(bTokens.getEndCharacter(bIndex), aTokens.getTextLength()); const bMetadata = bTokens.getMetadata(bIndex); const bMask = ( diff --git a/src/vs/editor/common/tokens/tokenArray.ts b/src/vs/editor/common/tokens/tokenArray.ts index bc089d1604a..555e5654dcc 100644 --- a/src/vs/editor/common/tokens/tokenArray.ts +++ b/src/vs/editor/common/tokens/tokenArray.ts @@ -77,6 +77,11 @@ export class TokenArray { } return TokenArray.create(result); } + + public append(other: TokenArray): TokenArray { + const result: TokenInfo[] = this._tokenInfo.concat(other._tokenInfo); + return TokenArray.create(result); + } } export type TokenMetadata = number; diff --git a/src/vs/editor/common/tokens/tokenWithTextArray.ts b/src/vs/editor/common/tokens/tokenWithTextArray.ts new file mode 100644 index 00000000000..765d480a12b --- /dev/null +++ b/src/vs/editor/common/tokens/tokenWithTextArray.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { OffsetRange } from '../core/offsetRange.js'; +import { ILanguageIdCodec } from '../languages.js'; +import { LineTokens } from './lineTokens.js'; + +/** + * This class represents a sequence of tokens. + * Conceptually, each token has a length and a metadata number. + * A token array might be used to annotate a string with metadata. + * Use {@link TokenWithTextArrayBuilder} to efficiently create a token array. + * + * TODO: Make this class more efficient (e.g. by using a Int32Array). +*/ +export class TokenWithTextArray { + public static fromLineTokens(lineTokens: LineTokens): TokenWithTextArray { + const tokenInfo: TokenWithTextInfo[] = []; + for (let i = 0; i < lineTokens.getCount(); i++) { + tokenInfo.push(new TokenWithTextInfo(lineTokens.getTokenText(i), lineTokens.getMetadata(i))); + } + return TokenWithTextArray.create(tokenInfo); + } + + public static create(tokenInfo: TokenWithTextInfo[]): TokenWithTextArray { + return new TokenWithTextArray(tokenInfo); + } + + private constructor( + private readonly _tokenInfo: TokenWithTextInfo[], + ) { } + + public toLineTokens(decoder: ILanguageIdCodec): LineTokens { + return LineTokens.createFromTextAndMetadata(this.map((_r, t) => ({ text: t.text, metadata: t.metadata })), decoder); + } + + public forEach(cb: (range: OffsetRange, tokenInfo: TokenWithTextInfo) => void): void { + let lengthSum = 0; + for (const tokenInfo of this._tokenInfo) { + const range = new OffsetRange(lengthSum, lengthSum + tokenInfo.text.length); + cb(range, tokenInfo); + lengthSum += tokenInfo.text.length; + } + } + + public map(cb: (range: OffsetRange, tokenInfo: TokenWithTextInfo) => T): T[] { + const result: T[] = []; + let lengthSum = 0; + for (const tokenInfo of this._tokenInfo) { + const range = new OffsetRange(lengthSum, lengthSum + tokenInfo.text.length); + result.push(cb(range, tokenInfo)); + lengthSum += tokenInfo.text.length; + } + return result; + } + + public slice(range: OffsetRange): TokenWithTextArray { + const result: TokenWithTextInfo[] = []; + let lengthSum = 0; + for (const tokenInfo of this._tokenInfo) { + const tokenStart = lengthSum; + const tokenEndEx = tokenStart + tokenInfo.text.length; + if (tokenEndEx > range.start) { + if (tokenStart >= range.endExclusive) { + break; + } + + const deltaBefore = Math.max(0, range.start - tokenStart); + const deltaAfter = Math.max(0, tokenEndEx - range.endExclusive); + + result.push(new TokenWithTextInfo(tokenInfo.text.slice(deltaBefore, tokenInfo.text.length - deltaAfter), tokenInfo.metadata)); + } + + lengthSum += tokenInfo.text.length; + } + return TokenWithTextArray.create(result); + } + + public append(other: TokenWithTextArray): TokenWithTextArray { + const result: TokenWithTextInfo[] = this._tokenInfo.concat(other._tokenInfo); + return TokenWithTextArray.create(result); + } +} + +export type TokenMetadata = number; + +export class TokenWithTextInfo { + constructor( + public readonly text: string, + public readonly metadata: TokenMetadata, + ) { } +} + +/** + * TODO: Make this class more efficient (e.g. by using a Int32Array). +*/ +export class TokenWithTextArrayBuilder { + private readonly _tokens: TokenWithTextInfo[] = []; + + public add(text: string, metadata: TokenMetadata): void { + this._tokens.push(new TokenWithTextInfo(text, metadata)); + } + + public build(): TokenWithTextArray { + return TokenWithTextArray.create(this._tokens); + } +} diff --git a/src/vs/editor/common/viewLayout/lineHeights.ts b/src/vs/editor/common/viewLayout/lineHeights.ts new file mode 100644 index 00000000000..37996f9f967 --- /dev/null +++ b/src/vs/editor/common/viewLayout/lineHeights.ts @@ -0,0 +1,393 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { binarySearch2 } from '../../../base/common/arrays.js'; +import { intersection } from '../../../base/common/collections.js'; + +export class CustomLine { + + public index: number; + public lineNumber: number; + public specialHeight: number; + public prefixSum: number; + public maximumSpecialHeight: number; + public decorationId: string; + public deleted: boolean; + + constructor(decorationId: string, index: number, lineNumber: number, specialHeight: number, prefixSum: number) { + this.decorationId = decorationId; + this.index = index; + this.lineNumber = lineNumber; + this.specialHeight = specialHeight; + this.prefixSum = prefixSum; + this.maximumSpecialHeight = specialHeight; + this.deleted = false; + } +} + +/** + * Manages line heights in the editor with support for custom line heights from decorations. + * + * This class maintains an ordered collection of line heights, where each line can have either + * the default height or a custom height specified by decorations. It supports efficient querying + * of individual line heights as well as accumulated heights up to a specific line. + * + * Line heights are stored in a sorted array for efficient binary search operations. Each line + * with custom height is represented by a {@link CustomLine} object which tracks its special height, + * accumulated height prefix sum, and associated decoration ID. + * + * The class optimizes performance by: + * - Using binary search to locate lines in the ordered array + * - Batching updates through a pending changes mechanism + * - Computing prefix sums for O(1) accumulated height lookup + * - Tracking maximum height for lines with multiple decorations + * - Efficiently handling document changes (line insertions and deletions) + * + * When lines are inserted or deleted, the manager updates line numbers and prefix sums + * for all affected lines. It also handles special cases like decorations that span + * the insertion/deletion points by re-applying those decorations appropriately. + * + * All query operations automatically commit pending changes to ensure consistent results. + * Clients can modify line heights by adding or removing custom line height decorations, + * which are tracked by their unique decoration IDs. + */ +export class LineHeightsManager { + + private _decorationIDToCustomLine: ArrayMap = new ArrayMap(); + private _orderedCustomLines: CustomLine[] = []; + private _pendingSpecialLinesToInsert: CustomLine[] = []; + private _invalidIndex: number = 0; + private _defaultLineHeight: number; + private _hasPending: boolean = false; + + constructor(defaultLineHeight: number, customLineHeightData: ICustomLineHeightData[]) { + this._defaultLineHeight = defaultLineHeight; + if (customLineHeightData.length > 0) { + for (const data of customLineHeightData) { + this.insertOrChangeCustomLineHeight(data.decorationId, data.startLineNumber, data.endLineNumber, data.lineHeight); + } + this.commit(); + } + } + + set defaultLineHeight(defaultLineHeight: number) { + this._defaultLineHeight = defaultLineHeight; + } + + get defaultLineHeight() { + return this._defaultLineHeight; + } + + public removeCustomLineHeight(decorationID: string): void { + const customLines = this._decorationIDToCustomLine.get(decorationID); + if (!customLines) { + return; + } + this._decorationIDToCustomLine.delete(decorationID); + for (const customLine of customLines) { + customLine.deleted = true; + this._invalidIndex = Math.min(this._invalidIndex, customLine.index); + } + this._hasPending = true; + } + + public insertOrChangeCustomLineHeight(decorationId: string, startLineNumber: number, endLineNumber: number, lineHeight: number): void { + this.removeCustomLineHeight(decorationId); + for (let lineNumber = startLineNumber; lineNumber <= endLineNumber; lineNumber++) { + const customLine = new CustomLine(decorationId, -1, lineNumber, lineHeight, 0); + this._pendingSpecialLinesToInsert.push(customLine); + } + this._hasPending = true; + } + + public heightForLineNumber(lineNumber: number): number { + const searchIndex = this._binarySearchOverOrderedCustomLinesArray(lineNumber); + if (searchIndex >= 0) { + return this._orderedCustomLines[searchIndex].maximumSpecialHeight; + } + return this._defaultLineHeight; + } + + public getAccumulatedLineHeightsIncludingLineNumber(lineNumber: number): number { + const searchIndex = this._binarySearchOverOrderedCustomLinesArray(lineNumber); + if (searchIndex >= 0) { + return this._orderedCustomLines[searchIndex].prefixSum + this._orderedCustomLines[searchIndex].maximumSpecialHeight; + } + if (searchIndex === -1) { + return this._defaultLineHeight * lineNumber; + } + const modifiedIndex = -(searchIndex + 1); + const previousSpecialLine = this._orderedCustomLines[modifiedIndex - 1]; + return previousSpecialLine.prefixSum + previousSpecialLine.maximumSpecialHeight + this._defaultLineHeight * (lineNumber - previousSpecialLine.lineNumber); + } + + public onLinesDeleted(fromLineNumber: number, toLineNumber: number): void { + const deleteCount = toLineNumber - fromLineNumber + 1; + const numberOfCustomLines = this._orderedCustomLines.length; + const candidateStartIndexOfDeletion = this._binarySearchOverOrderedCustomLinesArray(fromLineNumber); + let startIndexOfDeletion: number; + if (candidateStartIndexOfDeletion >= 0) { + startIndexOfDeletion = candidateStartIndexOfDeletion; + for (let i = candidateStartIndexOfDeletion - 1; i >= 0; i--) { + if (this._orderedCustomLines[i].lineNumber === fromLineNumber) { + startIndexOfDeletion--; + } else { + break; + } + } + } else { + startIndexOfDeletion = candidateStartIndexOfDeletion === -(numberOfCustomLines + 1) && candidateStartIndexOfDeletion !== -1 ? numberOfCustomLines - 1 : - (candidateStartIndexOfDeletion + 1); + } + const candidateEndIndexOfDeletion = this._binarySearchOverOrderedCustomLinesArray(toLineNumber); + let endIndexOfDeletion: number; + if (candidateEndIndexOfDeletion >= 0) { + endIndexOfDeletion = candidateEndIndexOfDeletion; + for (let i = candidateEndIndexOfDeletion + 1; i < numberOfCustomLines; i++) { + if (this._orderedCustomLines[i].lineNumber === toLineNumber) { + endIndexOfDeletion++; + } else { + break; + } + } + } else { + endIndexOfDeletion = candidateEndIndexOfDeletion === -(numberOfCustomLines + 1) && candidateEndIndexOfDeletion !== -1 ? numberOfCustomLines - 1 : - (candidateEndIndexOfDeletion + 1); + } + const isEndIndexBiggerThanStartIndex = endIndexOfDeletion > startIndexOfDeletion; + const isEndIndexEqualToStartIndexAndCoversCustomLine = endIndexOfDeletion === startIndexOfDeletion + && this._orderedCustomLines[startIndexOfDeletion] + && this._orderedCustomLines[startIndexOfDeletion].lineNumber >= fromLineNumber + && this._orderedCustomLines[startIndexOfDeletion].lineNumber <= toLineNumber; + + if (isEndIndexBiggerThanStartIndex || isEndIndexEqualToStartIndexAndCoversCustomLine) { + let maximumSpecialHeightOnDeletedInterval = 0; + for (let i = startIndexOfDeletion; i <= endIndexOfDeletion; i++) { + maximumSpecialHeightOnDeletedInterval = Math.max(maximumSpecialHeightOnDeletedInterval, this._orderedCustomLines[i].maximumSpecialHeight); + } + let prefixSumOnDeletedInterval = 0; + if (startIndexOfDeletion > 0) { + const previousSpecialLine = this._orderedCustomLines[startIndexOfDeletion - 1]; + prefixSumOnDeletedInterval = previousSpecialLine.prefixSum + previousSpecialLine.maximumSpecialHeight + this._defaultLineHeight * (fromLineNumber - previousSpecialLine.lineNumber - 1); + } else { + prefixSumOnDeletedInterval = fromLineNumber > 0 ? (fromLineNumber - 1) * this._defaultLineHeight : 0; + } + const firstSpecialLineDeleted = this._orderedCustomLines[startIndexOfDeletion]; + const lastSpecialLineDeleted = this._orderedCustomLines[endIndexOfDeletion]; + const firstSpecialLineAfterDeletion = this._orderedCustomLines[endIndexOfDeletion + 1]; + const heightOfFirstLineAfterDeletion = firstSpecialLineAfterDeletion && firstSpecialLineAfterDeletion.lineNumber === toLineNumber + 1 ? firstSpecialLineAfterDeletion.maximumSpecialHeight : this._defaultLineHeight; + const totalHeightDeleted = lastSpecialLineDeleted.prefixSum + + lastSpecialLineDeleted.maximumSpecialHeight + - firstSpecialLineDeleted.prefixSum + + this._defaultLineHeight * (toLineNumber - lastSpecialLineDeleted.lineNumber) + + this._defaultLineHeight * (firstSpecialLineDeleted.lineNumber - fromLineNumber) + + heightOfFirstLineAfterDeletion - maximumSpecialHeightOnDeletedInterval; + + const decorationIdsSeen = new Set(); + const newOrderedCustomLines: CustomLine[] = []; + const newDecorationIDToSpecialLine = new ArrayMap(); + let numberOfDeletions = 0; + for (let i = 0; i < this._orderedCustomLines.length; i++) { + const customLine = this._orderedCustomLines[i]; + if (i < startIndexOfDeletion) { + newOrderedCustomLines.push(customLine); + newDecorationIDToSpecialLine.add(customLine.decorationId, customLine); + } else if (i >= startIndexOfDeletion && i <= endIndexOfDeletion) { + const decorationId = customLine.decorationId; + if (!decorationIdsSeen.has(decorationId)) { + customLine.index -= numberOfDeletions; + customLine.lineNumber = fromLineNumber; + customLine.prefixSum = prefixSumOnDeletedInterval; + customLine.maximumSpecialHeight = maximumSpecialHeightOnDeletedInterval; + newOrderedCustomLines.push(customLine); + newDecorationIDToSpecialLine.add(customLine.decorationId, customLine); + } else { + numberOfDeletions++; + } + } else if (i > endIndexOfDeletion) { + customLine.index -= numberOfDeletions; + customLine.lineNumber -= deleteCount; + customLine.prefixSum -= totalHeightDeleted; + newOrderedCustomLines.push(customLine); + newDecorationIDToSpecialLine.add(customLine.decorationId, customLine); + } + decorationIdsSeen.add(customLine.decorationId); + } + this._orderedCustomLines = newOrderedCustomLines; + this._decorationIDToCustomLine = newDecorationIDToSpecialLine; + } else { + const totalHeightDeleted = deleteCount * this._defaultLineHeight; + for (let i = endIndexOfDeletion; i < this._orderedCustomLines.length; i++) { + const customLine = this._orderedCustomLines[i]; + customLine.lineNumber -= deleteCount; + customLine.prefixSum -= totalHeightDeleted; + } + } + } + + public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { + const insertCount = toLineNumber - fromLineNumber + 1; + const candidateStartIndexOfInsertion = this._binarySearchOverOrderedCustomLinesArray(fromLineNumber); + let startIndexOfInsertion: number; + if (candidateStartIndexOfInsertion >= 0) { + startIndexOfInsertion = candidateStartIndexOfInsertion; + for (let i = candidateStartIndexOfInsertion - 1; i >= 0; i--) { + if (this._orderedCustomLines[i].lineNumber === fromLineNumber) { + startIndexOfInsertion--; + } else { + break; + } + } + } else { + startIndexOfInsertion = -(candidateStartIndexOfInsertion + 1); + } + const toReAdd: ICustomLineHeightData[] = []; + const decorationsImmediatelyAfter = new Set(); + for (let i = startIndexOfInsertion; i < this._orderedCustomLines.length; i++) { + if (this._orderedCustomLines[i].lineNumber === fromLineNumber) { + decorationsImmediatelyAfter.add(this._orderedCustomLines[i].decorationId); + } + } + const decorationsImmediatelyBefore = new Set(); + for (let i = startIndexOfInsertion - 1; i >= 0; i--) { + if (this._orderedCustomLines[i].lineNumber === fromLineNumber - 1) { + decorationsImmediatelyBefore.add(this._orderedCustomLines[i].decorationId); + } + } + const decorationsWithGaps = intersection(decorationsImmediatelyBefore, decorationsImmediatelyAfter); + for (let i = startIndexOfInsertion; i < this._orderedCustomLines.length; i++) { + this._orderedCustomLines[i].lineNumber += insertCount; + this._orderedCustomLines[i].prefixSum += this._defaultLineHeight * insertCount; + } + + if (decorationsWithGaps.size > 0) { + for (const decorationId of decorationsWithGaps) { + const decoration = this._decorationIDToCustomLine.get(decorationId); + if (decoration) { + const startLineNumber = decoration.reduce((min, l) => Math.min(min, l.lineNumber), fromLineNumber); // min + const endLineNumber = decoration.reduce((max, l) => Math.max(max, l.lineNumber), fromLineNumber); // max + const lineHeight = decoration.reduce((max, l) => Math.max(max, l.specialHeight), 0); + toReAdd.push({ + decorationId, + startLineNumber, + endLineNumber, + lineHeight + }); + } + } + + for (const dec of toReAdd) { + this.insertOrChangeCustomLineHeight(dec.decorationId, dec.startLineNumber, dec.endLineNumber, dec.lineHeight); + } + this.commit(); + } + } + + public commit(): void { + if (!this._hasPending) { + return; + } + for (const pendingChange of this._pendingSpecialLinesToInsert) { + const candidateInsertionIndex = this._binarySearchOverOrderedCustomLinesArray(pendingChange.lineNumber); + const insertionIndex = candidateInsertionIndex >= 0 ? candidateInsertionIndex : -(candidateInsertionIndex + 1); + this._orderedCustomLines.splice(insertionIndex, 0, pendingChange); + this._invalidIndex = Math.min(this._invalidIndex, insertionIndex); + } + this._pendingSpecialLinesToInsert = []; + const newDecorationIDToSpecialLine = new ArrayMap(); + const newOrderedSpecialLines: CustomLine[] = []; + + for (let i = 0; i < this._invalidIndex; i++) { + const customLine = this._orderedCustomLines[i]; + newOrderedSpecialLines.push(customLine); + newDecorationIDToSpecialLine.add(customLine.decorationId, customLine); + } + + let numberOfDeletions = 0; + let previousSpecialLine: CustomLine | undefined = (this._invalidIndex > 0) ? newOrderedSpecialLines[this._invalidIndex - 1] : undefined; + for (let i = this._invalidIndex; i < this._orderedCustomLines.length; i++) { + const customLine = this._orderedCustomLines[i]; + if (customLine.deleted) { + numberOfDeletions++; + continue; + } + customLine.index = i - numberOfDeletions; + if (previousSpecialLine && previousSpecialLine.lineNumber === customLine.lineNumber) { + customLine.maximumSpecialHeight = previousSpecialLine.maximumSpecialHeight; + customLine.prefixSum = previousSpecialLine.prefixSum; + } else { + let maximumSpecialHeight = customLine.specialHeight; + for (let j = i; j < this._orderedCustomLines.length; j++) { + const nextSpecialLine = this._orderedCustomLines[j]; + if (nextSpecialLine.deleted) { + continue; + } + if (nextSpecialLine.lineNumber !== customLine.lineNumber) { + break; + } + maximumSpecialHeight = Math.max(maximumSpecialHeight, nextSpecialLine.specialHeight); + } + customLine.maximumSpecialHeight = maximumSpecialHeight; + + let prefixSum: number; + if (previousSpecialLine) { + prefixSum = previousSpecialLine.prefixSum + previousSpecialLine.maximumSpecialHeight + this._defaultLineHeight * (customLine.lineNumber - previousSpecialLine.lineNumber - 1); + } else { + prefixSum = this._defaultLineHeight * (customLine.lineNumber - 1); + } + customLine.prefixSum = prefixSum; + } + previousSpecialLine = customLine; + newOrderedSpecialLines.push(customLine); + newDecorationIDToSpecialLine.add(customLine.decorationId, customLine); + } + this._orderedCustomLines = newOrderedSpecialLines; + this._decorationIDToCustomLine = newDecorationIDToSpecialLine; + this._invalidIndex = Infinity; + this._hasPending = false; + } + + private _binarySearchOverOrderedCustomLinesArray(lineNumber: number): number { + return binarySearch2(this._orderedCustomLines.length, (index) => { + const line = this._orderedCustomLines[index]; + if (line.lineNumber === lineNumber) { + return 0; + } else if (line.lineNumber < lineNumber) { + return -1; + } else { + return 1; + } + }); + } +} + +export interface ICustomLineHeightData { + readonly decorationId: string; + readonly startLineNumber: number; + readonly endLineNumber: number; + readonly lineHeight: number; +} + +class ArrayMap { + + private _map: Map = new Map(); + + constructor() { } + + add(key: K, value: T) { + const array = this._map.get(key); + if (!array) { + this._map.set(key, [value]); + } else { + array.push(value); + } + } + + get(key: K): T[] | undefined { + return this._map.get(key); + } + + delete(key: K): void { + this._map.delete(key); + } +} diff --git a/src/vs/editor/common/viewLayout/linesLayout.ts b/src/vs/editor/common/viewLayout/linesLayout.ts index f7988abddce..b773e5dd4cb 100644 --- a/src/vs/editor/common/viewLayout/linesLayout.ts +++ b/src/vs/editor/common/viewLayout/linesLayout.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IEditorWhitespace, IPartialViewLinesViewportData, IViewWhitespaceViewportData, IWhitespaceChangeAccessor } from '../viewModel.js'; +import { IEditorWhitespace, IPartialViewLinesViewportData, ILineHeightChangeAccessor, IViewWhitespaceViewportData, IWhitespaceChangeAccessor } from '../viewModel.js'; import * as strings from '../../../base/common/strings.js'; +import { ICustomLineHeightData, LineHeightsManager } from './lineHeights.js'; interface IPendingChange { id: string; newAfterLineNumber: number; newHeight: number } interface IPendingRemove { id: string } @@ -37,10 +38,6 @@ class PendingChanges { this._removes.push(x); } - public mustCommit(): boolean { - return this._hasPending; - } - public commit(linesLayout: LinesLayout): void { if (!this._hasPending) { return; @@ -94,11 +91,11 @@ export class LinesLayout { private _prefixSumValidIndex: number; private _minWidth: number; private _lineCount: number; - private _lineHeight: number; private _paddingTop: number; private _paddingBottom: number; + private _lineHeightsManager: LineHeightsManager; - constructor(lineCount: number, lineHeight: number, paddingTop: number, paddingBottom: number) { + constructor(lineCount: number, defaultLineHeight: number, paddingTop: number, paddingBottom: number, customLineHeightData: ICustomLineHeightData[]) { this._instanceId = strings.singleLetterHash(++LinesLayout.INSTANCE_COUNT); this._pendingChanges = new PendingChanges(); this._lastWhitespaceId = 0; @@ -106,9 +103,9 @@ export class LinesLayout { this._prefixSumValidIndex = -1; this._minWidth = -1; /* marker for not being computed */ this._lineCount = lineCount; - this._lineHeight = lineHeight; this._paddingTop = paddingTop; this._paddingBottom = paddingBottom; + this._lineHeightsManager = new LineHeightsManager(defaultLineHeight, customLineHeightData); } /** @@ -141,9 +138,8 @@ export class LinesLayout { /** * Change the height of a line in pixels. */ - public setLineHeight(lineHeight: number): void { - this._checkPendingChanges(); - this._lineHeight = lineHeight; + public setDefaultLineHeight(lineHeight: number): void { + this._lineHeightsManager.defaultLineHeight = lineHeight; } /** @@ -159,9 +155,29 @@ export class LinesLayout { * * @param lineCount New number of lines. */ - public onFlushed(lineCount: number): void { - this._checkPendingChanges(); + public onFlushed(lineCount: number, customLineHeightData: ICustomLineHeightData[]): void { this._lineCount = lineCount; + this._lineHeightsManager = new LineHeightsManager(this._lineHeightsManager.defaultLineHeight, customLineHeightData); + } + + public changeLineHeights(callback: (accessor: ILineHeightChangeAccessor) => void): boolean { + let hadAChange = false; + try { + const accessor: ILineHeightChangeAccessor = { + insertOrChangeCustomLineHeight: (decorationId: string, startLineNumber: number, endLineNumber: number, lineHeight: number): void => { + hadAChange = true; + this._lineHeightsManager.insertOrChangeCustomLineHeight(decorationId, startLineNumber, endLineNumber, lineHeight); + }, + removeCustomLineHeight: (decorationId: string): void => { + hadAChange = true; + this._lineHeightsManager.removeCustomLineHeight(decorationId); + } + }; + callback(accessor); + } finally { + this._lineHeightsManager.commit(); + } + return hadAChange; } public changeWhitespace(callback: (accessor: IWhitespaceChangeAccessor) => void): boolean { @@ -259,12 +275,6 @@ export class LinesLayout { this._prefixSumValidIndex = -1; } - private _checkPendingChanges(): void { - if (this._pendingChanges.mustCommit()) { - this._pendingChanges.commit(this); - } - } - private _insertWhitespace(whitespace: EditorWhitespace): void { const insertIndex = LinesLayout.findInsertionIndex(this._arr, whitespace.afterLineNumber, whitespace.ordinal); this._arr.splice(insertIndex, 0, whitespace); @@ -318,7 +328,6 @@ export class LinesLayout { * @param toLineNumber The line number at which the deletion ended, inclusive */ public onLinesDeleted(fromLineNumber: number, toLineNumber: number): void { - this._checkPendingChanges(); fromLineNumber = fromLineNumber | 0; toLineNumber = toLineNumber | 0; @@ -336,6 +345,7 @@ export class LinesLayout { this._arr[i].afterLineNumber -= (toLineNumber - fromLineNumber + 1); } } + this._lineHeightsManager.onLinesDeleted(fromLineNumber, toLineNumber); } /** @@ -345,7 +355,6 @@ export class LinesLayout { * @param toLineNumber The line number at which the insertion ended, inclusive. */ public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { - this._checkPendingChanges(); fromLineNumber = fromLineNumber | 0; toLineNumber = toLineNumber | 0; @@ -357,13 +366,13 @@ export class LinesLayout { this._arr[i].afterLineNumber += (toLineNumber - fromLineNumber + 1); } } + this._lineHeightsManager.onLinesInserted(fromLineNumber, toLineNumber); } /** * Get the sum of all the whitespaces. */ public getWhitespacesTotalHeight(): number { - this._checkPendingChanges(); if (this._arr.length === 0) { return 0; } @@ -378,7 +387,6 @@ export class LinesLayout { * @return The sum of the heights of all whitespaces before the one at `index`, including the one at `index`. */ public getWhitespacesAccumulatedHeight(index: number): number { - this._checkPendingChanges(); index = index | 0; let startIndex = Math.max(0, this._prefixSumValidIndex + 1); @@ -400,8 +408,7 @@ export class LinesLayout { * @return The sum of heights for all objects. */ public getLinesTotalHeight(): number { - this._checkPendingChanges(); - const linesHeight = this._lineHeight * this._lineCount; + const linesHeight = this._lineHeightsManager.getAccumulatedLineHeightsIncludingLineNumber(this._lineCount); const whitespacesHeight = this.getWhitespacesTotalHeight(); return linesHeight + whitespacesHeight + this._paddingTop + this._paddingBottom; @@ -413,7 +420,6 @@ export class LinesLayout { * @param lineNumber The line number */ public getWhitespaceAccumulatedHeightBeforeLineNumber(lineNumber: number): number { - this._checkPendingChanges(); lineNumber = lineNumber | 0; const lastWhitespaceBeforeLineNumber = this._findLastWhitespaceBeforeLineNumber(lineNumber); @@ -470,7 +476,6 @@ export class LinesLayout { * @return The index of the first whitespace with `afterLineNumber` >= `lineNumber` or -1 if no whitespace is found. */ public getFirstWhitespaceIndexAfterLineNumber(lineNumber: number): number { - this._checkPendingChanges(); lineNumber = lineNumber | 0; return this._findFirstWhitespaceAfterLineNumber(lineNumber); @@ -483,12 +488,11 @@ export class LinesLayout { * @return The sum of heights for all objects above `lineNumber`. */ public getVerticalOffsetForLineNumber(lineNumber: number, includeViewZones = false): number { - this._checkPendingChanges(); lineNumber = lineNumber | 0; let previousLinesHeight: number; if (lineNumber > 1) { - previousLinesHeight = this._lineHeight * (lineNumber - 1); + previousLinesHeight = this._lineHeightsManager.getAccumulatedLineHeightsIncludingLineNumber(lineNumber - 1); } else { previousLinesHeight = 0; } @@ -498,16 +502,19 @@ export class LinesLayout { return previousLinesHeight + previousWhitespacesHeight + this._paddingTop; } + public getLineHeightForLineNumber(lineNumber: number): number { + return this._lineHeightsManager.heightForLineNumber(lineNumber); + } + /** - * Get the vertical offset (the sum of heights for all objects above) a certain line number. + * Get the vertical offset (the sum of heights for all objects above) a certain line number and also the line height of the line. * * @param lineNumber The line number * @return The sum of heights for all objects above `lineNumber`. */ public getVerticalOffsetAfterLineNumber(lineNumber: number, includeViewZones = false): number { - this._checkPendingChanges(); lineNumber = lineNumber | 0; - const previousLinesHeight = this._lineHeight * lineNumber; + const previousLinesHeight = this._lineHeightsManager.getAccumulatedLineHeightsIncludingLineNumber(lineNumber); const previousWhitespacesHeight = this.getWhitespaceAccumulatedHeightBeforeLineNumber(lineNumber + (includeViewZones ? 1 : 0)); return previousLinesHeight + previousWhitespacesHeight + this._paddingTop; } @@ -516,7 +523,6 @@ export class LinesLayout { * Returns if there is any whitespace in the document. */ public hasWhitespace(): boolean { - this._checkPendingChanges(); return this.getWhitespacesCount() > 0; } @@ -524,7 +530,6 @@ export class LinesLayout { * The maximum min width for all whitespaces. */ public getWhitespaceMinWidth(): number { - this._checkPendingChanges(); if (this._minWidth === -1) { let minWidth = 0; for (let i = 0, len = this._arr.length; i < len; i++) { @@ -539,7 +544,6 @@ export class LinesLayout { * Check if `verticalOffset` is below all lines. */ public isAfterLines(verticalOffset: number): boolean { - this._checkPendingChanges(); const totalHeight = this.getLinesTotalHeight(); return verticalOffset > totalHeight; } @@ -548,7 +552,6 @@ export class LinesLayout { if (this._paddingTop === 0) { return false; } - this._checkPendingChanges(); return (verticalOffset < this._paddingTop); } @@ -556,7 +559,6 @@ export class LinesLayout { if (this._paddingBottom === 0) { return false; } - this._checkPendingChanges(); const totalHeight = this.getLinesTotalHeight(); return (verticalOffset >= totalHeight - this._paddingBottom); } @@ -570,7 +572,6 @@ export class LinesLayout { * @return The line number at or after vertical offset `verticalOffset`. */ public getLineNumberAtOrAfterVerticalOffset(verticalOffset: number): number { - this._checkPendingChanges(); verticalOffset = verticalOffset | 0; if (verticalOffset < 0) { @@ -578,13 +579,13 @@ export class LinesLayout { } const linesCount = this._lineCount | 0; - const lineHeight = this._lineHeight; let minLineNumber = 1; let maxLineNumber = linesCount; while (minLineNumber < maxLineNumber) { const midLineNumber = ((minLineNumber + maxLineNumber) / 2) | 0; + const lineHeight = this.getLineHeightForLineNumber(midLineNumber); const midLineNumberVerticalOffset = this.getVerticalOffsetForLineNumber(midLineNumber) | 0; if (verticalOffset >= midLineNumberVerticalOffset + lineHeight) { @@ -614,10 +615,8 @@ export class LinesLayout { * @return A structure describing the lines positioned between `verticalOffset1` and `verticalOffset2`. */ public getLinesViewportData(verticalOffset1: number, verticalOffset2: number): IPartialViewLinesViewportData { - this._checkPendingChanges(); verticalOffset1 = verticalOffset1 | 0; verticalOffset2 = verticalOffset2 | 0; - const lineHeight = this._lineHeight; // Find first line number // We don't live in a perfect world, so the line number might start before or after verticalOffset1 @@ -650,7 +649,7 @@ export class LinesLayout { if (startLineNumberVerticalOffset >= STEP_SIZE) { // Compute a delta that guarantees that lines are positioned at `lineHeight` increments bigNumbersDelta = Math.floor(startLineNumberVerticalOffset / STEP_SIZE) * STEP_SIZE; - bigNumbersDelta = Math.floor(bigNumbersDelta / lineHeight) * lineHeight; + bigNumbersDelta = Math.floor(bigNumbersDelta / this._lineHeightsManager.defaultLineHeight) * this._lineHeightsManager.defaultLineHeight; currentLineRelativeOffset -= bigNumbersDelta; } @@ -662,7 +661,7 @@ export class LinesLayout { // Figure out how far the lines go for (let lineNumber = startLineNumber; lineNumber <= endLineNumber; lineNumber++) { - + const lineHeight = this.getLineHeightForLineNumber(lineNumber); if (centeredLineNumber === -1) { const currentLineTop = currentVerticalOffset; const currentLineBottom = currentVerticalOffset + lineHeight; @@ -715,7 +714,8 @@ export class LinesLayout { } } if (completelyVisibleStartLineNumber < completelyVisibleEndLineNumber) { - if (endLineNumberVerticalOffset + lineHeight > verticalOffset2) { + const endLineHeight = this.getLineHeightForLineNumber(endLineNumber); + if (endLineNumberVerticalOffset + endLineHeight > verticalOffset2) { completelyVisibleEndLineNumber--; } } @@ -728,19 +728,18 @@ export class LinesLayout { centeredLineNumber: centeredLineNumber, completelyVisibleStartLineNumber: completelyVisibleStartLineNumber, completelyVisibleEndLineNumber: completelyVisibleEndLineNumber, - lineHeight: this._lineHeight, + lineHeight: this._lineHeightsManager.defaultLineHeight, }; } public getVerticalOffsetForWhitespaceIndex(whitespaceIndex: number): number { - this._checkPendingChanges(); whitespaceIndex = whitespaceIndex | 0; const afterLineNumber = this.getAfterLineNumberForWhitespaceIndex(whitespaceIndex); let previousLinesHeight: number; if (afterLineNumber >= 1) { - previousLinesHeight = this._lineHeight * afterLineNumber; + previousLinesHeight = this._lineHeightsManager.getAccumulatedLineHeightsIncludingLineNumber(afterLineNumber); } else { previousLinesHeight = 0; } @@ -755,7 +754,6 @@ export class LinesLayout { } public getWhitespaceIndexAtOrAfterVerticallOffset(verticalOffset: number): number { - this._checkPendingChanges(); verticalOffset = verticalOffset | 0; let minWhitespaceIndex = 0; @@ -799,7 +797,6 @@ export class LinesLayout { * @return Precisely the whitespace that is layouted at `verticaloffset` or null. */ public getWhitespaceAtVerticalOffset(verticalOffset: number): IViewWhitespaceViewportData | null { - this._checkPendingChanges(); verticalOffset = verticalOffset | 0; const candidateIndex = this.getWhitespaceIndexAtOrAfterVerticallOffset(verticalOffset); @@ -838,7 +835,6 @@ export class LinesLayout { * @return An array with all the whitespaces in the viewport. If no whitespace is in viewport, the array is empty. */ public getWhitespaceViewportData(verticalOffset1: number, verticalOffset2: number): IViewWhitespaceViewportData[] { - this._checkPendingChanges(); verticalOffset1 = verticalOffset1 | 0; verticalOffset2 = verticalOffset2 | 0; @@ -872,7 +868,6 @@ export class LinesLayout { * Get all whitespaces. */ public getWhitespaces(): IEditorWhitespace[] { - this._checkPendingChanges(); return this._arr.slice(0); } @@ -880,7 +875,6 @@ export class LinesLayout { * The number of whitespaces. */ public getWhitespacesCount(): number { - this._checkPendingChanges(); return this._arr.length; } @@ -891,7 +885,6 @@ export class LinesLayout { * @return `id` of whitespace at `index`. */ public getIdForWhitespaceIndex(index: number): string { - this._checkPendingChanges(); index = index | 0; return this._arr[index].id; @@ -904,7 +897,6 @@ export class LinesLayout { * @return `afterLineNumber` of whitespace at `index`. */ public getAfterLineNumberForWhitespaceIndex(index: number): number { - this._checkPendingChanges(); index = index | 0; return this._arr[index].afterLineNumber; @@ -917,7 +909,6 @@ export class LinesLayout { * @return `height` of whitespace at `index`. */ public getHeightForWhitespaceIndex(index: number): number { - this._checkPendingChanges(); index = index | 0; return this._arr[index].height; diff --git a/src/vs/editor/common/viewLayout/viewLayout.ts b/src/vs/editor/common/viewLayout/viewLayout.ts index 767a308db9a..404a5823b04 100644 --- a/src/vs/editor/common/viewLayout/viewLayout.ts +++ b/src/vs/editor/common/viewLayout/viewLayout.ts @@ -10,8 +10,9 @@ import { ConfigurationChangedEvent, EditorOption } from '../config/editorOptions import { ScrollType } from '../editorCommon.js'; import { IEditorConfiguration } from '../config/editorConfiguration.js'; import { LinesLayout } from './linesLayout.js'; -import { IEditorWhitespace, IPartialViewLinesViewportData, IViewLayout, IViewWhitespaceViewportData, IWhitespaceChangeAccessor, Viewport } from '../viewModel.js'; +import { IEditorWhitespace, IPartialViewLinesViewportData, ILineHeightChangeAccessor, IViewLayout, IViewWhitespaceViewportData, IWhitespaceChangeAccessor, Viewport } from '../viewModel.js'; import { ContentSizeChangedEvent } from '../viewModelEventDispatcher.js'; +import { ICustomLineHeightData } from './lineHeights.js'; const SMOOTH_SCROLLING_TIME = 125; @@ -163,7 +164,7 @@ export class ViewLayout extends Disposable implements IViewLayout { public readonly onDidScroll: Event; public readonly onDidContentSizeChange: Event; - constructor(configuration: IEditorConfiguration, lineCount: number, scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable) { + constructor(configuration: IEditorConfiguration, lineCount: number, customLineHeightData: ICustomLineHeightData[], scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable) { super(); this._configuration = configuration; @@ -171,7 +172,7 @@ export class ViewLayout extends Disposable implements IViewLayout { const layoutInfo = options.get(EditorOption.layoutInfo); const padding = options.get(EditorOption.padding); - this._linesLayout = new LinesLayout(lineCount, options.get(EditorOption.lineHeight), padding.top, padding.bottom); + this._linesLayout = new LinesLayout(lineCount, options.get(EditorOption.lineHeight), padding.top, padding.bottom, customLineHeightData); this._maxLineWidth = 0; this._overlayWidgetsMinWidth = 0; @@ -211,7 +212,7 @@ export class ViewLayout extends Disposable implements IViewLayout { public onConfigurationChanged(e: ConfigurationChangedEvent): void { const options = this._configuration.options; if (e.hasChanged(EditorOption.lineHeight)) { - this._linesLayout.setLineHeight(options.get(EditorOption.lineHeight)); + this._linesLayout.setDefaultLineHeight(options.get(EditorOption.lineHeight)); } if (e.hasChanged(EditorOption.padding)) { const padding = options.get(EditorOption.padding); @@ -236,8 +237,8 @@ export class ViewLayout extends Disposable implements IViewLayout { this._configureSmoothScrollDuration(); } } - public onFlushed(lineCount: number): void { - this._linesLayout.onFlushed(lineCount); + public onFlushed(lineCount: number, customLineHeightData: ICustomLineHeightData[]): void { + this._linesLayout.onFlushed(lineCount, customLineHeightData); } public onLinesDeleted(fromLineNumber: number, toLineNumber: number): void { this._linesLayout.onLinesDeleted(fromLineNumber, toLineNumber); @@ -380,12 +381,24 @@ export class ViewLayout extends Disposable implements IViewLayout { } return hadAChange; } + + public changeSpecialLineHeights(callback: (accessor: ILineHeightChangeAccessor) => void): boolean { + const hadAChange = this._linesLayout.changeLineHeights(callback); + if (hadAChange) { + this.onHeightMaybeChanged(); + } + return hadAChange; + } + public getVerticalOffsetForLineNumber(lineNumber: number, includeViewZones: boolean = false): number { return this._linesLayout.getVerticalOffsetForLineNumber(lineNumber, includeViewZones); } public getVerticalOffsetAfterLineNumber(lineNumber: number, includeViewZones: boolean = false): number { return this._linesLayout.getVerticalOffsetAfterLineNumber(lineNumber, includeViewZones); } + public getLineHeightForLineNumber(lineNumber: number): number { + return this._linesLayout.getLineHeightForLineNumber(lineNumber); + } public isAfterLines(verticalOffset: number): boolean { return this._linesLayout.isAfterLines(verticalOffset); } diff --git a/src/vs/editor/common/viewModel.ts b/src/vs/editor/common/viewModel.ts index 1aefa04abeb..d9233c5c06c 100644 --- a/src/vs/editor/common/viewModel.ts +++ b/src/vs/editor/common/viewModel.ts @@ -133,6 +133,7 @@ export interface IViewLayout { getLineNumberAtVerticalOffset(verticalOffset: number): number; getVerticalOffsetForLineNumber(lineNumber: number, includeViewZones?: boolean): number; getVerticalOffsetAfterLineNumber(lineNumber: number, includeViewZones?: boolean): number; + getLineHeightForLineNumber(lineNumber: number): number; getWhitespaceAtVerticalOffset(verticalOffset: number): IViewWhitespaceViewportData | null; /** @@ -156,6 +157,11 @@ export interface IWhitespaceChangeAccessor { removeWhitespace(id: string): void; } +export interface ILineHeightChangeAccessor { + insertOrChangeCustomLineHeight(decorationId: string, startLineNumber: number, endLineNumber: number, lineHeight: number): void; + removeCustomLineHeight(decorationId: string): void; +} + export interface IPartialViewLinesViewportData { /** * Value to be substracted from `scrollTop` (in order to vertical offset numbers < 1MM) diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 8918d324ce7..09d842ecd35 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -34,12 +34,13 @@ import { ViewLayout } from '../viewLayout/viewLayout.js'; import { MinimapTokensColorTracker } from './minimapTokensColorTracker.js'; import { ILineBreaksComputer, ILineBreaksComputerFactory, InjectedText } from '../modelLineProjectionData.js'; import { ViewEventHandler } from '../viewEventHandler.js'; -import { ICoordinatesConverter, InlineDecoration, IViewModel, IWhitespaceChangeAccessor, MinimapLinesRenderingData, OverviewRulerDecorationsGroup, ViewLineData, ViewLineRenderingData, ViewModelDecoration } from '../viewModel.js'; +import { ICoordinatesConverter, InlineDecoration, ILineHeightChangeAccessor, IViewModel, IWhitespaceChangeAccessor, MinimapLinesRenderingData, OverviewRulerDecorationsGroup, ViewLineData, ViewLineRenderingData, ViewModelDecoration } from '../viewModel.js'; import { ViewModelDecorations } from './viewModelDecorations.js'; -import { FocusChangedEvent, HiddenAreasChangedEvent, ModelContentChangedEvent, ModelDecorationsChangedEvent, ModelLanguageChangedEvent, ModelLanguageConfigurationChangedEvent, ModelOptionsChangedEvent, ModelTokensChangedEvent, OutgoingViewModelEvent, ReadOnlyEditAttemptEvent, ScrollChangedEvent, ViewModelEventDispatcher, ViewModelEventsCollector, ViewZonesChangedEvent, WidgetFocusChangedEvent } from '../viewModelEventDispatcher.js'; +import { FocusChangedEvent, HiddenAreasChangedEvent, ModelContentChangedEvent, ModelDecorationsChangedEvent, ModelLanguageChangedEvent, ModelLanguageConfigurationChangedEvent, ModelLineHeightChangedEvent, ModelOptionsChangedEvent, ModelTokensChangedEvent, OutgoingViewModelEvent, ReadOnlyEditAttemptEvent, ScrollChangedEvent, ViewModelEventDispatcher, ViewModelEventsCollector, ViewZonesChangedEvent, WidgetFocusChangedEvent } from '../viewModelEventDispatcher.js'; import { IViewModelLines, ViewModelLinesFromModelAsIs, ViewModelLinesFromProjectedModel } from './viewModelLines.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { GlyphMarginLanesModel } from './glyphLanesModel.js'; +import { ICustomLineHeightData } from '../viewLayout/lineHeights.js'; const USE_IDENTITY_LINES_COLLECTION = true; @@ -116,7 +117,7 @@ export class ViewModel extends Disposable implements IViewModel { this._cursor = this._register(new CursorsController(model, this, this.coordinatesConverter, this.cursorConfig)); - this.viewLayout = this._register(new ViewLayout(this._configuration, this.getLineCount(), scheduleAtNextAnimationFrame)); + this.viewLayout = this._register(new ViewLayout(this._configuration, this.getLineCount(), this._getCustomLineHeights(), scheduleAtNextAnimationFrame)); this._register(this.viewLayout.onDidScroll((e) => { if (e.scrollTopChanged) { @@ -153,9 +154,9 @@ export class ViewModel extends Disposable implements IViewModel { this._eventDispatcher.emitSingleViewEvent(new viewEvents.ViewTokensColorsChangedEvent()); })); - this._register(this._themeService.onDidColorThemeChange((e) => { + this._register(this._themeService.onDidColorThemeChange((theme) => { this._invalidateDecorationsColorCache(); - this._eventDispatcher.emitSingleViewEvent(new viewEvents.ViewThemeChangedEvent(e.theme)); + this._eventDispatcher.emitSingleViewEvent(new viewEvents.ViewThemeChangedEvent(theme)); })); this._updateConfigurationViewLineCountNow(); @@ -183,6 +184,20 @@ export class ViewModel extends Disposable implements IViewModel { this._eventDispatcher.removeViewEventHandler(eventHandler); } + private _getCustomLineHeights(): ICustomLineHeightData[] { + const decorations = this.model.getCustomLineHeightsDecorations(this._editorId); + return decorations.map((d) => { + const lineNumber = d.range.startLineNumber; + const viewRange = this.coordinatesConverter.convertModelRangeToViewRange(new Range(lineNumber, 1, lineNumber, this.model.getLineMaxColumn(lineNumber))); + return { + decorationId: d.id, + startLineNumber: viewRange.startLineNumber, + endLineNumber: viewRange.endLineNumber, + lineHeight: d.options.lineHeight || 0 + }; + }); + } + private _updateConfigurationViewLineCountNow(): void { this._configuration.setViewLineCount(this._lines.getViewLineCount()); } @@ -254,7 +269,7 @@ export class ViewModel extends Disposable implements IViewModel { eventsCollector.emitViewEvent(new viewEvents.ViewDecorationsChangedEvent(null)); this._cursor.onLineMappingChanged(eventsCollector); this._decorations.onLineMappingChanged(); - this.viewLayout.onFlushed(this.getLineCount()); + this.viewLayout.onFlushed(this.getLineCount(), this._getCustomLineHeights()); this._updateConfigurationViewLineCount.schedule(); } @@ -327,7 +342,7 @@ export class ViewModel extends Disposable implements IViewModel { this._lines.onModelFlushed(); eventsCollector.emitViewEvent(new viewEvents.ViewFlushedEvent()); this._decorations.reset(); - this.viewLayout.onFlushed(this.getLineCount()); + this.viewLayout.onFlushed(this.getLineCount(), this._getCustomLineHeights()); hadOtherModelChange = true; break; } @@ -419,6 +434,28 @@ export class ViewModel extends Disposable implements IViewModel { this._handleVisibleLinesChanged(); })); + this._register(this.model.onDidChangeLineHeight((e) => { + const filteredChanges = e.changes.filter((change) => change.ownerId === this._editorId || change.ownerId === 0); + + this.viewLayout.changeSpecialLineHeights((accessor: ILineHeightChangeAccessor) => { + for (const change of filteredChanges) { + const { decorationId, lineNumber, lineHeight } = change; + const viewRange = this.coordinatesConverter.convertModelRangeToViewRange(new Range(lineNumber, 1, lineNumber, this.model.getLineMaxColumn(lineNumber))); + if (lineHeight !== null) { + accessor.insertOrChangeCustomLineHeight(decorationId, viewRange.startLineNumber, viewRange.endLineNumber, lineHeight); + } else { + accessor.removeCustomLineHeight(decorationId); + } + } + }); + + // recreate the model event using the filtered changes + if (filteredChanges.length > 0) { + const filteredEvent = new textModelEvents.ModelLineHeightChangedEvent(filteredChanges); + this._eventDispatcher.emitOutgoingEvent(new ModelLineHeightChangedEvent(filteredEvent)); + } + })); + this._register(this.model.onDidChangeTokens((e) => { const viewRanges: { fromLineNumber: number; toLineNumber: number }[] = []; for (let j = 0, lenJ = e.ranges.length; j < lenJ; j++) { @@ -457,7 +494,7 @@ export class ViewModel extends Disposable implements IViewModel { eventsCollector.emitViewEvent(new viewEvents.ViewDecorationsChangedEvent(null)); this._cursor.onLineMappingChanged(eventsCollector); this._decorations.onLineMappingChanged(); - this.viewLayout.onFlushed(this.getLineCount()); + this.viewLayout.onFlushed(this.getLineCount(), this._getCustomLineHeights()); } finally { this._eventDispatcher.endEmitViewEvents(); } @@ -506,7 +543,7 @@ export class ViewModel extends Disposable implements IViewModel { eventsCollector.emitViewEvent(new viewEvents.ViewDecorationsChangedEvent(null)); this._cursor.onLineMappingChanged(eventsCollector); this._decorations.onLineMappingChanged(); - this.viewLayout.onFlushed(this.getLineCount()); + this.viewLayout.onFlushed(this.getLineCount(), this._getCustomLineHeights()); this.viewLayout.onHeightMaybeChanged(); } diff --git a/src/vs/editor/common/viewModelEventDispatcher.ts b/src/vs/editor/common/viewModelEventDispatcher.ts index fc91875b319..81516adf725 100644 --- a/src/vs/editor/common/viewModelEventDispatcher.ts +++ b/src/vs/editor/common/viewModelEventDispatcher.ts @@ -10,7 +10,7 @@ import { Emitter } from '../../base/common/event.js'; import { Selection } from './core/selection.js'; import { Disposable } from '../../base/common/lifecycle.js'; import { CursorChangeReason } from './cursorEvents.js'; -import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent } from './textModelEvents.js'; +import { ModelLineHeightChangedEvent as OriginalModelLineHeightChangedEvent, IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent } from './textModelEvents.js'; export class ViewModelEventDispatcher extends Disposable { @@ -188,6 +188,7 @@ export type OutgoingViewModelEvent = ( | ModelContentChangedEvent | ModelOptionsChangedEvent | ModelTokensChangedEvent + | ModelLineHeightChangedEvent ); export const enum OutgoingViewModelEventKind { @@ -205,6 +206,7 @@ export const enum OutgoingViewModelEventKind { ModelContentChanged, ModelOptionsChanged, ModelTokensChanged, + ModelLineHeightChanged, } export class ContentSizeChangedEvent implements IContentSizeChangedEvent { @@ -553,3 +555,19 @@ export class ModelTokensChangedEvent { return null; } } + +export class ModelLineHeightChangedEvent { + public readonly kind = OutgoingViewModelEventKind.ModelLineHeightChanged; + + constructor( + public readonly event: OriginalModelLineHeightChangedEvent + ) { } + + public isNoOp(): boolean { + return false; + } + + public attemptToMerge(other: OutgoingViewModelEvent): OutgoingViewModelEvent | null { + return null; + } +} diff --git a/src/vs/editor/contrib/clipboard/browser/clipboard.ts b/src/vs/editor/contrib/clipboard/browser/clipboard.ts index aaf90905781..6a382131fce 100644 --- a/src/vs/editor/contrib/clipboard/browser/clipboard.ts +++ b/src/vs/editor/contrib/clipboard/browser/clipboard.ts @@ -4,15 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import * as browser from '../../../../base/browser/browser.js'; -import { getActiveDocument } from '../../../../base/browser/dom.js'; +import { getActiveDocument, getActiveWindow } from '../../../../base/browser/dom.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import * as platform from '../../../../base/common/platform.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; import * as nls from '../../../../nls.js'; import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { CopyOptions, InMemoryClipboardMetadataManager } from '../../../browser/controller/editContext/clipboardUtils.js'; import { NativeEditContextRegistry } from '../../../browser/controller/editContext/native/nativeEditContextRegistry.js'; import { ICodeEditor } from '../../../browser/editorBrowser.js'; @@ -229,34 +232,46 @@ if (PasteAction) { PasteAction.addImplementation(10000, 'code-editor', (accessor: ServicesAccessor, args: any) => { const codeEditorService = accessor.get(ICodeEditorService); const clipboardService = accessor.get(IClipboardService); + const telemetryService = accessor.get(ITelemetryService); + const productService = accessor.get(IProductService); // Only if editor text focus (i.e. not if editor has widget focus). const focusedEditor = codeEditorService.getFocusedCodeEditor(); if (focusedEditor && focusedEditor.hasModel() && focusedEditor.hasTextFocus()) { // execCommand(paste) does not work with edit context - let result: boolean; const experimentalEditContextEnabled = focusedEditor.getOption(EditorOption.effectiveExperimentalEditContextEnabled); if (experimentalEditContextEnabled) { - // Since we can not call execCommand('paste') on a dom node with edit context set - // we added a hidden text area that receives the paste execution - // see nativeEditContext.ts for more details const nativeEditContext = NativeEditContextRegistry.get(focusedEditor.getId()); if (nativeEditContext) { - const textArea = nativeEditContext.textArea; nativeEditContext.onWillPaste(); - textArea.focus(); - result = focusedEditor.getContainerDomNode().ownerDocument.execCommand('paste'); - textArea.domNode.textContent = ''; - nativeEditContext.domNode.focus(); - } else { - result = false; } - } else { - result = focusedEditor.getContainerDomNode().ownerDocument.execCommand('paste'); } - if (result) { - return CopyPasteController.get(focusedEditor)?.finishedPaste() ?? Promise.resolve(); - } else if (platform.isWeb) { + + const sw = StopWatch.create(true); + const triggerPaste = clipboardService.triggerPaste(getActiveWindow().vscodeWindowId); + if (triggerPaste) { + return triggerPaste.then(async () => { + + if (productService.quality !== 'stable') { + const duration = sw.elapsed(); + type EditorAsyncPasteClassification = { + duration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The duration of the paste operation.' }; + owner: 'aiday-mar'; + comment: 'Provides insight into the delay introduced by pasting async via keybindings.'; + }; + type EditorAsyncPasteEvent = { + duration: number; + }; + telemetryService.publicLog2( + 'editorAsyncPaste', + { duration } + ); + } + + return CopyPasteController.get(focusedEditor)?.finishedPaste() ?? Promise.resolve(); + }); + } + if (platform.isWeb) { // Use the clipboard service if document.execCommand('paste') was not successful return (async () => { const clipboardText = await clipboardService.readText(); @@ -286,8 +301,8 @@ if (PasteAction) { // 2. Paste: (default) handle case when focus is somewhere else. PasteAction.addImplementation(0, 'generic-dom', (accessor: ServicesAccessor, args: any) => { - getActiveDocument().execCommand('paste'); - return true; + const triggerPaste = accessor.get(IClipboardService).triggerPaste(getActiveWindow().vscodeWindowId); + return triggerPaste ?? false; }); } diff --git a/src/vs/editor/contrib/codeAction/browser/codeAction.ts b/src/vs/editor/contrib/codeAction/browser/codeAction.ts index c78ecc080d4..09a9c11a690 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeAction.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeAction.ts @@ -164,7 +164,9 @@ export async function getCodeActions( ...coalesce(actions.map(x => x.documentation)), ...getAdditionalDocumentationForShowingActions(registry, model, trigger, allActions) ]; - return new ManagedCodeActionSet(allActions, allDocumentation, disposables); + const managedCodeActionSet = new ManagedCodeActionSet(allActions, allDocumentation, disposables); + disposables.add(managedCodeActionSet); + return managedCodeActionSet; } catch (err) { disposables.dispose(); throw err; diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts index d5e249add65..63642ee0a04 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts @@ -232,6 +232,7 @@ export class CodeActionModel extends Disposable { const actions = createCancelablePromise(async token => { if (this._settingEnabledNearbyQuickfixes() && trigger.trigger.type === CodeActionTriggerType.Invoke && (trigger.trigger.triggerAction === CodeActionTriggerSource.QuickFix || trigger.trigger.filter?.include?.contains(CodeActionKind.QuickFix))) { const codeActionSet = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token); + this.codeActionsDisposable.value = codeActionSet; const allCodeActions = [...codeActionSet.allActions]; if (token.isCancellationRequested) { codeActionSet.dispose(); @@ -326,6 +327,7 @@ export class CodeActionModel extends Disposable { // Case for manual triggers - specifically Source Actions and Refactors if (trigger.trigger.type === CodeActionTriggerType.Invoke) { const codeActions = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token); + this.codeActionsDisposable.value = codeActions; return codeActions; } @@ -365,7 +367,7 @@ export class CodeActionModel extends Disposable { public trigger(trigger: CodeActionTrigger) { this._codeActionOracle.value?.trigger(trigger); - this.codeActionsDisposable.clear(); + this.codeActionsDisposable.dispose(); } private setState(newState: CodeActionsState.State, skipNotify?: boolean) { diff --git a/src/vs/editor/contrib/codelens/browser/codelens.ts b/src/vs/editor/contrib/codelens/browser/codelens.ts index be24cae08b0..78aa18c690f 100644 --- a/src/vs/editor/contrib/codelens/browser/codelens.ts +++ b/src/vs/editor/contrib/codelens/browser/codelens.ts @@ -22,6 +22,8 @@ export interface CodeLensItem { export class CodeLensModel { + static readonly Empty = new CodeLensModel(); + lenses: CodeLensItem[] = []; private _store: DisposableStore | undefined; @@ -67,6 +69,11 @@ export async function getCodeLensModel(registry: LanguageFeatureRegistry { // sort by lineNumber, provider-rank, and column if (a.symbol.range.startLineNumber < b.symbol.range.startLineNumber) { diff --git a/src/vs/editor/contrib/codelens/browser/codelensWidget.css b/src/vs/editor/contrib/codelens/browser/codelensWidget.css index ab6a97ab7bc..adc19b13de6 100644 --- a/src/vs/editor/contrib/codelens/browser/codelensWidget.css +++ b/src/vs/editor/contrib/codelens/browser/codelensWidget.css @@ -5,7 +5,8 @@ .monaco-editor .codelens-decoration { overflow: hidden; - display: inline-block; + display: inline-flex !important; /* !important to override inline display:block style */ + align-items: center; text-overflow: ellipsis; white-space: nowrap; color: var(--vscode-editorCodeLens-foreground); diff --git a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts index e6043ebbd33..b5a12f416cd 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts @@ -47,7 +47,7 @@ export class ColorDetector extends Disposable implements IEditorContribution { private readonly _ruleFactory: DynamicCssRules; - private readonly _decoratorLimitReporter = new DecoratorLimitReporter(); + private readonly _decoratorLimitReporter = this._register(new DecoratorLimitReporter()); constructor( private readonly _editor: ICodeEditor, @@ -269,8 +269,8 @@ export class ColorDetector extends Disposable implements IEditorContribution { } } -export class DecoratorLimitReporter { - private _onDidChange = new Emitter(); +export class DecoratorLimitReporter extends Disposable { + private _onDidChange = this._register(new Emitter()); public readonly onDidChange: Event = this._onDidChange.event; private _computed: number = 0; diff --git a/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerHeader.ts b/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerHeader.ts index 8d11bdf3ac7..8522f41e361 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerHeader.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerHeader.ts @@ -43,8 +43,8 @@ export class ColorPickerHeader extends Disposable { this._originalColorNode.style.backgroundColor = Color.Format.CSS.format(this.model.originalColor) || ''; this.backgroundColor = themeService.getColorTheme().getColor(editorHoverBackground) || Color.white; - this._register(themeService.onDidColorThemeChange(e => { - this.backgroundColor = e.theme.getColor(editorHoverBackground) || Color.white; + this._register(themeService.onDidColorThemeChange(theme => { + this.backgroundColor = theme.getColor(editorHoverBackground) || Color.white; })); this._register(dom.addDisposableListener(this._pickedColorNode, dom.EventType.CLICK, () => this.model.selectNextColorPresentation())); diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index 9e00a1d5d47..5ce76b9ccc8 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -29,7 +29,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IThemeService, themeColorFromId } from '../../../../platform/theme/common/themeService.js'; +import { themeColorFromId } from '../../../../platform/theme/common/themeService.js'; import { Selection } from '../../../common/core/selection.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { FindWidgetSearchHistory } from './findWidgetSearchHistory.js'; @@ -452,7 +452,6 @@ export class FindController extends CommonFindController implements IFindControl @IContextViewService private readonly _contextViewService: IContextViewService, @IContextKeyService _contextKeyService: IContextKeyService, @IKeybindingService private readonly _keybindingService: IKeybindingService, - @IThemeService private readonly _themeService: IThemeService, @INotificationService notificationService: INotificationService, @IStorageService _storageService: IStorageService, @IClipboardService clipboardService: IClipboardService, @@ -514,7 +513,7 @@ export class FindController extends CommonFindController implements IFindControl } private _createFindWidget() { - this._widget = this._register(new FindWidget(this._editor, this, this._state, this._contextViewService, this._keybindingService, this._contextKeyService, this._themeService, this._storageService, this._notificationService, this._hoverService, this._findWidgetSearchHistory, this._replaceWidgetHistory)); + this._widget = this._register(new FindWidget(this._editor, this, this._state, this._contextViewService, this._keybindingService, this._contextKeyService, this._hoverService, this._findWidgetSearchHistory, this._replaceWidgetHistory)); this._findOptionsWidget = this._register(new FindOptionsWidget(this._editor, this._state, this._keybindingService)); } diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index e60addcd084..24be44fddb5 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -33,11 +33,9 @@ import { ContextScopedFindInput, ContextScopedReplaceInput } from '../../../../p import { showHistoryKeybindingHint } from '../../../../platform/history/browser/historyWidgetKeybindingHint.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { asCssVariable, contrastBorder, editorFindMatchForeground, editorFindMatchHighlightBorder, editorFindMatchHighlightForeground, editorFindRangeHighlightBorder, inputActiveOptionBackground, inputActiveOptionBorder, inputActiveOptionForeground } from '../../../../platform/theme/common/colorRegistry.js'; import { registerIcon, widgetClose } from '../../../../platform/theme/common/iconRegistry.js'; -import { IThemeService, registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; +import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { isHighContrast } from '../../../../platform/theme/common/theme.js'; import { assertIsDefined } from '../../../../base/common/types.js'; @@ -87,7 +85,6 @@ let MAX_MATCHES_COUNT_WIDTH = 69; // let FIND_ALL_CONTROLS_WIDTH = 17/** Find Input margin-left */ + (MAX_MATCHES_COUNT_WIDTH + 3 + 1) /** Match Results */ + 23 /** Button */ * 4 + 2/** sash */; const FIND_INPUT_AREA_HEIGHT = 33; // The height of Find Widget when Replace Input is not visible. -const ctrlEnterReplaceAllWarningPromptedKey = 'ctrlEnterReplaceAll.windows.donotask'; const ctrlKeyMod = (platform.isMacintosh ? KeyMod.WinCtrl : KeyMod.CtrlCmd); export class FindWidgetViewZone implements IViewZone { @@ -130,8 +127,6 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private readonly _contextViewProvider: IContextViewProvider; private readonly _keybindingService: IKeybindingService; private readonly _contextKeyService: IContextKeyService; - private readonly _storageService: IStorageService; - private readonly _notificationService: INotificationService; private _domNode!: HTMLElement; private _cachedHeight: number | null = null; @@ -150,7 +145,6 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private _isVisible: boolean; private _isReplaceVisible: boolean; private _ignoreChangeEvent: boolean; - private _ctrlEnterReplaceAllWarningPrompted: boolean; private readonly _findFocusTracker: dom.IFocusTracker; private readonly _findInputFocused: IContextKey; @@ -170,9 +164,6 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL contextViewProvider: IContextViewProvider, keybindingService: IKeybindingService, contextKeyService: IContextKeyService, - themeService: IThemeService, - storageService: IStorageService, - notificationService: INotificationService, private readonly _hoverService: IHoverService, private readonly _findWidgetSearchHistory: IHistory | undefined, private readonly _replaceWidgetHistory: IHistory | undefined, @@ -184,10 +175,6 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._contextViewProvider = contextViewProvider; this._keybindingService = keybindingService; this._contextKeyService = contextKeyService; - this._storageService = storageService; - this._notificationService = notificationService; - - this._ctrlEnterReplaceAllWarningPrompted = !!storageService.getBoolean(ctrlEnterReplaceAllWarningPromptedKey, StorageScope.PROFILE); this._isVisible = false; this._isReplaceVisible = false; @@ -879,17 +866,6 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL e.preventDefault(); return; } else { - if (platform.isWindows && platform.isNative && !this._ctrlEnterReplaceAllWarningPrompted) { - // this is the first time when users press Ctrl + Enter to replace all - this._notificationService.info( - nls.localize('ctrlEnter.keybindingChanged', - 'Ctrl+Enter now inserts line break instead of replacing all. You can modify the keybinding for editor.action.replaceAll to override this behavior.') - ); - - this._ctrlEnterReplaceAllWarningPrompted = true; - this._storageService.store(ctrlEnterReplaceAllWarningPromptedKey, true, StorageScope.PROFILE, StorageTarget.USER); - } - this._replaceInput.inputBox.insertAtCursor('\n'); e.preventDefault(); return; diff --git a/src/vs/editor/contrib/folding/browser/folding.ts b/src/vs/editor/contrib/folding/browser/folding.ts index dedd1ce7964..edbc626e7b0 100644 --- a/src/vs/editor/contrib/folding/browser/folding.ts +++ b/src/vs/editor/contrib/folding/browser/folding.ts @@ -124,7 +124,7 @@ export class FoldingController extends Disposable implements IEditorContribution super(); this.editor = editor; - this._foldingLimitReporter = new RangesLimitReporter(editor); + this._foldingLimitReporter = this._register(new RangesLimitReporter(editor)); const options = this.editor.getOptions(); this._isEnabled = options.get(EditorOption.folding); @@ -513,15 +513,16 @@ export class FoldingController extends Disposable implements IEditorContribution } } -export class RangesLimitReporter implements FoldingLimitReporter { +export class RangesLimitReporter extends Disposable implements FoldingLimitReporter { constructor(private readonly editor: ICodeEditor) { + super(); } public get limit() { return this.editor.getOptions().get(EditorOption.foldingMaximumRegions); } - private _onDidChange = new Emitter(); + private _onDidChange = this._register(new Emitter()); public readonly onDidChange: Event = this._onDidChange.event; private _computed: number = 0; diff --git a/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts b/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts index d8828c06915..3d2538512e7 100644 --- a/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts +++ b/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts @@ -28,7 +28,7 @@ import { IMarker, IRelatedInformation, MarkerSeverity } from '../../../../platfo import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { contrastBorder, editorBackground, editorErrorBorder, editorErrorForeground, editorInfoBorder, editorInfoForeground, editorWarningBorder, editorWarningForeground, oneOf, registerColor, transparent } from '../../../../platform/theme/common/colorRegistry.js'; -import { IColorTheme, IThemeChangeEvent, IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js'; class MessageWidget { @@ -255,15 +255,11 @@ export class MarkerNavigationWidget extends PeekViewWidget { this._backgroundColor = Color.white; this._applyTheme(_themeService.getColorTheme()); - this._callOnDispose.add(_themeService.onDidColorThemeChange(this._ondDidColorThemeChange.bind(this))); + this._callOnDispose.add(_themeService.onDidColorThemeChange(this._applyTheme.bind(this))); this.create(); } - private _ondDidColorThemeChange(e: IThemeChangeEvent) { - this._applyTheme(e.theme); - } - private _applyTheme(theme: IColorTheme) { this._backgroundColor = theme.getColor(editorMarkerNavigationBackground); let colorId = editorMarkerNavigationError; diff --git a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts index 6c7da6476db..9581b5cf6ff 100644 --- a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts +++ b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts @@ -32,7 +32,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IWorkbenchAsyncDataTreeOptions, WorkbenchAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; -import { IColorTheme, IThemeChangeEvent, IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { IColorTheme, IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { FileReferences, OneReference, ReferencesModel } from '../referencesModel.js'; import { ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js'; import { DataTransfers, IDragAndDropData } from '../../../../../base/browser/dnd.js'; @@ -276,7 +276,7 @@ export class ReferenceWidget extends peekView.PeekViewWidget { super(editor, { showFrame: false, showArrow: true, isResizeable: true, isAccessible: true, supportOnTitleClick: true }, _instantiationService); this._applyTheme(themeService.getColorTheme()); - this._callOnDispose.add(themeService.onDidColorThemeChange(this._onDidColorThemeChange.bind(this))); + this._callOnDispose.add(themeService.onDidColorThemeChange(this._applyTheme.bind(this))); this._peekViewService.addExclusiveWidget(editor, this); this.create(); } @@ -298,10 +298,6 @@ export class ReferenceWidget extends peekView.PeekViewWidget { super.dispose(); } - private _onDidColorThemeChange(e: IThemeChangeEvent): void { - this._applyTheme(e.theme); - } - private _applyTheme(theme: IColorTheme) { const borderColor = theme.getColor(peekView.peekViewBorder) || Color.transparent; this.style({ diff --git a/src/vs/editor/contrib/gotoSymbol/browser/symbolNavigation.ts b/src/vs/editor/contrib/gotoSymbol/browser/symbolNavigation.ts index e02392bdad9..c1cd8f14db4 100644 --- a/src/vs/editor/contrib/gotoSymbol/browser/symbolNavigation.ts +++ b/src/vs/editor/contrib/gotoSymbol/browser/symbolNavigation.ts @@ -19,7 +19,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { createDecorator, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { INotificationService, IStatusHandle } from '../../../../platform/notification/common/notification.js'; export const ctxHasSymbols = new RawContextKey('hasSymbols', false, localize('hasSymbols', "Whether there are symbol locations that can be navigated via keyboard-only.")); @@ -41,7 +41,7 @@ class SymbolNavigationService implements ISymbolNavigationService { private _currentModel?: ReferencesModel = undefined; private _currentIdx: number = -1; private _currentState?: IDisposable; - private _currentMessage?: IDisposable; + private _currentMessage?: IStatusHandle; private _ignoreEditorChange: boolean = false; constructor( @@ -56,7 +56,7 @@ class SymbolNavigationService implements ISymbolNavigationService { reset(): void { this._ctxHasSymbols.reset(); this._currentState?.dispose(); - this._currentMessage?.dispose(); + this._currentMessage?.close(); this._currentModel = undefined; this._currentIdx = -1; } @@ -138,7 +138,7 @@ class SymbolNavigationService implements ISymbolNavigationService { private _showMessage(): void { - this._currentMessage?.dispose(); + this._currentMessage?.close(); const kb = this._keybindingService.lookupKeybinding('editor.gotoNextSymbolFromResult'); const message = kb diff --git a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts index 03b585a99b1..39b2d68e438 100644 --- a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts @@ -312,7 +312,7 @@ class MarkdownRenderedHoverParts implements IRenderedHoverParts { markdownHover: MarkdownHover, onFinishedRendering: () => void ): IRenderedHoverPart { - const renderedMarkdownHover = renderMarkdownInContainer( + const renderedMarkdownHover = renderMarkdown( this._editor, markdownHover, this._languageService, @@ -484,18 +484,20 @@ export function renderMarkdownHovers( markdownHovers.sort(compareBy(hover => hover.ordinal, numberComparator)); const renderedHoverParts: IRenderedHoverPart[] = []; for (const markdownHover of markdownHovers) { - renderedHoverParts.push(renderMarkdownInContainer( + const renderedHoverPart = renderMarkdown( editor, markdownHover, languageService, openerService, context.onContentsChanged, - )); + ); + context.fragment.appendChild(renderedHoverPart.hoverElement); + renderedHoverParts.push(renderedHoverPart); } return new RenderedHoverParts(renderedHoverParts); } -function renderMarkdownInContainer( +function renderMarkdown( editor: ICodeEditor, markdownHover: MarkdownHover, languageService: ILanguageService, diff --git a/src/vs/editor/contrib/indentation/browser/indentation.ts b/src/vs/editor/contrib/indentation/browser/indentation.ts index e467efa9ac0..41e9b7fbd32 100644 --- a/src/vs/editor/contrib/indentation/browser/indentation.ts +++ b/src/vs/editor/contrib/indentation/browser/indentation.ts @@ -386,7 +386,7 @@ export class AutoIndentOnPaste implements IEditorContribution { this.callOnModel.clear(); // we are disabled - if (this.editor.getOption(EditorOption.autoIndent) < EditorAutoIndentStrategy.Full || this.editor.getOption(EditorOption.formatOnPaste)) { + if (this.editor.getOption(EditorOption.autoIndent) < EditorAutoIndentStrategy.Full || !this.editor.getOption(EditorOption.formatOnPaste)) { return; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts index cb1b72e2125..093d6c31ae1 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts @@ -139,7 +139,7 @@ export class AcceptNextWordOfInlineCompletion extends EditorAction { public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { const controller = InlineCompletionsController.get(editor); - await controller?.model.get()?.acceptNextWord(controller.editor); + await controller?.model.get()?.acceptNextWord(); } } @@ -163,7 +163,7 @@ export class AcceptNextLineOfInlineCompletion extends EditorAction { public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { const controller = InlineCompletionsController.get(editor); - await controller?.model.get()?.acceptNextLine(controller.editor); + await controller?.model.get()?.acceptNextLine(); } } @@ -261,25 +261,6 @@ export class JumpToNextInlineEdit extends EditorAction { } } -export class AcceptNextInlineEditPart extends EditorAction { - constructor() { - super({ - id: 'editor.action.inlineSuggest.acceptNextInlineEditPart', - label: nls.localize2('action.inlineSuggest.acceptNextInlineEditPart', "Accept Next Inline Edit Part"), - precondition: ContextKeyExpr.and(EditorContextKeys.writable, InlineCompletionContextKeys.inlineEditVisible), - kbOpts: { - weight: KeybindingWeight.EditorContrib + 1, - kbExpr: ContextKeyExpr.and(EditorContextKeys.writable, InlineCompletionContextKeys.inlineEditVisible), - }, - }); - } - - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - const controller = InlineCompletionsController.get(editor); - await controller?.model.get()?.acceptNextInlineEditPart(controller.editor); - } -} - export class HideInlineCompletion extends EditorAction { public static ID = hideInlineCompletionId; @@ -368,7 +349,7 @@ export class DevExtractReproSample extends EditorAction { id: 'editor.action.inlineSuggest.dev.extractRepro', label: nls.localize('action.inlineSuggest.dev.extractRepro', "Developer: Extract Inline Suggest State"), alias: 'Developer: Inline Suggest Extract Repro', - precondition: InlineCompletionContextKeys.inlineEditVisible, + precondition: ContextKeyExpr.or(InlineCompletionContextKeys.inlineEditVisible, InlineCompletionContextKeys.inlineSuggestionVisible), }); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts index 1658944cc6d..e124b19e2b3 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts @@ -65,7 +65,7 @@ export class InlineCompletionsController extends Disposable { private readonly _suggestWidgetAdapter = this._register(new ObservableSuggestWidgetAdapter( this._editorObs, item => this.model.get()?.handleSuggestAccepted(item), - () => this.model.get()?.selectedInlineCompletion.get()?.toSingleTextEdit(undefined), + () => this.model.get()?.selectedInlineCompletion.get()?.getSingleTextEdit(), )); private readonly _enabledInConfig = observableFromEvent(this, this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineSuggest).enabled); @@ -215,7 +215,7 @@ export class InlineCompletionsController extends Disposable { const model = this.model.get(); if (!model) { return; } - if (model.state.get()?.inlineCompletion?.request.isExplicitRequest && model.inlineEditAvailable.get()) { + if (model.state.get()?.inlineCompletion?.isFromExplicitRequest && model.inlineEditAvailable.get()) { // dont hide inline edits on blur when requested explicitly return; } @@ -262,8 +262,8 @@ export class InlineCompletionsController extends Disposable { await timeout(50, cancelOnDispose(store)); await waitForState(this._suggestWidgetAdapter.selectedItem, isUndefined, () => false, cancelOnDispose(store)); + await this._accessibilitySignalService.playSignal(state.kind === 'ghostText' ? AccessibilitySignal.inlineSuggestion : AccessibilitySignal.nextEditSuggestion); - await this._accessibilitySignalService.playSignal(AccessibilitySignal.inlineSuggestion); if (this.editor.getOption(EditorOption.screenReaderAnnounceInlineSuggestion)) { if (state.kind === 'ghostText') { this._provideScreenReaderUpdate(state.primaryGhostText.renderForScreenReader(lineText)); @@ -285,7 +285,7 @@ export class InlineCompletionsController extends Disposable { this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.cursorInIndentation, this._cursorIsInIndentation)); this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.hasSelection, reader => !this._editorObs.cursorSelection.read(reader)?.isEmpty())); - this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.cursorAtInlineEdit, this.model.map((m, reader) => m?.inlineEditState?.read(reader)?.cursorAtInlineEdit))); + this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.cursorAtInlineEdit, this.model.map((m, reader) => m?.inlineEditState?.read(reader)?.cursorAtInlineEdit.read(reader)))); this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.tabShouldAcceptInlineEdit, this.model.map((m, r) => !!m?.tabShouldAcceptInlineEdit.read(r)))); this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.tabShouldJumpToInlineEdit, this.model.map((m, r) => !!m?.tabShouldJumpToInlineEdit.read(r)))); this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.inlineEditVisible, reader => this.model.read(reader)?.inlineEditState.read(reader) !== undefined)); @@ -298,7 +298,7 @@ export class InlineCompletionsController extends Disposable { this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.suppressSuggestions, reader => { const model = this.model.read(reader); const state = model?.inlineCompletionState.read(reader); - return state?.primaryGhostText && state?.inlineCompletion ? state.inlineCompletion.source.inlineCompletions.suppressSuggestions : undefined; + return state?.primaryGhostText && state?.inlineCompletion ? state.inlineCompletion.source.inlineSuggestions.suppressSuggestions : undefined; })); this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.inlineSuggestionVisible, reader => { const model = this.model.read(reader); @@ -350,4 +350,8 @@ export class InlineCompletionsController extends Disposable { m.jump(); } } + + public testOnlyDisableUi() { + this._view.dispose(); + } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.ts index 5bf486b8775..06a0b9d9204 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.ts @@ -8,7 +8,7 @@ import { registerAction2 } from '../../../../platform/actions/common/actions.js' import { wrapInHotClass1 } from '../../../../platform/observable/common/wrapInHotClass.js'; import { EditorContributionInstantiation, registerEditorAction, registerEditorCommand, registerEditorContribution } from '../../../browser/editorExtensions.js'; import { HoverParticipantRegistry } from '../../hover/browser/hoverTypes.js'; -import { AcceptInlineCompletion, AcceptNextLineOfInlineCompletion, AcceptNextWordOfInlineCompletion, DevExtractReproSample, HideInlineCompletion, JumpToNextInlineEdit, ShowNextInlineSuggestionAction, ShowPreviousInlineSuggestionAction, ToggleAlwaysShowInlineSuggestionToolbar, ExplicitTriggerInlineEditAction, TriggerInlineSuggestionAction, TriggerInlineEditAction, ToggleInlineCompletionShowCollapsed, AcceptNextInlineEditPart } from './controller/commands.js'; +import { AcceptInlineCompletion, AcceptNextLineOfInlineCompletion, AcceptNextWordOfInlineCompletion, DevExtractReproSample, HideInlineCompletion, JumpToNextInlineEdit, ShowNextInlineSuggestionAction, ShowPreviousInlineSuggestionAction, ToggleAlwaysShowInlineSuggestionToolbar, ExplicitTriggerInlineEditAction, TriggerInlineSuggestionAction, TriggerInlineEditAction, ToggleInlineCompletionShowCollapsed } from './controller/commands.js'; import { InlineCompletionsController } from './controller/inlineCompletionsController.js'; import { InlineCompletionsHoverParticipant } from './hintsWidget/hoverParticipant.js'; import { InlineCompletionsAccessibleView } from './inlineCompletionsAccessibleView.js'; @@ -29,7 +29,6 @@ registerEditorAction(AcceptNextLineOfInlineCompletion); registerEditorAction(AcceptInlineCompletion); registerEditorAction(ToggleInlineCompletionShowCollapsed); registerEditorAction(HideInlineCompletion); -registerEditorAction(AcceptNextInlineEditPart); registerEditorAction(JumpToNextInlineEdit); registerAction2(ToggleAlwaysShowInlineSuggestionToolbar); registerEditorAction(DevExtractReproSample); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsAccessibleView.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsAccessibleView.ts index 959a99ea366..a47deb89b84 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsAccessibleView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsAccessibleView.ts @@ -17,7 +17,6 @@ import { InlineCompletionsModel } from './model/inlineCompletionsModel.js'; import { TextEdit } from '../../../common/core/textEdit.js'; import { LineEdit } from '../../../common/core/lineEdit.js'; import { TextModelText } from '../../../common/model/textModelText.js'; -import { localize } from '../../../../nls.js'; export class InlineCompletionsAccessibleView implements IAccessibleViewImplementation { readonly type = AccessibleViewType.View; @@ -43,16 +42,17 @@ export class InlineCompletionsAccessibleView implements IAccessibleViewImplement class InlineCompletionsAccessibleViewContentProvider extends Disposable implements IAccessibleViewContentProvider { private readonly _onDidChangeContent: Emitter = this._register(new Emitter()); public readonly onDidChangeContent: Event = this._onDidChangeContent.event; + public readonly options: { language: string | undefined; type: AccessibleViewType.View }; constructor( private readonly _editor: ICodeEditor, private readonly _model: InlineCompletionsModel, ) { super(); + this.options = { language: this._editor.getModel()?.getLanguageId() ?? undefined, type: AccessibleViewType.View }; } public readonly id = AccessibleViewProviderId.InlineCompletions; public readonly verbositySettingKey = 'accessibility.verbosity.inlineCompletions'; - public readonly options = { language: this._editor.getModel()?.getLanguageId() ?? undefined, type: AccessibleViewType.View }; public provideContent(): string { const state = this._model.state.get(); @@ -70,7 +70,7 @@ class InlineCompletionsAccessibleViewContentProvider extends Disposable implemen } else { const text = new TextModelText(this._model.textModel); const lineEdit = LineEdit.fromTextEdit(new TextEdit(state.edits), text); - return localize('inlineEditAvailable', 'There is an inline edit available:') + '\n' + lineEdit.humanReadablePatch(text.getLines()); + return lineEdit.humanReadablePatch(text.getLines()); } } public provideNextContent(): string | undefined { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts index 91d80882aaf..34f134540a6 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts @@ -8,10 +8,42 @@ import { autorunWithStore } from '../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor } from '../../../../browser/editorBrowser.js'; import { CodeEditorWidget } from '../../../../browser/widget/codeEditor/codeEditorWidget.js'; -import { IRecordableEditorLogEntry, StructuredLogger } from '../structuredLogger.js'; +import { IDocumentEventDataSetChangeReason, IRecordableEditorLogEntry, StructuredLogger } from '../structuredLogger.js'; + +export interface ITextModelChangeRecorderMetadata { + source?: string; + extensionId?: string; + nes?: boolean; + type?: 'word' | 'line'; +} export class TextModelChangeRecorder extends Disposable { - private readonly _structuredLogger = this._register(this._instantiationService.createInstance(StructuredLogger.cast(), + private static _nextMetadataId = 0; + private static _metaDataMap = new Map(); + + /** + * Adds metadata to any edit operation made in the callback (sync). + */ + public static editWithMetadata(metadata: ITextModelChangeRecorderMetadata, cb: () => T): T { + const id = this._nextMetadataId++; + this._metaDataMap.set(id, metadata); + try { + const result = cb(); + return result; + } finally { + this._metaDataMap.delete(id); + } + } + + private static _getCurrentMetadata(): ITextModelChangeRecorderMetadata { + const result: ITextModelChangeRecorderMetadata = {}; + for (const metadata of this._metaDataMap.values()) { + Object.assign(result, metadata); + } + return result; + } + + private readonly _structuredLogger = this._register(this._instantiationService.createInstance(StructuredLogger.cast(), 'editor.inlineSuggest.logChangeReason.commandId' )); @@ -35,19 +67,28 @@ export class TextModelChangeRecorder extends Disposable { store.add(this._editor.onDidChangeModelContent(e => { const tm = this._editor.getModel(); if (!tm) { return; } + const metadata = TextModelChangeRecorder._getCurrentMetadata(); + if (sources.length === 0 && metadata.source) { + sources.push(metadata.source); + } + for (const source of sources) { - const data: IRecordableEditorLogEntry & { source: string } = { + const data: IRecordableEditorLogEntry & IDocumentEventDataSetChangeReason = { + ...metadata, sourceId: 'TextModel.setChangeReason', source: source, time: Date.now(), modelUri: tm.uri.toString(), modelVersion: tm.getVersionId(), }; - this._structuredLogger.log(data); + setTimeout(() => { + // To ensure that this reaches the extension host after the content change event. + // (Without the setTimeout, I observed this command being called before the content change event arrived) + this._structuredLogger.log(data); + }, 0); } sources.length = 0; })); - })); } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/ghostText.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/ghostText.ts index 700bc76bf64..26fd510b518 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/ghostText.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/ghostText.ts @@ -10,7 +10,7 @@ import { Range } from '../../../../common/core/range.js'; import { SingleTextEdit, TextEdit } from '../../../../common/core/textEdit.js'; import { LineDecoration } from '../../../../common/viewLayout/lineDecorations.js'; import { InlineDecoration } from '../../../../common/viewModel.js'; -import { ColumnRange } from '../utils.js'; +import { ColumnRange } from '../../../../common/core/columnRange.js'; export class GhostText { constructor( diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index cec6c1c6682..b92e6b7ba44 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -6,8 +6,9 @@ import { mapFindFirst } from '../../../../../base/common/arraysFind.js'; import { itemsEquals } from '../../../../../base/common/equals.js'; import { BugIndicatingError, onUnexpectedError, onUnexpectedExternalError } from '../../../../../base/common/errors.js'; +import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable, IObservableWithChange, IReader, ITransaction, autorun, constObservable, derived, derivedHandleChanges, derivedOpts, observableSignal, observableValue, recomputeInitiallyAndOnChange, subtransaction, transaction } from '../../../../../base/common/observable.js'; +import { IObservable, IObservableWithChange, IReader, ITransaction, autorun, autorunWithStore, constObservable, derived, derivedHandleChanges, derivedOpts, observableFromEvent, observableSignal, observableValue, recomputeInitiallyAndOnChange, subtransaction, transaction } from '../../../../../base/common/observable.js'; import { commonPrefixLength, firstNonWhitespaceIndex } from '../../../../../base/common/strings.js'; import { isDefined } from '../../../../../base/common/types.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; @@ -25,20 +26,22 @@ import { Selection } from '../../../../common/core/selection.js'; import { SingleTextEdit, TextEdit } from '../../../../common/core/textEdit.js'; import { TextLength } from '../../../../common/core/textLength.js'; import { ScrollType } from '../../../../common/editorCommon.js'; -import { Command, InlineCompletion, InlineCompletionContext, InlineCompletionTriggerKind, PartialAcceptTriggerKind } from '../../../../common/languages.js'; +import { Command, InlineCompletionEndOfLifeReasonKind, InlineCompletion, InlineCompletionContext, InlineCompletionTriggerKind, PartialAcceptTriggerKind, InlineCompletionsProvider } from '../../../../common/languages.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; import { EndOfLinePreference, IModelDeltaDecoration, ITextModel } from '../../../../common/model.js'; import { TextModelText } from '../../../../common/model/textModelText.js'; import { IFeatureDebounceInformation } from '../../../../common/services/languageFeatureDebounce.js'; +import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js'; import { IModelContentChangedEvent } from '../../../../common/textModelEvents.js'; import { SnippetController2 } from '../../../snippet/browser/snippetController2.js'; -import { addPositions, getEndPositionsAfterApplying, getModifiedRangesAfterApplying, substringPos, subtractPositions } from '../utils.js'; +import { addPositions, getEndPositionsAfterApplying, substringPos, subtractPositions } from '../utils.js'; import { AnimatedValue, easeOutCubic, ObservableAnimatedValue } from './animation.js'; +import { ITextModelChangeRecorderMetadata, TextModelChangeRecorder } from './changeRecorder.js'; import { computeGhostText } from './computeGhostText.js'; import { GhostText, GhostTextOrReplacement, ghostTextOrReplacementEquals, ghostTextsOrReplacementsEqual } from './ghostText.js'; -import { InlineCompletionWithUpdatedRange, InlineCompletionsSource } from './inlineCompletionsSource.js'; +import { InlineCompletionsSource } from './inlineCompletionsSource.js'; import { InlineEdit } from './inlineEdit.js'; -import { InlineCompletionItem } from './provideInlineCompletions.js'; +import { InlineCompletionItem, InlineEditItem, InlineSuggestionItem } from './inlineSuggestionItem.js'; import { singleTextEditAugments, singleTextRemoveCommonPrefix } from './singleTextEditHelpers.js'; import { SuggestItemInfo } from './suggestWidgetAdapter.js'; @@ -49,6 +52,8 @@ export class InlineCompletionsModel extends Disposable { private readonly _forceUpdateExplicitlySignal = observableSignal(this); private readonly _noDelaySignal = observableSignal(this); + private readonly _fetchSpecificProviderSignal = observableSignal(this); + // We use a semantic id to keep the same inline completion selected even if the provider reorders the completions. private readonly _selectedInlineCompletionId = observableValue(this, undefined); public readonly primaryPosition = derived(this, reader => this._positions.read(reader)[0] ?? new Position(1, 1)); @@ -56,6 +61,9 @@ export class InlineCompletionsModel extends Disposable { private _isAcceptingPartially = false; public get isAcceptingPartially() { return this._isAcceptingPartially; } + private readonly _onDidAccept = new Emitter(); + public readonly onDidAccept = this._onDidAccept.event; + private readonly _editorObs = observableCodeEditor(this._editor); private readonly _suggestPreviewEnabled = this._editorObs.getOption(EditorOption.suggest).map(v => v.preview); @@ -76,36 +84,18 @@ export class InlineCompletionsModel extends Disposable { @ICommandService private readonly _commandService: ICommandService, @ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, ) { super(); this._register(recomputeInitiallyAndOnChange(this._fetchInlineCompletionsPromise)); - let lastItem: InlineCompletionWithUpdatedRange | undefined = undefined; this._register(autorun(reader => { /** @description call handleItemDidShow */ const item = this.inlineCompletionState.read(reader); const completion = item?.inlineCompletion; - if (completion?.semanticId !== lastItem?.semanticId) { - lastItem = completion; - if (completion) { - const i = completion.inlineCompletion; - const src = i.source; - src.provider.handleItemDidShow?.(src.inlineCompletions, i.sourceInlineCompletion, i.insertText); - } - } - })); - this._register(autorun(reader => { - /** @description handle text edits collapsing */ - const inlineCompletions = this._source.inlineCompletions.read(reader); - if (!inlineCompletions) { - return; - } - for (const inlineCompletion of inlineCompletions.inlineCompletions) { - if (inlineCompletion.updatedEdit.read(reader) === undefined) { - this.stop(); - break; - } + if (completion) { + this.handleInlineSuggestionShown(completion); } })); @@ -115,7 +105,7 @@ export class InlineCompletionsModel extends Disposable { })); this._register(autorun(reader => { - const jumpToReset = this.state.map(s => !s || s.kind === 'inlineEdit' && !s.cursorAtInlineEdit).read(reader); + const jumpToReset = this.state.map((s, reader) => !s || s.kind === 'inlineEdit' && !s.cursorAtInlineEdit.read(reader)).read(reader); if (jumpToReset) { this._jumpedToId.set(undefined, undefined); } @@ -127,10 +117,69 @@ export class InlineCompletionsModel extends Disposable { const id = inlineEditSemanticId.read(reader); if (id) { this._editor.pushUndoStop(); + this._lastShownInlineCompletionInfo = { + alternateTextModelVersionId: this.textModel.getAlternativeVersionId(), + inlineCompletion: this.state.get()!.inlineCompletion!, + }; } })); + + const inlineCompletionProviders = observableFromEvent(this._languageFeaturesService.inlineCompletionsProvider.onDidChange, () => this._languageFeaturesService.inlineCompletionsProvider.all(textModel)); + this._register(autorunWithStore((reader, store) => { + const providers = inlineCompletionProviders.read(reader); + for (const provider of providers) { + if (!provider.onDidChangeInlineCompletions) { + continue; + } + + store.add(provider.onDidChangeInlineCompletions(() => { + if (!this._enabled.get()) { + return; + } + + // If there is an active suggestion from a different provider, we ignore the update + const activeState = this.state.get(); + if (activeState && (activeState.inlineCompletion || activeState.edits) && activeState.inlineCompletion?.source.provider !== provider) { + return; + } + + transaction(tx => { + this._fetchSpecificProviderSignal.trigger(tx, provider); + this.trigger(tx); + }); + + })); + } + })); + + this._didUndoInlineEdits.recomputeInitiallyAndOnChange(this._store); } + private _lastShownInlineCompletionInfo: { alternateTextModelVersionId: number; /* already freed! */ inlineCompletion: InlineSuggestionItem } | undefined = undefined; + private _lastAcceptedInlineCompletionInfo: { textModelVersionIdAfter: number; /* already freed! */ inlineCompletion: InlineSuggestionItem } | undefined = undefined; + private readonly _didUndoInlineEdits = derivedHandleChanges({ + owner: this, + changeTracker: { + createChangeSummary: () => ({ didUndo: false }), + handleChange: (ctx, changeSummary) => { + changeSummary.didUndo = ctx.didChange(this._textModelVersionId) && !!ctx.change?.isUndoing; + return true; + } + } + }, (reader, changeSummary) => { + const versionId = this._textModelVersionId.read(reader); + if (versionId !== null + && this._lastAcceptedInlineCompletionInfo + && this._lastAcceptedInlineCompletionInfo.textModelVersionIdAfter === versionId - 1 + && this._lastAcceptedInlineCompletionInfo.inlineCompletion.isInlineEdit + && changeSummary.didUndo + ) { + this._lastAcceptedInlineCompletionInfo = undefined; + return true; + } + return false; + }); + public debugGetSelectedSuggestItem(): IObservable { return this._selectedSuggestItem; } @@ -182,27 +231,30 @@ export class InlineCompletionsModel extends Disposable { private readonly _fetchInlineCompletionsPromise = derivedHandleChanges({ owner: this, - createEmptyChangeSummary: () => ({ - dontRefetch: false, - preserveCurrentCompletion: false, - inlineCompletionTriggerKind: InlineCompletionTriggerKind.Automatic, - onlyRequestInlineEdits: false, - shouldDebounce: true, - }), - handleChange: (ctx, changeSummary) => { - /** @description fetch inline completions */ - if (ctx.didChange(this._textModelVersionId) && this._preserveCurrentCompletionReasons.has(this._getReason(ctx.change))) { - changeSummary.preserveCurrentCompletion = true; - } else if (ctx.didChange(this._forceUpdateExplicitlySignal)) { - changeSummary.inlineCompletionTriggerKind = InlineCompletionTriggerKind.Explicit; - } else if (ctx.didChange(this.dontRefetchSignal)) { - changeSummary.dontRefetch = true; - } else if (ctx.didChange(this._onlyRequestInlineEditsSignal)) { - changeSummary.onlyRequestInlineEdits = true; - } else if (ctx.didChange(this._noDelaySignal)) { - changeSummary.shouldDebounce = false; - } - return true; + changeTracker: { + createChangeSummary: () => ({ + dontRefetch: false, + preserveCurrentCompletion: false, + inlineCompletionTriggerKind: InlineCompletionTriggerKind.Automatic, + onlyRequestInlineEdits: false, + shouldDebounce: true, + provider: undefined as InlineCompletionsProvider | undefined, + }), + handleChange: (ctx, changeSummary) => { + /** @description fetch inline completions */ + if (ctx.didChange(this._textModelVersionId) && this._preserveCurrentCompletionReasons.has(this._getReason(ctx.change))) { + changeSummary.preserveCurrentCompletion = true; + } else if (ctx.didChange(this._forceUpdateExplicitlySignal)) { + changeSummary.inlineCompletionTriggerKind = InlineCompletionTriggerKind.Explicit; + } else if (ctx.didChange(this.dontRefetchSignal)) { + changeSummary.dontRefetch = true; + } else if (ctx.didChange(this._onlyRequestInlineEditsSignal)) { + changeSummary.onlyRequestInlineEdits = true; + } else if (ctx.didChange(this._fetchSpecificProviderSignal)) { + changeSummary.provider = ctx.change; + } + return true; + }, }, }, (reader, changeSummary) => { this._source.clearOperationOnTextModelChange.read(reader); // Make sure the clear operation runs before the fetch operation @@ -210,6 +262,7 @@ export class InlineCompletionsModel extends Disposable { this.dontRefetchSignal.read(reader); this._onlyRequestInlineEditsSignal.read(reader); this._forceUpdateExplicitlySignal.read(reader); + this._fetchSpecificProviderSignal.read(reader); const shouldUpdate = (this._enabled.read(reader) && this._selectedSuggestItem.read(reader)) || this._isActive.read(reader); if (!shouldUpdate) { this._source.cancelUpdate(); @@ -221,14 +274,7 @@ export class InlineCompletionsModel extends Disposable { const suggestWidgetInlineCompletions = this._source.suggestWidgetInlineCompletions.get(); const suggestItem = this._selectedSuggestItem.read(reader); if (suggestWidgetInlineCompletions && !suggestItem) { - const inlineCompletions = this._source.inlineCompletions.get(); - transaction(tx => { - /** @description Seed inline completions with (newer) suggest widget inline completions */ - if (!inlineCompletions || suggestWidgetInlineCompletions.request.versionId > inlineCompletions.request.versionId) { - this._source.inlineCompletions.set(suggestWidgetInlineCompletions.clone(), tx); - } - this._source.clearSuggestWidgetInlineCompletions(tx); - }); + this._source.seedInlineCompletionsWithSuggestWidget(); } const cursorPosition = this.primaryPosition.get(); @@ -236,18 +282,40 @@ export class InlineCompletionsModel extends Disposable { return Promise.resolve(true); } - const context: InlineCompletionContext = { + if (this._didUndoInlineEdits.read(reader) && changeSummary.inlineCompletionTriggerKind !== InlineCompletionTriggerKind.Explicit) { + transaction(tx => { + this._source.clear(tx); + }); + return undefined; + } + + let context: InlineCompletionContext = { triggerKind: changeSummary.inlineCompletionTriggerKind, selectedSuggestionInfo: suggestItem?.toSelectedSuggestionInfo(), includeInlineCompletions: !changeSummary.onlyRequestInlineEdits, includeInlineEdits: this._inlineEditsEnabled.read(reader), }; + + if (context.triggerKind === InlineCompletionTriggerKind.Automatic) { + if (this.textModel.getAlternativeVersionId() === this._lastShownInlineCompletionInfo?.alternateTextModelVersionId) { + // When undoing back to a version where an inline edit/completion was shown, + // we want to show an inline edit (or completion) again if it was originally an inline edit (or completion). + context = { + ...context, + includeInlineCompletions: !this._lastShownInlineCompletionInfo.inlineCompletion.isInlineEdit, + includeInlineEdits: this._lastShownInlineCompletionInfo.inlineCompletion.isInlineEdit, + }; + } + } + const itemToPreserveCandidate = this.selectedInlineCompletion.get() ?? this._inlineCompletionItems.get()?.inlineEdit; const itemToPreserve = changeSummary.preserveCurrentCompletion || itemToPreserveCandidate?.forwardStable ? itemToPreserveCandidate : undefined; const userJumpedToActiveCompletion = this._jumpedToId.map(jumpedTo => !!jumpedTo && jumpedTo === this._inlineCompletionItems.get()?.inlineEdit?.semanticId); - return this._source.fetch(cursorPosition, context, itemToPreserve, changeSummary.shouldDebounce, userJumpedToActiveCompletion); + const providers = changeSummary.provider ? [changeSummary.provider] : this._languageFeaturesService.inlineCompletionsProvider.all(this.textModel); + + return this._source.fetch(providers, cursorPosition, context, itemToPreserve?.identity, changeSummary.shouldDebounce, userJumpedToActiveCompletion, !!changeSummary.provider); }); public async trigger(tx?: ITransaction, options?: { onlyFetchInlineEdits?: boolean; noDelay?: boolean }): Promise { @@ -279,14 +347,11 @@ export class InlineCompletionsModel extends Disposable { subtransaction(tx, tx => { if (stopReason === 'explicitCancel') { const inlineCompletion = this.state.get()?.inlineCompletion; - const source = inlineCompletion?.source; - const sourceInlineCompletion = inlineCompletion?.sourceInlineCompletion; - if (sourceInlineCompletion && source?.provider.handleRejection) { - source.provider.handleRejection(source.inlineCompletions, sourceInlineCompletion); + if (inlineCompletion) { + inlineCompletion.reportEndOfLife({ kind: InlineCompletionEndOfLifeReasonKind.Rejected }); } } - this._inAcceptPartialFlow.set(false, tx); this._isActive.set(false, tx); this._source.clear(tx); }); @@ -296,11 +361,11 @@ export class InlineCompletionsModel extends Disposable { const c = this._source.inlineCompletions.read(reader); if (!c) { return undefined; } const cursorPosition = this.primaryPosition.read(reader); - let inlineEdit: InlineCompletionWithUpdatedRange | undefined = undefined; - const visibleCompletions: InlineCompletionWithUpdatedRange[] = []; + let inlineEdit: InlineEditItem | undefined = undefined; + const visibleCompletions: InlineCompletionItem[] = []; for (const completion of c.inlineCompletions) { - if (!completion.sourceInlineCompletion.isInlineEdit) { - if (completion.isVisible(this.textModel, cursorPosition, reader)) { + if (!completion.isInlineEdit) { + if (completion.isVisible(this.textModel, cursorPosition)) { visibleCompletions.push(completion); } } else { @@ -337,18 +402,18 @@ export class InlineCompletionsModel extends Disposable { return idx; }); - public readonly selectedInlineCompletion = derived(this, (reader) => { + public readonly selectedInlineCompletion = derived(this, (reader) => { const filteredCompletions = this._filteredInlineCompletionItems.read(reader); const idx = this.selectedInlineCompletionIndex.read(reader); return filteredCompletions[idx]; }); public readonly activeCommands = derivedOpts({ owner: this, equalsFn: itemsEquals() }, - r => this.selectedInlineCompletion.read(r)?.source.inlineCompletions.commands ?? [] + r => this.selectedInlineCompletion.read(r)?.source.inlineSuggestions.commands ?? [] ); public readonly lastTriggerKind: IObservable - = this._source.inlineCompletions.map(this, v => v?.request.context.triggerKind); + = this._source.inlineCompletions.map(this, v => v?.request?.context.triggerKind); public readonly inlineCompletionsCount = derived(this, reader => { if (this.lastTriggerKind.read(reader) === InlineCompletionTriggerKind.Explicit) { @@ -366,13 +431,13 @@ export class InlineCompletionsModel extends Disposable { primaryGhostText: GhostTextOrReplacement; ghostTexts: readonly GhostTextOrReplacement[]; suggestItem: SuggestItemInfo | undefined; - inlineCompletion: InlineCompletionWithUpdatedRange | undefined; + inlineCompletion: InlineCompletionItem | undefined; } | { kind: 'inlineEdit'; edits: readonly SingleTextEdit[]; inlineEdit: InlineEdit; - inlineCompletion: InlineCompletionWithUpdatedRange; - cursorAtInlineEdit: boolean; + inlineCompletion: InlineEditItem; + cursorAtInlineEdit: IObservable; } | undefined>({ owner: this, equalsFn: (a, b) => { @@ -383,7 +448,7 @@ export class InlineCompletionsModel extends Disposable { && a.inlineCompletion === b.inlineCompletion && a.suggestItem === b.suggestItem; } else if (a.kind === 'inlineEdit' && b.kind === 'inlineEdit') { - return a.inlineEdit.equals(b.inlineEdit) && a.cursorAtInlineEdit === b.cursorAtInlineEdit; + return a.inlineEdit.equals(b.inlineEdit); } return false; } @@ -396,21 +461,15 @@ export class InlineCompletionsModel extends Disposable { if (this._hasVisiblePeekWidgets.read(reader)) { return undefined; } - let edit = inlineEditResult.toSingleTextEdit(reader); + let edit = inlineEditResult.getSingleTextEdit(); edit = singleTextRemoveCommonPrefix(edit, model); - const cursorPos = this.primaryPosition.read(reader); - const cursorAtInlineEdit = LineRange.fromRangeInclusive(edit.range).addMargin(1, 1).contains(cursorPos.lineNumber); - const cursorInsideShowRange = cursorAtInlineEdit || (inlineEditResult.inlineCompletion.cursorShowRange?.containsPosition(cursorPos) ?? true); + const cursorAtInlineEdit = this.primaryPosition.map(cursorPos => LineRange.fromRangeInclusive(inlineEditResult.targetRange).addMargin(1, 1).contains(cursorPos.lineNumber)); - if (!cursorInsideShowRange && !this._inAcceptFlow.read(reader)) { - return undefined; - } + const commands = inlineEditResult.source.inlineSuggestions.commands; + const inlineEdit = new InlineEdit(edit, commands ?? [], inlineEditResult); - const commands = inlineEditResult.inlineCompletion.source.inlineCompletions.commands; - const inlineEdit = new InlineEdit(edit, commands ?? [], inlineEditResult.inlineCompletion); - - const edits = inlineEditResult.updatedEdit.read(reader); + const edits = inlineEditResult.updatedEdit; const e = edits ? TextEdit.fromOffsetEdit(edits, new TextModelText(this.textModel)).edits : [edit]; return { kind: 'inlineEdit', inlineEdit, inlineCompletion: inlineEditResult, edits: e, cursorAtInlineEdit }; @@ -418,7 +477,7 @@ export class InlineCompletionsModel extends Disposable { const suggestItem = this._selectedSuggestItem.read(reader); if (suggestItem) { - const suggestCompletionEdit = singleTextRemoveCommonPrefix(suggestItem.toSingleTextEdit(), model); + const suggestCompletionEdit = singleTextRemoveCommonPrefix(suggestItem.getSingleTextEdit(), model); const augmentation = this._computeAugmentation(suggestCompletionEdit, reader); const isSuggestionPreviewEnabled = this._suggestPreviewEnabled.read(reader); @@ -440,7 +499,7 @@ export class InlineCompletionsModel extends Disposable { const inlineCompletion = this.selectedInlineCompletion.read(reader); if (!inlineCompletion) { return undefined; } - const replacement = inlineCompletion.toSingleTextEdit(reader); + const replacement = inlineCompletion.getSingleTextEdit(); const mode = this._inlineSuggestMode.read(reader); const positions = this._positions.read(reader); const edits = [replacement, ...getSecondaryEdits(this.textModel, positions, replacement)]; @@ -488,11 +547,11 @@ export class InlineCompletionsModel extends Disposable { const model = this.textModel; const suggestWidgetInlineCompletions = this._source.suggestWidgetInlineCompletions.read(reader); const candidateInlineCompletions = suggestWidgetInlineCompletions - ? suggestWidgetInlineCompletions.inlineCompletions + ? suggestWidgetInlineCompletions.inlineCompletions.filter(c => !c.isInlineEdit) : [this.selectedInlineCompletion.read(reader)].filter(isDefined); const augmentedCompletion = mapFindFirst(candidateInlineCompletions, completion => { - let r = completion.toSingleTextEdit(reader); + let r = completion.getSingleTextEdit(); r = singleTextRemoveCommonPrefix( r, model, @@ -505,7 +564,7 @@ export class InlineCompletionsModel extends Disposable { } public readonly warning = derived(this, reader => { - return this.inlineCompletionState.read(reader)?.inlineCompletion?.sourceInlineCompletion.warning; + return this.inlineCompletionState.read(reader)?.inlineCompletion?.warning; }); public readonly ghostTexts = derivedOpts({ owner: this, equalsFn: ghostTextsOrReplacementsEqual }, reader => { @@ -530,6 +589,10 @@ export class InlineCompletionsModel extends Disposable { return false; } + if (state.inlineCompletion.displayLocation) { + return false; + } + const isCurrentModelVersion = state.inlineCompletion.updatedEditModelVersion === this._textModelVersionId.read(reader); return (this._inlineEditsShowCollapsedEnabled.read(reader) || !isCurrentModelVersion) && this._jumpedToId.read(reader) !== state.inlineCompletion.semanticId @@ -576,7 +639,7 @@ export class InlineCompletionsModel extends Disposable { return true; } - return !s.cursorAtInlineEdit; + return !s.cursorAtInlineEdit.read(reader); }); public readonly tabShouldAcceptInlineEdit = derived(this, reader => { @@ -587,7 +650,7 @@ export class InlineCompletionsModel extends Disposable { if (this.showCollapsed.read(reader)) { return false; } - if (s.inlineEdit.range.startLineNumber === this._editorObs.cursorLineNumber.read(reader)) { + if (s.inlineCompletion.targetRange.startLineNumber === this._editorObs.cursorLineNumber.read(reader)) { return true; } if (this._jumpedToId.read(reader) === s.inlineCompletion.semanticId) { @@ -597,7 +660,7 @@ export class InlineCompletionsModel extends Disposable { return false; } - return s.cursorAtInlineEdit; + return s.cursorAtInlineEdit.read(reader); }); private async _deltaSelectedInlineCompletionIndex(delta: 1 | -1): Promise { @@ -616,32 +679,34 @@ export class InlineCompletionsModel extends Disposable { public async previous(): Promise { await this._deltaSelectedInlineCompletionIndex(-1); } + private _getMetadata(completion: InlineSuggestionItem, type: 'word' | 'line' | undefined = undefined): ITextModelChangeRecorderMetadata { + return { + extensionId: completion.source.provider.groupId, + nes: completion.isInlineEdit, + type + }; + } + public async accept(editor: ICodeEditor = this._editor): Promise { if (editor.getModel() !== this.textModel) { throw new BugIndicatingError(); } - if (this._inAcceptPartialFlow.get()) { - this._inAcceptPartialFlow.set(false, undefined); - this.jump(); - return; - } - - let completionWithUpdatedRange: InlineCompletionWithUpdatedRange; + let completion: InlineSuggestionItem; const state = this.state.get(); if (state?.kind === 'ghostText') { if (!state || state.primaryGhostText.isEmpty() || !state.inlineCompletion) { return; } - completionWithUpdatedRange = state.inlineCompletion; + completion = state.inlineCompletion; } else if (state?.kind === 'inlineEdit') { - completionWithUpdatedRange = state.inlineCompletion; + completion = state.inlineCompletion; } else { return; } - const completion = completionWithUpdatedRange.toInlineCompletion(undefined); + completion.reportEndOfLife({ kind: InlineCompletionEndOfLifeReasonKind.Accepted }); if (completion.command) { // Make sure the completion list will not be disposed. @@ -650,23 +715,31 @@ export class InlineCompletionsModel extends Disposable { editor.pushUndoStop(); if (completion.snippetInfo) { - editor.executeEdits( - 'inlineSuggestion.accept', - [ - EditOperation.replace(completion.range, ''), - ...completion.additionalTextEdits - ] - ); + TextModelChangeRecorder.editWithMetadata(this._getMetadata(completion), () => { + editor.executeEdits( + 'inlineSuggestion.accept', + [ + EditOperation.replace(completion.editRange, ''), + ...completion.additionalTextEdits + ] + ); + }); editor.setPosition(completion.snippetInfo.range.getStartPosition(), 'inlineCompletionAccept'); SnippetController2.get(editor)?.insert(completion.snippetInfo.snippet, { undoStopBefore: false }); } else { const edits = state.edits; const selections = getEndPositionsAfterApplying(edits).map(p => Selection.fromPositions(p)); - editor.executeEdits('inlineSuggestion.accept', [ - ...edits.map(edit => EditOperation.replace(edit.range, edit.text)), - ...completion.additionalTextEdits - ]); - editor.setSelections(state.kind === 'inlineEdit' ? selections.slice(-1) : selections, 'inlineCompletionAccept'); + + TextModelChangeRecorder.editWithMetadata(this._getMetadata(completion), () => { + editor.executeEdits('inlineSuggestion.accept', [ + ...edits.map(edit => EditOperation.replace(edit.range, edit.text)), + ...completion.additionalTextEdits + ]); + }); + if (completion.displayLocation === undefined) { + // do not move the cursor when the completion is displayed in a different location + editor.setSelections(state.kind === 'inlineEdit' ? selections.slice(-1) : selections, 'inlineCompletionAccept'); + } if (state.kind === 'inlineEdit' && !this._accessibilityService.isMotionReduced()) { // we can assume that edits is sorted! @@ -677,6 +750,8 @@ export class InlineCompletionsModel extends Disposable { } } + this._onDidAccept.fire(); + // Reset before invoking the command, as the command might cause a follow up trigger (which we don't want to reset). this.stop(); @@ -688,10 +763,11 @@ export class InlineCompletionsModel extends Disposable { } this._inAcceptFlow.set(true, undefined); + this._lastAcceptedInlineCompletionInfo = { textModelVersionIdAfter: this.textModel.getVersionId(), inlineCompletion: completion }; } - public async acceptNextWord(editor: ICodeEditor): Promise { - await this._acceptNext(editor, (pos, text) => { + public async acceptNextWord(): Promise { + await this._acceptNext(this._editor, 'word', (pos, text) => { const langId = this.textModel.getLanguageIdAtPosition(pos.lineNumber, pos.column); const config = this._languageConfigurationService.getLanguageConfiguration(langId); const wordRegExp = new RegExp(config.wordDefinition.source, config.wordDefinition.flags.replace('g', '')); @@ -719,8 +795,8 @@ export class InlineCompletionsModel extends Disposable { }, PartialAcceptTriggerKind.Word); } - public async acceptNextLine(editor: ICodeEditor): Promise { - await this._acceptNext(editor, (pos, text) => { + public async acceptNextLine(): Promise { + await this._acceptNext(this._editor, 'line', (pos, text) => { const m = text.match(/\n/); if (m && m.index !== undefined) { return m.index + 1; @@ -729,7 +805,7 @@ export class InlineCompletionsModel extends Disposable { }, PartialAcceptTriggerKind.Line); } - private async _acceptNext(editor: ICodeEditor, getAcceptUntilIndex: (position: Position, text: string) => number, kind: PartialAcceptTriggerKind): Promise { + private async _acceptNext(editor: ICodeEditor, type: 'word' | 'line', getAcceptUntilIndex: (position: Position, text: string) => number, kind: PartialAcceptTriggerKind): Promise { if (editor.getModel() !== this.textModel) { throw new BugIndicatingError(); } @@ -739,9 +815,9 @@ export class InlineCompletionsModel extends Disposable { return; } const ghostText = state.primaryGhostText; - const completion = state.inlineCompletion.toInlineCompletion(undefined); + const completion = state.inlineCompletion; - if (completion.snippetInfo || completion.filterText !== completion.insertText) { + if (completion.snippetInfo) { // not in WYSIWYG mode, partial commit might change completion, thus it is not supported await this.accept(editor); return; @@ -771,122 +847,47 @@ export class InlineCompletionsModel extends Disposable { const primaryEdit = new SingleTextEdit(replaceRange, newText); const edits = [primaryEdit, ...getSecondaryEdits(this.textModel, positions, primaryEdit)]; const selections = getEndPositionsAfterApplying(edits).map(p => Selection.fromPositions(p)); - editor.executeEdits('inlineSuggestion.accept', edits.map(edit => EditOperation.replace(edit.range, edit.text))); + TextModelChangeRecorder.editWithMetadata(this._getMetadata(completion, type), () => { + editor.executeEdits('inlineSuggestion.accept', edits.map(edit => EditOperation.replace(edit.range, edit.text))); + }); editor.setSelections(selections, 'inlineCompletionPartialAccept'); editor.revealPositionInCenterIfOutsideViewport(editor.getPosition()!, ScrollType.Immediate); } finally { this._isAcceptingPartially = false; } - if (completion.source.provider.handlePartialAccept) { - const acceptedRange = Range.fromPositions(completion.range.getStartPosition(), TextLength.ofText(partialGhostTextVal).addToPosition(ghostTextPos)); - // This assumes that the inline completion and the model use the same EOL style. - const text = editor.getModel()!.getValueInRange(acceptedRange, EndOfLinePreference.LF); - const acceptedLength = text.length; - completion.source.provider.handlePartialAccept( - completion.source.inlineCompletions, - completion.sourceInlineCompletion, - acceptedLength, - { kind, acceptedLength: acceptedLength, } - ); - } - } finally { - completion.source.removeRef(); - } - } + const acceptedRange = Range.fromPositions(completion.editRange.getStartPosition(), TextLength.ofText(partialGhostTextVal).addToPosition(ghostTextPos)); + // This assumes that the inline completion and the model use the same EOL style. + const text = editor.getModel()!.getValueInRange(acceptedRange, EndOfLinePreference.LF); + const acceptedLength = text.length; + completion.reportPartialAccept(acceptedLength, { kind, acceptedLength: acceptedLength }); - // TODO: clean this up if we keep it - private readonly _inAcceptPartialFlow = observableValue(this, false); - public readonly inPartialAcceptFlow: IObservable = this._inAcceptPartialFlow; - public async acceptNextInlineEditPart(editor: ICodeEditor): Promise { - if (editor.getModel() !== this.textModel) { - throw new BugIndicatingError(); - } - - const state = this.inlineEditState.get(); - const updatedEdit = state?.inlineCompletion.updatedEdit.get(); - const completion = state?.inlineCompletion.toInlineCompletion(undefined); - if (!updatedEdit || updatedEdit.isEmpty || !completion) { - return; - } - - const nextPart = updatedEdit.edits[0]; - - const edit = new SingleTextEdit(Range.fromPositions( - this.textModel.getPositionAt(nextPart.replaceRange.start), - this.textModel.getPositionAt(nextPart.replaceRange.endExclusive) - ), nextPart.newText); - - const cursorAtStartPosition = this._editor.getSelection()?.getStartPosition().equals(edit.range.getStartPosition()); - if (!cursorAtStartPosition || !this._inAcceptPartialFlow.get()) { - this._inAcceptPartialFlow.set(true, undefined); - this.jump(); - return; - } - - const partToJumpToNext = updatedEdit.edits[1] ?? undefined; - const editToJumpToNext = partToJumpToNext ? new SingleTextEdit(Range.fromPositions( - this.textModel.getPositionAt(partToJumpToNext.replaceRange.start), - this.textModel.getPositionAt(partToJumpToNext.replaceRange.endExclusive) - ), partToJumpToNext.newText) : undefined; - - // Executing the edit might free the completion, so we have to hold a reference on it. - completion.source.addRef(); - try { - this._isAcceptingPartially = true; - try { - editor.pushUndoStop(); - - let selections; - if (editToJumpToNext) { - const [_, rangeOfEditToJumpTo] = getModifiedRangesAfterApplying([edit, editToJumpToNext]); - selections = [Selection.fromPositions(rangeOfEditToJumpTo.getStartPosition())]; - } else { - selections = getEndPositionsAfterApplying([edit]).map(p => Selection.fromPositions(p)); - } - - const edits = [edit]; - editor.executeEdits('inlineSuggestion.accept', edits.map(edit => EditOperation.replace(edit.range, edit.text))); - editor.setSelections(selections, 'inlineCompletionPartialAccept'); - editor.revealPositionInCenterIfOutsideViewport(editor.getPosition()!, ScrollType.Immediate); - } finally { - this._isAcceptingPartially = false; - } } finally { completion.source.removeRef(); } } public handleSuggestAccepted(item: SuggestItemInfo) { - const itemEdit = singleTextRemoveCommonPrefix(item.toSingleTextEdit(), this.textModel); + const itemEdit = singleTextRemoveCommonPrefix(item.getSingleTextEdit(), this.textModel); const augmentedCompletion = this._computeAugmentation(itemEdit, undefined); if (!augmentedCompletion) { return; } - const source = augmentedCompletion.completion.source; - const sourceInlineCompletion = augmentedCompletion.completion.sourceInlineCompletion; - - const completion = augmentedCompletion.completion.toInlineCompletion(undefined); // This assumes that the inline completion and the model use the same EOL style. - const alreadyAcceptedLength = this.textModel.getValueInRange(completion.range, EndOfLinePreference.LF).length; + const alreadyAcceptedLength = this.textModel.getValueInRange(augmentedCompletion.completion.editRange, EndOfLinePreference.LF).length; const acceptedLength = alreadyAcceptedLength + itemEdit.text.length; - source.provider.handlePartialAccept?.( - source.inlineCompletions, - sourceInlineCompletion, - itemEdit.text.length, - { - kind: PartialAcceptTriggerKind.Suggest, - acceptedLength, - } - ); + augmentedCompletion.completion.reportPartialAccept(itemEdit.text.length, { + kind: PartialAcceptTriggerKind.Suggest, + acceptedLength, + }); } public extractReproSample(): Repro { const value = this.textModel.getValue(); - const item = this.state.get()?.inlineCompletion?.toInlineCompletion(undefined); + const item = this.state.get()?.inlineCompletion; return { documentValue: value, - inlineCompletion: item?.sourceInlineCompletion, + inlineCompletion: item?.getSourceCompletion(), }; } @@ -901,15 +902,16 @@ export class InlineCompletionsModel extends Disposable { transaction(tx => { this._jumpedToId.set(s.inlineCompletion.semanticId, tx); this.dontRefetchSignal.trigger(tx); - const edit = s.inlineCompletion.toSingleTextEdit(undefined); - this._editor.setPosition(edit.range.getStartPosition(), 'inlineCompletions.jump'); + const targetRange = s.inlineCompletion.targetRange; + const targetPosition = targetRange.getStartPosition(); + this._editor.setPosition(targetPosition, 'inlineCompletions.jump'); // TODO: consider using view information to reveal it - const isSingleLineChange = edit.range.startLineNumber === edit.range.endLineNumber && !edit.text.includes('\n'); + const isSingleLineChange = targetRange.isSingleLine() && (s.inlineCompletion.displayLocation || !s.inlineCompletion.insertText.includes('\n')); if (isSingleLineChange) { - this._editor.revealPosition(edit.range.getStartPosition()); + this._editor.revealPosition(targetPosition); } else { - const revealRange = new Range(edit.range.startLineNumber - 1, 1, edit.range.endLineNumber + 1, 1); + const revealRange = new Range(targetRange.startLineNumber - 1, 1, targetRange.endLineNumber + 1, 1); this._editor.revealRange(revealRange, ScrollType.Immediate); } @@ -917,17 +919,8 @@ export class InlineCompletionsModel extends Disposable { }); } - public async handleInlineEditShown(inlineCompletion: InlineCompletionItem): Promise { - if (inlineCompletion.didShow) { - return; - } - inlineCompletion.markAsShown(); - - inlineCompletion.source.provider.handleItemDidShow?.(inlineCompletion.source.inlineCompletions, inlineCompletion.sourceInlineCompletion, inlineCompletion.insertText); - - if (inlineCompletion.shownCommand) { - await this._commandService.executeCommand(inlineCompletion.shownCommand.id, ...(inlineCompletion.shownCommand.arguments || [])); - } + public async handleInlineSuggestionShown(inlineCompletion: InlineSuggestionItem): Promise { + await inlineCompletion.reportInlineEditShown(this._commandService); } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts index ea9f5fcd76c..9ff5450279c 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts @@ -5,41 +5,34 @@ import { compareUndefinedSmallest, numberComparator } from '../../../../../base/common/arrays.js'; import { findLastMax } from '../../../../../base/common/arraysFind.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { equalsIfDefined, itemEquals } from '../../../../../base/common/equals.js'; -import { BugIndicatingError } from '../../../../../base/common/errors.js'; -import { matchesSubString } from '../../../../../base/common/filters.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable, IObservableWithChange, IReader, ITransaction, derived, derivedHandleChanges, disposableObservableValue, observableValue, transaction } from '../../../../../base/common/observable.js'; -import { commonPrefixLength, commonSuffixLength, splitLines } from '../../../../../base/common/strings.js'; +import { derived, IObservable, IObservableWithChange, ITransaction, observableValue, recordChanges, transaction } from '../../../../../base/common/observable.js'; +// eslint-disable-next-line local/code-no-deep-import-of-internal +import { observableReducerSettable } from '../../../../../base/common/observableInternal/reducer.js'; +import { isDefined } from '../../../../../base/common/types.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; -import { applyEditsToRanges, OffsetEdit, SingleOffsetEdit } from '../../../../common/core/offsetEdit.js'; -import { OffsetRange } from '../../../../common/core/offsetRange.js'; +import { OffsetEdit } from '../../../../common/core/offsetEdit.js'; import { Position } from '../../../../common/core/position.js'; -import { Range } from '../../../../common/core/range.js'; -import { SingleTextEdit, StringText } from '../../../../common/core/textEdit.js'; -import { TextLength } from '../../../../common/core/textLength.js'; -import { linesDiffComputers } from '../../../../common/diff/linesDiffComputers.js'; -import { InlineCompletionContext, InlineCompletionTriggerKind } from '../../../../common/languages.js'; +import { InlineCompletionEndOfLifeReasonKind, InlineCompletionContext, InlineCompletionTriggerKind, InlineCompletionsProvider } from '../../../../common/languages.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; -import { EndOfLinePreference, ITextModel } from '../../../../common/model.js'; +import { ITextModel } from '../../../../common/model.js'; import { OffsetEdits } from '../../../../common/model/textModelOffsetEdit.js'; import { IFeatureDebounceInformation } from '../../../../common/services/languageFeatureDebounce.js'; -import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js'; import { IModelContentChangedEvent } from '../../../../common/textModelEvents.js'; -import { InlineCompletionItem, InlineCompletionProviderResult, provideInlineCompletions } from './provideInlineCompletions.js'; -import { singleTextRemoveCommonPrefix } from './singleTextEditHelpers.js'; -import { StructuredLogger, IRecordableEditorLogEntry, IRecordableLogEntry, formatRecordableLogEntry } from '../structuredLogger.js'; +import { formatRecordableLogEntry, IRecordableEditorLogEntry, IRecordableLogEntry, StructuredLogger } from '../structuredLogger.js'; +import { wait } from '../utils.js'; +import { InlineSuggestionIdentity, InlineSuggestionItem } from './inlineSuggestionItem.js'; +import { InlineCompletionProviderResult, provideInlineCompletions } from './provideInlineCompletions.js'; export class InlineCompletionsSource extends Disposable { private static _requestId = 0; private readonly _updateOperation = this._register(new MutableDisposable()); - public readonly inlineCompletions = this._register(disposableObservableValue('inlineCompletions', undefined)); - public readonly suggestWidgetInlineCompletions = this._register(disposableObservableValue('suggestWidgetInlineCompletions', undefined)); private readonly _loggingEnabled = observableConfigValue('editor.inlineSuggest.logFetch', false, this._configurationService).recomputeInitiallyAndOnChange(this._store); @@ -50,11 +43,41 @@ export class InlineCompletionsSource extends Disposable { 'editor.inlineSuggest.logFetch.commandId' )); + private readonly _state = observableReducerSettable(this, { + initial: () => ({ + inlineCompletions: InlineCompletionsState.createEmpty(), + suggestWidgetInlineCompletions: InlineCompletionsState.createEmpty(), + }), + disposeFinal: (values) => { + values.inlineCompletions.dispose(); + values.suggestWidgetInlineCompletions.dispose(); + }, + changeTracker: recordChanges({ versionId: this._versionId }), + update: (reader, previousValue, changes) => { + const edit = OffsetEdit.join(changes.changes.map(c => c.change ? OffsetEdits.fromContentChanges(c.change.changes) : OffsetEdit.empty).filter(isDefined)); + + if (edit.isEmpty) { + return previousValue; + } + try { + return { + inlineCompletions: previousValue.inlineCompletions.createStateWithAppliedEdit(edit, this._textModel), + suggestWidgetInlineCompletions: previousValue.suggestWidgetInlineCompletions.createStateWithAppliedEdit(edit, this._textModel), + }; + } finally { + previousValue.inlineCompletions.dispose(); + previousValue.suggestWidgetInlineCompletions.dispose(); + } + } + }); + + public readonly inlineCompletions = this._state.map(this, v => v.inlineCompletions); + public readonly suggestWidgetInlineCompletions = this._state.map(this, v => v.suggestWidgetInlineCompletions); + constructor( private readonly _textModel: ITextModel, private readonly _versionId: IObservableWithChange, private readonly _debounceValue: IFeatureDebounceInformation, - @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, @ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService, @ILogService private readonly _logService: ILogService, @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -84,14 +107,14 @@ export class InlineCompletionsSource extends Disposable { private readonly _loadingCount = observableValue(this, 0); public readonly loading = this._loadingCount.map(this, v => v > 0); - public fetch(position: Position, context: InlineCompletionContext, activeInlineCompletion: InlineCompletionWithUpdatedRange | undefined, withDebounce: boolean, userJumpedToActiveCompletion: IObservable): Promise { + public fetch(providers: InlineCompletionsProvider[], position: Position, context: InlineCompletionContext, activeInlineCompletion: InlineSuggestionIdentity | undefined, withDebounce: boolean, userJumpedToActiveCompletion: IObservable, providerhasChangedCompletion: boolean): Promise { const request = new UpdateRequest(position, context, this._textModel.getVersionId()); - const target = context.selectedSuggestionInfo ? this.suggestWidgetInlineCompletions : this.inlineCompletions; + const target = context.selectedSuggestionInfo ? this.suggestWidgetInlineCompletions.get() : this.inlineCompletions.get(); - if (this._updateOperation.value?.request.satisfies(request)) { + if (!providerhasChangedCompletion && this._updateOperation.value?.request.satisfies(request)) { return this._updateOperation.value.promise; - } else if (target.get()?.request.satisfies(request)) { + } else if (target?.request?.satisfies(request)) { return Promise.resolve(true); } @@ -105,7 +128,7 @@ export class InlineCompletionsSource extends Disposable { try { const recommendedDebounceValue = this._debounceValue.get(this._textModel); const debounceValue = findLastMax( - this._languageFeaturesService.inlineCompletionsProvider.all(this._textModel).map(p => p.debounceDelayMs), + providers.map(p => p.debounceDelayMs), compareUndefinedSmallest(numberComparator) ) ?? recommendedDebounceValue; @@ -126,11 +149,11 @@ export class InlineCompletionsSource extends Disposable { } const startTime = new Date(); - let updatedCompletions: InlineCompletionProviderResult | undefined = undefined; + let providerResult: InlineCompletionProviderResult | undefined = undefined; let error: any = undefined; try { - updatedCompletions = await provideInlineCompletions( - this._languageFeaturesService.inlineCompletionsProvider, + providerResult = await provideInlineCompletions( + providers, position, this._textModel, context, @@ -145,7 +168,7 @@ export class InlineCompletionsSource extends Disposable { if (source.token.isCancellationRequested || this._store.isDisposed || this._textModel.getVersionId() !== request.versionId) { error = 'canceled'; } - const result = updatedCompletions?.completions.map(c => ({ + const result = providerResult?.completions.map(c => ({ range: c.range.toString(), text: c.insertText, isInlineEdit: !!c.isInlineEdit, @@ -156,37 +179,32 @@ export class InlineCompletionsSource extends Disposable { } if (source.token.isCancellationRequested || this._store.isDisposed || this._textModel.getVersionId() !== request.versionId || userJumpedToActiveCompletion.get() /* In the meantime the user showed interest for the active completion so dont hide it */) { - updatedCompletions.dispose(); - return false; - } - - // Reuse Inline Edit if possible - if (activeInlineCompletion && activeInlineCompletion.isInlineEdit && ( - activeInlineCompletion.canBeReused(this._textModel, position) - || updatedCompletions.has(activeInlineCompletion.inlineCompletion) /* Inline Edit wins over completions if it's already been shown*/ - || updatedCompletions.isEmpty() /* Incoming completion is empty, keep the current one alive */ - )) { - activeInlineCompletion.reuse(); - updatedCompletions.dispose(); + providerResult.dispose(); return false; } const endTime = new Date(); this._debounceValue.update(this._textModel, endTime.getTime() - startTime.getTime()); - // Reuse Inline Completion if possible - const completions = new UpToDateInlineCompletions(updatedCompletions, request, this._textModel, this._versionId); - if (activeInlineCompletion && !activeInlineCompletion.isInlineEdit && activeInlineCompletion.canBeReused(this._textModel, position)) { - const asInlineCompletion = activeInlineCompletion.toInlineCompletion(undefined); - if (!updatedCompletions.has(asInlineCompletion)) { - completions.prepend(activeInlineCompletion.inlineCompletion, asInlineCompletion.range, true); - } - } - this._updateOperation.clear(); transaction(tx => { + const v = this._state.get(); /** @description Update completions with provider result */ - target.set(completions, tx); + if (context.selectedSuggestionInfo) { + this._state.set({ + inlineCompletions: InlineCompletionsState.createEmpty(), + suggestWidgetInlineCompletions: v.suggestWidgetInlineCompletions.createStateWithAppliedResults(providerResult, request, this._textModel, activeInlineCompletion), + }, tx); + } else { + this._state.set({ + inlineCompletions: v.inlineCompletions.createStateWithAppliedResults(providerResult, request, this._textModel, activeInlineCompletion), + suggestWidgetInlineCompletions: InlineCompletionsState.createEmpty(), + }, tx); + } + + providerResult.dispose(); + v.inlineCompletions.dispose(); + v.suggestWidgetInlineCompletions.dispose(); }); } finally { @@ -204,15 +222,41 @@ export class InlineCompletionsSource extends Disposable { public clear(tx: ITransaction): void { this._updateOperation.clear(); - this.inlineCompletions.set(undefined, tx); - this.suggestWidgetInlineCompletions.set(undefined, tx); + const v = this._state.get(); + this._state.set({ + inlineCompletions: InlineCompletionsState.createEmpty(), + suggestWidgetInlineCompletions: InlineCompletionsState.createEmpty() + }, tx); + v.inlineCompletions.dispose(); + v.suggestWidgetInlineCompletions.dispose(); + } + + public seedInlineCompletionsWithSuggestWidget(): void { + const inlineCompletions = this.inlineCompletions.get(); + const suggestWidgetInlineCompletions = this.suggestWidgetInlineCompletions.get(); + if (!suggestWidgetInlineCompletions) { + return; + } + transaction(tx => { + /** @description Seed inline completions with (newer) suggest widget inline completions */ + if (!inlineCompletions || (suggestWidgetInlineCompletions.request?.versionId ?? -1) > (inlineCompletions.request?.versionId ?? -1)) { + inlineCompletions?.dispose(); + const s = this._state.get(); + this._state.set({ + inlineCompletions: suggestWidgetInlineCompletions.clone(), + suggestWidgetInlineCompletions: InlineCompletionsState.createEmpty(), + }, tx); + s.inlineCompletions.dispose(); + s.suggestWidgetInlineCompletions.dispose(); + } + this.clearSuggestWidgetInlineCompletions(tx); + }); } public clearSuggestWidgetInlineCompletions(tx: ITransaction): void { if (this._updateOperation.value?.request.context.selectedSuggestionInfo) { this._updateOperation.clear(); } - this.suggestWidgetInlineCompletions.set(undefined, tx); } public cancelUpdate(): void { @@ -220,23 +264,6 @@ export class InlineCompletionsSource extends Disposable { } } -function wait(ms: number, cancellationToken?: CancellationToken): Promise { - return new Promise(resolve => { - let d: IDisposable | undefined = undefined; - const handle = setTimeout(() => { - if (d) { d.dispose(); } - resolve(); - }, ms); - if (cancellationToken) { - d = cancellationToken.onCancellationRequested(() => { - clearTimeout(handle); - if (d) { d.dispose(); } - resolve(); - }); - } - }); -} - class UpdateRequest { constructor( public readonly position: Position, @@ -271,531 +298,71 @@ class UpdateOperation implements IDisposable { } } -export class UpToDateInlineCompletions implements IDisposable { - private readonly _inlineCompletions: InlineCompletionWithUpdatedRange[]; - public get inlineCompletions(): ReadonlyArray { return this._inlineCompletions; } - - private _refCount = 1; - private readonly _prependedInlineCompletionItems: InlineCompletionItem[] = []; +class InlineCompletionsState extends Disposable { + public static createEmpty(): InlineCompletionsState { + return new InlineCompletionsState([], undefined); + } constructor( - private readonly inlineCompletionProviderResult: InlineCompletionProviderResult, - public readonly request: UpdateRequest, - private readonly _textModel: ITextModel, - private readonly _versionId: IObservableWithChange, + public readonly inlineCompletions: readonly InlineSuggestionItem[], + public readonly request: UpdateRequest | undefined, ) { - this._inlineCompletions = inlineCompletionProviderResult.completions.map( - completion => new InlineCompletionWithUpdatedRange(completion, undefined, this._textModel, this._versionId, this.request) - ); - } - - public clone(): this { - this._refCount++; - return this; - } - - public dispose(): void { - this._refCount--; - if (this._refCount === 0) { - this.inlineCompletionProviderResult.dispose(); - for (const i of this._prependedInlineCompletionItems) { - i.source.removeRef(); - } - this._inlineCompletions.forEach(i => i.dispose()); - } - } - - public prepend(inlineCompletion: InlineCompletionItem, range: Range, addRefToSource: boolean): void { - if (addRefToSource) { - inlineCompletion.source.addRef(); + for (const inlineCompletion of inlineCompletions) { + inlineCompletion.addRef(); } - this._inlineCompletions.unshift(new InlineCompletionWithUpdatedRange(inlineCompletion, range, this._textModel, this._versionId, this.request)); - this._prependedInlineCompletionItems.push(inlineCompletion); - } -} - -export class InlineCompletionWithUpdatedRange extends Disposable { - public readonly semanticId = JSON.stringify([ - this.inlineCompletion.filterText, - this.inlineCompletion.insertText, - this.inlineCompletion.range.getStartPosition().toString() - ]); - - public get forwardStable() { - return this.source.inlineCompletions.enableForwardStability ?? false; - } - - private readonly _updatedEditObj: UpdatedEdit; // helper as derivedHandleChanges can not access previous value - public get updatedEdit(): IObservable { return this._updatedEditObj.offsetEdit; } - public get updatedEditModelVersion() { return this._updatedEditObj.modelVersion; } - - public get source() { return this.inlineCompletion.source; } - public get sourceInlineCompletion() { return this.inlineCompletion.sourceInlineCompletion; } - public get isInlineEdit() { return this.inlineCompletion.isInlineEdit; } - - constructor( - public readonly inlineCompletion: InlineCompletionItem, - updatedRange: Range | undefined, - private readonly _textModel: ITextModel, - private readonly _modelVersion: IObservableWithChange, - public readonly request: UpdateRequest, - ) { super(); - this._updatedEditObj = this._register(this._toUpdatedEdit(updatedRange ?? this.inlineCompletion.range, this.inlineCompletion.insertText)); - } - - public toInlineCompletion(reader: IReader | undefined): InlineCompletionItem { - const singleTextEdit = this.toSingleTextEdit(reader); - return this.inlineCompletion.withRangeInsertTextAndFilterText(singleTextEdit.range, singleTextEdit.text, singleTextEdit.text); - } - - public toSingleTextEdit(reader: IReader | undefined): SingleTextEdit { - this._modelVersion.read(reader); - const offsetEdit = this.updatedEdit.read(reader); - if (!offsetEdit) { - return new SingleTextEdit(this._updatedRange.read(reader) ?? emptyRange, this.inlineCompletion.insertText); - } - - const startOffset = offsetEdit.edits[0].replaceRange.start; - const endOffset = offsetEdit.edits[offsetEdit.edits.length - 1].replaceRange.endExclusive; - const overallOffsetRange = new OffsetRange(startOffset, endOffset); - const overallLnColRange = Range.fromPositions( - this._textModel.getPositionAt(overallOffsetRange.start), - this._textModel.getPositionAt(overallOffsetRange.endExclusive) - ); - let text = this._textModel.getValueInRange(overallLnColRange); - for (let i = offsetEdit.edits.length - 1; i >= 0; i--) { - const edit = offsetEdit.edits[i]; - const relativeStartOffset = edit.replaceRange.start - startOffset; - const relativeEndOffset = edit.replaceRange.endExclusive - startOffset; - text = text.substring(0, relativeStartOffset) + edit.newText + text.substring(relativeEndOffset); - } - return new SingleTextEdit(overallLnColRange, text); - } - - public isVisible(model: ITextModel, cursorPosition: Position, reader: IReader | undefined): boolean { - const minimizedReplacement = singleTextRemoveCommonPrefix(this.toSingleTextEdit(reader), model); - const updatedRange = this._updatedRange.read(reader); - if ( - !updatedRange - || !this.inlineCompletion.range.getStartPosition().equals(updatedRange.getStartPosition()) - || cursorPosition.lineNumber !== minimizedReplacement.range.startLineNumber - || minimizedReplacement.isEmpty // if the completion is empty after removing the common prefix of the completion and the model, the completion item would not be visible - ) { - return false; - } - - // We might consider comparing by .toLowerText, but this requires GhostTextReplacement - const originalValue = model.getValueInRange(minimizedReplacement.range, EndOfLinePreference.LF); - const filterText = minimizedReplacement.text; - - const cursorPosIndex = Math.max(0, cursorPosition.column - minimizedReplacement.range.startColumn); - - let filterTextBefore = filterText.substring(0, cursorPosIndex); - let filterTextAfter = filterText.substring(cursorPosIndex); - - let originalValueBefore = originalValue.substring(0, cursorPosIndex); - let originalValueAfter = originalValue.substring(cursorPosIndex); - - const originalValueIndent = model.getLineIndentColumn(minimizedReplacement.range.startLineNumber); - if (minimizedReplacement.range.startColumn <= originalValueIndent) { - // Remove indentation - originalValueBefore = originalValueBefore.trimStart(); - if (originalValueBefore.length === 0) { - originalValueAfter = originalValueAfter.trimStart(); + this._register({ + dispose: () => { + for (const inlineCompletion of this.inlineCompletions) { + inlineCompletion.removeRef(); + } } - filterTextBefore = filterTextBefore.trimStart(); - if (filterTextBefore.length === 0) { - filterTextAfter = filterTextAfter.trimStart(); - } - } - - return filterTextBefore.startsWith(originalValueBefore) - && !!matchesSubString(originalValueAfter, filterTextAfter); - } - - public reuse(): void { - this._updatedEditObj.reuse(); - } - - public canBeReused(model: ITextModel, position: Position): boolean { - if (!this.updatedEdit.get()) { - return false; - } - - if (this.sourceInlineCompletion.isInlineEdit) { - return this._updatedEditObj.lastChangePartOfInlineEdit; - } - - const updatedRange = this._updatedRange.read(undefined); - const result = !!updatedRange - && updatedRange.containsPosition(position) - && this.isVisible(model, position, undefined) - && TextLength.ofRange(updatedRange).isGreaterThanOrEqualTo(TextLength.ofRange(this.inlineCompletion.range)); - return result; - } - - private readonly _updatedRange = derived(reader => { - const edit = this.updatedEdit.read(reader); - if (!edit || edit.edits.length === 0) { - return undefined; - } - - return Range.fromPositions( - this._textModel.getPositionAt(edit.edits[0].replaceRange.start), - this._textModel.getPositionAt(edit.edits[edit.edits.length - 1].replaceRange.endExclusive) - ); - }); - - private _toUpdatedEdit(editRange: Range, replaceText: string): UpdatedEdit { - return this.isInlineEdit - ? this._toInlineEditEdit(editRange, replaceText) - : this._toInlineCompletionEdit(editRange, replaceText); - } - - private _toInlineCompletionEdit(editRange: Range, replaceText: string): UpdatedEdit { - const startOffset = this._textModel.getOffsetAt(editRange.getStartPosition()); - const endOffset = this._textModel.getOffsetAt(editRange.getEndPosition()); - const originalRange = OffsetRange.ofStartAndLength(startOffset, endOffset - startOffset); - const offsetEdit = new OffsetEdit([new SingleOffsetEdit(originalRange, replaceText)]); - return new UpdatedEdit(offsetEdit, this._textModel, this._modelVersion, false); - } - - private _toInlineEditEdit(editRange: Range, replaceText: string): UpdatedEdit { - const eol = this._textModel.getEOL(); - const editOriginalText = this._textModel.getValueInRange(editRange); - const editReplaceText = replaceText.replace(/\r\n|\r|\n/g, eol); - - const diffAlgorithm = linesDiffComputers.getDefault(); - const lineDiffs = diffAlgorithm.computeDiff( - splitLines(editOriginalText), - splitLines(editReplaceText), - { - ignoreTrimWhitespace: false, - computeMoves: false, - extendToSubwords: true, - maxComputationTimeMs: 500, - } - ); - - const innerChanges = lineDiffs.changes.flatMap(c => c.innerChanges ?? []); - - function addRangeToPos(pos: Position, range: Range): Range { - const start = TextLength.fromPosition(range.getStartPosition()); - return TextLength.ofRange(range).createRange(start.addToPosition(pos)); - } - - const modifiedText = new StringText(editReplaceText); - - const offsetEdit = new OffsetEdit( - innerChanges.map(c => { - const range = addRangeToPos(editRange.getStartPosition(), c.originalRange); - const startOffset = this._textModel.getOffsetAt(range.getStartPosition()); - const endOffset = this._textModel.getOffsetAt(range.getEndPosition()); - const originalRange = OffsetRange.ofStartAndLength(startOffset, endOffset - startOffset); - - const replaceText = modifiedText.getValueOfRange(c.modifiedRange); - const originalText = this._textModel.getValueInRange(range); - const edit = new SingleOffsetEdit(originalRange, replaceText); - - return reshapeEdit(edit, originalText, innerChanges.length, this._textModel); - }) - ); - - return new UpdatedEdit(offsetEdit, this._textModel, this._modelVersion, true); - } -} - -class UpdatedEdit extends Disposable { - - private _innerEdits: SingleUpdatedEdit[]; - - private _inlineEditModelVersion: number; - public get modelVersion() { return this._inlineEditModelVersion; } - - private _lastChangePartOfInlineEdit = false; - public get lastChangePartOfInlineEdit() { return this._lastChangePartOfInlineEdit; } - - protected readonly _updatedEdit = derivedHandleChanges({ - owner: this, - equalityComparer: equalsIfDefined((a, b) => a?.equals(b)), - createEmptyChangeSummary: () => [] as OffsetEdit[], - handleChange: (context, changeSummary) => { - if (context.didChange(this._modelVersion) && context.change) { - changeSummary.push(OffsetEdits.fromContentChanges(context.change.changes)); - } - return true; - } - }, (reader, changeSummary) => { - this._modelVersion.read(reader); - - for (const change of changeSummary) { - this._innerEdits = this._applyTextModelChanges(change, this._innerEdits); - } - - if (this._innerEdits.length === 0) { - return undefined; - } - - if (this._innerEdits.some(e => e.edit === undefined)) { - throw new BugIndicatingError('UpdatedEdit: Invalid state'); - } - - return new OffsetEdit(this._innerEdits.map(edit => edit.edit!)); - }); - - public get offsetEdit(): IObservable { return this._updatedEdit.map(e => e ?? undefined); } - - constructor( - offsetEdit: OffsetEdit, - private readonly _textModel: ITextModel, - private readonly _modelVersion: IObservableWithChange, - isInlineEdit: boolean, - ) { - super(); - - this._inlineEditModelVersion = this._modelVersion.get() ?? -1; - - this._innerEdits = offsetEdit.edits.map(edit => { - if (isInlineEdit) { - const replacedRange = Range.fromPositions(this._textModel.getPositionAt(edit.replaceRange.start), this._textModel.getPositionAt(edit.replaceRange.endExclusive)); - const replacedText = this._textModel.getValueInRange(replacedRange); - return new SingleUpdatedNextEdit(edit, replacedText); - } - - return new SingleUpdatedCompletion(edit); }); - - this._updatedEdit.recomputeInitiallyAndOnChange(this._store); // make sure to call this after setting `_lastEdit` } - private _applyTextModelChanges(textModelChanges: OffsetEdit, edits: SingleUpdatedEdit[]): SingleUpdatedEdit[] { - for (const innerEdit of edits) { - innerEdit.applyTextModelChanges(textModelChanges); - } - - if (edits.some(edit => edit.edit === undefined)) { - return []; // change is invalid, so we will have to drop the completion - } - - const currentModelVersion = this._modelVersion.get(); - - this._lastChangePartOfInlineEdit = edits.some(edit => edit.lastChangeUpdatedEdit); - if (this._lastChangePartOfInlineEdit) { - this._inlineEditModelVersion = currentModelVersion ?? -1; - } - - if (currentModelVersion === null || this._inlineEditModelVersion + 20 < currentModelVersion) { - return []; // the completion has been ignored for a while, remove it - } - - edits = edits.filter(innerEdit => !innerEdit.edit!.isEmpty); - if (edits.length === 0) { - return []; // the completion has been typed by the user - } - - return edits; + private _findById(id: InlineSuggestionIdentity): InlineSuggestionItem | undefined { + return this.inlineCompletions.find(i => i.identity === id); } - reuse(): void { - this._inlineEditModelVersion = this._modelVersion.get() ?? -1; + private _findByHash(hash: string): InlineSuggestionItem | undefined { + return this.inlineCompletions.find(i => i.hash === hash); + } + + /** + * Applies the edit on the state. + */ + public createStateWithAppliedEdit(edit: OffsetEdit, textModel: ITextModel): InlineCompletionsState { + const newInlineCompletions = this.inlineCompletions.map(i => i.withEdit(edit, textModel)).filter(isDefined); + return new InlineCompletionsState(newInlineCompletions, this.request); + } + + public createStateWithAppliedResults(update: InlineCompletionProviderResult, request: UpdateRequest, textModel: ITextModel, itemToPreserve: InlineSuggestionIdentity | undefined): InlineCompletionsState { + const items: InlineSuggestionItem[] = []; + + for (const item of update.completions) { + const i = InlineSuggestionItem.create(item, textModel); + const oldItem = this._findByHash(i.hash); + if (oldItem) { + items.push(i.withIdentity(oldItem.identity)); + oldItem.setEndOfLifeReason({ kind: InlineCompletionEndOfLifeReasonKind.Ignored, userTypingDisagreed: false, supersededBy: i.getSourceCompletion() }); + } else { + items.push(i); + } + } + + if (itemToPreserve) { + const item = this._findById(itemToPreserve); + if (item && !update.has(item.getSingleTextEdit()) && item.canBeReused(textModel, request.position)) { + items.unshift(item); + } + } + + return new InlineCompletionsState(items, request); + } + + public clone(): InlineCompletionsState { + return new InlineCompletionsState(this.inlineCompletions, this.request); } } - -abstract class SingleUpdatedEdit { - - private _edit: SingleOffsetEdit | undefined; - public get edit() { return this._edit; } - - private _lastChangeUpdatedEdit = false; - public get lastChangeUpdatedEdit() { return this._lastChangeUpdatedEdit; } - - constructor( - edit: SingleOffsetEdit, - ) { - this._edit = edit; - } - - public applyTextModelChanges(textModelChanges: OffsetEdit) { - this._lastChangeUpdatedEdit = false; - - if (!this._edit) { - throw new BugIndicatingError('UpdatedInnerEdits: No edit to apply changes to'); - } - - const result = this.applyChanges(this._edit, textModelChanges); - if (!result) { - this._edit = undefined; - return; - } - - this._edit = result.edit; - this._lastChangeUpdatedEdit = result.editHasChanged; - } - - protected abstract applyChanges(edit: SingleOffsetEdit, textModelChanges: OffsetEdit): { edit: SingleOffsetEdit; editHasChanged: boolean } | undefined; -} - -class SingleUpdatedCompletion extends SingleUpdatedEdit { - - constructor( - edit: SingleOffsetEdit, - ) { - super(edit); - } - - protected applyChanges(edit: SingleOffsetEdit, textModelChanges: OffsetEdit): { edit: SingleOffsetEdit; editHasChanged: boolean } { - const newEditRange = applyEditsToRanges([edit.replaceRange], textModelChanges)[0]; - return { edit: new SingleOffsetEdit(newEditRange, edit.newText), editHasChanged: !newEditRange.equals(edit.replaceRange) }; - } -} - -class SingleUpdatedNextEdit extends SingleUpdatedEdit { - - private _trimmedNewText: string; - private _prefixLength: number; - private _suffixLength: number; - - constructor( - edit: SingleOffsetEdit, - replacedText: string, - ) { - super(edit); - - this._prefixLength = commonPrefixLength(edit.newText, replacedText); - this._suffixLength = commonSuffixLength(edit.newText, replacedText); - this._trimmedNewText = edit.newText.substring(this._prefixLength, edit.newText.length - this._suffixLength); - } - - protected applyChanges(edit: SingleOffsetEdit, textModelChanges: OffsetEdit): { edit: SingleOffsetEdit; editHasChanged: boolean } | undefined { - let editStart = edit.replaceRange.start; - let editEnd = edit.replaceRange.endExclusive; - let editReplaceText = edit.newText; - let editHasChanged = false; - - const shouldPreserveEditShape = this._prefixLength > 0 || this._suffixLength > 0; - - for (let i = textModelChanges.edits.length - 1; i >= 0; i--) { - const change = textModelChanges.edits[i]; - - // INSERTIONS (only support inserting at start of edit) - const isInsertion = change.newText.length > 0 && change.replaceRange.isEmpty; - - if (isInsertion && !shouldPreserveEditShape && change.replaceRange.start === editStart && editReplaceText.startsWith(change.newText)) { - editStart += change.newText.length; - editReplaceText = editReplaceText.substring(change.newText.length); - editEnd = Math.max(editStart, editEnd); - editHasChanged = true; - continue; - } - - if (isInsertion && shouldPreserveEditShape && change.replaceRange.start === editStart + this._prefixLength && this._trimmedNewText.startsWith(change.newText)) { - editEnd += change.newText.length; - editHasChanged = true; - this._prefixLength += change.newText.length; - this._trimmedNewText = this._trimmedNewText.substring(change.newText.length); - continue; - } - - // DELETIONS - const isDeletion = change.newText.length === 0 && change.replaceRange.length > 0; - if (isDeletion && change.replaceRange.start >= editStart + this._prefixLength && change.replaceRange.endExclusive <= editEnd - this._suffixLength) { - // user deleted text IN-BETWEEN the deletion range - editEnd -= change.replaceRange.length; - editHasChanged = true; - continue; - } - - // user did exactly the edit - if (change.equals(edit)) { - editHasChanged = true; - editStart = change.replaceRange.endExclusive; - editReplaceText = ''; - continue; - } - - // MOVE EDIT - if (change.replaceRange.start > editEnd) { - // the change happens after the completion range - continue; - } - if (change.replaceRange.endExclusive < editStart) { - // the change happens before the completion range - editStart += change.newText.length - change.replaceRange.length; - editEnd += change.newText.length - change.replaceRange.length; - continue; - } - - // The change intersects the completion, so we will have to drop the completion - return undefined; - } - - // the resulting edit is a noop as the original and new text are the same - if (this._trimmedNewText.length === 0 && editStart + this._prefixLength === editEnd - this._suffixLength) { - return { edit: new SingleOffsetEdit(new OffsetRange(editStart + this._prefixLength, editStart + this._prefixLength), ''), editHasChanged: true }; - } - - return { edit: new SingleOffsetEdit(new OffsetRange(editStart, editEnd), editReplaceText), editHasChanged }; - } -} - -const emptyRange = new Range(1, 1, 1, 1); - -function reshapeEdit(edit: SingleOffsetEdit, originalText: string, totalInnerEdits: number, textModel: ITextModel): SingleOffsetEdit { - // TODO: EOL are not properly trimmed by the diffAlgorithm #12680 - const eol = textModel.getEOL(); - if (edit.newText.endsWith(eol) && originalText.endsWith(eol)) { - edit = new SingleOffsetEdit(edit.replaceRange.deltaEnd(-eol.length), edit.newText.slice(0, -eol.length)); - } - - // INSERTION - // If the insertion ends with a new line and is inserted at the start of a line which has text, - // we move the insertion to the end of the previous line if possible - if (totalInnerEdits === 1 && edit.replaceRange.isEmpty && edit.newText.includes(eol)) { - edit = reshapeMultiLineInsertion(edit, textModel); - } - - // The diff algorithm extended a simple edit to the entire word - // shrink it back to a simple edit if it is deletion/insertion only - if (totalInnerEdits === 1) { - const prefixLength = commonPrefixLength(originalText, edit.newText); - const suffixLength = commonSuffixLength(originalText.slice(prefixLength), edit.newText.slice(prefixLength)); - - // reshape it back to an insertion - if (prefixLength + suffixLength === originalText.length) { - return new SingleOffsetEdit(edit.replaceRange.deltaStart(prefixLength).deltaEnd(-suffixLength), edit.newText.substring(prefixLength, edit.newText.length - suffixLength)); - } - - // reshape it back to a deletion - if (prefixLength + suffixLength === edit.newText.length) { - return new SingleOffsetEdit(edit.replaceRange.deltaStart(prefixLength).deltaEnd(-suffixLength), ''); - } - } - - return edit; -} - -function reshapeMultiLineInsertion(edit: SingleOffsetEdit, textModel: ITextModel): SingleOffsetEdit { - if (!edit.replaceRange.isEmpty) { - throw new BugIndicatingError('Unexpected original range'); - } - - if (edit.replaceRange.start === 0) { - return edit; - } - - const eol = textModel.getEOL(); - const startPosition = textModel.getPositionAt(edit.replaceRange.start); - const startColumn = startPosition.column; - const startLineNumber = startPosition.lineNumber; - - // If the insertion ends with a new line and is inserted at the start of a line which has text, - // we move the insertion to the end of the previous line if possible - if (startColumn === 1 && startLineNumber > 1 && textModel.getLineLength(startLineNumber) !== 0 && edit.newText.endsWith(eol) && !edit.newText.startsWith(eol)) { - return new SingleOffsetEdit(edit.replaceRange.delta(-1), eol + edit.newText.slice(0, -eol.length)); - } - - return edit; -} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEdit.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEdit.ts index 13e6d6edf74..f7ba383b8f4 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEdit.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEdit.ts @@ -5,13 +5,13 @@ import { SingleTextEdit } from '../../../../common/core/textEdit.js'; import { Command } from '../../../../common/languages.js'; -import { InlineCompletionItem } from './provideInlineCompletions.js'; +import { InlineSuggestionItem } from './inlineSuggestionItem.js'; export class InlineEdit { constructor( public readonly edit: SingleTextEdit, public readonly commands: readonly Command[], - public readonly inlineCompletion: InlineCompletionItem, + public readonly inlineCompletion: InlineSuggestionItem, ) { } public get range() { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts new file mode 100644 index 00000000000..adc37495bd8 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts @@ -0,0 +1,646 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { matchesSubString } from '../../../../../base/common/filters.js'; +import { observableSignal, IObservable } from '../../../../../base/common/observable.js'; +import { commonPrefixLength, commonSuffixLength, splitLines } from '../../../../../base/common/strings.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ISingleEditOperation } from '../../../../common/core/editOperation.js'; +import { applyEditsToRanges, OffsetEdit, SingleOffsetEdit } from '../../../../common/core/offsetEdit.js'; +import { OffsetRange } from '../../../../common/core/offsetRange.js'; +import { Position } from '../../../../common/core/position.js'; +import { getPositionOffsetTransformerFromTextModel, PositionOffsetTransformer } from '../../../../common/core/positionToOffset.js'; +import { Range } from '../../../../common/core/range.js'; +import { SingleTextEdit, StringText, TextEdit } from '../../../../common/core/textEdit.js'; +import { TextLength } from '../../../../common/core/textLength.js'; +import { linesDiffComputers } from '../../../../common/diff/linesDiffComputers.js'; +import { InlineCompletion, InlineCompletionTriggerKind, Command, InlineCompletionWarning, PartialAcceptInfo, InlineCompletionEndOfLifeReason } from '../../../../common/languages.js'; +import { ITextModel, EndOfLinePreference } from '../../../../common/model.js'; +import { TextModelText } from '../../../../common/model/textModelText.js'; +import { IDisplayLocation, InlineSuggestData, InlineSuggestionList, SnippetInfo } from './provideInlineCompletions.js'; +import { singleTextRemoveCommonPrefix } from './singleTextEditHelpers.js'; + +export type InlineSuggestionItem = InlineEditItem | InlineCompletionItem; + +export namespace InlineSuggestionItem { + export function create( + data: InlineSuggestData, + textModel: ITextModel, + ): InlineSuggestionItem { + if (!data.isInlineEdit) { + return InlineCompletionItem.create(data, textModel); + } else { + return InlineEditItem.create(data, textModel); + } + } +} + +abstract class InlineSuggestionItemBase { + constructor( + protected readonly _data: InlineSuggestData, + public readonly identity: InlineSuggestionIdentity, + public readonly displayLocation: InlineSuggestDisplayLocation | undefined + ) { } + + /** + * A reference to the original inline completion list this inline completion has been constructed from. + * Used for event data to ensure referential equality. + */ + public get source(): InlineSuggestionList { return this._data.source; } + + public get isFromExplicitRequest(): boolean { return this._data.context.triggerKind === InlineCompletionTriggerKind.Explicit; } + public get forwardStable(): boolean { return this.source.inlineSuggestions.enableForwardStability ?? false; } + public get editRange(): Range { return this.getSingleTextEdit().range; } + public get targetRange(): Range { return this.displayLocation?.range ?? this.editRange; } + public get insertText(): string { return this.getSingleTextEdit().text; } + public get semanticId(): string { return this.hash; } + public get action(): Command | undefined { return this._sourceInlineCompletion.action; } + public get command(): Command | undefined { return this._sourceInlineCompletion.command; } + public get warning(): InlineCompletionWarning | undefined { return this._sourceInlineCompletion.warning; } + public get showInlineEditMenu(): boolean { return !!this._sourceInlineCompletion.showInlineEditMenu; } + public get hash() { + return JSON.stringify([ + this.getSingleTextEdit().text, + this.getSingleTextEdit().range.getStartPosition().toString() + ]); + } + /** @deprecated */ + public get shownCommand(): Command | undefined { return this._sourceInlineCompletion.shownCommand; } + + + /** + * A reference to the original inline completion this inline completion has been constructed from. + * Used for event data to ensure referential equality. + */ + private get _sourceInlineCompletion(): InlineCompletion { return this._data.sourceInlineCompletion; } + + + public abstract getSingleTextEdit(): SingleTextEdit; + + public abstract withEdit(userEdit: OffsetEdit, textModel: ITextModel): InlineSuggestionItem | undefined; + + public abstract withIdentity(identity: InlineSuggestionIdentity): InlineSuggestionItem; + public abstract canBeReused(model: ITextModel, position: Position): boolean; + + + public addRef(): void { + this.identity.addRef(); + this.source.addRef(); + } + + public removeRef(): void { + this.identity.removeRef(); + this.source.removeRef(); + } + + public reportInlineEditShown(commandService: ICommandService) { + this._data.reportInlineEditShown(commandService, this.insertText); + } + + public reportPartialAccept(acceptedCharacters: number, info: PartialAcceptInfo) { + this._data.reportPartialAccept(acceptedCharacters, info); + } + + public reportEndOfLife(reason: InlineCompletionEndOfLifeReason): void { + this._data.reportEndOfLife(reason); + } + + public setEndOfLifeReason(reason: InlineCompletionEndOfLifeReason): void { + this._data.setEndOfLifeReason(reason); + } + + /** + * Avoid using this method. Instead introduce getters for the needed properties. + */ + public getSourceCompletion(): InlineCompletion { + return this._sourceInlineCompletion; + } +} + +export class InlineSuggestionIdentity { + private static idCounter = 0; + private readonly _onDispose = observableSignal(this); + public readonly onDispose: IObservable = this._onDispose; + + private _refCount = 1; + public readonly id = 'InlineCompletionIdentity' + InlineSuggestionIdentity.idCounter++; + + addRef() { + this._refCount++; + } + + removeRef() { + this._refCount--; + if (this._refCount === 0) { + this._onDispose.trigger(undefined); + } + } +} + +class InlineSuggestDisplayLocation implements IDisplayLocation { + + public static create(displayLocation: IDisplayLocation, textmodel: ITextModel) { + const offsetRange = new OffsetRange( + textmodel.getOffsetAt(displayLocation.range.getStartPosition()), + textmodel.getOffsetAt(displayLocation.range.getEndPosition()) + ); + + return new InlineSuggestDisplayLocation( + offsetRange, + displayLocation.range, + displayLocation.label, + ); + } + + private constructor( + private readonly _offsetRange: OffsetRange, + public readonly range: Range, + public readonly label: string, + ) { } + + public withEdit(edit: OffsetEdit, positionOffsetTransformer: PositionOffsetTransformer): InlineSuggestDisplayLocation | undefined { + const newOffsetRange = applyEditsToRanges([this._offsetRange], edit)[0]; + if (!newOffsetRange || newOffsetRange.length !== this._offsetRange.length) { + return undefined; + } + + const newRange = positionOffsetTransformer.getRange(newOffsetRange); + + return new InlineSuggestDisplayLocation( + newOffsetRange, + newRange, + this.label, + ); + } +} + +export class InlineCompletionItem extends InlineSuggestionItemBase { + public static create( + data: InlineSuggestData, + textModel: ITextModel, + ): InlineCompletionItem { + const identity = new InlineSuggestionIdentity(); + const textEdit = new SingleTextEdit(data.range, data.insertText); + const edit = getPositionOffsetTransformerFromTextModel(textModel).getSingleOffsetEdit(textEdit); + const displayLocation = data.displayLocation ? InlineSuggestDisplayLocation.create(data.displayLocation, textModel) : undefined; + + return new InlineCompletionItem(edit, textEdit, data.range, data.snippetInfo, data.additionalTextEdits, data, identity, displayLocation); + } + + public readonly isInlineEdit = false; + + private constructor( + private readonly _edit: SingleOffsetEdit, + private readonly _textEdit: SingleTextEdit, + private readonly _originalRange: Range, + public readonly snippetInfo: SnippetInfo | undefined, + public readonly additionalTextEdits: readonly ISingleEditOperation[], + + data: InlineSuggestData, + identity: InlineSuggestionIdentity, + displayLocation: InlineSuggestDisplayLocation | undefined, + ) { + super(data, identity, displayLocation); + } + + override getSingleTextEdit(): SingleTextEdit { return this._textEdit; } + + override withIdentity(identity: InlineSuggestionIdentity): InlineCompletionItem { + return new InlineCompletionItem( + this._edit, + this._textEdit, + this._originalRange, + this.snippetInfo, + this.additionalTextEdits, + this._data, + identity, + this.displayLocation + ); + } + + override withEdit(textModelEdit: OffsetEdit, textModel: ITextModel): InlineCompletionItem | undefined { + const newEditRange = applyEditsToRanges([this._edit.replaceRange], textModelEdit); + if (newEditRange.length === 0) { + return undefined; + } + const newEdit = new SingleOffsetEdit(newEditRange[0], this._textEdit.text); + const positionOffsetTransformer = getPositionOffsetTransformerFromTextModel(textModel); + const newTextEdit = positionOffsetTransformer.getSingleTextEdit(newEdit); + + let newDisplayLocation = this.displayLocation; + if (newDisplayLocation) { + newDisplayLocation = newDisplayLocation.withEdit(textModelEdit, positionOffsetTransformer); + if (!newDisplayLocation) { + return undefined; + } + } + + return new InlineCompletionItem( + newEdit, + newTextEdit, + this._originalRange, + this.snippetInfo, + this.additionalTextEdits, + this._data, + this.identity, + newDisplayLocation + ); + } + + override canBeReused(model: ITextModel, position: Position): boolean { + // TODO@hediet I believe this can be simplified to `return true;`, as applying an edit should kick out this suggestion. + const updatedRange = this._textEdit.range; + const result = !!updatedRange + && updatedRange.containsPosition(position) + && this.isVisible(model, position) + && TextLength.ofRange(updatedRange).isGreaterThanOrEqualTo(TextLength.ofRange(this._originalRange)); + return result; + } + + public isVisible(model: ITextModel, cursorPosition: Position): boolean { + const minimizedReplacement = singleTextRemoveCommonPrefix(this.getSingleTextEdit(), model); + if (!this.editRange + || !this._originalRange.getStartPosition().equals(this.editRange.getStartPosition()) + || cursorPosition.lineNumber !== minimizedReplacement.range.startLineNumber + || minimizedReplacement.isEmpty // if the completion is empty after removing the common prefix of the completion and the model, the completion item would not be visible + ) { + return false; + } + + // We might consider comparing by .toLowerText, but this requires GhostTextReplacement + const originalValue = model.getValueInRange(minimizedReplacement.range, EndOfLinePreference.LF); + const filterText = minimizedReplacement.text; + + const cursorPosIndex = Math.max(0, cursorPosition.column - minimizedReplacement.range.startColumn); + + let filterTextBefore = filterText.substring(0, cursorPosIndex); + let filterTextAfter = filterText.substring(cursorPosIndex); + + let originalValueBefore = originalValue.substring(0, cursorPosIndex); + let originalValueAfter = originalValue.substring(cursorPosIndex); + + const originalValueIndent = model.getLineIndentColumn(minimizedReplacement.range.startLineNumber); + if (minimizedReplacement.range.startColumn <= originalValueIndent) { + // Remove indentation + originalValueBefore = originalValueBefore.trimStart(); + if (originalValueBefore.length === 0) { + originalValueAfter = originalValueAfter.trimStart(); + } + filterTextBefore = filterTextBefore.trimStart(); + if (filterTextBefore.length === 0) { + filterTextAfter = filterTextAfter.trimStart(); + } + } + + return filterTextBefore.startsWith(originalValueBefore) + && !!matchesSubString(originalValueAfter, filterTextAfter); + } +} + +export class InlineEditItem extends InlineSuggestionItemBase { + public static create( + data: InlineSuggestData, + textModel: ITextModel, + ): InlineEditItem { + const offsetEdit = getOffsetEdit(textModel, data.range, data.insertText); + const text = new TextModelText(textModel); + const textEdit = TextEdit.fromOffsetEdit(offsetEdit, text); + const singleTextEdit = textEdit.toSingle(text); + const identity = new InlineSuggestionIdentity(); + + const edits = offsetEdit.edits.map(edit => { + const replacedRange = Range.fromPositions(textModel.getPositionAt(edit.replaceRange.start), textModel.getPositionAt(edit.replaceRange.endExclusive)); + const replacedText = textModel.getValueInRange(replacedRange); + return SingleUpdatedNextEdit.create(edit, replacedText); + }); + const displayLocation = data.displayLocation ? InlineSuggestDisplayLocation.create(data.displayLocation, textModel) : undefined; + return new InlineEditItem(offsetEdit, singleTextEdit, data, identity, edits, displayLocation, false, textModel.getVersionId()); + } + + public readonly snippetInfo: SnippetInfo | undefined = undefined; + public readonly additionalTextEdits: readonly ISingleEditOperation[] = []; + public readonly isInlineEdit = true; + + private constructor( + private readonly _edit: OffsetEdit, + private readonly _textEdit: SingleTextEdit, + + data: InlineSuggestData, + + identity: InlineSuggestionIdentity, + private readonly _edits: readonly SingleUpdatedNextEdit[], + displayLocation: InlineSuggestDisplayLocation | undefined, + private readonly _lastChangePartOfInlineEdit = false, + private readonly _inlineEditModelVersion: number, + ) { + super(data, identity, displayLocation); + } + + public get updatedEditModelVersion(): number { return this._inlineEditModelVersion; } + public get updatedEdit(): OffsetEdit { return this._edit; } + + override getSingleTextEdit(): SingleTextEdit { + return this._textEdit; + } + + override withIdentity(identity: InlineSuggestionIdentity): InlineEditItem { + return new InlineEditItem( + this._edit, + this._textEdit, + this._data, + identity, + this._edits, + this.displayLocation, + this._lastChangePartOfInlineEdit, + this._inlineEditModelVersion, + ); + } + + override canBeReused(model: ITextModel, position: Position): boolean { + // TODO@hediet I believe this can be simplified to `return true;`, as applying an edit should kick out this suggestion. + return this._lastChangePartOfInlineEdit && this.updatedEditModelVersion === model.getVersionId(); + } + + override withEdit(textModelChanges: OffsetEdit, textModel: ITextModel): InlineEditItem | undefined { + const edit = this._applyTextModelChanges(textModelChanges, this._edits, textModel); + return edit; + } + + private _applyTextModelChanges(textModelChanges: OffsetEdit, edits: readonly SingleUpdatedNextEdit[], textModel: ITextModel): InlineEditItem | undefined { + edits = edits.map(innerEdit => innerEdit.applyTextModelChanges(textModelChanges)); + + if (edits.some(edit => edit.edit === undefined)) { + return undefined; // change is invalid, so we will have to drop the completion + } + + const newTextModelVersion = textModel.getVersionId(); + + let inlineEditModelVersion = this._inlineEditModelVersion; + const lastChangePartOfInlineEdit = edits.some(edit => edit.lastChangeUpdatedEdit); + if (lastChangePartOfInlineEdit) { + inlineEditModelVersion = newTextModelVersion ?? -1; + } + + if (newTextModelVersion === null || inlineEditModelVersion + 20 < newTextModelVersion) { + return undefined; // the completion has been ignored for a while, remove it + } + + edits = edits.filter(innerEdit => !innerEdit.edit!.isEmpty); + if (edits.length === 0) { + return undefined; // the completion has been typed by the user + } + + const newEdit = new OffsetEdit(edits.map(edit => edit.edit!)); + const positionOffsetTransformer = getPositionOffsetTransformerFromTextModel(textModel); + const newTextEdit = positionOffsetTransformer.getTextEdit(newEdit).toSingle(new TextModelText(textModel)); + + let newDisplayLocation = this.displayLocation; + if (newDisplayLocation) { + newDisplayLocation = newDisplayLocation.withEdit(textModelChanges, positionOffsetTransformer); + if (!newDisplayLocation) { + return undefined; + } + } + + return new InlineEditItem( + newEdit, + newTextEdit, + this._data, + this.identity, + edits, + newDisplayLocation, + lastChangePartOfInlineEdit, + inlineEditModelVersion, + ); + } +} + +function getOffsetEdit(textModel: ITextModel, editRange: Range, replaceText: string): OffsetEdit { + const eol = textModel.getEOL(); + const editOriginalText = textModel.getValueInRange(editRange); + const editReplaceText = replaceText.replace(/\r\n|\r|\n/g, eol); + + const diffAlgorithm = linesDiffComputers.getDefault(); + const lineDiffs = diffAlgorithm.computeDiff( + splitLines(editOriginalText), + splitLines(editReplaceText), + { + ignoreTrimWhitespace: false, + computeMoves: false, + extendToSubwords: true, + maxComputationTimeMs: 500, + } + ); + + const innerChanges = lineDiffs.changes.flatMap(c => c.innerChanges ?? []); + + function addRangeToPos(pos: Position, range: Range): Range { + const start = TextLength.fromPosition(range.getStartPosition()); + return TextLength.ofRange(range).createRange(start.addToPosition(pos)); + } + + const modifiedText = new StringText(editReplaceText); + + const offsetEdit = new OffsetEdit( + innerChanges.map(c => { + const rangeInModel = addRangeToPos(editRange.getStartPosition(), c.originalRange); + const originalRange = getPositionOffsetTransformerFromTextModel(textModel).getOffsetRange(rangeInModel); + + const replaceText = modifiedText.getValueOfRange(c.modifiedRange); + const edit = new SingleOffsetEdit(originalRange, replaceText); + + const originalText = textModel.getValueInRange(rangeInModel); + return reshapeEdit(edit, originalText, innerChanges.length, textModel); + }) + ); + + return offsetEdit; +} + +class SingleUpdatedNextEdit { + public static create( + edit: SingleOffsetEdit, + replacedText: string, + ): SingleUpdatedNextEdit { + const prefixLength = commonPrefixLength(edit.newText, replacedText); + const suffixLength = commonSuffixLength(edit.newText, replacedText); + const trimmedNewText = edit.newText.substring(prefixLength, edit.newText.length - suffixLength); + return new SingleUpdatedNextEdit(edit, trimmedNewText, prefixLength, suffixLength); + } + + public get edit() { return this._edit; } + public get lastChangeUpdatedEdit() { return this._lastChangeUpdatedEdit; } + + constructor( + private _edit: SingleOffsetEdit | undefined, + private _trimmedNewText: string, + private _prefixLength: number, + private _suffixLength: number, + private _lastChangeUpdatedEdit: boolean = false, + ) { + } + + public applyTextModelChanges(textModelChanges: OffsetEdit) { + const c = this._clone(); + c._applyTextModelChanges(textModelChanges); + return c; + } + + private _clone(): SingleUpdatedNextEdit { + return new SingleUpdatedNextEdit( + this._edit, + this._trimmedNewText, + this._prefixLength, + this._suffixLength, + this._lastChangeUpdatedEdit, + ); + } + + private _applyTextModelChanges(textModelChanges: OffsetEdit) { + this._lastChangeUpdatedEdit = false; + + if (!this._edit) { + throw new BugIndicatingError('UpdatedInnerEdits: No edit to apply changes to'); + } + + const result = this._applyChanges(this._edit, textModelChanges); + if (!result) { + this._edit = undefined; + return; + } + + this._edit = result.edit; + this._lastChangeUpdatedEdit = result.editHasChanged; + } + + private _applyChanges(edit: SingleOffsetEdit, textModelChanges: OffsetEdit): { edit: SingleOffsetEdit; editHasChanged: boolean } | undefined { + let editStart = edit.replaceRange.start; + let editEnd = edit.replaceRange.endExclusive; + let editReplaceText = edit.newText; + let editHasChanged = false; + + const shouldPreserveEditShape = this._prefixLength > 0 || this._suffixLength > 0; + + for (let i = textModelChanges.edits.length - 1; i >= 0; i--) { + const change = textModelChanges.edits[i]; + + // INSERTIONS (only support inserting at start of edit) + const isInsertion = change.newText.length > 0 && change.replaceRange.isEmpty; + + if (isInsertion && !shouldPreserveEditShape && change.replaceRange.start === editStart && editReplaceText.startsWith(change.newText)) { + editStart += change.newText.length; + editReplaceText = editReplaceText.substring(change.newText.length); + editEnd = Math.max(editStart, editEnd); + editHasChanged = true; + continue; + } + + if (isInsertion && shouldPreserveEditShape && change.replaceRange.start === editStart + this._prefixLength && this._trimmedNewText.startsWith(change.newText)) { + editEnd += change.newText.length; + editHasChanged = true; + this._prefixLength += change.newText.length; + this._trimmedNewText = this._trimmedNewText.substring(change.newText.length); + continue; + } + + // DELETIONS + const isDeletion = change.newText.length === 0 && change.replaceRange.length > 0; + if (isDeletion && change.replaceRange.start >= editStart + this._prefixLength && change.replaceRange.endExclusive <= editEnd - this._suffixLength) { + // user deleted text IN-BETWEEN the deletion range + editEnd -= change.replaceRange.length; + editHasChanged = true; + continue; + } + + // user did exactly the edit + if (change.equals(edit)) { + editHasChanged = true; + editStart = change.replaceRange.endExclusive; + editReplaceText = ''; + continue; + } + + // MOVE EDIT + if (change.replaceRange.start > editEnd) { + // the change happens after the completion range + continue; + } + if (change.replaceRange.endExclusive < editStart) { + // the change happens before the completion range + editStart += change.newText.length - change.replaceRange.length; + editEnd += change.newText.length - change.replaceRange.length; + continue; + } + + // The change intersects the completion, so we will have to drop the completion + return undefined; + } + + // the resulting edit is a noop as the original and new text are the same + if (this._trimmedNewText.length === 0 && editStart + this._prefixLength === editEnd - this._suffixLength) { + return { edit: new SingleOffsetEdit(new OffsetRange(editStart + this._prefixLength, editStart + this._prefixLength), ''), editHasChanged: true }; + } + + return { edit: new SingleOffsetEdit(new OffsetRange(editStart, editEnd), editReplaceText), editHasChanged }; + } +} + +function reshapeEdit(edit: SingleOffsetEdit, originalText: string, totalInnerEdits: number, textModel: ITextModel): SingleOffsetEdit { + // TODO: EOL are not properly trimmed by the diffAlgorithm #12680 + const eol = textModel.getEOL(); + if (edit.newText.endsWith(eol) && originalText.endsWith(eol)) { + edit = new SingleOffsetEdit(edit.replaceRange.deltaEnd(-eol.length), edit.newText.slice(0, -eol.length)); + } + + // INSERTION + // If the insertion ends with a new line and is inserted at the start of a line which has text, + // we move the insertion to the end of the previous line if possible + if (totalInnerEdits === 1 && edit.replaceRange.isEmpty && edit.newText.includes(eol)) { + edit = reshapeMultiLineInsertion(edit, textModel); + } + + // The diff algorithm extended a simple edit to the entire word + // shrink it back to a simple edit if it is deletion/insertion only + if (totalInnerEdits === 1) { + const prefixLength = commonPrefixLength(originalText, edit.newText); + const suffixLength = commonSuffixLength(originalText.slice(prefixLength), edit.newText.slice(prefixLength)); + + // reshape it back to an insertion + if (prefixLength + suffixLength === originalText.length) { + return new SingleOffsetEdit(edit.replaceRange.deltaStart(prefixLength).deltaEnd(-suffixLength), edit.newText.substring(prefixLength, edit.newText.length - suffixLength)); + } + + // reshape it back to a deletion + if (prefixLength + suffixLength === edit.newText.length) { + return new SingleOffsetEdit(edit.replaceRange.deltaStart(prefixLength).deltaEnd(-suffixLength), ''); + } + } + + return edit; +} + +function reshapeMultiLineInsertion(edit: SingleOffsetEdit, textModel: ITextModel): SingleOffsetEdit { + if (!edit.replaceRange.isEmpty) { + throw new BugIndicatingError('Unexpected original range'); + } + + if (edit.replaceRange.start === 0) { + return edit; + } + + const eol = textModel.getEOL(); + const startPosition = textModel.getPositionAt(edit.replaceRange.start); + const startColumn = startPosition.column; + const startLineNumber = startPosition.lineNumber; + + // If the insertion ends with a new line and is inserted at the start of a line which has text, + // we move the insertion to the end of the previous line if possible + if (startColumn === 1 && startLineNumber > 1 && textModel.getLineLength(startLineNumber) !== 0 && edit.newText.endsWith(eol) && !edit.newText.startsWith(eol)) { + return new SingleOffsetEdit(edit.replaceRange.delta(-1), eol + edit.newText.slice(0, -eol.length)); + } + + return edit; +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts index 49c79efeafe..1cfd4b6aa91 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts @@ -10,14 +10,14 @@ import { onUnexpectedExternalError } from '../../../../../base/common/errors.js' import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { SetMap } from '../../../../../base/common/map.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ISingleEditOperation } from '../../../../common/core/editOperation.js'; import { SingleOffsetEdit } from '../../../../common/core/offsetEdit.js'; import { OffsetRange } from '../../../../common/core/offsetRange.js'; import { Position } from '../../../../common/core/position.js'; import { Range } from '../../../../common/core/range.js'; import { SingleTextEdit } from '../../../../common/core/textEdit.js'; -import { LanguageFeatureRegistry } from '../../../../common/languageFeatureRegistry.js'; -import { Command, InlineCompletion, InlineCompletionContext, InlineCompletionProviderGroupId, InlineCompletions, InlineCompletionsProvider, InlineCompletionTriggerKind } from '../../../../common/languages.js'; +import { InlineCompletionEndOfLifeReason, InlineCompletionEndOfLifeReasonKind, InlineCompletion, InlineCompletionContext, InlineCompletionProviderGroupId, InlineCompletions, InlineCompletionsProvider, InlineCompletionTriggerKind, PartialAcceptInfo } from '../../../../common/languages.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; import { ITextModel } from '../../../../common/model.js'; import { fixBracketsInLine } from '../../../../common/model/bracketPairsTextModelPart/fixBrackets.js'; @@ -26,7 +26,7 @@ import { SnippetParser, Text } from '../../../snippet/browser/snippetParser.js'; import { getReadonlyEmptyArray } from '../utils.js'; export async function provideInlineCompletions( - registry: LanguageFeatureRegistry, + providers: InlineCompletionsProvider[], positionOrRange: Position | Range, model: ITextModel, context: InlineCompletionContext, @@ -39,7 +39,6 @@ export async function provideInlineCompletions( const contextWithUuid = { ...context, requestUuid: requestUuid }; const defaultReplaceRange = positionOrRange instanceof Position ? getDefaultRange(positionOrRange, model) : positionOrRange; - const providers = registry.all(model); const multiMap = new SetMap>(); for (const provider of providers) { @@ -60,13 +59,12 @@ export async function provideInlineCompletions( return result; } - type Result = Promise; - const states = new Map(); + type Result = Promise; - const seen = new Set(); function findPreferredProviderCircle( provider: InlineCompletionsProvider, - stack: InlineCompletionsProvider[] + stack: InlineCompletionsProvider[], + seen: Set, ): InlineCompletionsProvider[] | undefined { stack = [...stack, provider]; if (seen.has(provider)) { return stack; } @@ -75,7 +73,7 @@ export async function provideInlineCompletions( try { const preferred = getPreferredProviders(provider); for (const p of preferred) { - const c = findPreferredProviderCircle(p, stack); + const c = findPreferredProviderCircle(p, stack, seen); if (c) { return c; } } } finally { @@ -84,25 +82,25 @@ export async function provideInlineCompletions( return undefined; } - function queryProviderOrPreferredProvider(provider: InlineCompletionsProvider): Result { + function queryProviderOrPreferredProvider(provider: InlineCompletionsProvider, states: Map): Result { const state = states.get(provider); if (state) { return state; } - const circle = findPreferredProviderCircle(provider, []); + const circle = findPreferredProviderCircle(provider, [], new Set()); if (circle) { onUnexpectedExternalError(new Error(`Inline completions: cyclic yield-to dependency detected.` + ` Path: ${circle.map(s => s.toString ? s.toString() : ('' + s)).join(' -> ')}`)); } - const deferredPromise = new DeferredPromise(); + const deferredPromise = new DeferredPromise(); states.set(provider, deferredPromise.p); (async () => { if (!circle) { const preferred = getPreferredProviders(provider); for (const p of preferred) { - const result = await queryProviderOrPreferredProvider(p); - if (result && result.inlineCompletions.items.length > 0) { + const result = await queryProviderOrPreferredProvider(p, states); + if (result && result.inlineSuggestions.items.length > 0) { // Skip provider return undefined; } @@ -115,7 +113,7 @@ export async function provideInlineCompletions( return deferredPromise.p; } - async function query(provider: InlineCompletionsProvider): Promise { + async function query(provider: InlineCompletionsProvider): Promise { let result: InlineCompletions | null | undefined; try { if (positionOrRange instanceof Position) { @@ -129,13 +127,18 @@ export async function provideInlineCompletions( } if (!result) { return undefined; } - const list = new InlineCompletionList(result, provider); + const data: InlineSuggestData[] = []; + const list = new InlineSuggestionList(result, data, provider); + for (const item of result.items) { + data.push(createInlineCompletionItem(item, list, defaultReplaceRange, model, languageConfigurationService, contextWithUuid)); + } runWhenCancelled(token, () => list.removeRef()); return list; } - const inlineCompletionLists = AsyncIterableObject.fromPromisesResolveOrder(providers.map(queryProviderOrPreferredProvider)); + const states = new Map(); + const inlineCompletionLists = AsyncIterableObject.fromPromisesResolveOrder(providers.map(p => queryProviderOrPreferredProvider(p, states))); if (token.isCancellationRequested) { tokenSource.dispose(true); @@ -143,8 +146,8 @@ export async function provideInlineCompletions( return new InlineCompletionProviderResult([], new Set(), []); } - const result = await addRefAndCreateResult(contextWithUuid, inlineCompletionLists, defaultReplaceRange, model, languageConfigurationService); - tokenSource.dispose(true); // This disposes results that are not referenced. + const result = await addRefAndCreateResult(contextWithUuid, inlineCompletionLists, model); + tokenSource.dispose(true); // This disposes results that are not referenced by now. return result; } @@ -162,43 +165,33 @@ function runWhenCancelled(token: CancellationToken, callback: () => void): IDisp } } -// TODO: check cancellation token! async function addRefAndCreateResult( context: InlineCompletionContext, - inlineCompletionLists: AsyncIterable<(InlineCompletionList | undefined)>, - defaultReplaceRange: Range, + inlineCompletionLists: AsyncIterable<(InlineSuggestionList | undefined)>, model: ITextModel, - languageConfigurationService: ILanguageConfigurationService | undefined ): Promise { // for deduplication - const itemsByHash = new Map(); + const itemsByHash = new Map(); let shouldStop = false; - const lists: InlineCompletionList[] = []; + const lists: InlineSuggestionList[] = []; for await (const completions of inlineCompletionLists) { if (!completions) { continue; } completions.addRef(); lists.push(completions); - for (const item of completions.inlineCompletions.items) { + for (const item of completions.inlineSuggestionsData) { if (!context.includeInlineEdits && (item.isInlineEdit || item.showInlineEditMenu)) { continue; } if (!context.includeInlineCompletions && !(item.isInlineEdit || item.showInlineEditMenu)) { continue; } - const inlineCompletionItem = InlineCompletionItem.from( - item, - completions, - defaultReplaceRange, - model, - languageConfigurationService - ); - itemsByHash.set(inlineCompletionItem.hash(), inlineCompletionItem); + itemsByHash.set(createHashFromSingleTextEdit(item.getSingleTextEdit()), item); // Stop after first visible inline completion if (!(item.isInlineEdit || item.showInlineEditMenu) && context.triggerKind === InlineCompletionTriggerKind.Automatic) { - const minifiedEdit = inlineCompletionItem.toSingleTextEdit().removeCommonPrefix(new TextModelText(model)); + const minifiedEdit = item.getSingleTextEdit().removeCommonPrefix(new TextModelText(model)); if (!minifiedEdit.isEmpty) { shouldStop = true; } @@ -219,13 +212,13 @@ export class InlineCompletionProviderResult implements IDisposable { /** * Free of duplicates. */ - public readonly completions: readonly InlineCompletionItem[], + public readonly completions: readonly InlineSuggestData[], private readonly hashs: Set, - private readonly providerResults: readonly InlineCompletionList[], + private readonly providerResults: readonly InlineSuggestionList[], ) { } - public has(item: InlineCompletionItem): boolean { - return this.hashs.has(item.hash()); + public has(edit: SingleTextEdit): boolean { + return this.hashs.has(createHashFromSingleTextEdit(edit)); } // TODO: This is not complete as it does not take the textmodel into account @@ -241,14 +234,191 @@ export class InlineCompletionProviderResult implements IDisposable { } } +function createHashFromSingleTextEdit(edit: SingleTextEdit): string { + return JSON.stringify([edit.text, edit.range.getStartPosition().toString()]); +} + +function createInlineCompletionItem( + inlineCompletion: InlineCompletion, + source: InlineSuggestionList, + defaultReplaceRange: Range, + textModel: ITextModel, + languageConfigurationService: ILanguageConfigurationService | undefined, + context: InlineCompletionContext, +): InlineSuggestData { + let insertText: string; + let snippetInfo: SnippetInfo | undefined; + let range = inlineCompletion.range ? Range.lift(inlineCompletion.range) : defaultReplaceRange; + + if (typeof inlineCompletion.insertText === 'string') { + insertText = inlineCompletion.insertText; + + if (languageConfigurationService && inlineCompletion.completeBracketPairs) { + insertText = closeBrackets( + insertText, + range.getStartPosition(), + textModel, + languageConfigurationService + ); + + // Modify range depending on if brackets are added or removed + const diff = insertText.length - inlineCompletion.insertText.length; + if (diff !== 0) { + range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff); + } + } + + snippetInfo = undefined; + } else if ('snippet' in inlineCompletion.insertText) { + const preBracketCompletionLength = inlineCompletion.insertText.snippet.length; + + if (languageConfigurationService && inlineCompletion.completeBracketPairs) { + inlineCompletion.insertText.snippet = closeBrackets( + inlineCompletion.insertText.snippet, + range.getStartPosition(), + textModel, + languageConfigurationService + ); + + // Modify range depending on if brackets are added or removed + const diff = inlineCompletion.insertText.snippet.length - preBracketCompletionLength; + if (diff !== 0) { + range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff); + } + } + + const snippet = new SnippetParser().parse(inlineCompletion.insertText.snippet); + + if (snippet.children.length === 1 && snippet.children[0] instanceof Text) { + insertText = snippet.children[0].value; + snippetInfo = undefined; + } else { + insertText = snippet.toString(); + snippetInfo = { + snippet: inlineCompletion.insertText.snippet, + range: range + }; + } + } else { + assertNever(inlineCompletion.insertText); + } + + const displayLocation = inlineCompletion.displayLocation ? { + range: Range.lift(inlineCompletion.displayLocation.range), + label: inlineCompletion.displayLocation.label + } : undefined; + + return new InlineSuggestData( + range, + insertText, + snippetInfo, + displayLocation, + inlineCompletion.additionalTextEdits || getReadonlyEmptyArray(), + inlineCompletion, + source, + context, + inlineCompletion.isInlineEdit ?? false, + ); +} + +export class InlineSuggestData { + private _didShow = false; + private _didReportEndOfLife = false; + private _lastSetEndOfLifeReason: InlineCompletionEndOfLifeReason | undefined = undefined; + + constructor( + public readonly range: Range, + public readonly insertText: string, + public readonly snippetInfo: SnippetInfo | undefined, + public readonly displayLocation: IDisplayLocation | undefined, + public readonly additionalTextEdits: readonly ISingleEditOperation[], + + public readonly sourceInlineCompletion: InlineCompletion, + public readonly source: InlineSuggestionList, + public readonly context: InlineCompletionContext, + public readonly isInlineEdit: boolean, + ) { } + + public get showInlineEditMenu() { return this.sourceInlineCompletion.showInlineEditMenu ?? false; } + + public getSingleTextEdit() { + return new SingleTextEdit(this.range, this.insertText); + } + + public async reportInlineEditShown(commandService: ICommandService, updatedInsertText: string): Promise { + if (this._didShow) { + return; + } + this._didShow = true; + + this.source.provider.handleItemDidShow?.(this.source.inlineSuggestions, this.sourceInlineCompletion, updatedInsertText); + + if (this.sourceInlineCompletion.shownCommand) { + await commandService.executeCommand(this.sourceInlineCompletion.shownCommand.id, ...(this.sourceInlineCompletion.shownCommand.arguments || [])); + } + } + + public reportPartialAccept(acceptedCharacters: number, info: PartialAcceptInfo) { + this.source.provider.handlePartialAccept?.( + this.source.inlineSuggestions, + this.sourceInlineCompletion, + acceptedCharacters, + info + ); + } + + /** + * Sends the end of life event to the provider. + * If no reason is provided, the last set reason is used. + * If no reason was set, the default reason is used. + */ + public reportEndOfLife(reason?: InlineCompletionEndOfLifeReason): void { + if (this._didReportEndOfLife) { + return; + } + this._didReportEndOfLife = true; + + if (!reason) { + reason = this._lastSetEndOfLifeReason ?? { kind: InlineCompletionEndOfLifeReasonKind.Ignored, userTypingDisagreed: false, supersededBy: undefined }; + } + + if (reason.kind === InlineCompletionEndOfLifeReasonKind.Rejected && this.source.provider.handleRejection) { + this.source.provider.handleRejection(this.source.inlineSuggestions, this.sourceInlineCompletion); + } + + if (this.source.provider.handleEndOfLifetime) { + this.source.provider.handleEndOfLifetime(this.source.inlineSuggestions, this.sourceInlineCompletion, reason); + } + } + + /** + * Sets the end of life reason, but does not send the event to the provider yet. + */ + public setEndOfLifeReason(reason: InlineCompletionEndOfLifeReason): void { + this._lastSetEndOfLifeReason = reason; + } +} + +export interface SnippetInfo { + snippet: string; + /* Could be different than the main range */ + range: Range; +} + +export interface IDisplayLocation { + range: Range; + label: string; +} + /** * A ref counted pointer to the computed `InlineCompletions` and the `InlineCompletionsProvider` that * computed them. */ -export class InlineCompletionList { +export class InlineSuggestionList { private refCount = 1; constructor( - public readonly inlineCompletions: InlineCompletions, + public readonly inlineSuggestions: InlineCompletions, + public readonly inlineSuggestionsData: readonly InlineSuggestData[], public readonly provider: InlineCompletionsProvider, ) { } @@ -259,168 +429,15 @@ export class InlineCompletionList { removeRef(): void { this.refCount--; if (this.refCount === 0) { - this.provider.freeInlineCompletions(this.inlineCompletions); + for (const item of this.inlineSuggestionsData) { + // Fallback if it has not been called before + item.reportEndOfLife(); + } + this.provider.freeInlineCompletions(this.inlineSuggestions); } } } -export class InlineCompletionItem { - public static from( - inlineCompletion: InlineCompletion, - source: InlineCompletionList, - defaultReplaceRange: Range, - textModel: ITextModel, - languageConfigurationService: ILanguageConfigurationService | undefined, - ) { - let insertText: string; - let snippetInfo: SnippetInfo | undefined; - let range = inlineCompletion.range ? Range.lift(inlineCompletion.range) : defaultReplaceRange; - - if (typeof inlineCompletion.insertText === 'string') { - insertText = inlineCompletion.insertText; - - if (languageConfigurationService && inlineCompletion.completeBracketPairs) { - insertText = closeBrackets( - insertText, - range.getStartPosition(), - textModel, - languageConfigurationService - ); - - // Modify range depending on if brackets are added or removed - const diff = insertText.length - inlineCompletion.insertText.length; - if (diff !== 0) { - range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff); - } - } - - snippetInfo = undefined; - } else if ('snippet' in inlineCompletion.insertText) { - const preBracketCompletionLength = inlineCompletion.insertText.snippet.length; - - if (languageConfigurationService && inlineCompletion.completeBracketPairs) { - inlineCompletion.insertText.snippet = closeBrackets( - inlineCompletion.insertText.snippet, - range.getStartPosition(), - textModel, - languageConfigurationService - ); - - // Modify range depending on if brackets are added or removed - const diff = inlineCompletion.insertText.snippet.length - preBracketCompletionLength; - if (diff !== 0) { - range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff); - } - } - - const snippet = new SnippetParser().parse(inlineCompletion.insertText.snippet); - - if (snippet.children.length === 1 && snippet.children[0] instanceof Text) { - insertText = snippet.children[0].value; - snippetInfo = undefined; - } else { - insertText = snippet.toString(); - snippetInfo = { - snippet: inlineCompletion.insertText.snippet, - range: range - }; - } - } else { - assertNever(inlineCompletion.insertText); - } - - return new InlineCompletionItem( - insertText, - inlineCompletion.command, - inlineCompletion.shownCommand, - inlineCompletion.action, - range, - insertText, - snippetInfo, - Range.lift(inlineCompletion.showRange) ?? undefined, - inlineCompletion.additionalTextEdits || getReadonlyEmptyArray(), - inlineCompletion, - source, - ); - } - - static ID = 1; - - private _didCallShow = false; - - constructor( - readonly filterText: string, - readonly command: Command | undefined, - /** @deprecated. Use handleItemDidShow */ - readonly shownCommand: Command | undefined, - readonly action: Command | undefined, - readonly range: Range, - readonly insertText: string, - readonly snippetInfo: SnippetInfo | undefined, - readonly cursorShowRange: Range | undefined, - - readonly additionalTextEdits: readonly ISingleEditOperation[], - - - /** - * A reference to the original inline completion this inline completion has been constructed from. - * Used for event data to ensure referential equality. - */ - readonly sourceInlineCompletion: InlineCompletion, - - /** - * A reference to the original inline completion list this inline completion has been constructed from. - * Used for event data to ensure referential equality. - */ - readonly source: InlineCompletionList, - - readonly id = `InlineCompletion:${InlineCompletionItem.ID++}`, - ) { - } - - get isInlineEdit(): boolean { - return this.sourceInlineCompletion.isInlineEdit!!; - } - - public get didShow(): boolean { - return this._didCallShow; - } - public markAsShown(): void { - this._didCallShow = true; - } - - public withRangeInsertTextAndFilterText(updatedRange: Range, updatedInsertText: string, updatedFilterText: string): InlineCompletionItem { - return new InlineCompletionItem( - updatedFilterText, - this.command, - this.shownCommand, - this.action, - updatedRange, - updatedInsertText, - this.snippetInfo, - this.cursorShowRange, - this.additionalTextEdits, - this.sourceInlineCompletion, - this.source, - this.id, - ); - } - - public hash(): string { - return JSON.stringify({ insertText: this.insertText, range: this.range.toString() }); - } - - public toSingleTextEdit(): SingleTextEdit { - return new SingleTextEdit(this.range, this.insertText); - } -} - -export interface SnippetInfo { - snippet: string; - /* Could be different than the main range */ - range: Range; -} - function getDefaultRange(position: Position, model: ITextModel): Range { const word = model.getWordAtPosition(position); const maxColumn = model.getLineMaxColumn(position.lineNumber); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts index 88df0a98a52..bb20091f146 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts @@ -74,7 +74,7 @@ export class SuggestWidgetAdaptor extends Disposable { const candidates = suggestItems .map((suggestItem, index) => { const suggestItemInfo = SuggestItemInfo.fromSuggestion(suggestController, textModel, position, suggestItem, this.isShiftKeyPressed); - const suggestItemTextEdit = singleTextRemoveCommonPrefix(suggestItemInfo.toSingleTextEdit(), textModel); + const suggestItemTextEdit = singleTextRemoveCommonPrefix(suggestItemInfo.getSingleTextEdit(), textModel); const valid = singleTextEditAugments(itemToPreselect, suggestItemTextEdit); return { index, valid, prefixLength: suggestItemTextEdit.text.length, suggestItem }; }) @@ -224,7 +224,7 @@ export class SuggestItemInfo { return new SelectedSuggestionInfo(this.range, this.insertText, this.completionItemKind, this.isSnippetText); } - public toSingleTextEdit(): SingleTextEdit { + public getSingleTextEdit(): SingleTextEdit { return new SingleTextEdit(this.range, this.insertText); } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/structuredLogger.ts b/src/vs/editor/contrib/inlineCompletions/browser/structuredLogger.ts index 8a307df3bd3..a6171e0aa03 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/structuredLogger.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/structuredLogger.ts @@ -18,6 +18,37 @@ export interface IRecordableEditorLogEntry extends IRecordableLogEntry { modelVersion: number; } +export type EditorLogEntryData = IDocumentEventDataSetChangeReason | IDocumentEventFetchStart; +export type LogEntryData = IEventFetchEnd; + +export interface IDocumentEventDataSetChangeReason { + sourceId: 'TextModel.setChangeReason'; + source: 'inlineSuggestion.accept' | 'snippet' | string; + detailedSource?: string; +} + +interface IDocumentEventFetchStart { + sourceId: 'InlineCompletions.fetch'; + kind: 'start'; + requestId: number; +} + +export interface IEventFetchEnd { + sourceId: 'InlineCompletions.fetch'; + kind: 'end'; + requestId: number; + error: string | undefined; + result: IFetchResult[]; +} + +interface IFetchResult { + range: string; + text: string; + isInlineEdit: boolean; + source: string; +} + + /** * The sourceLabel must not contain '@'! */ diff --git a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts index 3d0da6db7e2..bb9c627f3fd 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Permutation, compareBy } from '../../../../base/common/arrays.js'; -import { BugIndicatingError } from '../../../../base/common/errors.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue, ISettableObservable, autorun, transaction, IReader } from '../../../../base/common/observable.js'; import { ContextKeyValue, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; @@ -19,26 +19,6 @@ export function getReadonlyEmptyArray(): readonly T[] { return array; } -export class ColumnRange { - constructor( - public readonly startColumn: number, - public readonly endColumnExclusive: number, - ) { - if (startColumn > endColumnExclusive) { - throw new BugIndicatingError(`startColumn ${startColumn} cannot be after endColumnExclusive ${endColumnExclusive}`); - } - } - - toRange(lineNumber: number): Range { - return new Range(lineNumber, this.startColumn, lineNumber, this.endColumnExclusive); - } - - equals(other: ColumnRange): boolean { - return this.startColumn === other.startColumn - && this.endColumnExclusive === other.endColumnExclusive; - } -} - export function addPositions(pos1: Position, pos2: Position): Position { return new Position(pos1.lineNumber + pos2.lineNumber - 1, pos2.lineNumber === 1 ? pos1.column + pos2.column - 1 : pos2.column); } @@ -101,3 +81,20 @@ export class ObservableContextKeyService { return bindContextKey(key, this._contextKeyService, obs instanceof Function ? obs : reader => obs.read(reader)); } } + +export function wait(ms: number, cancellationToken?: CancellationToken): Promise { + return new Promise(resolve => { + let d: IDisposable | undefined = undefined; + const handle = setTimeout(() => { + if (d) { d.dispose(); } + resolve(); + }, ms); + if (cancellationToken) { + d = cancellationToken.onCancellationRequested(() => { + clearTimeout(handle); + if (d) { d.dispose(); } + resolve(); + }); + } + }); +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts index d592eca09ea..a4215f06f8e 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts @@ -27,11 +27,13 @@ import { LineDecoration } from '../../../../../common/viewLayout/lineDecorations import { RenderLineInput, renderViewLine } from '../../../../../common/viewLayout/viewLineRenderer.js'; import { InlineDecorationType } from '../../../../../common/viewModel.js'; import { GhostText, GhostTextReplacement, IGhostTextLine } from '../../model/ghostText.js'; -import { ColumnRange } from '../../utils.js'; +import { RangeSingleLine } from '../../../../../common/core/rangeSingleLine.js'; +import { ColumnRange } from '../../../../../common/core/columnRange.js'; import { addDisposableListener, getWindow, isHTMLElement, n } from '../../../../../../base/browser/dom.js'; import './ghostTextView.css'; import { IMouseEvent, StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; import { CodeEditorWidget } from '../../../../../browser/widget/codeEditor/codeEditorWidget.js'; +import { TokenWithTextArray } from '../../../../../common/tokens/tokenWithTextArray.js'; export interface IGhostTextWidgetModel { readonly targetTextModel: IObservable; @@ -170,7 +172,7 @@ export class GhostTextView extends Disposable { const syntaxHighlightingEnabled = this._useSyntaxHighlighting.read(reader); const extraClassNames = this._extraClassNames.read(reader); - const { inlineTexts, additionalLines, hiddenRange } = computeGhostTextViewData(ghostText, textModel, GHOST_TEXT_CLASS_NAME + extraClassNames); + const { inlineTexts, additionalLines, hiddenRange, additionalLinesOriginalSuffix } = computeGhostTextViewData(ghostText, textModel, GHOST_TEXT_CLASS_NAME + extraClassNames); const currentLine = textModel.getLineContent(ghostText.lineNumber); const edit = new OffsetEdit(inlineTexts.map(t => SingleOffsetEdit.insert(t.column - 1, t.text))); @@ -178,10 +180,18 @@ export class GhostTextView extends Disposable { const newRanges = edit.getNewTextRanges(); const inlineTextsWithTokens = inlineTexts.map((t, idx) => ({ ...t, tokens: tokens?.[0]?.getTokensInRange(newRanges[idx]) })); - const tokenizedAdditionalLines: LineData[] = additionalLines.map((l, idx) => ({ - content: tokens?.[idx + 1] ?? LineTokens.createEmpty(l.content, this._languageService.languageIdCodec), - decorations: l.decorations, - })); + const tokenizedAdditionalLines: LineData[] = additionalLines.map((l, idx) => { + let content = tokens?.[idx + 1] ?? LineTokens.createEmpty(l.content, this._languageService.languageIdCodec); + if (idx === additionalLines.length - 1 && additionalLinesOriginalSuffix) { + const t = TokenWithTextArray.fromLineTokens(textModel.tokenization.getLineTokens(additionalLinesOriginalSuffix.lineNumber)); + const existingContent = t.slice(additionalLinesOriginalSuffix.columnRange.toZeroBasedOffsetRange()); + content = TokenWithTextArray.fromLineTokens(content).append(existingContent).toLineTokens(content.languageIdCodec); + } + return { + content, + decorations: l.decorations, + }; + }); return { replacedRange, @@ -344,8 +354,9 @@ function computeGhostTextViewData(ghostText: GhostText | GhostTextReplacement, t lastIdx = part.column - 1; } + let additionalLinesOriginalSuffix: RangeSingleLine | undefined = undefined; if (hiddenTextStartColumn !== undefined) { - addToAdditionalLines([{ line: textBufferLine.substring(lastIdx), lineDecorations: [] }], undefined); + additionalLinesOriginalSuffix = new RangeSingleLine(ghostText.lineNumber, new ColumnRange(lastIdx + 1, textBufferLine.length + 1)); } const hiddenRange = hiddenTextStartColumn !== undefined ? new ColumnRange(hiddenTextStartColumn, textBufferLine.length + 1) : undefined; @@ -354,6 +365,7 @@ function computeGhostTextViewData(ghostText: GhostText | GhostTextReplacement, t inlineTexts, additionalLines, hiddenRange, + additionalLinesOriginalSuffix, }; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts index bcf120f4996..7437ac73eb9 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts @@ -45,7 +45,7 @@ export class InlineCompletionsView extends Disposable { ).recomputeInitiallyAndOnChange(this._store); private readonly _inlineEdit = derived(this, reader => this._model.read(reader)?.inlineEditState.read(reader)?.inlineEdit); - private readonly _everHadInlineEdit = derivedObservableWithCache(this, (reader, last) => last || !!this._inlineEdit.read(reader) || !!this._model.read(reader)?.inlineCompletionState.read(reader)?.inlineCompletion?.sourceInlineCompletion.showInlineEditMenu); + private readonly _everHadInlineEdit = derivedObservableWithCache(this, (reader, last) => last || !!this._inlineEdit.read(reader) || !!this._model.read(reader)?.inlineCompletionState.read(reader)?.inlineCompletion?.showInlineEditMenu); protected readonly _inlineEditWidget = derivedDisposable(reader => { if (!this._everHadInlineEdit.read(reader)) { return undefined; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts index 848af4d2613..57d3134c250 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts @@ -6,7 +6,7 @@ import { ChildNode, LiveElement, n } from '../../../../../../../base/browser/dom.js'; import { ActionBar, IActionBarOptions } from '../../../../../../../base/browser/ui/actionbar/actionbar.js'; import { renderIcon } from '../../../../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { KeybindingLabel, unthemedKeybindingLabelOptions } from '../../../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; +import { KeybindingLabel } from '../../../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IAction } from '../../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { ResolvedKeybinding } from '../../../../../../../base/common/keybindings.js'; @@ -18,7 +18,8 @@ import { ICommandService } from '../../../../../../../platform/commands/common/c import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { nativeHoverDelegate } from '../../../../../../../platform/hover/browser/hover.js'; import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; -import { asCssVariable, descriptionForeground, editorActionListForeground, editorHoverBorder } from '../../../../../../../platform/theme/common/colorRegistry.js'; +import { defaultKeybindingLabelStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; +import { asCssVariable, descriptionForeground, editorActionListForeground, editorHoverBorder, keybindingLabelBackground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; import { EditorOption } from '../../../../../../common/config/editorOptions.js'; import { hideInlineCompletionId, inlineSuggestCommitId, jumpToNextInlineEditId, toggleShowCollapsedId } from '../../../controller/commandIds.js'; @@ -119,14 +120,12 @@ export class GutterIndicatorMenuContent { title, gotoAndAccept, reject, - separator(), - - ...extensionCommands, - extensionCommands.length ? separator() : undefined, - toggleCollapsedMode, settings, + extensionCommands.length ? separator() : undefined, + ...extensionCommands, + actionBarFooter ? separator() : undefined, actionBarFooter ]); @@ -194,9 +193,16 @@ function option(props: { }, [ThemeIcon.isThemeIcon(props.icon) ? renderIcon(props.icon) : props.icon.map(icon => renderIcon(icon))]), n.elem('span', {}, [props.title]), n.div({ - style: { marginLeft: 'auto', opacity: '0.6' }, + style: { marginLeft: 'auto' }, ref: elem => { - const keybindingLabel = store.add(new KeybindingLabel(elem, OS, { disableTitle: true, ...unthemedKeybindingLabelOptions })); + const keybindingLabel = store.add(new KeybindingLabel(elem, OS, { + disableTitle: true, + ...defaultKeybindingLabelStyles, + keybindingLabelShadow: undefined, + keybindingLabelBackground: asCssVariable(keybindingLabelBackground), + keybindingLabelBorder: 'transparent', + keybindingLabelBottomBorder: undefined, + })); store.add(autorun(reader => { keybindingLabel.set(props.keybinding.read(reader)); })); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts index 77e3b3fc1b4..fc90fe16e58 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts @@ -8,22 +8,24 @@ import { renderIcon } from '../../../../../../../base/browser/ui/iconLabel/iconL import { Codicon } from '../../../../../../../base/common/codicons.js'; import { BugIndicatingError } from '../../../../../../../base/common/errors.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../../base/common/lifecycle.js'; -import { IObservable, ISettableObservable, constObservable, derived, observableFromEvent, observableValue, runOnChange } from '../../../../../../../base/common/observable.js'; -import { debouncedObservable } from '../../../../../../../base/common/observableInternal/utils.js'; +import { IObservable, ISettableObservable, autorun, constObservable, debouncedObservable, derived, observableFromEvent, observableValue, runOnChange } from '../../../../../../../base/common/observable.js'; import { IAccessibilityService } from '../../../../../../../platform/accessibility/common/accessibility.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; +import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; +import { IEditorMouseEvent } from '../../../../../../browser/editorBrowser.js'; import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; +import { Point } from '../../../../../../browser/point.js'; import { Rect } from '../../../../../../browser/rect.js'; import { HoverService } from '../../../../../../browser/services/hoverService/hoverService.js'; import { HoverWidget } from '../../../../../../browser/services/hoverService/hoverWidget.js'; -import { EditorOption } from '../../../../../../common/config/editorOptions.js'; +import { EditorOption, RenderLineNumbersType } from '../../../../../../common/config/editorOptions.js'; import { LineRange } from '../../../../../../common/core/lineRange.js'; import { OffsetRange } from '../../../../../../common/core/offsetRange.js'; import { StickyScrollController } from '../../../../../stickyScroll/browser/stickyScrollController.js'; import { IInlineEditModel, InlineEditTabAction } from '../inlineEditsViewInterface.js'; -import { inlineEditIndicatorBackground, inlineEditIndicatorPrimaryBackground, inlineEditIndicatorPrimaryForeground, inlineEditIndicatorSecondaryBackground, inlineEditIndicatorSecondaryForeground, inlineEditIndicatorsuccessfulBackground, inlineEditIndicatorsuccessfulForeground } from '../theme.js'; +import { getEditorBlendedColor, inlineEditIndicatorBackground, inlineEditIndicatorPrimaryBackground, inlineEditIndicatorPrimaryBorder, inlineEditIndicatorPrimaryForeground, inlineEditIndicatorSecondaryBackground, inlineEditIndicatorSecondaryBorder, inlineEditIndicatorSecondaryForeground, inlineEditIndicatorsuccessfulBackground, inlineEditIndicatorsuccessfulBorder, inlineEditIndicatorsuccessfulForeground } from '../theme.js'; import { mapOutFalsy, rectToProps } from '../utils/utils.js'; import { GutterIndicatorMenuContent } from './gutterIndicatorMenu.js'; @@ -35,8 +37,7 @@ export class InlineEditsGutterIndicator extends Disposable { return model; } - private readonly _gutterIndicatorBackgroundColor: IObservable; - private readonly _gutterIndicatorForegroundColor: IObservable; + private readonly _gutterIndicatorStyles: IObservable<{ background: string; foreground: string; border: string }>; private readonly _isHoveredOverInlineEditDebounced: IObservable; constructor( @@ -48,22 +49,28 @@ export class InlineEditsGutterIndicator extends Disposable { private readonly _focusIsInMenu: ISettableObservable, @IHoverService private readonly _hoverService: HoverService, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IAccessibilityService accessibilityService: IAccessibilityService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + @IThemeService themeService: IThemeService, ) { super(); - this._gutterIndicatorBackgroundColor = this._tabAction.map(v => { + this._gutterIndicatorStyles = this._tabAction.map((v, reader) => { switch (v) { - case InlineEditTabAction.Inactive: return asCssVariable(inlineEditIndicatorSecondaryBackground); - case InlineEditTabAction.Jump: return asCssVariable(inlineEditIndicatorPrimaryBackground); - case InlineEditTabAction.Accept: return asCssVariable(inlineEditIndicatorsuccessfulBackground); - } - }); - this._gutterIndicatorForegroundColor = this._tabAction.map(v => { - switch (v) { - case InlineEditTabAction.Inactive: return asCssVariable(inlineEditIndicatorSecondaryForeground); - case InlineEditTabAction.Jump: return asCssVariable(inlineEditIndicatorPrimaryForeground); - case InlineEditTabAction.Accept: return asCssVariable(inlineEditIndicatorsuccessfulForeground); + case InlineEditTabAction.Inactive: return { + background: getEditorBlendedColor(inlineEditIndicatorSecondaryBackground, themeService).read(reader).toString(), + foreground: getEditorBlendedColor(inlineEditIndicatorSecondaryForeground, themeService).read(reader).toString(), + border: getEditorBlendedColor(inlineEditIndicatorSecondaryBorder, themeService).read(reader).toString(), + }; + case InlineEditTabAction.Jump: return { + background: getEditorBlendedColor(inlineEditIndicatorPrimaryBackground, themeService).read(reader).toString(), + foreground: getEditorBlendedColor(inlineEditIndicatorPrimaryForeground, themeService).read(reader).toString(), + border: getEditorBlendedColor(inlineEditIndicatorPrimaryBorder, themeService).read(reader).toString() + }; + case InlineEditTabAction.Accept: return { + background: getEditorBlendedColor(inlineEditIndicatorsuccessfulBackground, themeService).read(reader).toString(), + foreground: getEditorBlendedColor(inlineEditIndicatorsuccessfulForeground, themeService).read(reader).toString(), + border: getEditorBlendedColor(inlineEditIndicatorsuccessfulBorder, themeService).read(reader).toString() + }; } }); @@ -74,39 +81,58 @@ export class InlineEditsGutterIndicator extends Disposable { minContentWidthInPx: constObservable(0), })); + this._register(this._editorObs.editor.onMouseMove((e: IEditorMouseEvent) => { + const state = this._state.get(); + if (state === undefined) { return; } + + const el = this._iconRef.element; + const rect = el.getBoundingClientRect(); + const rectangularArea = Rect.fromLeftTopWidthHeight(rect.left, rect.top, rect.width, rect.height); + const point = new Point(e.event.posx, e.event.posy); + this._isHoveredOverIcon.set(rectangularArea.containsPoint(point), undefined); + })); + + this._register(this._editorObs.editor.onDidScrollChange(() => { + this._isHoveredOverIcon.set(false, undefined); + })); + this._isHoveredOverInlineEditDebounced = debouncedObservable(this._isHoveringOverInlineEdit, 100); - if (!accessibilityService.isMotionReduced()) { - this._register(runOnChange(this._isHoveredOverInlineEditDebounced, (isHovering) => { - if (!isHovering) { - return; - } + // pulse animation when hovering inline edit + this._register(runOnChange(this._isHoveredOverInlineEditDebounced, (isHovering) => { + if (isHovering) { + this.triggerAnimation(); + } + })); - // WIGGLE ANIMATION: - /* this._iconRef.element.animate([ - { transform: 'rotate(0) scale(1)', offset: 0 }, - { transform: 'rotate(14.4deg) scale(1.1)', offset: 0.15 }, - { transform: 'rotate(-14.4deg) scale(1.2)', offset: 0.3 }, - { transform: 'rotate(14.4deg) scale(1.1)', offset: 0.45 }, - { transform: 'rotate(-14.4deg) scale(1.2)', offset: 0.6 }, - { transform: 'rotate(0) scale(1)', offset: 1 } - ], { duration: 800 }); */ + this._register(autorun(reader => { + this._indicator.readEffect(reader); + if (this._indicator.element) { + this._editorObs.editor.applyFontInfo(this._indicator.element); + } + })); + } - // PULSE ANIMATION: - this._iconRef.element.animate([ - { - outline: `2px solid ${this._gutterIndicatorBackgroundColor.get()}`, - outlineOffset: '-1px', - offset: 0 - }, - { - outline: `2px solid transparent`, - outlineOffset: '10px', - offset: 1 - }, - ], { duration: 500 }); - })); + public triggerAnimation(): Promise { + if (this._accessibilityService.isMotionReduced()) { + return new Animation(null, null).finished; } + + // PULSE ANIMATION: + const animation = this._iconRef.element.animate([ + { + outline: `2px solid ${this._gutterIndicatorStyles.map(v => v.border).get()}`, + outlineOffset: '-1px', + offset: 0 + }, + { + outline: `2px solid transparent`, + outlineOffset: '10px', + offset: 1 + }, + ], { duration: 500 }); + + return animation.finished; } private readonly _originalRangeObs = mapOutFalsy(this._originalRange); @@ -125,68 +151,211 @@ export class InlineEditsGutterIndicator extends Disposable { ? observableFromEvent(this._stickyScrollController.onDidChangeStickyScrollHeight, () => this._stickyScrollController!.stickyScrollWidgetHeight) : constObservable(0); + private readonly _lineNumberToRender = derived(this, reader => { + if (this._verticalOffset.read(reader) !== 0) { + return ''; + } + + const lineNumber = this._originalRange.read(reader)?.startLineNumber; + const lineNumberOptions = this._editorObs.getOption(EditorOption.lineNumbers).read(reader); + + if (lineNumber === undefined || lineNumberOptions.renderType === RenderLineNumbersType.Off) { + return ''; + } + + if (lineNumberOptions.renderType === RenderLineNumbersType.Interval) { + const cursorPosition = this._editorObs.cursorPosition.read(reader); + if (lineNumber % 10 === 0 || cursorPosition && cursorPosition.lineNumber === lineNumber) { + return lineNumber.toString(); + } + return ''; + } + + if (lineNumberOptions.renderType === RenderLineNumbersType.Relative) { + const cursorPosition = this._editorObs.cursorPosition.read(reader); + if (!cursorPosition) { + return ''; + } + const relativeLineNumber = Math.abs(lineNumber - cursorPosition.lineNumber); + if (relativeLineNumber === 0) { + return lineNumber.toString(); + } + return relativeLineNumber.toString(); + } + + if (lineNumberOptions.renderType === RenderLineNumbersType.Custom) { + if (lineNumberOptions.renderFn) { + return lineNumberOptions.renderFn(lineNumber); + } + return ''; + } + + return lineNumber.toString(); + }); + + private readonly _availableWidthForIcon = derived(this, reader => { + const textModel = this._editorObs.editor.getModel(); + const editor = this._editorObs.editor; + const layout = this._editorObs.layoutInfo.read(reader); + const gutterWidth = layout.decorationsLeft + layout.decorationsWidth - layout.glyphMarginLeft; + + if (!textModel || gutterWidth <= 0) { + return () => 0; + } + + // no glyph margin => the entire gutter width is available as there is no optimal place to put the icon + if (layout.lineNumbersLeft === 0) { + return () => gutterWidth; + } + + const lineNumberOptions = this._editorObs.getOption(EditorOption.lineNumbers).read(reader); + if (lineNumberOptions.renderType === RenderLineNumbersType.Relative || /* likely to flicker */ + lineNumberOptions.renderType === RenderLineNumbersType.Off) { + return () => gutterWidth; + } + + const w = editor.getOption(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; + const rightOfLineNumber = layout.lineNumbersLeft + layout.lineNumbersWidth; + const totalLines = textModel.getLineCount(); + const totalLinesDigits = (totalLines + 1 /* 0 based to 1 based*/).toString().length; + + const offsetDigits: { + firstLineNumberWithDigitCount: number; + topOfLineNumber: number; + usableWidthLeftOfLineNumber: number; + }[] = []; + + // We only need to pre compute the usable width left of the line number for the first line number with a given digit count + for (let digits = 1; digits <= totalLinesDigits; digits++) { + const firstLineNumberWithDigitCount = 10 ** (digits - 1); + const topOfLineNumber = editor.getTopForLineNumber(firstLineNumberWithDigitCount); + const digitsWidth = digits * w; + const usableWidthLeftOfLineNumber = Math.min(gutterWidth, Math.max(0, rightOfLineNumber - digitsWidth - layout.glyphMarginLeft)); + offsetDigits.push({ firstLineNumberWithDigitCount, topOfLineNumber, usableWidthLeftOfLineNumber }); + } + + return (topOffset: number) => { + for (let i = offsetDigits.length - 1; i >= 0; i--) { + if (topOffset >= offsetDigits[i].topOfLineNumber) { + return offsetDigits[i].usableWidthLeftOfLineNumber; + } + } + throw new BugIndicatingError('Could not find avilable width for icon'); + }; + }); + private readonly _layout = derived(this, reader => { const s = this._state.read(reader); if (!s) { return undefined; } const layout = this._editorObs.layoutInfo.read(reader); - const bottomPadding = 1; - const fullViewPort = Rect.fromLeftTopRightBottom(0, 0, layout.width, layout.height - bottomPadding); - const viewPortWithStickyScroll = fullViewPort.withTop(this._stickyScrollHeight.read(reader)); - - const targetVertRange = s.lineOffsetRange.read(reader); - - const space = 1; - - const targetRect = Rect.fromRanges(OffsetRange.fromTo(space + layout.glyphMarginLeft, layout.lineNumbersLeft + layout.lineNumbersWidth + 4), targetVertRange); - - const lineHeight = this._editorObs.getOption(EditorOption.lineHeight).read(reader); + const gutterViewPortPadding = 1; + + // Entire gutter view from top left to bottom right + const gutterWidthWithoutPadding = layout.decorationsLeft + layout.decorationsWidth - layout.glyphMarginLeft - 2 * gutterViewPortPadding; + const gutterHeightWithoutPadding = layout.height - 2 * gutterViewPortPadding; + const gutterViewPortWithStickyScroll = Rect.fromLeftTopWidthHeight(gutterViewPortPadding, gutterViewPortPadding, gutterWidthWithoutPadding, gutterHeightWithoutPadding); + const gutterViewPortWithoutStickyScroll = gutterViewPortWithStickyScroll.withTop(this._stickyScrollHeight.read(reader) + gutterViewPortPadding); + + // The glyph margin area across all relevant lines + const verticalEditRange = s.lineOffsetRange.read(reader); + const gutterEditArea = Rect.fromRanges(OffsetRange.fromTo(gutterViewPortWithoutStickyScroll.left, gutterViewPortWithoutStickyScroll.right), verticalEditRange); + + // The gutter view container (pill) + const pillHeight = lineHeight; const pillOffset = this._verticalOffset.read(reader); - const pillRect = targetRect.withHeight(lineHeight).withWidth(22).translateY(pillOffset); - const pillRectMoved = pillRect.moveToBeContainedIn(viewPortWithStickyScroll); + const pillFullyDockedRect = gutterEditArea.withHeight(pillHeight).translateY(pillOffset); + const pillIsFullyDocked = gutterViewPortWithoutStickyScroll.containsRect(pillFullyDockedRect); - const rect = targetRect; + // The icon which will be rendered in the pill + const iconNoneDocked = this._tabAction.map(action => action === InlineEditTabAction.Accept ? Codicon.keyboardTab : Codicon.arrowRight); + const iconDocked = derived(reader => { + if (this._isHoveredOverIconDebounced.read(reader) || this._isHoveredOverInlineEditDebounced.read(reader)) { + return Codicon.check; + } + if (this._tabAction.read(reader) === InlineEditTabAction.Accept) { + return Codicon.keyboardTab; + } + const cursorLineNumber = this._editorObs.cursorLineNumber.read(reader) ?? 0; + const editStartLineNumber = s.range.read(reader).startLineNumber; + return cursorLineNumber <= editStartLineNumber ? Codicon.keyboardTabAbove : Codicon.keyboardTabBelow; + }); - const iconRect = (targetRect.containsRect(pillRectMoved)) - ? pillRectMoved - : pillRectMoved.moveToBeContainedIn(fullViewPort.intersect(targetRect.union(fullViewPort.withHeight(lineHeight)))!); //viewPortWithStickyScroll.intersect(rect)!; + const idealIconWidth = 22; + const minimalIconWidth = 16; // codicon size + const iconWidth = (pillRect: Rect) => { + const availableWidth = this._availableWidthForIcon.get()(pillRect.bottom + this._editorObs.editor.getScrollTop()) - gutterViewPortPadding; + return Math.max(Math.min(availableWidth, idealIconWidth), minimalIconWidth); + }; + if (pillIsFullyDocked) { + const pillRect = pillFullyDockedRect; + const lineNumberWidth = Math.max(layout.lineNumbersLeft + layout.lineNumbersWidth - gutterViewPortWithStickyScroll.left, 0); + const lineNumberRect = pillRect.withWidth(lineNumberWidth); + const iconWidth = Math.max(Math.min(layout.decorationsWidth, idealIconWidth), minimalIconWidth); + const iconRect = pillRect.withWidth(iconWidth).translateX(lineNumberWidth); - const docked = rect.containsRect(iconRect) && viewPortWithStickyScroll.containsRect(iconRect); - let iconDirecion = (targetRect.containsRect(iconRect) ? 'right' as const - : iconRect.top > targetRect.top ? 'top' as const : 'bottom' as const); - - let icon; - if (docked && (this._isHoveredOverIconDebounced.read(reader) || this._isHoveredOverInlineEditDebounced.read(reader))) { - icon = renderIcon(Codicon.check); - iconDirecion = 'right'; - } else { - icon = this._tabAction.read(reader) === InlineEditTabAction.Accept ? renderIcon(Codicon.keyboardTab) : renderIcon(Codicon.arrowRight); + return { + gutterEditArea, + icon: iconDocked, + iconDirection: 'right' as const, + iconRect, + pillRect, + lineNumberRect, + }; } - let rotation = 0; - switch (iconDirecion) { - case 'right': rotation = 0; break; - case 'bottom': rotation = 90; break; - case 'top': rotation = -90; break; + const pillPartiallyDockedPossibleArea = gutterViewPortWithStickyScroll.intersect(gutterEditArea); // The area in which the pill could be partially docked + const pillIsPartiallyDocked = pillPartiallyDockedPossibleArea && pillPartiallyDockedPossibleArea.height >= pillHeight; + + if (pillIsPartiallyDocked) { + // pillFullyDockedRect is outside viewport, move it into the viewport under sticky scroll as we prefer the pill to not be on top of the sticky scroll + // then move it into the possible area which will only cause it to move if it has to be rendered on top of the sticky scroll + const pillRectMoved = pillFullyDockedRect.moveToBeContainedIn(gutterViewPortWithoutStickyScroll).moveToBeContainedIn(pillPartiallyDockedPossibleArea); + const pillRect = pillRectMoved.withWidth(iconWidth(pillRectMoved)); + const iconRect = pillRect; + + return { + gutterEditArea, + icon: iconDocked, + iconDirection: 'right' as const, + iconRect, + pillRect, + }; } + // pillFullyDockedRect is outside viewport, so move it into viewport + const pillRectMoved = pillFullyDockedRect.moveToBeContainedIn(gutterViewPortWithStickyScroll); + const pillRect = pillRectMoved.withWidth(iconWidth(pillRectMoved)); + const iconRect = pillRect; + + // docked = pill was already in the viewport + const iconDirection = pillRect.top < pillFullyDockedRect.top ? + 'top' as const : + 'bottom' as const; + return { - rect, - icon, - rotation, - docked, + gutterEditArea, + icon: iconNoneDocked, + iconDirection, iconRect, + pillRect, }; }); + private readonly _iconRef = n.ref(); + + public readonly isVisible = this._layout.map(l => !!l); + private readonly _hoverVisible = observableValue(this, false); public readonly isHoverVisible: IObservable = this._hoverVisible; + private readonly _isHoveredOverIcon = observableValue(this, false); private readonly _isHoveredOverIconDebounced: IObservable = debouncedObservable(this._isHoveredOverIcon, 100); + public readonly isHoveredOverIcon: IObservable = this._isHoveredOverIconDebounced; private _showHover(): void { if (this._hoverVisible.get()) { @@ -217,6 +386,7 @@ export class InlineEditsGutterIndicator extends Disposable { }) as HoverWidget | undefined; if (h) { this._hoverVisible.set(true, undefined); + disposableStore.add(this._editorObs.editor.onDidScrollChange(() => h.dispose())); disposableStore.add(h.onDispose(() => { this._hoverVisible.set(false, undefined); disposableStore.dispose(); @@ -235,9 +405,11 @@ export class InlineEditsGutterIndicator extends Disposable { private readonly _indicator = n.div({ class: 'inline-edits-view-gutter-indicator', onclick: () => { - const docked = this._layout.map(l => l && l.docked).get(); + const layout = this._layout.get(); + const acceptOnClick = layout?.icon.get() === Codicon.check; + this._editorObs.editor.focus(); - if (docked) { + if (acceptOnClick) { this.model.accept(); } else { this.model.jump(); @@ -254,7 +426,7 @@ export class InlineEditsGutterIndicator extends Disposable { position: 'absolute', background: asCssVariable(inlineEditIndicatorBackground), borderRadius: '4px', - ...rectToProps(reader => layout.read(reader).rect), + ...rectToProps(reader => layout.read(reader).gutterEditArea), } }), n.div({ @@ -262,34 +434,59 @@ export class InlineEditsGutterIndicator extends Disposable { ref: this._iconRef, onmouseenter: () => { // TODO show hover when hovering ghost text etc. - this._isHoveredOverIcon.set(true, undefined); this._showHover(); }, - onmouseleave: () => { this._isHoveredOverIcon.set(false, undefined); }, style: { cursor: 'pointer', - zIndex: '1000', + zIndex: '20', position: 'absolute', - backgroundColor: this._gutterIndicatorBackgroundColor, - ['--vscodeIconForeground' as any]: this._gutterIndicatorForegroundColor, + backgroundColor: this._gutterIndicatorStyles.map(v => v.background), + ['--vscodeIconForeground' as any]: this._gutterIndicatorStyles.map(v => v.foreground), + border: this._gutterIndicatorStyles.map(v => `1px solid ${v.border}`), + boxSizing: 'border-box', borderRadius: '4px', display: 'flex', - justifyContent: 'center', - transition: 'background-color 0.2s ease-in-out', - ...rectToProps(reader => layout.read(reader).iconRect), + justifyContent: 'flex-end', + transition: 'background-color 0.2s ease-in-out, width 0.2s ease-in-out', + ...rectToProps(reader => layout.read(reader).pillRect), } }, [ n.div({ + className: 'line-number', style: { - rotate: layout.map(i => `${i.rotation}deg`), + lineHeight: layout.map(l => l.lineNumberRect ? l.lineNumberRect.height : 0), + display: layout.map(l => l.lineNumberRect ? 'flex' : 'none'), + alignItems: 'center', + justifyContent: 'flex-end', + width: layout.map(l => l.lineNumberRect ? l.lineNumberRect.width : 0), + height: '100%', + color: this._gutterIndicatorStyles.map(v => v.foreground), + } + }, + this._lineNumberToRender + ), + n.div({ + style: { + rotate: layout.map(l => `${getRotationFromDirection(l.iconDirection)}deg`), transition: 'rotate 0.2s ease-in-out', display: 'flex', alignItems: 'center', justifyContent: 'center', + height: '100%', + marginRight: layout.map(l => l.pillRect.width - l.iconRect.width - (l.lineNumberRect?.width ?? 0)), + width: layout.map(l => l.iconRect.width), } }, [ - layout.map(i => i.icon), + layout.map((l, reader) => renderIcon(l.icon.read(reader))), ]) ]), ])).keepUpdated(this._store); } + +function getRotationFromDirection(direction: 'top' | 'bottom' | 'right'): number { + switch (direction) { + case 'top': return 90; + case 'bottom': return -90; + case 'right': return 0; + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts index 0aac963340b..10394bd1b22 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts @@ -4,23 +4,34 @@ *--------------------------------------------------------------------------------------------*/ import { SingleLineEdit } from '../../../../../common/core/lineEdit.js'; +import { LineRange } from '../../../../../common/core/lineRange.js'; import { Position } from '../../../../../common/core/position.js'; import { AbstractText, TextEdit } from '../../../../../common/core/textEdit.js'; import { Command } from '../../../../../common/languages.js'; -import { InlineCompletionItem } from '../../model/provideInlineCompletions.js'; +import { InlineSuggestionItem } from '../../model/inlineSuggestionItem.js'; export class InlineEditWithChanges { - public readonly lineEdit = SingleLineEdit.fromSingleTextEdit(this.edit.toSingle(this.originalText), this.originalText); + public get lineEdit() { + return SingleLineEdit.fromSingleTextEdit(this.edit.toSingle(this.originalText), this.originalText); + } - public readonly originalLineRange = this.lineEdit.lineRange; - public readonly modifiedLineRange = this.lineEdit.toLineEdit().getNewLineRanges()[0]; + public get originalLineRange() { return this.lineEdit.lineRange; } + public get modifiedLineRange() { return this.lineEdit.toLineEdit().getNewLineRanges()[0]; } + + public get displayRange() { + return this.originalText.lineRange.intersect( + this.originalLineRange.join( + LineRange.ofLength(this.originalLineRange.startLineNumber, this.lineEdit.newLines.length) + ) + )!; + } constructor( public readonly originalText: AbstractText, public readonly edit: TextEdit, public readonly cursorPosition: Position, public readonly commands: readonly Command[], - public readonly inlineCompletion: InlineCompletionItem + public readonly inlineCompletion: InlineSuggestionItem ) { } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts index 9556dfbce7a..62275ba537a 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsModel.ts @@ -3,16 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../../../base/common/event.js'; import { derived, IObservable } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; import { ICodeEditor } from '../../../../../browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; import { LineRange } from '../../../../../common/core/lineRange.js'; import { StringText, TextEdit } from '../../../../../common/core/textEdit.js'; -import { Command } from '../../../../../common/languages.js'; +import { Command, InlineCompletionDisplayLocation } from '../../../../../common/languages.js'; import { InlineCompletionsModel } from '../../model/inlineCompletionsModel.js'; -import { InlineCompletionWithUpdatedRange } from '../../model/inlineCompletionsSource.js'; -import { IInlineEditModel, InlineEditTabAction } from './inlineEditsViewInterface.js'; +import { InlineCompletionItem } from '../../model/inlineSuggestionItem.js'; +import { IInlineEditHost, IInlineEditModel, InlineEditTabAction } from './inlineEditsViewInterface.js'; import { InlineEditWithChanges } from './inlineEditWithChanges.js'; export class InlineEditModel implements IInlineEditModel { @@ -21,9 +22,8 @@ export class InlineEditModel implements IInlineEditModel { readonly displayName: string; readonly extensionCommands: Command[]; + readonly displayLocation: InlineCompletionDisplayLocation | undefined; readonly showCollapsed: IObservable; - readonly inAcceptFlow: IObservable; - readonly inPartialAcceptFlow: IObservable; constructor( private readonly _model: InlineCompletionsModel, @@ -32,10 +32,9 @@ export class InlineEditModel implements IInlineEditModel { ) { this.action = this.inlineEdit.inlineCompletion.action; this.displayName = this.inlineEdit.inlineCompletion.source.provider.displayName ?? localize('inlineEdit', "Inline Edit"); - this.extensionCommands = this.inlineEdit.inlineCompletion.source.inlineCompletions.commands ?? []; + this.extensionCommands = this.inlineEdit.inlineCompletion.source.inlineSuggestions.commands ?? []; - this.inAcceptFlow = this._model.inAcceptFlow; - this.inPartialAcceptFlow = this._model.inPartialAcceptFlow; + this.displayLocation = this.inlineEdit.inlineCompletion.displayLocation; this.showCollapsed = this._model.showCollapsed; } @@ -53,10 +52,21 @@ export class InlineEditModel implements IInlineEditModel { } handleInlineEditShown() { - this._model.handleInlineEditShown(this.inlineEdit.inlineCompletion); + this._model.handleInlineSuggestionShown(this.inlineEdit.inlineCompletion); } } +export class InlineEditHost implements IInlineEditHost { + readonly onDidAccept: Event; + readonly inAcceptFlow: IObservable; + + constructor( + private readonly _model: InlineCompletionsModel, + ) { + this.onDidAccept = this._model.onDidAccept; + this.inAcceptFlow = this._model.inAcceptFlow; + } +} export class GhostTextIndicator { @@ -66,12 +76,12 @@ export class GhostTextIndicator { editor: ICodeEditor, model: InlineCompletionsModel, readonly lineRange: LineRange, - inlineCompletion: InlineCompletionWithUpdatedRange, + inlineCompletion: InlineCompletionItem, ) { const editorObs = observableCodeEditor(editor); const tabAction = derived(this, reader => { if (editorObs.isFocused.read(reader)) { - if (model.inlineCompletionState.read(reader)?.inlineCompletion?.sourceInlineCompletion.showInlineEditMenu) { + if (inlineCompletion.showInlineEditMenu) { return InlineEditTabAction.Accept; } } @@ -82,10 +92,10 @@ export class GhostTextIndicator { model, new InlineEditWithChanges( new StringText(''), - new TextEdit([]), + new TextEdit([inlineCompletion.getSingleTextEdit()]), model.primaryPosition.get(), - inlineCompletion.source.inlineCompletions.commands ?? [], - inlineCompletion.inlineCompletion + inlineCompletion.source.inlineSuggestions.commands ?? [], + inlineCompletion ), tabAction, ); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsNewUsers.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsNewUsers.ts new file mode 100644 index 00000000000..c6ebf022433 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsNewUsers.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { timeout } from '../../../../../../base/common/async.js'; +import { BugIndicatingError } from '../../../../../../base/common/errors.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, autorunWithStore, derived, IObservable, observableValue, runOnChange, runOnChangeWithCancellationToken } from '../../../../../../base/common/observable.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { InlineEditsGutterIndicator } from './components/gutterIndicatorView.js'; +import { IInlineEditHost, IInlineEditModel } from './inlineEditsViewInterface.js'; +import { InlineEditsCollapsedView } from './inlineEditsViews/inlineEditsCollapsedView.js'; + +enum UserKind { + FirstTime = 'firstTime', + SecondTime = 'secondTime', + Active = 'active' +} + +export class InlineEditsOnboardingExperience extends Disposable { + + private readonly _disposables = this._register(new MutableDisposable()); + + private readonly _setupDone = observableValue({ name: 'setupDone' }, false); + + private readonly _activeCompletionId = derived(reader => { + const model = this._model.read(reader); + if (!model) { return undefined; } + + if (!this._setupDone.read(reader)) { return undefined; } + + const indicator = this._indicator.read(reader); + if (!indicator || !indicator.isVisible.read(reader)) { return undefined; } + + return model.inlineEdit.inlineCompletion.identity.id; + }); + + constructor( + private readonly _host: IObservable, + private readonly _model: IObservable, + private readonly _indicator: IObservable, + private readonly _collapsedView: InlineEditsCollapsedView, + @IStorageService private readonly _storageService: IStorageService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + + this._register(this._initializeDebugSetting()); + + // Setup the onboarding experience for new users + this._disposables.value = this.setupNewUserExperience(); + + this._setupDone.set(true, undefined); + } + + private setupNewUserExperience(): IDisposable | undefined { + if (this.getNewUserType() === UserKind.Active) { + return undefined; + } + + const disposableStore = new DisposableStore(); + + let userHasHoveredOverIcon = false; + let inlineEditHasBeenAccepted = false; + let firstTimeUserAnimationCount = 0; + let secondTimeUserAnimationCount = 0; + + // pulse animation for new users + disposableStore.add(runOnChangeWithCancellationToken(this._activeCompletionId, async (id, _, __, token) => { + if (id === undefined) { return; } + let userType = this.getNewUserType(); + + // User Kind Transition + switch (userType) { + case UserKind.FirstTime: { + if (firstTimeUserAnimationCount++ >= 5 || userHasHoveredOverIcon) { + userType = UserKind.SecondTime; + this.setNewUserType(userType); + } + break; + } + case UserKind.SecondTime: { + if (secondTimeUserAnimationCount++ >= 3 && inlineEditHasBeenAccepted) { + userType = UserKind.Active; + this.setNewUserType(userType); + } + break; + } + } + + // Animation + switch (userType) { + case UserKind.FirstTime: { + for (let i = 0; i < 3 && !token.isCancellationRequested; i++) { + await this._indicator.get()?.triggerAnimation(); + await timeout(500); + } + break; + } + case UserKind.SecondTime: { + this._indicator.get()?.triggerAnimation(); + break; + } + } + })); + + disposableStore.add(autorun(reader => { + if (this._collapsedView.isVisible.read(reader)) { + if (this.getNewUserType() !== UserKind.Active) { + this._collapsedView.triggerAnimation(); + } + } + })); + + // Remember when the user has hovered over the icon + disposableStore.add(autorunWithStore((reader, store) => { + const indicator = this._indicator.read(reader); + if (!indicator) { return; } + store.add(runOnChange(indicator.isHoveredOverIcon, async (isHovered) => { + if (isHovered) { + userHasHoveredOverIcon = true; + } + })); + })); + + // Remember when the user has accepted an inline edit + disposableStore.add(autorunWithStore((reader, store) => { + const host = this._host.read(reader); + if (!host) { return; } + store.add(host.onDidAccept(() => { + inlineEditHasBeenAccepted = true; + })); + })); + + return disposableStore; + } + + private getNewUserType(): UserKind { + return this._storageService.get('inlineEditsGutterIndicatorUserKind', StorageScope.APPLICATION, UserKind.FirstTime) as UserKind; + } + + private setNewUserType(value: UserKind): void { + switch (value) { + case UserKind.FirstTime: + throw new BugIndicatingError('UserKind should not be set to first time'); + case UserKind.SecondTime: + break; + case UserKind.Active: + this._disposables.clear(); + break; + } + + this._storageService.store('inlineEditsGutterIndicatorUserKind', value, StorageScope.APPLICATION, StorageTarget.USER); + } + + private _initializeDebugSetting(): IDisposable { + // Debug setting to reset the new user experience + const hiddenDebugSetting = 'editor.inlineSuggest.edits.resetNewUserExperience'; + if (this._configurationService.getValue(hiddenDebugSetting)) { + this._storageService.remove('inlineEditsGutterIndicatorUserKind', StorageScope.APPLICATION); + } + + const disposable = this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(hiddenDebugSetting) && this._configurationService.getValue(hiddenDebugSetting)) { + this._storageService.remove('inlineEditsGutterIndicatorUserKind', StorageScope.APPLICATION); + this._disposables.value = this.setupNewUserExperience(); + } + }); + + return disposable; + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts index 6670696ecdc..cdd3bd12518 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts @@ -20,10 +20,12 @@ import { TextLength } from '../../../../../common/core/textLength.js'; import { DetailedLineRangeMapping, lineRangeMappingFromRangeMappings, RangeMapping } from '../../../../../common/diff/rangeMapping.js'; import { TextModel } from '../../../../../common/model/textModel.js'; import { InlineEditsGutterIndicator } from './components/gutterIndicatorView.js'; -import { InlineEditsIndicator } from './components/indicatorView.js'; import { InlineEditWithChanges } from './inlineEditWithChanges.js'; -import { GhostTextIndicator, InlineEditModel } from './inlineEditsModel.js'; +import { GhostTextIndicator, InlineEditHost, InlineEditModel } from './inlineEditsModel.js'; +import { InlineEditsOnboardingExperience } from './inlineEditsNewUsers.js'; import { IInlineEditModel, InlineEditTabAction } from './inlineEditsViewInterface.js'; +import { InlineEditsCollapsedView } from './inlineEditsViews/inlineEditsCollapsedView.js'; +import { InlineEditsCustomView } from './inlineEditsViews/inlineEditsCustomView.js'; import { InlineEditsDeletionView } from './inlineEditsViews/inlineEditsDeletionView.js'; import { InlineEditsInsertionView } from './inlineEditsViews/inlineEditsInsertionView.js'; import { InlineEditsLineReplacementView } from './inlineEditsViews/inlineEditsLineReplacementView.js'; @@ -51,6 +53,7 @@ export class InlineEditsView extends Disposable { constructor( private readonly _editor: ICodeEditor, + private readonly _host: IObservable, private readonly _model: IObservable, private readonly _ghostTextIndicator: IObservable, private readonly _focusIsInMenu: ISettableObservable, @@ -76,6 +79,7 @@ export class InlineEditsView extends Disposable { this._insertion.onDidClick, ...this._wordReplacementViews.read(reader).map(w => w.onDidClick), this._inlineDiffView.onDidClick, + this._customView.onDidClick, )(e => { if (this._viewHasBeenShownLongerThan(350)) { e.preventDefault(); @@ -89,18 +93,23 @@ export class InlineEditsView extends Disposable { this._wordReplacementViews.recomputeInitiallyAndOnChange(this._store); this._indicatorCyclicDependencyCircuitBreaker.set(true, undefined); + + this._register(this._instantiationService.createInstance(InlineEditsOnboardingExperience, this._host, this._model, this._indicator, this._inlineCollapsedView)); + + this._constructorDone.set(true, undefined); // TODO: remove and use correct initialization order } + private readonly _constructorDone = observableValue(this, false); + private readonly _uiState = derived<{ state: ReturnType; diff: DetailedLineRangeMapping[]; edit: InlineEditWithChanges; newText: string; newTextLineCount: number; - originalDisplayRange: LineRange; } | undefined>(this, reader => { const model = this._model.read(reader); - if (!model) { + if (!model || !this._constructorDone.read(reader)) { return undefined; } @@ -111,13 +120,7 @@ export class InlineEditsView extends Disposable { let newText = inlineEdit.edit.apply(inlineEdit.originalText); let diff = lineRangeMappingFromRangeMappings(mappings, inlineEdit.originalText, new StringText(newText)); - const originalDisplayRange = inlineEdit.originalText.lineRange.intersect( - inlineEdit.originalLineRange.join( - LineRange.ofLength(inlineEdit.originalLineRange.startLineNumber, inlineEdit.lineEdit.newLines.length) - ) - )!; - - let state = this.determineRenderState(model, reader, diff, new StringText(newText), originalDisplayRange); + let state = this.determineRenderState(model, reader, diff, new StringText(newText)); if (!state) { model.abort(`unable to determine view: tried to render ${this._previousView?.view}`); return undefined; @@ -140,7 +143,7 @@ export class InlineEditsView extends Disposable { } if (model.showCollapsed.read(reader) && !this._indicator.read(reader)?.isHoverVisible.read(reader)) { - state = { kind: 'hidden' }; + state = { kind: 'collapsed' }; } return { @@ -149,7 +152,6 @@ export class InlineEditsView extends Disposable { edit: inlineEdit, newText, newTextLineCount: inlineEdit.modifiedLineRange.length, - originalDisplayRange: originalDisplayRange, }; }); @@ -163,7 +165,7 @@ export class InlineEditsView extends Disposable { private readonly _indicatorCyclicDependencyCircuitBreaker = observableValue(this, false); - protected readonly _indicator = derivedWithStore(this, (reader, store) => { + protected readonly _indicator = derivedWithStore(this, (reader, store) => { if (!this._indicatorCyclicDependencyCircuitBreaker.read(reader)) { return undefined; } @@ -175,10 +177,21 @@ export class InlineEditsView extends Disposable { } const state = this._uiState.read(reader); - if (state?.state?.kind === 'insertionMultiLine') { + if (!state) { return undefined; } + + if (state.state?.kind === 'custom') { + const range = state.state.displayLocation?.range; + if (!range) { + throw new BugIndicatingError('custom view should have a range'); + } + return new LineRange(range.startLineNumber, range.endLineNumber); + } + + if (state.state?.kind === 'insertionMultiLine') { return this._insertion.originalLines.read(reader); } - return state?.originalDisplayRange; + + return state.edit.displayRange; }); const modelWithGhostTextSupport = derived(this, reader => { @@ -212,7 +225,8 @@ export class InlineEditsView extends Disposable { || this._deletion.isHovered.read(reader) || this._inlineDiffView.isHovered.read(reader) || this._lineReplacementView.isHovered.read(reader) - || this._insertion.isHovered.read(reader); + || this._insertion.isHovered.read(reader) + || this._customView.isHovered.read(reader); }); private readonly _gutterIndicatorOffset = derived(this, reader => { @@ -228,9 +242,7 @@ export class InlineEditsView extends Disposable { this._model.map(m => m?.inlineEdit), this._previewTextModel, this._uiState.map(s => s && s.state?.kind === 'sideBySide' ? ({ - edit: s.edit, newTextLineCount: s.newTextLineCount, - originalDisplayRange: s.originalDisplayRange, }) : undefined), this._tabAction, )); @@ -258,7 +270,7 @@ export class InlineEditsView extends Disposable { private readonly _inlineDiffViewState = derived(this, reader => { const e = this._uiState.read(reader); if (!e || !e.state) { return undefined; } - if (e.state.kind === 'wordReplacements' || e.state.kind === 'lineReplacement' || e.state.kind === 'insertionMultiLine' || e.state.kind === 'hidden') { + if (e.state.kind === 'wordReplacements' || e.state.kind === 'lineReplacement' || e.state.kind === 'insertionMultiLine' || e.state.kind === 'collapsed' || e.state.kind === 'custom') { return undefined; } return { @@ -269,10 +281,21 @@ export class InlineEditsView extends Disposable { }; }); + protected readonly _inlineCollapsedView = this._register(this._instantiationService.createInstance(InlineEditsCollapsedView, + this._editor, + this._model.map((m, reader) => this._uiState.read(reader)?.state?.kind === 'collapsed' ? m?.inlineEdit : undefined) + )); + + protected readonly _customView = this._register(this._instantiationService.createInstance(InlineEditsCustomView, + this._editor, + this._model.map((m, reader) => this._uiState.read(reader)?.state?.kind === 'custom' ? m?.displayLocation : undefined), + this._tabAction, + )); + protected readonly _inlineDiffView = this._register(new OriginalEditorInlineDiffView(this._editor, this._inlineDiffViewState, this._previewTextModel)); protected readonly _wordReplacementViews = mapObservableArrayCached(this, this._uiState.map(s => s?.state?.kind === 'wordReplacements' ? s.state.replacements : []), (e, store) => { - return store.add(this._instantiationService.createInstance(InlineEditsWordReplacementView, this._editorObs, e, [e], this._tabAction)); + return store.add(this._instantiationService.createInstance(InlineEditsWordReplacementView, this._editorObs, e, this._tabAction)); }); protected readonly _lineReplacementView = this._register(this._instantiationService.createInstance(InlineEditsLineReplacementView, @@ -287,15 +310,10 @@ export class InlineEditsView extends Disposable { )); private getCacheId(model: IInlineEditModel) { - const inlineEdit = model.inlineEdit; - if (model.inPartialAcceptFlow.get()) { - return `${inlineEdit.inlineCompletion.id}_${inlineEdit.edit.edits.map(innerEdit => innerEdit.range.toString() + innerEdit.text).join(',')}`; - } - - return inlineEdit.inlineCompletion.id; + return model.inlineEdit.inlineCompletion.identity.id; } - private determineView(model: IInlineEditModel, reader: IReader, diff: DetailedLineRangeMapping[], newText: StringText, originalDisplayRange: LineRange): string { + private determineView(model: IInlineEditModel, reader: IReader, diff: DetailedLineRangeMapping[], newText: StringText): string { // Check if we can use the previous view if it is the same InlineCompletion as previously shown const inlineEdit = model.inlineEdit; const canUseCache = this._previousView?.id === this.getCacheId(model); @@ -309,6 +327,10 @@ export class InlineEditsView extends Disposable { return this._previousView!.view; } + if (model.displayLocation) { + return 'custom'; + } + // Determine the view based on the edit / diff const inner = diff.flatMap(d => d.innerChanges ?? []); @@ -342,8 +364,13 @@ export class InlineEditsView extends Disposable { return 'wordReplacements'; } } + if (numOriginalLines > 0 && numModifiedLines > 0) { - if (this._renderSideBySide.read(reader) !== 'never' && InlineEditsSideBySideView.fitsInsideViewport(this._editor, inlineEdit, originalDisplayRange, reader)) { + if (numOriginalLines === 1 && numModifiedLines === 1) { + return 'lineReplacement'; + } + + if (this._renderSideBySide.read(reader) !== 'never' && InlineEditsSideBySideView.fitsInsideViewport(this._editor, this._previewTextModel, inlineEdit, reader)) { return 'sideBySide'; } @@ -353,17 +380,18 @@ export class InlineEditsView extends Disposable { return 'sideBySide'; } - private determineRenderState(model: IInlineEditModel, reader: IReader, diff: DetailedLineRangeMapping[], newText: StringText, originalDisplayRange: LineRange) { + private determineRenderState(model: IInlineEditModel, reader: IReader, diff: DetailedLineRangeMapping[], newText: StringText) { const inlineEdit = model.inlineEdit; - const view = this.determineView(model, reader, diff, newText, originalDisplayRange); + const view = this.determineView(model, reader, diff, newText); this._previousView = { id: this.getCacheId(model), view, editorWidth: this._editor.getLayoutInfo().width, timestamp: Date.now() }; switch (view) { + case 'custom': return { kind: 'custom' as const, displayLocation: model.displayLocation }; case 'insertionInline': return { kind: 'insertionInline' as const }; case 'sideBySide': return { kind: 'sideBySide' as const }; - case 'hidden': return { kind: 'hidden' as const }; + case 'collapsed': return { kind: 'collapsed' as const }; } const inner = diff.flatMap(d => d.innerChanges ?? []); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.ts index a00fc94d390..0e2d21c8540 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.ts @@ -6,7 +6,7 @@ import { IMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; import { Event } from '../../../../../../base/common/event.js'; import { IObservable } from '../../../../../../base/common/observable.js'; -import { Command } from '../../../../../common/languages.js'; +import { Command, InlineCompletionDisplayLocation } from '../../../../../common/languages.js'; import { InlineEditWithChanges } from './inlineEditWithChanges.js'; export enum InlineEditTabAction { @@ -20,15 +20,19 @@ export interface IInlineEditsView { onDidClick: Event; } +export interface IInlineEditHost { + readonly onDidAccept: Event; + inAcceptFlow: IObservable; +} + export interface IInlineEditModel { displayName: string; action: Command | undefined; extensionCommands: Command[]; inlineEdit: InlineEditWithChanges; - tabAction: IObservable; - inAcceptFlow: IObservable; - inPartialAcceptFlow: IObservable; + showCollapsed: IObservable; + displayLocation: InlineCompletionDisplayLocation | undefined; handleInlineEditShown(): void; accept(): void; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts index c20a1bcf546..69e0c7c1b32 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts @@ -16,7 +16,7 @@ import { TextModelText } from '../../../../../common/model/textModelText.js'; import { InlineCompletionsModel } from '../../model/inlineCompletionsModel.js'; import { InlineEdit } from '../../model/inlineEdit.js'; import { InlineEditWithChanges } from './inlineEditWithChanges.js'; -import { GhostTextIndicator, InlineEditModel } from './inlineEditsModel.js'; +import { GhostTextIndicator, InlineEditHost, InlineEditModel } from './inlineEditsModel.js'; import { InlineEditsView } from './inlineEditsView.js'; import { InlineEditTabAction } from './inlineEditsViewInterface.js'; @@ -33,11 +33,10 @@ export class InlineEditsViewAndDiffProducer extends Disposable { // TODO: This c const textModel = this._editor.getModel(); if (!textModel) { return undefined; } - const editOffset = model.inlineEditState.get()?.inlineCompletion.updatedEdit.read(reader); + const editOffset = model.inlineEditState.get()?.inlineCompletion.updatedEdit; if (!editOffset) { return undefined; } - const offsetEdits = model.inPartialAcceptFlow.read(reader) ? [editOffset.edits[0]] : editOffset.edits; - const edits = offsetEdits.map(e => { + const edits = editOffset.edits.map(e => { const innerEditRange = Range.fromPositions( textModel.getPositionAt(e.replaceRange.start), textModel.getPositionAt(e.replaceRange.endExclusive) @@ -68,6 +67,12 @@ export class InlineEditsViewAndDiffProducer extends Disposable { // TODO: This c return new InlineEditModel(model, edit, tabAction); }); + private readonly _inlineEditHost = derived(this, reader => { + const model = this._model.read(reader); + if (!model) { return undefined; } + return new InlineEditHost(model); + }); + private readonly _ghostTextIndicator = derived(this, reader => { const model = this._model.read(reader); if (!model) { return undefined; } @@ -76,7 +81,7 @@ export class InlineEditsViewAndDiffProducer extends Disposable { // TODO: This c const inlineCompletion = state.inlineCompletion; if (!inlineCompletion) { return undefined; } - if (!inlineCompletion.sourceInlineCompletion.showInlineEditMenu) { + if (!inlineCompletion.showInlineEditMenu) { return undefined; } @@ -96,6 +101,6 @@ export class InlineEditsViewAndDiffProducer extends Disposable { // TODO: This c this._editorObs = observableCodeEditor(this._editor); - this._register(instantiationService.createInstance(InlineEditsView, this._editor, this._inlineEditModel, this._ghostTextIndicator, this._focusIsInMenu)); + this._register(instantiationService.createInstance(InlineEditsView, this._editor, this._inlineEditHost, this._inlineEditModel, this._ghostTextIndicator, this._focusIsInMenu)); } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsCollapsedView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsCollapsedView.ts new file mode 100644 index 00000000000..458aacf7533 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsCollapsedView.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { n } from '../../../../../../../base/browser/dom.js'; +import { IMouseEvent } from '../../../../../../../base/browser/mouseEvent.js'; +import { Emitter } from '../../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { constObservable, derived, IObservable } from '../../../../../../../base/common/observable.js'; +import { IAccessibilityService } from '../../../../../../../platform/accessibility/common/accessibility.js'; +import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; +import { ICodeEditor } from '../../../../../../browser/editorBrowser.js'; +import { ObservableCodeEditor, observableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; +import { Point } from '../../../../../../browser/point.js'; +import { singleTextRemoveCommonPrefix } from '../../../model/singleTextEditHelpers.js'; +import { IInlineEditsView } from '../inlineEditsViewInterface.js'; +import { InlineEditWithChanges } from '../inlineEditWithChanges.js'; +import { inlineEditIndicatorPrimaryBorder } from '../theme.js'; +import { PathBuilder } from '../utils/utils.js'; + +export class InlineEditsCollapsedView extends Disposable implements IInlineEditsView { + + private readonly _onDidClick = this._register(new Emitter()); + readonly onDidClick = this._onDidClick.event; + + private readonly _editorObs: ObservableCodeEditor; + private readonly _iconRef = n.ref(); + + readonly isVisible: IObservable; + + constructor( + private readonly _editor: ICodeEditor, + private readonly _edit: IObservable, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + ) { + super(); + + this._editorObs = observableCodeEditor(this._editor); + + const firstEdit = this._edit.map(inlineEdit => inlineEdit?.edit.edits[0] ?? null); + + const startPosition = firstEdit.map(edit => edit ? singleTextRemoveCommonPrefix(edit, this._editor.getModel()!).range.getStartPosition() : null); + const observedStartPoint = this._editorObs.observePosition(startPosition, this._store); + const startPoint = derived(reader => { + const point = observedStartPoint.read(reader); + if (!point) { return null; } + + const contentLeft = this._editorObs.layoutInfoContentLeft.read(reader); + const scrollLeft = this._editorObs.scrollLeft.read(reader); + return new Point(contentLeft + point.x - scrollLeft, point.y); + }); + + const overlayElement = n.div({ + class: 'inline-edits-collapsed-view', + style: { + position: 'absolute', + overflow: 'visible', + top: '0px', + left: '0px', + display: 'block', + }, + }, [ + [this.getCollapsedIndicator(startPoint)], + ]).keepUpdated(this._store).element; + + this._register(this._editorObs.createOverlayWidget({ + domNode: overlayElement, + position: constObservable(null), + allowEditorOverflow: false, + minContentWidthInPx: constObservable(0), + })); + + this.isVisible = this._edit.map((inlineEdit, reader) => !!inlineEdit && startPoint.read(reader) !== null); + } + + public triggerAnimation(): Promise { + if (this._accessibilityService.isMotionReduced()) { + return new Animation(null, null).finished; + } + + // PULSE ANIMATION: + const animation = this._iconRef.element.animate([ + { offset: 0.00, transform: 'translateY(-3px)', }, + { offset: 0.20, transform: 'translateY(1px)', }, + { offset: 0.36, transform: 'translateY(-1px)', }, + { offset: 0.52, transform: 'translateY(1px)', }, + { offset: 0.68, transform: 'translateY(-1px)', }, + { offset: 0.84, transform: 'translateY(1px)', }, + { offset: 1.00, transform: 'translateY(0px)', }, + ], { duration: 2000 }); + + return animation.finished; + } + + private getCollapsedIndicator(startPoint: IObservable) { + const contentLeft = this._editorObs.layoutInfoContentLeft; + const startPointTranslated = startPoint.map((p, reader) => p ? p.deltaX(-contentLeft.read(reader)) : null); + const iconPath = this.createIconPath(startPointTranslated); + + return n.svg({ + class: 'collapsedView', + ref: this._iconRef, + style: { + position: 'absolute', + top: 0, + left: contentLeft, + width: this._editorObs.contentWidth, + height: this._editorObs.editor.getContentHeight(), + overflow: 'hidden', + pointerEvents: 'none', + } + }, [ + n.svgElem('path', { + class: 'collapsedViewPath', + d: iconPath, + fill: asCssVariable(inlineEditIndicatorPrimaryBorder), + }), + ]); + } + + private createIconPath(indicatorPoint: IObservable): IObservable { + const width = 6; + const triangleHeight = 3; + const baseHeight = 1; + + return indicatorPoint.map(point => { + if (!point) { return new PathBuilder().build(); } + const baseTopLeft = point.deltaX(-width / 2).deltaY(-baseHeight); + const baseTopRight = baseTopLeft.deltaX(width); + const baseBottomLeft = baseTopLeft.deltaY(baseHeight); + const baseBottomRight = baseTopRight.deltaY(baseHeight); + const triangleBottomCenter = baseBottomLeft.deltaX(width / 2).deltaY(triangleHeight); + return new PathBuilder() + .moveTo(baseTopLeft) + .lineTo(baseTopRight) + .lineTo(baseBottomRight) + .lineTo(triangleBottomCenter) + .lineTo(baseBottomLeft) + .lineTo(baseTopLeft) + .build(); + }); + } + + readonly isHovered = constObservable(false); +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsCustomView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsCustomView.ts new file mode 100644 index 00000000000..16fad4d39ac --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsCustomView.ts @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { getWindow, n } from '../../../../../../../base/browser/dom.js'; +import { IMouseEvent, StandardMouseEvent } from '../../../../../../../base/browser/mouseEvent.js'; +import { Emitter } from '../../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { autorun, constObservable, derived, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; +import { editorBackground } from '../../../../../../../platform/theme/common/colorRegistry.js'; +import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; +import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; +import { ICodeEditor } from '../../../../../../browser/editorBrowser.js'; +import { ObservableCodeEditor, observableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; +import { Rect } from '../../../../../../browser/rect.js'; +import { LineSource, renderLines, RenderOptions } from '../../../../../../browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; +import { EditorOption } from '../../../../../../common/config/editorOptions.js'; +import { LineRange } from '../../../../../../common/core/lineRange.js'; +import { InlineCompletionDisplayLocation } from '../../../../../../common/languages.js'; +import { ILanguageService } from '../../../../../../common/languages/language.js'; +import { LineTokens } from '../../../../../../common/tokens/lineTokens.js'; +import { TokenArray } from '../../../../../../common/tokens/tokenArray.js'; +import { IInlineEditsView, InlineEditTabAction } from '../inlineEditsViewInterface.js'; +import { getEditorBlendedColor, inlineEditIndicatorPrimaryBackground, inlineEditIndicatorSecondaryBackground, inlineEditIndicatorsuccessfulBackground } from '../theme.js'; +import { maxContentWidthInRange, rectToProps } from '../utils/utils.js'; + +export class InlineEditsCustomView extends Disposable implements IInlineEditsView { + + private readonly _onDidClick = this._register(new Emitter()); + readonly onDidClick = this._onDidClick.event; + + private readonly _isHovered = observableValue(this, false); + readonly isHovered: IObservable = this._isHovered; + private readonly _viewRef = n.ref(); + + private readonly _editorObs: ObservableCodeEditor; + + constructor( + private readonly _editor: ICodeEditor, + displayLocation: IObservable, + tabAction: IObservable, + @IThemeService themeService: IThemeService, + @ILanguageService private readonly _languageService: ILanguageService, + ) { + super(); + + this._editorObs = observableCodeEditor(this._editor); + + /* const styles = derived(reader => ({ + background: getEditorBlendedColor(modifiedChangedLineBackgroundColor, themeService).read(reader).toString(), + border: asCssVariable(getModifiedBorderColor(tabAction).read(reader)), + })); */ + + const styles = tabAction.map((v, reader) => { + let border; + switch (v) { + case InlineEditTabAction.Inactive: border = inlineEditIndicatorSecondaryBackground; break; + case InlineEditTabAction.Jump: border = inlineEditIndicatorPrimaryBackground; break; + case InlineEditTabAction.Accept: border = inlineEditIndicatorsuccessfulBackground; break; + } + return { + border: getEditorBlendedColor(border, themeService).read(reader).toString(), + background: asCssVariable(editorBackground) + }; + }); + + /* const styles = derived(reader => ({ + background: asCssVariable(editorBackground), + border: asCssVariable(getModifiedBorderColor(tabAction).read(reader)), + })); */ + + const state = displayLocation.map(dl => dl ? this.getState(dl) : undefined); + + const view = state.map(s => s ? this.getRendering(s, styles) : undefined); + + const overlay = n.div({ + class: 'inline-edits-custom-view', + style: { + position: 'absolute', + overflow: 'visible', + top: '0px', + left: '0px', + display: 'block', + }, + }, [view]).keepUpdated(this._store); + + this._register(this._editorObs.createOverlayWidget({ + domNode: overlay.element, + position: constObservable(null), + allowEditorOverflow: false, + minContentWidthInPx: constObservable(0), + })); + + this._register(autorun((reader) => { + const v = view.read(reader); + if (!v) { this._isHovered.set(false, undefined); return; } + this._isHovered.set(overlay.isHovered.read(reader), undefined); + })); + } + + private getState(displayLocation: InlineCompletionDisplayLocation): { rect: IObservable; label: string } { + + const contentState = derived((reader) => { + const startLineNumber = displayLocation.range.startLineNumber; + const endLineNumber = displayLocation.range.endLineNumber; + const startColumn = displayLocation.range.startColumn; + const endColumn = displayLocation.range.endColumn; + const lineCount = this._editor.getModel()?.getLineCount() ?? 0; + + const lineWidth = maxContentWidthInRange(this._editorObs, new LineRange(startLineNumber, startLineNumber + 1), reader); + const lineWidthBelow = startLineNumber + 1 <= lineCount ? maxContentWidthInRange(this._editorObs, new LineRange(startLineNumber + 1, startLineNumber + 2), reader) : undefined; + const lineWidthAbove = startLineNumber - 1 >= 1 ? maxContentWidthInRange(this._editorObs, new LineRange(startLineNumber - 1, startLineNumber), reader) : undefined; + const startContentLeftOffset = this._editor.getOffsetForColumn(startLineNumber, startColumn); + const endContentLeftOffset = this._editor.getOffsetForColumn(endLineNumber, endColumn); + + return { + lineWidth, + lineWidthBelow, + lineWidthAbove, + startContentLeftOffset, + endContentLeftOffset + }; + }); + + const minEndOfLinePadding = 14; + const paddingVertically = 0; + const paddingHorizontally = 4; + const horizontalOffsetWhenAboveBelow = 4; + const verticalOffsetWhenAboveBelow = 2; + // !! minEndOfLinePadding should always be larger than paddingHorizontally + horizontalOffsetWhenAboveBelow + + const rect = derived((reader) => { + const w = this._editorObs.getOption(EditorOption.fontInfo).read(reader).typicalHalfwidthCharacterWidth; + + const startLineNumber = displayLocation.range.startLineNumber; + const endLineNumber = displayLocation.range.endLineNumber; + const { lineWidth, lineWidthBelow, lineWidthAbove, startContentLeftOffset, endContentLeftOffset } = contentState.read(reader); + + const contentLeft = this._editorObs.layoutInfoContentLeft.read(reader); + const lineHeight = this._editorObs.getOption(EditorOption.lineHeight).read(reader); + const scrollTop = this._editorObs.scrollTop.read(reader); + const scrollLeft = this._editorObs.scrollLeft.read(reader); + + let position: 'end' | 'below' | 'above'; + if (startLineNumber === endLineNumber && endContentLeftOffset + 5 * w >= lineWidth) { + position = 'end'; // Render at the end of the line if the range ends almost at the end of the line + } else if (lineWidthBelow !== undefined && lineWidthBelow + minEndOfLinePadding - horizontalOffsetWhenAboveBelow - paddingHorizontally < startContentLeftOffset) { + position = 'below'; // Render Below if possible + } else if (lineWidthAbove !== undefined && lineWidthAbove + minEndOfLinePadding - horizontalOffsetWhenAboveBelow - paddingHorizontally < startContentLeftOffset) { + position = 'above'; // Render Above if possible + } else { + position = 'end'; // Render at the end of the line otherwise + } + + let topOfLine; + let contentStartOffset; + let deltaX = 0; + let deltaY = 0; + + switch (position) { + case 'end': { + topOfLine = this._editorObs.editor.getTopForLineNumber(startLineNumber); + contentStartOffset = lineWidth; + deltaX = paddingHorizontally + minEndOfLinePadding; + break; + } + case 'below': { + topOfLine = this._editorObs.editor.getTopForLineNumber(startLineNumber + 1); + contentStartOffset = startContentLeftOffset; + deltaX = paddingHorizontally + horizontalOffsetWhenAboveBelow; + deltaY = paddingVertically + verticalOffsetWhenAboveBelow; + break; + } + case 'above': { + topOfLine = this._editorObs.editor.getTopForLineNumber(startLineNumber - 1); + contentStartOffset = startContentLeftOffset; + deltaX = paddingHorizontally + horizontalOffsetWhenAboveBelow; + deltaY = -paddingVertically + verticalOffsetWhenAboveBelow; + break; + } + } + + const textRect = Rect.fromLeftTopWidthHeight( + contentLeft + contentStartOffset - scrollLeft, + topOfLine - scrollTop, + w * displayLocation.label.length, + lineHeight + ); + + return textRect.withMargin(paddingVertically, paddingHorizontally).translateX(deltaX).translateY(deltaY); + }); + + return { + rect, + label: displayLocation.label + }; + } + + private getRendering(state: { rect: IObservable; label: string }, styles: IObservable<{ background: string; border: string }>) { + + const line = document.createElement('div'); + const t = this._editor.getModel()!.tokenization.tokenizeLinesAt(1, [state.label])?.[0]; + let tokens: LineTokens; + if (t) { + tokens = TokenArray.fromLineTokens(t).toLineTokens(state.label, this._languageService.languageIdCodec); + } else { + tokens = LineTokens.createEmpty(state.label, this._languageService.languageIdCodec); + } + + const result = renderLines(new LineSource([tokens]), RenderOptions.fromEditor(this._editor).withSetWidth(false).withScrollBeyondLastColumn(0), [], line, true); + line.style.width = `${result.minWidthInPx}px`; + + const rect = state.rect.map(r => r.withMargin(0, 4)); + + return n.div({ + class: 'collapsedView', + ref: this._viewRef, + style: { + position: 'absolute', + ...rectToProps(reader => rect.read(reader)), + overflow: 'hidden', + boxSizing: 'border-box', + cursor: 'pointer', + border: styles.map(s => `1px solid ${s.border}`), + borderRadius: '4px', + backgroundColor: styles.map(s => s.background), + + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + whiteSpace: 'nowrap', + }, + onclick: (e) => { this._onDidClick.fire(new StandardMouseEvent(getWindow(e), e)); } + }, [ + line + ]); + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts index 9feed6f4a6b..253d45b1ea5 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsDeletionView.ts @@ -7,17 +7,26 @@ import { IMouseEvent } from '../../../../../../../base/browser/mouseEvent.js'; import { Emitter } from '../../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { constObservable, derived, derivedObservableWithCache, IObservable } from '../../../../../../../base/common/observable.js'; +import { editorBackground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { ICodeEditor } from '../../../../../../browser/editorBrowser.js'; import { ObservableCodeEditor, observableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; -import { Point } from '../../../../../../browser/point.js'; +import { Rect } from '../../../../../../browser/rect.js'; +import { EditorOption } from '../../../../../../common/config/editorOptions.js'; import { LineRange } from '../../../../../../common/core/lineRange.js'; +import { OffsetRange } from '../../../../../../common/core/offsetRange.js'; import { Position } from '../../../../../../common/core/position.js'; import { Range } from '../../../../../../common/core/range.js'; import { IInlineEditsView, InlineEditTabAction } from '../inlineEditsViewInterface.js'; import { InlineEditWithChanges } from '../inlineEditWithChanges.js'; import { getOriginalBorderColor, originalBackgroundColor } from '../theme.js'; -import { createRectangle, getPrefixTrim, mapOutFalsy, maxContentWidthInRange } from '../utils/utils.js'; +import { getPrefixTrim, mapOutFalsy, maxContentWidthInRange } from '../utils/utils.js'; + +const HORIZONTAL_PADDING = 0; +const VERTICAL_PADDING = 0; +const BORDER_WIDTH = 1; +const WIDGET_SEPARATOR_WIDTH = 1; +const BORDER_RADIUS = 4; export class InlineEditsDeletionView extends Disposable implements IInlineEditsView { @@ -65,7 +74,7 @@ export class InlineEditsDeletionView extends Disposable implements IInlineEditsV minContentWidthInPx: derived(reader => { const info = this._editorLayoutInfo.read(reader); if (info === null) { return 0; } - return info.code1.x - info.codeStart1.x; + return info.codeRect.width; }), })); } @@ -107,70 +116,78 @@ export class InlineEditsDeletionView extends Disposable implements IInlineEditsV const editorLayout = this._editorObs.layoutInfo.read(reader); const horizontalScrollOffset = this._editorObs.scrollLeft.read(reader); + const w = this._editorObs.getOption(EditorOption.fontInfo).map(f => f.typicalHalfwidthCharacterWidth).read(reader); - const left = editorLayout.contentLeft + this._editorMaxContentWidthInRange.read(reader) - horizontalScrollOffset; + const right = editorLayout.contentLeft + Math.max(this._editorMaxContentWidthInRange.read(reader), w) - horizontalScrollOffset; const range = inlineEdit.originalLineRange; const selectionTop = this._originalVerticalStartPosition.read(reader) ?? this._editor.getTopForLineNumber(range.startLineNumber) - this._editorObs.scrollTop.read(reader); const selectionBottom = this._originalVerticalEndPosition.read(reader) ?? this._editor.getTopForLineNumber(range.endLineNumberExclusive) - this._editorObs.scrollTop.read(reader); - const codeLeft = editorLayout.contentLeft + this._maxPrefixTrim.read(reader).prefixLeftOffset; + const left = editorLayout.contentLeft + this._maxPrefixTrim.read(reader).prefixLeftOffset - horizontalScrollOffset; - if (left <= codeLeft) { + if (right <= left) { return null; } - const code1 = new Point(left, selectionTop); - const codeStart1 = new Point(codeLeft, selectionTop); - const code2 = new Point(left, selectionBottom); - const codeStart2 = new Point(codeLeft, selectionBottom); - const codeHeight = selectionBottom - selectionTop; + const codeRect = Rect.fromLeftTopRightBottom(left, selectionTop, right, selectionBottom).withMargin(VERTICAL_PADDING, HORIZONTAL_PADDING); return { - code1, - codeStart1, - code2, - codeStart2, - codeHeight, - horizontalScrollOffset, - padding: 3, - borderRadius: 4, + codeRect, + contentLeft: editorLayout.contentLeft, }; }).recomputeInitiallyAndOnChange(this._store); - private readonly _foregroundSvg = n.svg({ - transform: 'translate(-0.5 -0.5)', - style: { overflow: 'visible', pointerEvents: 'none', position: 'absolute' }, + private readonly _originalOverlay = n.div({ + style: { pointerEvents: 'none', } }, derived(reader => { const layoutInfoObs = mapOutFalsy(this._editorLayoutInfo).read(reader); if (!layoutInfoObs) { return undefined; } - const layoutInfo = layoutInfoObs.read(reader); + // Create an overlay which hides the left hand side of the original overlay when it overflows to the left + // such that there is a smooth transition at the edge of content left + const overlayhider = layoutInfoObs.map(layoutInfo => Rect.fromLeftTopRightBottom( + layoutInfo.contentLeft - BORDER_RADIUS - BORDER_WIDTH, + layoutInfo.codeRect.top, + layoutInfo.contentLeft, + layoutInfo.codeRect.bottom + )); - // TODO: look into why 1px offset is needed - const rectangleOverlay = createRectangle( - { - topLeft: layoutInfo.codeStart1, - width: layoutInfo.code1.x - layoutInfo.codeStart1.x + 1, - height: layoutInfo.code2.y - layoutInfo.code1.y + 1, - }, - layoutInfo.padding, - layoutInfo.borderRadius, - { hideLeft: layoutInfo.horizontalScrollOffset !== 0 } - ); + const overlayRect = derived(reader => { + const rect = layoutInfoObs.read(reader).codeRect; + const overlayHider = overlayhider.read(reader); + return rect.intersectHorizontal(new OffsetRange(overlayHider.left, Number.MAX_SAFE_INTEGER)); + }); - const originalBorderColor = getOriginalBorderColor(this._tabAction).read(reader); + const separatorRect = overlayRect.map(rect => rect.withMargin(WIDGET_SEPARATOR_WIDTH, WIDGET_SEPARATOR_WIDTH)); return [ - n.svgElem('path', { - class: 'originalOverlay', - d: rectangleOverlay, + n.div({ + class: 'originalSeparatorDeletion', style: { - fill: asCssVariable(originalBackgroundColor), - stroke: originalBorderColor, - strokeWidth: '1px', + ...separatorRect.read(reader).toStyles(), + borderRadius: `${BORDER_RADIUS}px`, + border: `${BORDER_WIDTH + WIDGET_SEPARATOR_WIDTH}px solid ${asCssVariable(editorBackground)}`, + boxSizing: 'border-box', } }), + n.div({ + class: 'originalOverlayDeletion', + style: { + ...overlayRect.read(reader).toStyles(), + borderRadius: `${BORDER_RADIUS}px`, + border: getOriginalBorderColor(this._tabAction).map(bc => `${BORDER_WIDTH}px solid ${asCssVariable(bc)}`), + boxSizing: 'border-box', + backgroundColor: asCssVariable(originalBackgroundColor), + } + }), + n.div({ + class: 'originalOverlayHiderDeletion', + style: { + ...overlayhider.read(reader).toStyles(), + backgroundColor: asCssVariable(editorBackground), + } + }) ]; })).keepUpdated(this._store); @@ -181,11 +198,10 @@ export class InlineEditsDeletionView extends Disposable implements IInlineEditsV overflow: 'visible', top: '0px', left: '0px', - zIndex: '0', display: this._display, }, }, [ - [this._foregroundSvg], + [this._originalOverlay], ]).keepUpdated(this._store); readonly isHovered = constObservable(false); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts index bb20b7d863e..6cafdf6f116 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts @@ -8,6 +8,7 @@ import { Emitter } from '../../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { constObservable, derived, derivedWithStore, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { editorBackground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { ICodeEditor } from '../../../../../../browser/editorBrowser.js'; import { ObservableCodeEditor, observableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; @@ -15,6 +16,7 @@ import { Rect } from '../../../../../../browser/rect.js'; import { LineSource, renderLines, RenderOptions } from '../../../../../../browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; import { EditorOption } from '../../../../../../common/config/editorOptions.js'; import { LineRange } from '../../../../../../common/core/lineRange.js'; +import { OffsetRange } from '../../../../../../common/core/offsetRange.js'; import { Position } from '../../../../../../common/core/position.js'; import { Range } from '../../../../../../common/core/range.js'; import { ILanguageService } from '../../../../../../common/languages/language.js'; @@ -24,8 +26,12 @@ import { InlineDecoration, InlineDecorationType } from '../../../../../../common import { GhostText, GhostTextPart } from '../../../model/ghostText.js'; import { GhostTextView } from '../../ghostText/ghostTextView.js'; import { IInlineEditsView, InlineEditTabAction } from '../inlineEditsViewInterface.js'; -import { getModifiedBorderColor, modifiedChangedLineBackgroundColor } from '../theme.js'; -import { createRectangle, getPrefixTrim, mapOutFalsy } from '../utils/utils.js'; +import { getModifiedBorderColor, modifiedBackgroundColor } from '../theme.js'; +import { getPrefixTrim, mapOutFalsy } from '../utils/utils.js'; + +const BORDER_WIDTH = 1; +const WIDGET_SEPARATOR_WIDTH = 1; +const BORDER_RADIUS = 4; export class InlineEditsInsertionView extends Disposable implements IInlineEditsView { private readonly _editorObs: ObservableCodeEditor; @@ -48,23 +54,49 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits return { lineNumber: state.lineNumber, column: state.startColumn, text: state.text }; }); + private readonly _trimVertically = derived(this, reader => { + const text = this._state.read(reader)?.text; + if (!text || text.trim() === '') { + return { topOffset: 0, bottomOffset: 0, linesTop: 0, linesBottom: 0 }; + } + + // Adjust for leading/trailing newlines + const lineHeight = this._editor.getOption(EditorOption.lineHeight); + const eol = this._editor.getModel()!.getEOL(); + let linesTop = 0; + let linesBottom = 0; + + let i = 0; + for (; i < text.length && text.startsWith(eol, i); i += eol.length) { + linesTop += 1; + } + + for (let j = text.length; j > i && text.endsWith(eol, j); j -= eol.length) { + linesBottom += 1; + } + + return { topOffset: linesTop * lineHeight, bottomOffset: linesBottom * lineHeight, linesTop, linesBottom }; + }); + private readonly _maxPrefixTrim = derived(reader => { const state = this._state.read(reader); if (!state) { return { prefixLeftOffset: 0, prefixTrim: 0 }; - } + const textModel = this._editor.getModel()!; const eol = textModel.getEOL(); - const startsWithEol = state.text.startsWith(eol); - const originalRange = new LineRange(state.lineNumber, state.lineNumber + (startsWithEol ? 0 : 1)); - let modifiedLines = state.text.split(eol); - if (startsWithEol) { - modifiedLines = modifiedLines.splice(1); - } else { + + const trimVertically = this._trimVertically.read(reader); + + const lines = state.text.split(eol); + const modifiedLines = lines.slice(trimVertically.linesTop, lines.length - trimVertically.linesBottom); + if (trimVertically.linesTop === 0) { modifiedLines[0] = textModel.getLineContent(state.lineNumber) + modifiedLines[0]; } + const originalRange = new LineRange(state.lineNumber, state.lineNumber + (trimVertically.linesTop > 0 ? 0 : 1)); + return getPrefixTrim([], originalRange, modifiedLines, this._editor); }); @@ -124,7 +156,7 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits })); this._register(this._editorObs.createOverlayWidget({ - domNode: this._nonOverflowView.element, + domNode: this._view.element, position: constObservable(null), allowEditorOverflow: false, minContentWidthInPx: derived(reader => { @@ -151,7 +183,7 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits const text = textBeforeInsertion + state.text + textAfterInsertion; const lines = text.split(eol); - const renderOptions = RenderOptions.fromEditor(this._editor).withSetWidth(false); + const renderOptions = RenderOptions.fromEditor(this._editor).withSetWidth(false).withScrollBeyondLastColumn(0); const lineWidths = lines.map(line => { const t = textModel.tokenization.tokenizeLinesAt(state.lineNumber, [line])?.[0]; let tokens: LineTokens; @@ -161,7 +193,7 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits tokens = LineTokens.createEmpty(line, this._languageService.languageIdCodec); } - return renderLines(new LineSource([tokens]), renderOptions, [], $('div'), true).minWidthInPx - 20; // TODO: always too much padding included, why? + return renderLines(new LineSource([tokens]), renderOptions, [], $('div'), true).minWidthInPx; }); // Take the max value that we observed. @@ -169,31 +201,7 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits return Math.max(...lineWidths); }); - private readonly _trimVertically = derived(this, reader => { - const text = this._state.read(reader)?.text; - if (!text || text.trim() === '') { - return { top: 0, bottom: 0 }; - } - - // Adjust for leading/trailing newlines - const lineHeight = this._editor.getOption(EditorOption.lineHeight); - const eol = this._editor.getModel()!.getEOL(); - let topTrim = 0; - let bottomTrim = 0; - - let i = 0; - for (; i < text.length && text.startsWith(eol, i); i += eol.length) { - topTrim += lineHeight; - } - - for (let j = text.length; j > i && text.endsWith(eol, j); j -= eol.length) { - bottomTrim += lineHeight; - } - - return { top: topTrim, bottom: bottomTrim }; - }); - - public readonly startLineOffset = this._trimVertically.map(v => v.top); + public readonly startLineOffset = this._trimVertically.map(v => v.topOffset); public readonly originalLines = this._state.map(s => s ? new LineRange( s.lineNumber, @@ -222,7 +230,7 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits return null; } - const { top: topTrim, bottom: bottomTrim } = this._trimVertically.read(reader); + const { topOffset: topTrim, bottomOffset: bottomTrim } = this._trimVertically.read(reader); const scrollTop = this._editorObs.scrollTop.read(reader); const height = this._ghostTextView.height.read(reader) - topTrim - bottomTrim; @@ -233,61 +241,70 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits return { overlay, + startsAtContentLeft: prefixLeftOffset === 0, contentLeft: editorLayout.contentLeft, minContentWidthRequired: prefixLeftOffset + overlay.width + verticalScrollbarWidth, - borderRadius: 4, - padding: 3 }; }).recomputeInitiallyAndOnChange(this._store); - private readonly _foregroundSvg = n.svg({ - transform: 'translate(-0.5 -0.5)', - style: { overflow: 'visible', pointerEvents: 'none', position: 'absolute' }, + private readonly _modifiedOverlay = n.div({ + style: { pointerEvents: 'none', } }, derived(reader => { const overlayLayoutObs = mapOutFalsy(this._overlayLayout).read(reader); if (!overlayLayoutObs) { return undefined; } - const layoutInfo = overlayLayoutObs.read(reader); - const overlay = layoutInfo.overlay; - const croppedOverlay = new Rect(Math.max(overlay.left, layoutInfo.contentLeft), overlay.top, overlay.right, overlay.bottom); + // Create an overlay which hides the left hand side of the original overlay when it overflows to the left + // such that there is a smooth transition at the edge of content left + const overlayHider = overlayLayoutObs.map(layoutInfo => Rect.fromLeftTopRightBottom( + layoutInfo.contentLeft - BORDER_RADIUS - BORDER_WIDTH, + layoutInfo.overlay.top, + layoutInfo.contentLeft, + layoutInfo.overlay.bottom + )).read(reader); - const rectangleOverlay = createRectangle( - { - topLeft: croppedOverlay.getLeftTop(), - width: croppedOverlay.width + 1, - height: croppedOverlay.height + 1, - }, - layoutInfo.padding, - layoutInfo.borderRadius, - { hideLeft: croppedOverlay.left !== overlay.left } - ); - - const modifiedBorderColor = getModifiedBorderColor(this._tabAction).read(reader); + const overlayRect = overlayLayoutObs.map(l => l.overlay.withMargin(0, BORDER_WIDTH, 0, l.startsAtContentLeft ? 0 : BORDER_WIDTH).intersectHorizontal(new OffsetRange(overlayHider.left, Number.MAX_SAFE_INTEGER))); + const underlayRect = overlayRect.map(rect => rect.withMargin(WIDGET_SEPARATOR_WIDTH, WIDGET_SEPARATOR_WIDTH)); return [ - n.svgElem('path', { - class: 'originalOverlay', - d: rectangleOverlay, + n.div({ + class: 'originalUnderlayInsertion', style: { - fill: asCssVariable(modifiedChangedLineBackgroundColor), - stroke: modifiedBorderColor, - strokeWidth: '1px', + ...underlayRect.read(reader).toStyles(), + borderRadius: BORDER_RADIUS, + border: `${BORDER_WIDTH + WIDGET_SEPARATOR_WIDTH}px solid ${asCssVariable(editorBackground)}`, + boxSizing: 'border-box', } }), + n.div({ + class: 'originalOverlayInsertion', + style: { + ...overlayRect.read(reader).toStyles(), + borderRadius: BORDER_RADIUS, + border: getModifiedBorderColor(this._tabAction).map(bc => `${BORDER_WIDTH}px solid ${asCssVariable(bc)}`), + boxSizing: 'border-box', + backgroundColor: asCssVariable(modifiedBackgroundColor), + } + }), + n.div({ + class: 'originalOverlayHiderInsertion', + style: { + ...overlayHider.toStyles(), + backgroundColor: asCssVariable(editorBackground), + } + }) ]; })).keepUpdated(this._store); - private readonly _nonOverflowView = n.div({ + private readonly _view = n.div({ class: 'inline-edits-view', style: { position: 'absolute', overflow: 'visible', top: '0px', left: '0px', - zIndex: '0', display: this._display, }, }, [ - [this._foregroundSvg], + [this._modifiedOverlay], ]).keepUpdated(this._store); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsLineReplacementView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsLineReplacementView.ts index eb7460eeed2..0d5e96cee9f 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsLineReplacementView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsLineReplacementView.ts @@ -10,6 +10,7 @@ import { Disposable, toDisposable } from '../../../../../../../base/common/lifec import { autorun, autorunDelta, constObservable, derived, IObservable } from '../../../../../../../base/common/observable.js'; import { editorBackground, scrollbarShadow } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; +import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; import { IEditorMouseEvent, IViewZoneChangeAccessor } from '../../../../../../browser/editorBrowser.js'; import { EditorMouseEvent } from '../../../../../../browser/editorDom.js'; import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; @@ -26,9 +27,8 @@ import { LineTokens } from '../../../../../../common/tokens/lineTokens.js'; import { TokenArray } from '../../../../../../common/tokens/tokenArray.js'; import { InlineDecoration, InlineDecorationType } from '../../../../../../common/viewModel.js'; import { IInlineEditsView, InlineEditTabAction } from '../inlineEditsViewInterface.js'; -import { getModifiedBorderColor, modifiedChangedLineBackgroundColor } from '../theme.js'; +import { getEditorBlendedColor, getModifiedBorderColor, getOriginalBorderColor, modifiedChangedLineBackgroundColor, originalBackgroundColor } from '../theme.js'; import { getPrefixTrim, mapOutFalsy, rectToProps } from '../utils/utils.js'; -import { rangesToBubbleRanges, Replacement } from './inlineEditsWordReplacementView.js'; export class InlineEditsLineReplacementView extends Disposable implements IInlineEditsView { @@ -82,7 +82,7 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin } // TODO: All lines should be rendered at once for one dom element - const result = renderLines(new LineSource([tokens]), RenderOptions.fromEditor(this._editor.editor).withSetWidth(false), decorations, line, true); + const result = renderLines(new LineSource([tokens]), RenderOptions.fromEditor(this._editor.editor).withSetWidth(false).withScrollBeyondLastColumn(0), decorations, line, true); this._editor.getOption(EditorOption.fontInfo).read(reader); // update when font info changes requiredWidth = Math.max(requiredWidth, result.minWidthInPx); @@ -90,7 +90,7 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin lines.push(line); } - return { lines, requiredWidth: requiredWidth - 10 }; // TODO: Width is always too large, why? + return { lines, requiredWidth: requiredWidth }; }); @@ -111,7 +111,6 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin const scrollLeft = this._editor.scrollLeft.read(reader); const scrollTop = this._editor.scrollTop.read(reader); const editorLeftOffset = contentLeft - scrollLeft; - const PADDING = 4; const textModel = this._editor.editor.getModel()!; @@ -128,18 +127,18 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin editorLeftOffset + prefixLeftOffset, topOfOriginalLines, maxLineWidth, - bottomOfOriginalLines - topOfOriginalLines + PADDING + bottomOfOriginalLines - topOfOriginalLines ); const modifiedLinesOverlay = Rect.fromLeftTopWidthHeight( originalLinesOverlay.left, - originalLinesOverlay.bottom + PADDING, + originalLinesOverlay.bottom, originalLinesOverlay.width, edit.modifiedRange.length * lineHeight ); - const background = Rect.hull([originalLinesOverlay, modifiedLinesOverlay]).withMargin(PADDING); + const background = Rect.hull([originalLinesOverlay, modifiedLinesOverlay]); const lowerBackground = background.intersectVertical(new OffsetRange(originalLinesOverlay.bottom, Number.MAX_SAFE_INTEGER)); - const lowerText = new Rect(lowerBackground.left + PADDING, lowerBackground.top + PADDING, lowerBackground.right, lowerBackground.bottom); + const lowerText = new Rect(lowerBackground.left, lowerBackground.top, lowerBackground.right, lowerBackground.bottom); return { originalLinesOverlay, @@ -147,8 +146,7 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin background, lowerBackground, lowerText, - padding: PADDING, - minContentWidthRequired: prefixLeftOffset + maxLineWidth + PADDING * 2 + verticalScrollbarWidth, + minContentWidthRequired: prefixLeftOffset + maxLineWidth + verticalScrollbarWidth, }; }); @@ -164,7 +162,7 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin return undefined; } - const viewZoneHeight = layout.lowerBackground.height + 2 * layout.padding; + const viewZoneHeight = layout.lowerBackground.height; const viewZoneLineNumber = edit.originalRange.endLineNumberExclusive; return { height: viewZoneHeight, lineNumber: viewZoneLineNumber }; }); @@ -180,24 +178,19 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin } const layoutProps = layout.read(reader); - const scrollLeft = this._editor.scrollLeft.read(reader); - let contentLeft = this._editor.layoutInfoContentLeft.read(reader); - let contentWidth = this._editor.contentWidth.read(reader); + const contentLeft = this._editor.layoutInfoContentLeft.read(reader); + const contentWidth = this._editor.contentWidth.read(reader); const contentHeight = this._editor.editor.getContentHeight(); - if (scrollLeft === 0) { - contentLeft -= layoutProps.padding; - contentWidth += layoutProps.padding; - } - const lineHeight = this._editor.getOption(EditorOption.lineHeight).read(reader); modifiedLineElements.lines.forEach(l => { - l.style.width = `${layout.read(reader).lowerText.width}px`; + l.style.width = `${layoutProps.lowerText.width}px`; l.style.height = `${lineHeight}px`; l.style.position = 'relative'; }); const modifiedBorderColor = getModifiedBorderColor(this._tabAction).read(reader); + const originalBorderColor = getOriginalBorderColor(this._tabAction).read(reader); return [ n.div({ @@ -212,23 +205,28 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin } }, [ n.div({ + class: 'originalOverlayLineReplacement', style: { position: 'absolute', - top: layoutProps.lowerBackground.top - layoutProps.padding, - left: layoutProps.lowerBackground.left - contentLeft, - width: layoutProps.lowerBackground.width, - height: layoutProps.padding * 2, - background: asCssVariable(editorBackground), - }, + ...rectToProps(reader => layout.read(reader).background.translateX(-contentLeft)), + borderRadius: '4px', + + border: getEditorBlendedColor(originalBorderColor, this._themeService).map(c => `1px solid ${c.toString()}`), + pointerEvents: 'none', + boxSizing: 'border-box', + background: asCssVariable(originalBackgroundColor), + } }), n.div({ + class: 'modifiedOverlayLineReplacement', style: { position: 'absolute', ...rectToProps(reader => layout.read(reader).lowerBackground.translateX(-contentLeft)), - borderRadius: '4px', + borderRadius: '0 0 4px 4px', background: asCssVariable(editorBackground), boxShadow: `${asCssVariable(scrollbarShadow)} 0 6px 6px -6px`, - borderTop: `1px solid ${modifiedBorderColor}`, + border: `1px solid ${asCssVariable(modifiedBorderColor)}`, + boxSizing: 'border-box', overflow: 'hidden', cursor: 'pointer', pointerEvents: 'auto', @@ -240,38 +238,26 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin }, [ n.div({ style: { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', + position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', background: asCssVariable(modifiedChangedLineBackgroundColor), }, }) ]), n.div({ + class: 'modifiedLinesLineReplacement', style: { position: 'absolute', - padding: '0px', boxSizing: 'border-box', ...rectToProps(reader => layout.read(reader).lowerText.translateX(-contentLeft)), fontFamily: this._editor.getOption(EditorOption.fontFamily), fontSize: this._editor.getOption(EditorOption.fontSize), fontWeight: this._editor.getOption(EditorOption.fontWeight), pointerEvents: 'none', + whiteSpace: 'nowrap', + borderRadius: '0 0 4px 4px', + overflow: 'hidden', } }, [...modifiedLineElements.lines]), - n.div({ - style: { - position: 'absolute', - ...rectToProps(reader => layout.read(reader).background.translateX(-contentLeft)), - borderRadius: '4px', - - border: `1px solid ${modifiedBorderColor}`, - pointerEvents: 'none', - boxSizing: 'border-box', - } - }, []), ]) ]; }) @@ -288,7 +274,8 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin replacements: Replacement[]; } | undefined>, private readonly _tabAction: IObservable, - @ILanguageService private readonly _languageService: ILanguageService + @ILanguageService private readonly _languageService: ILanguageService, + @IThemeService private readonly _themeService: IThemeService, ) { super(); @@ -367,3 +354,23 @@ export class InlineEditsLineReplacementView extends Disposable implements IInlin } } } + +function rangesToBubbleRanges(ranges: Range[]): Range[] { + const result: Range[] = []; + while (ranges.length) { + let range = ranges.shift()!; + if (range.startLineNumber !== range.endLineNumber) { + ranges.push(new Range(range.startLineNumber + 1, 1, range.endLineNumber, range.endColumn)); + range = new Range(range.startLineNumber, range.startColumn, range.startLineNumber, Number.MAX_SAFE_INTEGER); // TODO: this is not correct + } + + result.push(range); + } + return result; + +} + +export interface Replacement { + originalRange: Range; + modifiedRange: Range; +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsSideBySideView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsSideBySideView.ts index 41ff1f50b23..9e30a4860dd 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsSideBySideView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsSideBySideView.ts @@ -9,14 +9,14 @@ import { Emitter } from '../../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { IObservable, IReader, autorun, constObservable, derived, derivedObservableWithCache, observableFromEvent } from '../../../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; -import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; +import { editorBackground } from '../../../../../../../platform/theme/common/colorRegistry.js'; +import { asCssVariable, asCssVariableWithDefault } from '../../../../../../../platform/theme/common/colorUtils.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; import { ICodeEditor } from '../../../../../../browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; import { Rect } from '../../../../../../browser/rect.js'; import { EmbeddedCodeEditorWidget } from '../../../../../../browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorOption } from '../../../../../../common/config/editorOptions.js'; -import { LineRange } from '../../../../../../common/core/lineRange.js'; import { OffsetRange } from '../../../../../../common/core/offsetRange.js'; import { Position } from '../../../../../../common/core/position.js'; import { Range } from '../../../../../../common/core/range.js'; @@ -25,29 +25,35 @@ import { StickyScrollController } from '../../../../../stickyScroll/browser/stic import { InlineCompletionContextKeys } from '../../../controller/inlineCompletionContextKeys.js'; import { IInlineEditsView, InlineEditTabAction } from '../inlineEditsViewInterface.js'; import { InlineEditWithChanges } from '../inlineEditWithChanges.js'; -import { getModifiedBorderColor, getOriginalBorderColor, modifiedBackgroundColor, originalBackgroundColor } from '../theme.js'; -import { PathBuilder, createRectangle, getOffsetForPos, mapOutFalsy, maxContentWidthInRange } from '../utils/utils.js'; +import { getEditorBlendedColor, getModifiedBorderColor, getOriginalBorderColor, modifiedBackgroundColor, originalBackgroundColor } from '../theme.js'; +import { PathBuilder, getContentRenderWidth, getOffsetForPos, mapOutFalsy, maxContentWidthInRange } from '../utils/utils.js'; -const PADDING = 4; +const HORIZONTAL_PADDING = 0; +const VERTICAL_PADDING = 0; const ENABLE_OVERFLOW = false; +const BORDER_WIDTH = 1; +const WIDGET_SEPARATOR_WIDTH = 1; +const BORDER_RADIUS = 4; +const ORIGINAL_END_PADDING = 20; +const MODIFIED_END_PADDING = 12; + export class InlineEditsSideBySideView extends Disposable implements IInlineEditsView { // This is an approximation and should be improved by using the real parameters used bellow - static fitsInsideViewport(editor: ICodeEditor, edit: InlineEditWithChanges, originalDisplayRange: LineRange, reader: IReader): boolean { + static fitsInsideViewport(editor: ICodeEditor, textModel: ITextModel, edit: InlineEditWithChanges, reader: IReader): boolean { const editorObs = observableCodeEditor(editor); const editorWidth = editorObs.layoutInfoWidth.read(reader); const editorContentLeft = editorObs.layoutInfoContentLeft.read(reader); const editorVerticalScrollbar = editor.getLayoutInfo().verticalScrollbarWidth; - const w = editor.getOption(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; const minimapWidth = editorObs.layoutInfoMinimap.read(reader).minimapLeft !== 0 ? editorObs.layoutInfoMinimap.read(reader).minimapWidth : 0; - const maxOriginalContent = maxContentWidthInRange(editorObs, originalDisplayRange, undefined/* do not reconsider on each layout info change */); - const maxModifiedContent = edit.lineEdit.newLines.reduce((max, line) => Math.max(max, line.length * w), 0); - const endOfEditorPadding = 20; // padding after last line of editor - const editorsPadding = edit.modifiedLineRange.length <= edit.originalLineRange.length ? PADDING * 3 + endOfEditorPadding : 60 + endOfEditorPadding * 2; + const maxOriginalContent = maxContentWidthInRange(editorObs, edit.displayRange, undefined/* do not reconsider on each layout info change */); + const maxModifiedContent = edit.lineEdit.newLines.reduce((max, line) => Math.max(max, getContentRenderWidth(line, editor, textModel)), 0); + const originalPadding = ORIGINAL_END_PADDING; // padding after last line of original editor + const modifiedPadding = MODIFIED_END_PADDING + 2 * BORDER_WIDTH; // padding after last line of modified editor - return maxOriginalContent + maxModifiedContent + editorsPadding < editorWidth - editorContentLeft - editorVerticalScrollbar - minimapWidth; + return maxOriginalContent + maxModifiedContent + originalPadding + modifiedPadding < editorWidth - editorContentLeft - editorVerticalScrollbar - minimapWidth; } private readonly _editorObs = observableCodeEditor(this._editor); @@ -60,9 +66,7 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit private readonly _edit: IObservable, private readonly _previewTextModel: ITextModel, private readonly _uiState: IObservable<{ - edit: InlineEditWithChanges; newTextLineCount: number; - originalDisplayRange: LineRange; } | undefined>, private readonly _tabAction: IObservable, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -70,18 +74,6 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit ) { super(); - this._register(this._editorObs.createOverlayWidget({ - domNode: this._overflowView.element, - position: constObservable({ - preference: { - top: 0, - left: 0 - } - }), - allowEditorOverflow: true, - minContentWidthInPx: constObservable(0), - })); - this._register(this._editorObs.createOverlayWidget({ domNode: this._nonOverflowView.element, position: constObservable(null), @@ -100,12 +92,13 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit if (!layoutInfo) { return; } - const editorRect = layoutInfo.editRect.deltaTop(layoutInfo.padding).deltaBottom(-layoutInfo.padding); + const editorRect = layoutInfo.editRect.withMargin(-VERTICAL_PADDING, -HORIZONTAL_PADDING); this.previewEditor.layout({ height: editorRect.height, width: layoutInfo.previewEditorWidth + 15 /* Make sure editor does not scroll horizontally */ }); this._editorContainer.element.style.top = `${editorRect.top}px`; this._editorContainer.element.style.left = `${editorRect.left}px`; - this._editorContainer.element.style.width = `${layoutInfo.previewEditorWidth}px`; // Set width to clip view zone + this._editorContainer.element.style.width = `${layoutInfo.previewEditorWidth + HORIZONTAL_PADDING}px`; // Set width to clip view zone + //this._editorContainer.element.style.borderRadius = `0 ${BORDER_RADIUS}px ${BORDER_RADIUS}px 0`; })); this._register(autorun(reader => { @@ -116,6 +109,8 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit this._previewEditorObs.editor.setScrollLeft(layoutInfo.desiredPreviewEditorScrollLeft); })); + + this._updatePreviewEditor.recomputeInitiallyAndOnChange(this._store); } private readonly _display = derived(this, reader => !!this._uiState.read(reader) ? 'block' : 'none'); @@ -194,19 +189,17 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit // because of the auto run initial // Before removing these, verify with a non-monospace font family this._display.read(reader); - if (this._overflowView) { - this._overflowView.element.style.display = this._display.read(reader); - } if (this._nonOverflowView) { this._nonOverflowView.element.style.display = this._display.read(reader); } const uiState = this._uiState.read(reader); - if (!uiState) { + const edit = this._edit.read(reader); + if (!uiState || !edit) { return; } - const range = uiState.edit.originalLineRange; + const range = edit.originalLineRange; const hiddenAreas: Range[] = []; if (range.startLineNumber > 1) { @@ -235,8 +228,7 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit })); } }); - - }).recomputeInitiallyAndOnChange(this._store); + }); private readonly _previewEditorWidth = derived(this, reader => { const edit = this._edit.read(reader); @@ -266,7 +258,7 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit private readonly _originalVerticalStartPosition = this._editorObs.observePosition(this._originalStartPosition, this._store).map(p => p?.y); private readonly _originalVerticalEndPosition = this._editorObs.observePosition(this._originalEndPosition, this._store).map(p => p?.y); - private readonly _originalDisplayRange = this._uiState.map(s => s?.originalDisplayRange); + private readonly _originalDisplayRange = this._edit.map(e => e?.displayRange); private readonly _editorMaxContentWidthInRange = derived(this, reader => { const originalDisplayRange = this._originalDisplayRange.read(reader); if (!originalDisplayRange) { @@ -319,52 +311,48 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit editorContentAreaWidth + horizontalScrollOffset ) ); - const previewEditorLeftInTextArea = Math.min(editorContentMaxWidthInRange + 20, maxPreviewEditorLeft); + const previewEditorLeftInTextArea = Math.min(editorContentMaxWidthInRange + ORIGINAL_END_PADDING, maxPreviewEditorLeft); - const maxContentWidth = editorContentMaxWidthInRange + 20 + previewContentWidth + 70; + const maxContentWidth = editorContentMaxWidthInRange + ORIGINAL_END_PADDING + previewContentWidth + 70; const dist = maxPreviewEditorLeft - previewEditorLeftInTextArea; let desiredPreviewEditorScrollLeft; - let left; + let codeRight; if (previewEditorLeftInTextArea > horizontalScrollOffset) { desiredPreviewEditorScrollLeft = 0; - left = editorLayout.contentLeft + previewEditorLeftInTextArea - horizontalScrollOffset; + codeRight = editorLayout.contentLeft + previewEditorLeftInTextArea - horizontalScrollOffset; } else { desiredPreviewEditorScrollLeft = horizontalScrollOffset - previewEditorLeftInTextArea; - left = editorLayout.contentLeft; + codeRight = editorLayout.contentLeft; } const selectionTop = this._originalVerticalStartPosition.read(reader) ?? this._editor.getTopForLineNumber(range.startLineNumber) - this._editorObs.scrollTop.read(reader); const selectionBottom = this._originalVerticalEndPosition.read(reader) ?? this._editor.getBottomForLineNumber(range.endLineNumberExclusive - 1) - this._editorObs.scrollTop.read(reader); // TODO: const { prefixLeftOffset } = getPrefixTrim(inlineEdit.edit.edits.map(e => e.range), inlineEdit.originalLineRange, [], this._editor); - const codeLeft = editorLayout.contentLeft; + const codeLeft = editorLayout.contentLeft - horizontalScrollOffset; - let codeRect = Rect.fromLeftTopRightBottom(codeLeft, selectionTop, left, selectionBottom); + let codeRect = Rect.fromLeftTopRightBottom(codeLeft, selectionTop, codeRight, selectionBottom); + const isInsertion = codeRect.height === 0; + if (!isInsertion) { + codeRect = codeRect.withMargin(VERTICAL_PADDING, HORIZONTAL_PADDING); + } const editHeight = this._editor.getOption(EditorOption.lineHeight) * inlineEdit.modifiedLineRange.length; const codeHeight = selectionBottom - selectionTop; const previewEditorHeight = Math.max(codeHeight, editHeight); - const editIsSameHeight = codeRect.height === previewEditorHeight; - const codeEditDistRange = editIsSameHeight - ? new OffsetRange(4, 61) - : new OffsetRange(60, 61); - const clipped = dist === 0; - const codeEditDist = editIsSameHeight ? PADDING : codeEditDistRange.clip(dist); // TODO: Is there a better way to specify the distance? - const previewEditorWidth = Math.min(previewContentWidth, remainingWidthRightOfEditor + editorLayout.width - editorLayout.contentLeft - codeEditDist); + const codeEditDist = 0; + const previewEditorWidth = Math.min(previewContentWidth + MODIFIED_END_PADDING, remainingWidthRightOfEditor + editorLayout.width - editorLayout.contentLeft - codeEditDist); - let editRect = Rect.fromLeftTopWidthHeight(left + codeEditDist, selectionTop, previewEditorWidth, previewEditorHeight); - - const isInsertion = codeRect.height === 0; + let editRect = Rect.fromLeftTopWidthHeight(codeRect.right + codeEditDist, selectionTop, previewEditorWidth, previewEditorHeight); if (!isInsertion) { - codeRect = codeRect.withMargin(PADDING).deltaRight(-PADDING); - editRect = editRect.withMargin(PADDING).deltaLeft(PADDING); + editRect = editRect.withMargin(VERTICAL_PADDING, HORIZONTAL_PADDING).translateX(HORIZONTAL_PADDING + BORDER_WIDTH); } else { // Align top of edit with insertion line - editRect = editRect.withMargin(PADDING).translateY(PADDING); + editRect = editRect.withMargin(VERTICAL_PADDING, HORIZONTAL_PADDING).translateY(VERTICAL_PADDING); } // debugView(debugLogRects({ codeRect, editRect }, this._editor.getDomNode()!), reader); @@ -373,13 +361,13 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit codeRect, editRect, codeScrollLeft: horizontalScrollOffset, + contentLeft: editorLayout.contentLeft, + isInsertion, maxContentWidth, shouldShowShadow: clipped, desiredPreviewEditorScrollLeft, previewEditorWidth, - padding: PADDING, - borderRadius: PADDING }; }); @@ -406,27 +394,6 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit return true; }); - private readonly _extendedModifiedPath = derived(reader => { - const layoutInfo = this._previewEditorLayoutInfo.read(reader); - if (!layoutInfo) { return undefined; } - - const path = new PathBuilder() - .moveTo(layoutInfo.codeRect.getRightBottom()) - .lineTo(layoutInfo.codeRect.getRightTop()) - .lineTo(layoutInfo.editRect.getLeftTop()) - .lineTo(layoutInfo.editRect.getRightTop().deltaX(-layoutInfo.borderRadius)) - .curveTo(layoutInfo.editRect.getRightTop(), layoutInfo.editRect.getRightTop().deltaY(layoutInfo.borderRadius)) - .lineTo(layoutInfo.editRect.getRightBottom().deltaY(-layoutInfo.borderRadius)) - .curveTo(layoutInfo.editRect.getRightBottom(), layoutInfo.editRect.getRightBottom().deltaX(-layoutInfo.borderRadius)) - .lineTo(layoutInfo.editRect.getLeftBottom()); - - if (layoutInfo.editRect.bottom !== layoutInfo.codeRect.bottom) { - path.curveTo2(layoutInfo.editRect.getLeftBottom().deltaX(-20), layoutInfo.codeRect.getRightBottom().deltaX(20), layoutInfo.codeRect.getRightBottom().deltaX(0)); - } - path.lineTo(layoutInfo.codeRect.getRightBottom()); - return path.build(); - }); - private readonly _originalBackgroundColor = observableFromEvent(this, this._themeService.onDidColorThemeChange, () => { return this._themeService.getColorTheme().getColor(originalBackgroundColor) ?? Color.transparent; }); @@ -455,98 +422,153 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit .build(); }), style: { - fill: 'var(--vscode-editor-background, transparent)', + fill: asCssVariableWithDefault(editorBackground, 'transparent'), } }), ]).keepUpdated(this._store); - private readonly _modifiedBackgroundSvg = n.svg({ - transform: 'translate(-0.5 -0.5)', - style: { overflow: 'visible', pointerEvents: 'none', position: 'absolute' }, - }, [ - n.svgElem('path', { - class: 'extendedModifiedBackgroundCoverUp', - d: this._extendedModifiedPath, - style: { - fill: 'var(--vscode-editor-background, transparent)', - strokeWidth: '0px', - } - }), - ]).keepUpdated(this._store); - - private readonly _foregroundBackgroundSvg = n.svg({ - transform: 'translate(-0.5 -0.5)', - style: { overflow: 'visible', pointerEvents: 'none', position: 'absolute' }, - }, [ - n.svgElem('path', { - class: 'extendedModifiedBackgroundCoverUp', - d: this._extendedModifiedPath, - style: { - fill: 'var(--vscode-inlineEdit-modifiedChangedLineBackground, transparent)', - strokeWidth: '1px', - } - }), - ]).keepUpdated(this._store); - - private readonly _middleBorderWithShadow = n.div({ - class: ['middleBorderWithShadow'], - style: { - position: 'absolute', - display: this._previewEditorLayoutInfo.map(i => i?.shouldShowShadow ? 'block' : 'none'), - width: '6px', - boxShadow: 'var(--vscode-scrollbar-shadow) -6px 0 6px -6px inset', - left: this._previewEditorLayoutInfo.map(i => i ? i.codeRect.right - 6 : 0), - top: this._previewEditorLayoutInfo.map(i => i ? i.codeRect.top : 0), - height: this._previewEditorLayoutInfo.map(i => i ? i.codeRect.height : 0), - }, - }, []).keepUpdated(this._store); - - private readonly _foregroundSvg = n.svg({ - transform: 'translate(-0.5 -0.5)', - style: { overflow: 'visible', pointerEvents: 'none', position: 'absolute' }, + private readonly _originalOverlay = n.div({ + style: { pointerEvents: 'none', display: this._previewEditorLayoutInfo.map(layoutInfo => layoutInfo?.isInsertion ? 'none' : 'block') }, }, derived(reader => { const layoutInfoObs = mapOutFalsy(this._previewEditorLayoutInfo).read(reader); if (!layoutInfoObs) { return undefined; } - const modifiedBorderColor = getModifiedBorderColor(this._tabAction).read(reader); - const originalBorderColor = getOriginalBorderColor(this._tabAction).read(reader); + const borderStyling = getOriginalBorderColor(this._tabAction).map(bc => `${BORDER_WIDTH}px solid ${asCssVariable(bc)}`); + const borderStylingSeparator = `${BORDER_WIDTH + WIDGET_SEPARATOR_WIDTH}px solid ${asCssVariable(editorBackground)}`; + + const hasBorderLeft = layoutInfoObs.read(reader).codeScrollLeft !== 0; + const isModifiedLower = layoutInfoObs.map(layoutInfo => layoutInfo.codeRect.bottom < layoutInfo.editRect.bottom); + const transitionRectSize = BORDER_RADIUS * 2 + BORDER_WIDTH * 2; + + // Create an overlay which hides the left hand side of the original overlay when it overflows to the left + // such that there is a smooth transition at the edge of content left + const overlayHider = layoutInfoObs.map(layoutInfo => Rect.fromLeftTopRightBottom( + layoutInfo.contentLeft - BORDER_RADIUS - BORDER_WIDTH, + layoutInfo.codeRect.top, + layoutInfo.contentLeft, + layoutInfo.codeRect.bottom + transitionRectSize + )).read(reader); + + const intersectionLine = new OffsetRange(overlayHider.left, Number.MAX_SAFE_INTEGER); + const overlayRect = layoutInfoObs.map(layoutInfo => layoutInfo.codeRect.intersectHorizontal(intersectionLine)); + const separatorRect = overlayRect.map(overlayRect => overlayRect.withMargin(WIDGET_SEPARATOR_WIDTH, 0, WIDGET_SEPARATOR_WIDTH, WIDGET_SEPARATOR_WIDTH).intersectHorizontal(intersectionLine)); + + const transitionRect = overlayRect.map(overlayRect => Rect.fromLeftTopWidthHeight(overlayRect.right - transitionRectSize + BORDER_WIDTH, overlayRect.bottom - BORDER_WIDTH, transitionRectSize, transitionRectSize).intersectHorizontal(intersectionLine)); return [ - n.svgElem('path', { - class: 'originalOverlay', - d: layoutInfoObs.map(layoutInfo => createRectangle( - { topLeft: layoutInfo.codeRect.getLeftTop(), width: layoutInfo.codeRect.width, height: layoutInfo.codeRect.height }, - 0, - { topLeft: layoutInfo.borderRadius, bottomLeft: layoutInfo.borderRadius, topRight: 0, bottomRight: 0 }, - { hideRight: true, hideLeft: layoutInfo.codeScrollLeft !== 0 } - )), + n.div({ + class: 'originalSeparatorSideBySide', style: { - fill: asCssVariable(originalBackgroundColor), - stroke: originalBorderColor, - strokeWidth: '1px', + ...separatorRect.read(reader).toStyles(), + boxSizing: 'border-box', + borderRadius: `${BORDER_RADIUS}px 0 0 ${BORDER_RADIUS}px`, + borderTop: borderStylingSeparator, + borderBottom: borderStylingSeparator, + borderLeft: hasBorderLeft ? 'none' : borderStylingSeparator, } }), - n.svgElem('path', { - class: 'extendedModifiedOverlay', - d: this._extendedModifiedPath, + n.div({ + class: 'originalOverlaySideBySide', style: { - fill: asCssVariable(modifiedBackgroundColor), - stroke: modifiedBorderColor, - strokeWidth: '1px', + ...overlayRect.read(reader).toStyles(), + boxSizing: 'border-box', + borderRadius: `${BORDER_RADIUS}px 0 0 ${BORDER_RADIUS}px`, + borderTop: borderStyling, + borderBottom: borderStyling, + borderLeft: hasBorderLeft ? 'none' : borderStyling, + backgroundColor: asCssVariable(originalBackgroundColor), } }), - n.svgElem('path', { - class: 'middleBorder', - d: layoutInfoObs.map(layoutInfo => new PathBuilder() - .moveTo(layoutInfo.codeRect.getRightTop()) - .lineTo(layoutInfo.codeRect.getRightBottom()) - .build() - ), + + n.div({ + class: 'originalCornerCutoutSideBySide', style: { - display: layoutInfoObs.map(i => i.shouldShowShadow ? 'none' : 'block'), - stroke: modifiedBorderColor, - strokeWidth: '1px' + pointerEvents: 'none', + display: isModifiedLower.map(isLower => isLower ? 'block' : 'none'), + ...transitionRect.read(reader).toStyles(), + } + }, [ + n.div({ + class: 'originalCornerCutoutBackground', + style: { + position: 'absolute', top: '0px', left: '0px', width: '100%', height: '100%', + backgroundColor: getEditorBlendedColor(originalBackgroundColor, this._themeService).map(c => c.toString()), + } + }), + n.div({ + class: 'originalCornerCutoutBorder', + style: { + position: 'absolute', top: '0px', left: '0px', width: '100%', height: '100%', + boxSizing: 'border-box', + borderTop: borderStyling, + borderRight: borderStyling, + borderRadius: `0 100% 0 0`, + backgroundColor: asCssVariable(editorBackground) + } + }) + ]), + n.div({ + class: 'originalOverlaySideBySideHider', + style: { + ...overlayHider.toStyles(), + backgroundColor: asCssVariable(editorBackground), + } + }), + ]; + })).keepUpdated(this._store); + + private readonly _modifiedOverlay = n.div({ + style: { pointerEvents: 'none', } + }, derived(reader => { + const layoutInfoObs = mapOutFalsy(this._previewEditorLayoutInfo).read(reader); + if (!layoutInfoObs) { return undefined; } + + const isModifiedLower = layoutInfoObs.map(layoutInfo => layoutInfo.codeRect.bottom < layoutInfo.editRect.bottom); + + const borderRadius = isModifiedLower.map(isLower => `0 ${BORDER_RADIUS}px ${BORDER_RADIUS}px ${isLower ? BORDER_RADIUS : 0}px`); + const borderStyling = getEditorBlendedColor(getModifiedBorderColor(this._tabAction), this._themeService).map(c => `1px solid ${c.toString()}`); + const borderStylingSeparator = `${BORDER_WIDTH + WIDGET_SEPARATOR_WIDTH}px solid ${asCssVariable(editorBackground)}`; + + const overlayRect = layoutInfoObs.map(layoutInfo => layoutInfo.editRect.withMargin(0, BORDER_WIDTH)); + const separatorRect = overlayRect.map(overlayRect => overlayRect.withMargin(WIDGET_SEPARATOR_WIDTH, WIDGET_SEPARATOR_WIDTH, WIDGET_SEPARATOR_WIDTH, 0)); + + const insertionRect = derived(reader => { + const overlay = overlayRect.read(reader); + const layoutinfo = layoutInfoObs.read(reader); + if (!layoutinfo.isInsertion || layoutinfo.contentLeft >= overlay.left) { + return Rect.fromLeftTopWidthHeight(overlay.left, overlay.top, 0, 0); + } + return new Rect(layoutinfo.contentLeft, overlay.top, overlay.left, overlay.top + BORDER_WIDTH * 2); + }); + + return [ + n.div({ + class: 'modifiedInsertionSideBySide', + style: { + ...insertionRect.read(reader).toStyles(), + backgroundColor: getModifiedBorderColor(this._tabAction).map(c => asCssVariable(c)), + } + }), + n.div({ + class: 'modifiedSeparatorSideBySide', + style: { + ...separatorRect.read(reader).toStyles(), + borderRadius, + borderTop: borderStylingSeparator, + borderBottom: borderStylingSeparator, + borderRight: borderStylingSeparator, + boxSizing: 'border-box', + } + }), + n.div({ + class: 'modifiedOverlaySideBySide', + style: { + ...overlayRect.read(reader).toStyles(), + borderRadius, + border: borderStyling, + boxSizing: 'border-box', + backgroundColor: asCssVariable(modifiedBackgroundColor), } }) ]; @@ -559,22 +581,10 @@ export class InlineEditsSideBySideView extends Disposable implements IInlineEdit overflow: 'visible', top: '0px', left: '0px', - zIndex: '0', display: this._display, }, }, [ this._backgroundSvg, - derived(this, reader => this._shouldOverflow.read(reader) ? [] : [this._modifiedBackgroundSvg, this._foregroundBackgroundSvg, this._editorContainer, this._foregroundSvg, this._middleBorderWithShadow]), - ]).keepUpdated(this._store); - - private readonly _overflowView = n.div({ - class: 'inline-edits-view', - style: { - overflow: 'visible', - zIndex: '20', - display: this._display, - }, - }, [ - derived(this, reader => this._shouldOverflow.read(reader) ? [this._modifiedBackgroundSvg, this._foregroundBackgroundSvg, this._editorContainer, this._foregroundSvg, this._middleBorderWithShadow] : []), + derived(this, reader => this._shouldOverflow.read(reader) ? [] : [this._editorContainer, this._originalOverlay, this._modifiedOverlay]), ]).keepUpdated(this._store); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordInsertView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordInsertView.ts index d18dad373c3..55ee6e9b87d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordInsertView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordInsertView.ts @@ -8,6 +8,7 @@ import { IMouseEvent } from '../../../../../../../base/browser/mouseEvent.js'; import { Emitter } from '../../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable } from '../../../../../../../base/common/observable.js'; +import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; import { Point } from '../../../../../../browser/point.js'; import { Rect } from '../../../../../../browser/rect.js'; @@ -58,7 +59,7 @@ export class InlineEditsWordInsertView extends Disposable implements IInlineEdit return []; } - const modifiedBorderColor = getModifiedBorderColor(this._tabAction).read(reader); + const modifiedBorderColor = asCssVariable(getModifiedBorderColor(this._tabAction).read(reader)); return [ n.div({ diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordReplacementView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordReplacementView.ts index f7272f15052..2702159e1d2 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordReplacementView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsWordReplacementView.ts @@ -8,7 +8,7 @@ import { IMouseEvent, StandardMouseEvent } from '../../../../../../../base/brows import { Emitter } from '../../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; -import { editorBackground, editorHoverForeground, scrollbarShadow } from '../../../../../../../platform/theme/common/colorRegistry.js'; +import { editorBackground, editorHoverForeground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; import { Point } from '../../../../../../browser/point.js'; @@ -17,13 +17,12 @@ import { LineSource, renderLines, RenderOptions } from '../../../../../../browse import { EditorOption } from '../../../../../../common/config/editorOptions.js'; import { SingleOffsetEdit } from '../../../../../../common/core/offsetEdit.js'; import { OffsetRange } from '../../../../../../common/core/offsetRange.js'; -import { Range } from '../../../../../../common/core/range.js'; import { SingleTextEdit } from '../../../../../../common/core/textEdit.js'; import { ILanguageService } from '../../../../../../common/languages/language.js'; import { LineTokens } from '../../../../../../common/tokens/lineTokens.js'; import { TokenArray } from '../../../../../../common/tokens/tokenArray.js'; import { IInlineEditsView, InlineEditTabAction } from '../inlineEditsViewInterface.js'; -import { getModifiedBorderColor, modifiedChangedTextOverlayColor, originalChangedTextOverlayColor, replacementViewBackground } from '../theme.js'; +import { getModifiedBorderColor, getOriginalBorderColor, modifiedChangedTextOverlayColor, originalChangedTextOverlayColor } from '../theme.js'; import { mapOutFalsy, rectToProps } from '../utils/utils.js'; export class InlineEditsWordReplacementView extends Disposable implements IInlineEditsView { @@ -46,8 +45,7 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin private readonly _editor: ObservableCodeEditor, /** Must be single-line in both sides */ private readonly _edit: SingleTextEdit, - private readonly _innerEdits: SingleTextEdit[], - private readonly _tabAction: IObservable, + protected readonly _tabAction: IObservable, @ILanguageService private readonly _languageService: ILanguageService, ) { super(); @@ -73,70 +71,40 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin } else { tokens = LineTokens.createEmpty(this._edit.text, this._languageService.languageIdCodec); } - renderLines(new LineSource([tokens]), RenderOptions.fromEditor(this._editor.editor).withSetWidth(false), [], this._line, true); - }); - - private readonly _editLocations = this._innerEdits.map(edit => { - const start = this._editor.observePosition(constObservable(edit.range.getStartPosition()), this._store); - const end = this._editor.observePosition(constObservable(edit.range.getEndPosition()), this._store); - return { start, end, edit }; + const res = renderLines(new LineSource([tokens]), RenderOptions.fromEditor(this._editor.editor).withSetWidth(false).withScrollBeyondLastColumn(0), [], this._line, true); + this._line.style.width = `${res.minWidthInPx}px`; }); private readonly _layout = derived(this, reader => { this._renderTextEffect.read(reader); const widgetStart = this._start.read(reader); - const widgetEnd = this._end.read(reader);// + const widgetEnd = this._end.read(reader); - if (!widgetStart || !widgetEnd || widgetStart.x > widgetEnd.x) { + // TODO@hediet better about widgetStart and widgetEnd in a single transaction! + if (!widgetStart || !widgetEnd || widgetStart.x > widgetEnd.x || widgetStart.y > widgetEnd.y) { return undefined; } - const contentLeft = this._editor.layoutInfoContentLeft.read(reader); const lineHeight = this._editor.getOption(EditorOption.lineHeight).read(reader); const scrollLeft = this._editor.scrollLeft.read(reader); const w = this._editor.getOption(EditorOption.fontInfo).read(reader).typicalHalfwidthCharacterWidth; - const modifiedLeftOffset = 20; - const modifiedTopOffset = 5; + const modifiedLeftOffset = 3 * w; + const modifiedTopOffset = 4; const modifiedOffset = new Point(modifiedLeftOffset, modifiedTopOffset); - const PADDING = 4; - const originalLine = Rect.fromPoints(widgetStart, widgetEnd).withHeight(lineHeight).translateX(contentLeft - scrollLeft); - const modifiedLine = Rect.fromPointSize(originalLine.getLeftBottom().add(modifiedOffset), new Point(this._edit.text.length * w + 5, originalLine.height)); - const background = Rect.hull([originalLine, modifiedLine]).withMargin(PADDING); + const originalLine = Rect.fromPoints(widgetStart, widgetEnd).withHeight(lineHeight).translateX(-scrollLeft); + const modifiedLine = Rect.fromPointSize(originalLine.getLeftBottom().add(modifiedOffset), new Point(this._edit.text.length * w, originalLine.height)); - let textLengthDelta = 0; - const innerEdits = []; - for (const editLocation of this._editLocations) { - const editStart = editLocation.start.read(reader); - const editEnd = editLocation.end.read(reader); - const edit = editLocation.edit; - - if (!editStart || !editEnd || editStart.x > editEnd.x) { - return undefined; - } - - const original = Rect.fromLeftTopWidthHeight(editStart.x + contentLeft - scrollLeft, editStart.y, editEnd.x - editStart.x, lineHeight); - const modified = Rect.fromLeftTopWidthHeight(original.left + modifiedLeftOffset + textLengthDelta * w, original.bottom + modifiedTopOffset, edit.text.length * w + 5, original.height); - - textLengthDelta += edit.text.length - (edit.range.endColumn - edit.range.startColumn); - - innerEdits.push({ original, modified }); - } - - const lowerBackground = background.intersectVertical(new OffsetRange(originalLine.bottom, Number.MAX_SAFE_INTEGER)); - const lowerText = new Rect(lowerBackground.left + modifiedLeftOffset + 6, lowerBackground.top + modifiedTopOffset, lowerBackground.right, lowerBackground.bottom); // TODO: left seems slightly off? zooming? + const lowerBackground = modifiedLine.withLeft(originalLine.left); // debugView(debugLogRects({ lowerBackground }, this._editor.editor.getContainerDomNode()), reader); return { originalLine, modifiedLine, - background, - innerEdits, lowerBackground, - lowerText, - padding: PADDING + lineHeight, }; }); @@ -149,20 +117,11 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin return []; } - const layoutProps = layout.read(reader); - const scrollLeft = this._editor.scrollLeft.read(reader); - let contentLeft = this._editor.layoutInfoContentLeft.read(reader); - let contentWidth = this._editor.contentWidth.read(reader); - const contentHeight = this._editor.editor.getContentHeight(); + const contentLeft = this._editor.layoutInfoContentLeft.read(reader); + const borderWidth = 1; - if (scrollLeft === 0) { - contentLeft -= layoutProps.padding; - contentWidth += layoutProps.padding; - } - - const edits = layoutProps.innerEdits.map(edit => ({ modified: edit.modified.translateX(-contentLeft), original: edit.original.translateX(-contentLeft) })); - - const modifiedBorderColor = getModifiedBorderColor(this._tabAction).read(reader); + const originalBorderColor = getOriginalBorderColor(this._tabAction).map(c => asCssVariable(c)).read(reader); + const modifiedBorderColor = getModifiedBorderColor(this._tabAction).map(c => asCssVariable(c)).read(reader); return [ n.div({ @@ -170,8 +129,8 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin position: 'absolute', top: 0, left: contentLeft, - width: contentWidth, - height: contentHeight, + width: this._editor.contentWidth, + height: this._editor.editor.getContentHeight(), overflow: 'hidden', pointerEvents: 'none', } @@ -179,10 +138,9 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin n.div({ style: { position: 'absolute', - ...rectToProps(reader => layout.read(reader).lowerBackground.translateX(-contentLeft)), - borderRadius: '4px', + ...rectToProps(reader => layout.read(reader).lowerBackground.withMargin(borderWidth, 2 * borderWidth, borderWidth, 0)), background: asCssVariable(editorBackground), - boxShadow: `${asCssVariable(scrollbarShadow)} 0 6px 6px -6px`, + //boxShadow: `${asCssVariable(scrollbarShadow)} 0 6px 6px -6px`, cursor: 'pointer', pointerEvents: 'auto', }, @@ -197,72 +155,53 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin n.div({ style: { position: 'absolute', - padding: '0px', - boxSizing: 'border-box', - ...rectToProps(reader => layout.read(reader).lowerText.translateX(-contentLeft)), + ...rectToProps(reader => layout.read(reader).modifiedLine.withMargin(1, 2)), fontFamily: this._editor.getOption(EditorOption.fontFamily), fontSize: this._editor.getOption(EditorOption.fontSize), fontWeight: this._editor.getOption(EditorOption.fontWeight), + pointerEvents: 'none', - } - }, [this._line]), - ...edits.map(edit => n.div({ - style: { - position: 'absolute', - top: edit.modified.top, - left: edit.modified.left, - width: edit.modified.width, - height: edit.modified.height, + boxSizing: 'border-box', borderRadius: '4px', + border: `${borderWidth}px solid ${modifiedBorderColor}`, background: asCssVariable(modifiedChangedTextOverlayColor), - pointerEvents: 'none', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + + outline: `2px solid ${asCssVariable(editorBackground)}`, } - }), []), - ...edits.map(edit => n.div({ - style: { - position: 'absolute', - top: edit.original.top, - left: edit.original.left, - width: edit.original.width, - height: edit.original.height, - borderRadius: '4px', - boxSizing: 'border-box', - background: asCssVariable(originalChangedTextOverlayColor), - pointerEvents: 'none', - } - }, [])), + }, [this._line]), n.div({ style: { position: 'absolute', - ...rectToProps(reader => layout.read(reader).background.translateX(-contentLeft)), - borderRadius: '4px', - - border: `1px solid ${modifiedBorderColor}`, - //background: 'rgba(122, 122, 122, 0.12)', looks better - background: asCssVariable(replacementViewBackground), - pointerEvents: 'none', + ...rectToProps(reader => layout.read(reader).originalLine.withMargin(1)), boxSizing: 'border-box', + borderRadius: '4px', + border: `${borderWidth}px solid ${originalBorderColor}`, + background: asCssVariable(originalChangedTextOverlayColor), + pointerEvents: 'none', } }, []), n.svg({ width: 11, - height: 13, - viewBox: '0 0 11 13', + height: 14, + viewBox: '0 0 11 14', fill: 'none', style: { position: 'absolute', - left: derived(reader => layout.read(reader).modifiedLine.translateX(-contentLeft).left - 15), - top: derived(reader => layout.read(reader).modifiedLine.top), + left: layout.map(l => l.modifiedLine.left - 16), + top: layout.map(l => l.modifiedLine.top + Math.round((l.lineHeight - 14 - 5) / 2)), } }, [ n.svgElem('path', { - d: 'M1 0C1 2.98966 1 4.92087 1 7.49952C1 8.60409 1.89543 9.5 3 9.5H10.5', + d: 'M1 0C1 2.98966 1 5.92087 1 8.49952C1 9.60409 1.89543 10.5 3 10.5H10.5', stroke: asCssVariable(editorHoverForeground), }), n.svgElem('path', { - d: 'M6 6.5L9.99999 9.49998L6 12.5', + d: 'M6 7.5L9.99999 10.49998L6 13.5', stroke: asCssVariable(editorHoverForeground), }) ]), @@ -272,23 +211,3 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin }) ]).keepUpdated(this._store); } - -export function rangesToBubbleRanges(ranges: Range[]): Range[] { - const result: Range[] = []; - while (ranges.length) { - let range = ranges.shift()!; - if (range.startLineNumber !== range.endLineNumber) { - ranges.push(new Range(range.startLineNumber + 1, 1, range.endLineNumber, range.endColumn)); - range = new Range(range.startLineNumber, range.startColumn, range.startLineNumber, Number.MAX_SAFE_INTEGER); // TODO: this is not correct - } - - result.push(range); - } - return result; - -} - -export interface Replacement { - originalRange: Range; - modifiedRange: Range; -} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/originalEditorInlineDiffView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/originalEditorInlineDiffView.ts index 6af87b7dff8..63b438213af 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/originalEditorInlineDiffView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/originalEditorInlineDiffView.ts @@ -78,6 +78,7 @@ export class OriginalEditorInlineDiffView extends Disposable implements IInlineE const modified = diff.modifiedText; const showInline = diff.mode === 'insertionInline'; + const hasOneInnerChange = diff.diff.length === 1 && diff.diff[0].innerChanges?.length === 1; const showEmptyDecorations = true; @@ -122,7 +123,7 @@ export class OriginalEditorInlineDiffView extends Disposable implements IInlineE }); for (const m of diff.diff) { - const showFullLineDecorations = diff.mode !== 'sideBySide'; + const showFullLineDecorations = diff.mode !== 'sideBySide' && diff.mode !== 'deletion' && diff.mode !== 'insertionInline'; if (showFullLineDecorations) { if (!m.original.isEmpty) { originalDecorations.push({ @@ -160,7 +161,7 @@ export class OriginalEditorInlineDiffView extends Disposable implements IInlineE 'inlineCompletions-char-delete', i.originalRange.isSingleLine() && diff.mode === 'insertionInline' && 'single-line-inline', i.originalRange.isEmpty() && 'empty', - ((i.originalRange.isEmpty() || diff.mode === 'deletion' && replacedText === '\n') && showEmptyDecorations && !useInlineDiff) && 'diff-range-empty' + ((i.originalRange.isEmpty() && hasOneInnerChange || diff.mode === 'deletion' && replacedText === '\n') && showEmptyDecorations && !useInlineDiff) && 'diff-range-empty' ), inlineClassName: useInlineDiff ? classNames('strike-through', 'inlineCompletions') : null, zIndex: 1 @@ -170,7 +171,7 @@ export class OriginalEditorInlineDiffView extends Disposable implements IInlineE if (m.modified.contains(i.modifiedRange.startLineNumber)) { modifiedDecorations.push({ range: i.modifiedRange, - options: (i.modifiedRange.isEmpty() && showEmptyDecorations && !useInlineDiff) + options: (i.modifiedRange.isEmpty() && showEmptyDecorations && !useInlineDiff && hasOneInnerChange) ? diffAddDecorationEmpty : diffAddDecoration }); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/theme.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/theme.ts index 056fbf695d1..ff04b209adc 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/theme.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/theme.ts @@ -4,35 +4,37 @@ *--------------------------------------------------------------------------------------------*/ import { Color } from '../../../../../../base/common/color.js'; -import { IObservable } from '../../../../../../base/common/observable.js'; +import { BugIndicatingError } from '../../../../../../base/common/errors.js'; +import { IObservable, observableFromEventOpts } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; -import { diffRemoved, diffInsertedLine, diffInserted, editorHoverBorder, editorHoverStatusBarBackground, buttonBackground, buttonForeground, buttonSecondaryBackground, buttonSecondaryForeground } from '../../../../../../platform/theme/common/colorRegistry.js'; -import { registerColor, transparent, asCssVariable, lighten, darken } from '../../../../../../platform/theme/common/colorUtils.js'; +import { buttonBackground, buttonForeground, buttonSecondaryBackground, buttonSecondaryForeground, diffInserted, diffInsertedLine, diffRemoved, editorBackground } from '../../../../../../platform/theme/common/colorRegistry.js'; +import { ColorIdentifier, darken, registerColor, transparent } from '../../../../../../platform/theme/common/colorUtils.js'; +import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { InlineEditTabAction } from './inlineEditsViewInterface.js'; export const originalBackgroundColor = registerColor( 'inlineEdit.originalBackground', - Color.transparent, + transparent(diffRemoved, 0.2), localize('inlineEdit.originalBackground', 'Background color for the original text in inline edits.'), true ); export const modifiedBackgroundColor = registerColor( 'inlineEdit.modifiedBackground', - Color.transparent, + transparent(diffInserted, 0.3), localize('inlineEdit.modifiedBackground', 'Background color for the modified text in inline edits.'), true ); export const originalChangedLineBackgroundColor = registerColor( 'inlineEdit.originalChangedLineBackground', - Color.transparent, + transparent(diffRemoved, 0.8), localize('inlineEdit.originalChangedLineBackground', 'Background color for the changed lines in the original text of inline edits.'), true ); export const originalChangedTextOverlayColor = registerColor( 'inlineEdit.originalChangedTextBackground', - diffRemoved, + transparent(diffRemoved, 0.8), localize('inlineEdit.originalChangedTextBackground', 'Overlay color for the changed text in the original text of inline edits.'), true ); @@ -40,8 +42,8 @@ export const originalChangedTextOverlayColor = registerColor( export const modifiedChangedLineBackgroundColor = registerColor( 'inlineEdit.modifiedChangedLineBackground', { - light: transparent(diffInsertedLine, 0.5), - dark: transparent(diffInsertedLine, 0.5), + light: transparent(diffInsertedLine, 0.7), + dark: transparent(diffInsertedLine, 0.7), hcDark: diffInsertedLine, hcLight: diffInsertedLine }, @@ -51,22 +53,11 @@ export const modifiedChangedLineBackgroundColor = registerColor( export const modifiedChangedTextOverlayColor = registerColor( 'inlineEdit.modifiedChangedTextBackground', - diffInserted, + transparent(diffInserted, 0.7), localize('inlineEdit.modifiedChangedTextBackground', 'Overlay color for the changed text in the modified text of inline edits.'), true ); -export const replacementViewBackground = registerColor( - 'inlineEdit.wordReplacementView.background', - { - light: transparent(editorHoverStatusBarBackground, 0.1), - dark: transparent(editorHoverStatusBarBackground, 0.1), - hcLight: transparent(editorHoverStatusBarBackground, 0.1), - hcDark: transparent(editorHoverStatusBarBackground, 0.1), - }, - localize('inlineEdit.wordReplacementView.background', 'Background color for the inline edit word replacement view.') -); - // ------- GUTTER INDICATOR ------- export const inlineEditIndicatorPrimaryForeground = registerColor( @@ -74,9 +65,19 @@ export const inlineEditIndicatorPrimaryForeground = registerColor( buttonForeground, localize('inlineEdit.gutterIndicator.primaryForeground', 'Foreground color for the primary inline edit gutter indicator.') ); +export const inlineEditIndicatorPrimaryBorder = registerColor( + 'inlineEdit.gutterIndicator.primaryBorder', + buttonBackground, + localize('inlineEdit.gutterIndicator.primaryBorder', 'Border color for the primary inline edit gutter indicator.') +); export const inlineEditIndicatorPrimaryBackground = registerColor( 'inlineEdit.gutterIndicator.primaryBackground', - buttonBackground, + { + light: transparent(inlineEditIndicatorPrimaryBorder, 0.5), + dark: transparent(inlineEditIndicatorPrimaryBorder, 0.4), + hcDark: transparent(inlineEditIndicatorPrimaryBorder, 0.4), + hcLight: transparent(inlineEditIndicatorPrimaryBorder, 0.5), + }, localize('inlineEdit.gutterIndicator.primaryBackground', 'Background color for the primary inline edit gutter indicator.') ); @@ -85,9 +86,14 @@ export const inlineEditIndicatorSecondaryForeground = registerColor( buttonSecondaryForeground, localize('inlineEdit.gutterIndicator.secondaryForeground', 'Foreground color for the secondary inline edit gutter indicator.') ); +export const inlineEditIndicatorSecondaryBorder = registerColor( + 'inlineEdit.gutterIndicator.secondaryBorder', + buttonSecondaryBackground, + localize('inlineEdit.gutterIndicator.secondaryBorder', 'Border color for the secondary inline edit gutter indicator.') +); export const inlineEditIndicatorSecondaryBackground = registerColor( 'inlineEdit.gutterIndicator.secondaryBackground', - buttonSecondaryBackground, + inlineEditIndicatorSecondaryBorder, localize('inlineEdit.gutterIndicator.secondaryBackground', 'Background color for the secondary inline edit gutter indicator.') ); @@ -96,9 +102,14 @@ export const inlineEditIndicatorsuccessfulForeground = registerColor( buttonForeground, localize('inlineEdit.gutterIndicator.successfulForeground', 'Foreground color for the successful inline edit gutter indicator.') ); +export const inlineEditIndicatorsuccessfulBorder = registerColor( + 'inlineEdit.gutterIndicator.successfulBorder', + buttonBackground, + localize('inlineEdit.gutterIndicator.successfulBorder', 'Border color for the successful inline edit gutter indicator.') +); export const inlineEditIndicatorsuccessfulBackground = registerColor( 'inlineEdit.gutterIndicator.successfulBackground', - { light: '#2e825c', dark: '#2e825c', hcLight: '#2e825c', hcDark: '#2e825c' }, + inlineEditIndicatorsuccessfulBorder, localize('inlineEdit.gutterIndicator.successfulBackground', 'Background color for the successful inline edit gutter indicator.') ); @@ -118,10 +129,10 @@ export const inlineEditIndicatorBackground = registerColor( const originalBorder = registerColor( 'inlineEdit.originalBorder', { - light: editorHoverBorder, - dark: editorHoverBorder, - hcDark: editorHoverBorder, - hcLight: editorHoverBorder + light: diffRemoved, + dark: diffRemoved, + hcDark: diffRemoved, + hcLight: diffRemoved }, localize('inlineEdit.originalBorder', 'Border color for the original text in inline edits.') ); @@ -129,40 +140,70 @@ const originalBorder = registerColor( const modifiedBorder = registerColor( 'inlineEdit.modifiedBorder', { - light: editorHoverBorder, - dark: editorHoverBorder, - hcDark: editorHoverBorder, - hcLight: editorHoverBorder + light: darken(diffInserted, 0.6), + dark: diffInserted, + hcDark: diffInserted, + hcLight: diffInserted }, localize('inlineEdit.modifiedBorder', 'Border color for the modified text in inline edits.') ); const tabWillAcceptModifiedBorder = registerColor( - 'inlineEdit.tabWillAcceptBorder', + 'inlineEdit.tabWillAcceptModifiedBorder', { - light: darken(modifiedBorder, 0.25), - dark: lighten(modifiedBorder, 0.25), - hcDark: lighten(modifiedBorder, 0.5), - hcLight: darken(modifiedBorder, 0.5) + light: darken(modifiedBorder, 0), + dark: darken(modifiedBorder, 0), + hcDark: darken(modifiedBorder, 0), + hcLight: darken(modifiedBorder, 0) }, - localize('inlineEdit.tabWillAcceptBorder', 'Border color for the inline edits widget when tab will accept it.') + localize('inlineEdit.tabWillAcceptModifiedBorder', 'Modified border color for the inline edits widget when tab will accept it.') ); const tabWillAcceptOriginalBorder = registerColor( - 'inlineEdit.tabWillAcceptBorder', + 'inlineEdit.tabWillAcceptOriginalBorder', { - light: darken(originalBorder, 0.25), - dark: lighten(originalBorder, 0.25), - hcDark: lighten(originalBorder, 0.5), - hcLight: darken(originalBorder, 0.5) + light: darken(originalBorder, 0), + dark: darken(originalBorder, 0), + hcDark: darken(originalBorder, 0), + hcLight: darken(originalBorder, 0) }, - localize('inlineEdit.tabWillAcceptOriginalBorder', 'Border color for the inline edits widget over the original text when tab will accept it.') + localize('inlineEdit.tabWillAcceptOriginalBorder', 'Original border color for the inline edits widget over the original text when tab will accept it.') ); export function getModifiedBorderColor(tabAction: IObservable): IObservable { - return tabAction.map(a => asCssVariable(a === InlineEditTabAction.Accept ? tabWillAcceptModifiedBorder : modifiedBorder)); + return tabAction.map(a => a === InlineEditTabAction.Accept ? tabWillAcceptModifiedBorder : modifiedBorder); } export function getOriginalBorderColor(tabAction: IObservable): IObservable { - return tabAction.map(a => asCssVariable(a === InlineEditTabAction.Accept ? tabWillAcceptOriginalBorder : originalBorder)); + return tabAction.map(a => a === InlineEditTabAction.Accept ? tabWillAcceptOriginalBorder : originalBorder); +} + +export function getEditorBlendedColor(colorIdentifier: ColorIdentifier | IObservable, themeService: IThemeService): IObservable { + let color: IObservable; + if (typeof colorIdentifier === 'string') { + color = observeColor(colorIdentifier, themeService); + } else { + color = colorIdentifier.map((identifier, reader) => observeColor(identifier, themeService).read(reader)); + } + + const backgroundColor = observeColor(editorBackground, themeService); + + return color.map((c, reader) => c.makeOpaque(backgroundColor.read(reader))); +} + +export function observeColor(colorIdentifier: ColorIdentifier, themeService: IThemeService): IObservable { + return observableFromEventOpts( + { + owner: { observeColor: colorIdentifier }, + equalsFn: (a: Color, b: Color) => a.equals(b), + }, + themeService.onDidColorThemeChange, + () => { + const color = themeService.getColorTheme().getColor(colorIdentifier); + if (!color) { + throw new BugIndicatingError(`Missing color: ${colorIdentifier}`); + } + return color; + } + ); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/utils/utils.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/utils/utils.ts index a393e04e241..8aac0066ab6 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/utils/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/utils/utils.ts @@ -24,6 +24,7 @@ import { Position } from '../../../../../../common/core/position.js'; import { Range } from '../../../../../../common/core/range.js'; import { SingleTextEdit, TextEdit } from '../../../../../../common/core/textEdit.js'; import { RangeMapping } from '../../../../../../common/diff/rangeMapping.js'; +import { ITextModel } from '../../../../../../common/model.js'; import { indentOfLine } from '../../../../../../common/model/textModel.js'; export function maxContentWidthInRange(editor: ObservableCodeEditor, range: LineRange, reader: IReader | undefined): number { @@ -83,10 +84,9 @@ export function getPrefixTrim(diffRanges: Range[], originalLinesRange: LineRange if (startLineIndent >= prefixTrim + 1) { // We can use the editor to get the offset prefixLeftOffset = editor.getOffsetForColumn(originalLinesRange.startLineNumber, prefixTrim + 1); - } else if (startLineIndent !== 1) { - // We need to approximate the offset as the editor does not contain the modified lines yet - const startLineIndentOffset = editor.getOffsetForColumn(originalLinesRange.startLineNumber, startLineIndent); - prefixLeftOffset = startLineIndentOffset / (startLineIndent - 1) * prefixTrim; + } else if (modifiedLines.length > 0) { + // Content is not in the editor, we can use the content width to calculate the offset + prefixLeftOffset = getContentRenderWidth(modifiedLines[0].slice(0, prefixTrim), editor, textModel); } else { // unable to approximate the offset return { prefixTrim: 0, prefixLeftOffset: 0 }; @@ -95,6 +95,15 @@ export function getPrefixTrim(diffRanges: Range[], originalLinesRange: LineRange return { prefixTrim, prefixLeftOffset }; } +export function getContentRenderWidth(content: string, editor: ICodeEditor, textModel: ITextModel) { + const w = editor.getOption(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; + const tabSize = textModel.getOptions().tabSize * w; + + const numTabs = content.split('\t').length - 1; + const numNoneTabs = content.length - numTabs; + return numNoneTabs * w + numTabs * tabSize; +} + export class StatusBarViewItem extends MenuEntryActionViewItem { protected readonly _updateLabelListener = this._register(this._contextKeyService.onDidChangeContext(() => { this.updateLabel(); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css index bbc23c3b1f0..bff0b88c59d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css @@ -76,71 +76,19 @@ } } - .inline-edits-view { - &.toolbarDropdownVisible, .editorContainer.showHover:hover { - .toolbar { - display: block; + .inline-edits-view .editorContainer { + .preview .monaco-editor { + .view-overlays .current-line-exact { + border: none; + } + + .current-line-margin { + border: none; } } - .editorContainer { - color: var(--vscode-editorHoverWidget-foreground); - - .toolbar { - display: none; - border-top: 1px solid rgba(69, 69, 69, 0.5); - background-color: var(--vscode-editorHoverWidget-statusBarBackground); - - a { - color: var(--vscode-foreground); - } - - a:hover { - color: var(--vscode-foreground); - } - - .keybinding { - display: flex; - margin-left: 4px; - opacity: 0.6; - } - - .keybinding .monaco-keybinding-key { - font-size: 8px; - padding: 2px 3px; - } - - .availableSuggestionCount a { - display: flex; - min-width: 19px; - justify-content: center; - } - - .inlineSuggestionStatusBarItemLabel { - margin-right: 2px; - } - - } - - .preview { - .monaco-editor { - .view-overlays .current-line-exact { - border: none; - } - - .current-line-margin { - border: none; - } - } - - .monaco-editor-background { - background-color: var(--vscode-inlineEdit-modifiedChangedLineBackground) - } - } - - .inline-edits-view-zone.diagonal-fill { - opacity: 0.5; - } + .inline-edits-view-zone.diagonal-fill { + opacity: 0.5; } } @@ -175,25 +123,23 @@ } .inlineCompletions-char-delete.single-line-inline { /* Editor Decoration */ - border-radius: 4px; border: 1px solid var(--vscode-editorHoverWidget-border); margin: -2px 0 0 -2px; } .inlineCompletions-char-insert.single-line-inline { /* Inline Decoration */ - padding: 1px 0; - border-top: 1px solid var(--vscode-editorHoverWidget-border); /* TODO: Do not set border inline but create overlaywidget (like deletion view) */ - border-bottom: 1px solid var(--vscode-editorHoverWidget-border); /* TODO: Do not set border inline but create overlaywidget (like deletion view) */ + border-top: 1px solid var(--vscode-inlineEdit-modifiedBorder); /* TODO: Do not set border inline but create overlaywidget (like deletion view) */ + border-bottom: 1px solid var(--vscode-inlineEdit-modifiedBorder); /* TODO: Do not set border inline but create overlaywidget (like deletion view) */ } .inlineCompletions-char-insert.single-line-inline.start { border-top-left-radius: 4px; border-bottom-left-radius: 4px; - border-left: 1px solid var(--vscode-editorHoverWidget-border); /* TODO: Do not set border inline but create overlaywidget (like deletion view) */ + border-left: 1px solid var(--vscode-inlineEdit-modifiedBorder); /* TODO: Do not set border inline but create overlaywidget (like deletion view) */ } .inlineCompletions-char-insert.single-line-inline.end { border-top-right-radius: 4px; border-bottom-right-radius: 4px; - border-right: 1px solid var(--vscode-editorHoverWidget-border); /* TODO: Do not set border inline but create overlaywidget (like deletion view) */ + border-right: 1px solid var(--vscode-inlineEdit-modifiedBorder); /* TODO: Do not set border inline but create overlaywidget (like deletion view) */ } .inlineCompletions-char-delete.single-line-inline.empty, @@ -213,7 +159,6 @@ .inlineCompletions-original-bubble{ background: var(--vscode-inlineEdit-originalChangedTextBackground); - border-radius: 4px; } .inlineCompletions-modified-bubble, @@ -222,16 +167,6 @@ display: inline-block; } - .inlineCompletions-modified-bubble.start { - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - } - - .inlineCompletions-modified-bubble.end { - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - } - .inline-edit.ghost-text, .inline-edit.ghost-text-decoration, .inline-edit.ghost-text-decoration-preview, diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/computeGhostText.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/computeGhostText.test.ts new file mode 100644 index 00000000000..c6e68393ab1 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/computeGhostText.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Range } from '../../../../common/core/range.js'; +import { SingleTextEdit } from '../../../../common/core/textEdit.js'; +import { createTextModel } from '../../../../test/common/testTextModel.js'; +import { computeGhostText } from '../../browser/model/computeGhostText.js'; + +suite('computeGhostText', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function getOutput(text: string, suggestion: string): unknown { + const rangeStartOffset = text.indexOf('['); + const rangeEndOffset = text.indexOf(']') - 1; + const cleanedText = text.replace('[', '').replace(']', ''); + const tempModel = createTextModel(cleanedText); + const range = Range.fromPositions(tempModel.getPositionAt(rangeStartOffset), tempModel.getPositionAt(rangeEndOffset)); + const options = ['prefix', 'subword'] as const; + const result = {} as any; + for (const option of options) { + result[option] = computeGhostText(new SingleTextEdit(range, suggestion), tempModel, option)?.render(cleanedText, true); + } + + tempModel.dispose(); + + if (new Set(Object.values(result)).size === 1) { + return Object.values(result)[0]; + } + + return result; + } + + test('Basic', () => { + assert.deepStrictEqual(getOutput('[foo]baz', 'foobar'), 'foo[bar]baz'); + assert.deepStrictEqual(getOutput('[aaa]aaa', 'aaaaaa'), 'aaa[aaa]aaa'); + assert.deepStrictEqual(getOutput('[foo]baz', 'boobar'), undefined); + assert.deepStrictEqual(getOutput('[foo]foo', 'foofoo'), 'foo[foo]foo'); + assert.deepStrictEqual(getOutput('foo[]', 'bar\nhello'), 'foo[bar\nhello]'); + }); + + test('Empty ghost text', () => { + assert.deepStrictEqual(getOutput('[foo]', 'foo'), 'foo'); + }); + + test('Whitespace (indentation)', () => { + assert.deepStrictEqual(getOutput('[ foo]', 'foobar'), ' foo[bar]'); + assert.deepStrictEqual(getOutput('[\tfoo]', 'foobar'), '\tfoo[bar]'); + assert.deepStrictEqual(getOutput('[\t foo]', '\tfoobar'), ' foo[bar]'); + assert.deepStrictEqual(getOutput('[\tfoo]', '\t\tfoobar'), { prefix: undefined, subword: '\t[\t]foo[bar]' }); + assert.deepStrictEqual(getOutput('[\t]', '\t\tfoobar'), '\t[\tfoobar]'); + assert.deepStrictEqual(getOutput('\t[]', '\t'), '\t[\t]'); + assert.deepStrictEqual(getOutput('\t[\t]', ''), '\t\t'); + + assert.deepStrictEqual(getOutput('[ ]', 'return 1'), ' [return 1]'); + }); + + test('Whitespace (outside of indentation)', () => { + assert.deepStrictEqual(getOutput('bar[ foo]', 'foobar'), undefined); + assert.deepStrictEqual(getOutput('bar[\tfoo]', 'foobar'), undefined); + }); + + test('Unsupported Case', () => { + assert.deepStrictEqual(getOutput('fo[o\n]', 'x\nbar'), undefined); + }); + + test('New Line', () => { + assert.deepStrictEqual(getOutput('fo[o\n]', 'o\nbar'), 'foo\n[bar]'); + }); + + test('Multi Part Diffing', () => { + assert.deepStrictEqual(getOutput('foo[()]', '(x);'), { prefix: undefined, subword: 'foo([x])[;]' }); + assert.deepStrictEqual(getOutput('[\tfoo]', '\t\tfoobar'), { prefix: undefined, subword: '\t[\t]foo[bar]' }); + assert.deepStrictEqual(getOutput('[(y ===)]', '(y === 1) { f(); }'), { prefix: undefined, subword: '(y ===[ 1])[ { f(); }]' }); + assert.deepStrictEqual(getOutput('[(y ==)]', '(y === 1) { f(); }'), { prefix: undefined, subword: '(y ==[= 1])[ { f(); }]' }); + + assert.deepStrictEqual(getOutput('[(y ==)]', '(y === 1) { f(); }'), { prefix: undefined, subword: '(y ==[= 1])[ { f(); }]' }); + }); + + test('Multi Part Diffing 1', () => { + assert.deepStrictEqual(getOutput('[if () ()]', 'if (1 == f()) ()'), { prefix: undefined, subword: 'if ([1 == f()]) ()' }); + }); + + test('Multi Part Diffing 2', () => { + assert.deepStrictEqual(getOutput('[)]', '())'), ({ prefix: undefined, subword: "[(])[)]" })); + assert.deepStrictEqual(getOutput('[))]', '(())'), ({ prefix: undefined, subword: "[((]))" })); + }); + + test('Parenthesis Matching', () => { + assert.deepStrictEqual(getOutput('[console.log()]', 'console.log({ label: "(" })'), { + prefix: undefined, + subword: 'console.log([{ label: "(" }])' + }); + }); +}); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsModel.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/getSecondaryEdits.test.ts similarity index 90% rename from src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsModel.test.ts rename to src/vs/editor/contrib/inlineCompletions/test/browser/getSecondaryEdits.test.ts index 6ba5f06b158..b418f100b03 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsModel.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/getSecondaryEdits.test.ts @@ -10,11 +10,11 @@ import { createTextModel } from '../../../../test/common/testTextModel.js'; import { Range } from '../../../../common/core/range.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -suite('inlineCompletionModel', () => { +suite('getSecondaryEdits', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('getSecondaryEdits - basic', async function () { + test('basic', async function () { const textModel = createTextModel([ 'function fib(', @@ -33,7 +33,7 @@ suite('inlineCompletionModel', () => { textModel.dispose(); }); - test('getSecondaryEdits - cursor not on same line as primary edit 1', async function () { + test('cursor not on same line as primary edit 1', async function () { const textModel = createTextModel([ 'function fib(', @@ -60,7 +60,7 @@ suite('inlineCompletionModel', () => { textModel.dispose(); }); - test('getSecondaryEdits - cursor not on same line as primary edit 2', async function () { + test('cursor not on same line as primary edit 2', async function () { const textModel = createTextModel([ 'class A {', diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts similarity index 51% rename from src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts rename to src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts index 28bd5ab66a4..e1266b26613 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts @@ -5,114 +5,16 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Range } from '../../../../common/core/range.js'; -import { InlineCompletionsProvider } from '../../../../common/languages.js'; -import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js'; -import { LanguageFeaturesService } from '../../../../common/services/languageFeaturesService.js'; -import { ViewModel } from '../../../../common/viewModel/viewModelImpl.js'; -import { InlineCompletionsController } from '../../browser/controller/inlineCompletionsController.js'; import { InlineCompletionsModel } from '../../browser/model/inlineCompletionsModel.js'; -import { SingleTextEdit } from '../../../../common/core/textEdit.js'; -import { GhostTextContext, MockInlineCompletionsProvider } from './utils.js'; -import { ITestCodeEditor, TestCodeEditorInstantiationOptions, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; -import { createTextModel } from '../../../../test/common/testTextModel.js'; -import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { IWithAsyncTestCodeEditorAndInlineCompletionsModel, MockInlineCompletionsProvider, withAsyncTestCodeEditorAndInlineCompletionsModel } from './utils.js'; +import { ITestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; import { Selection } from '../../../../common/core/selection.js'; -import { computeGhostText } from '../../browser/model/computeGhostText.js'; suite('Inline Completions', () => { ensureNoDisposablesAreLeakedInTestSuite(); - suite('inlineCompletionToGhostText', () => { - - function getOutput(text: string, suggestion: string): unknown { - const rangeStartOffset = text.indexOf('['); - const rangeEndOffset = text.indexOf(']') - 1; - const cleanedText = text.replace('[', '').replace(']', ''); - const tempModel = createTextModel(cleanedText); - const range = Range.fromPositions(tempModel.getPositionAt(rangeStartOffset), tempModel.getPositionAt(rangeEndOffset)); - const options = ['prefix', 'subword'] as const; - const result = {} as any; - for (const option of options) { - result[option] = computeGhostText(new SingleTextEdit(range, suggestion), tempModel, option)?.render(cleanedText, true); - } - - tempModel.dispose(); - - if (new Set(Object.values(result)).size === 1) { - return Object.values(result)[0]; - } - - return result; - } - - test('Basic', () => { - assert.deepStrictEqual(getOutput('[foo]baz', 'foobar'), 'foo[bar]baz'); - assert.deepStrictEqual(getOutput('[aaa]aaa', 'aaaaaa'), 'aaa[aaa]aaa'); - assert.deepStrictEqual(getOutput('[foo]baz', 'boobar'), undefined); - assert.deepStrictEqual(getOutput('[foo]foo', 'foofoo'), 'foo[foo]foo'); - assert.deepStrictEqual(getOutput('foo[]', 'bar\nhello'), 'foo[bar\nhello]'); - }); - - test('Empty ghost text', () => { - assert.deepStrictEqual(getOutput('[foo]', 'foo'), 'foo'); - }); - - test('Whitespace (indentation)', () => { - assert.deepStrictEqual(getOutput('[ foo]', 'foobar'), ' foo[bar]'); - assert.deepStrictEqual(getOutput('[\tfoo]', 'foobar'), '\tfoo[bar]'); - assert.deepStrictEqual(getOutput('[\t foo]', '\tfoobar'), ' foo[bar]'); - assert.deepStrictEqual(getOutput('[\tfoo]', '\t\tfoobar'), { prefix: undefined, subword: '\t[\t]foo[bar]' }); - assert.deepStrictEqual(getOutput('[\t]', '\t\tfoobar'), '\t[\tfoobar]'); - assert.deepStrictEqual(getOutput('\t[]', '\t'), '\t[\t]'); - assert.deepStrictEqual(getOutput('\t[\t]', ''), '\t\t'); - - assert.deepStrictEqual(getOutput('[ ]', 'return 1'), ' [return 1]'); - }); - - test('Whitespace (outside of indentation)', () => { - assert.deepStrictEqual(getOutput('bar[ foo]', 'foobar'), undefined); - assert.deepStrictEqual(getOutput('bar[\tfoo]', 'foobar'), undefined); - }); - - test('Unsupported Case', () => { - assert.deepStrictEqual(getOutput('fo[o\n]', 'x\nbar'), undefined); - }); - - test('New Line', () => { - assert.deepStrictEqual(getOutput('fo[o\n]', 'o\nbar'), 'foo\n[bar]'); - }); - - test('Multi Part Diffing', () => { - assert.deepStrictEqual(getOutput('foo[()]', '(x);'), { prefix: undefined, subword: 'foo([x])[;]' }); - assert.deepStrictEqual(getOutput('[\tfoo]', '\t\tfoobar'), { prefix: undefined, subword: '\t[\t]foo[bar]' }); - assert.deepStrictEqual(getOutput('[(y ===)]', '(y === 1) { f(); }'), { prefix: undefined, subword: '(y ===[ 1])[ { f(); }]' }); - assert.deepStrictEqual(getOutput('[(y ==)]', '(y === 1) { f(); }'), { prefix: undefined, subword: '(y ==[= 1])[ { f(); }]' }); - - assert.deepStrictEqual(getOutput('[(y ==)]', '(y === 1) { f(); }'), { prefix: undefined, subword: '(y ==[= 1])[ { f(); }]' }); - }); - - test('Multi Part Diffing 1', () => { - assert.deepStrictEqual(getOutput('[if () ()]', 'if (1 == f()) ()'), { prefix: undefined, subword: 'if ([1 == f()]) ()' }); - }); - - test('Multi Part Diffing 2', () => { - assert.deepStrictEqual(getOutput('[)]', '())'), ({ prefix: undefined, subword: "[(])[)]" })); - assert.deepStrictEqual(getOutput('[))]', '(())'), ({ prefix: undefined, subword: "[((]))" })); - }); - - test('Parenthesis Matching', () => { - assert.deepStrictEqual(getOutput('[console.log()]', 'console.log({ label: "(" })'), { - prefix: undefined, - subword: 'console.log([{ label: "(" }])' - }); - }); - }); - test('Does not trigger automatically if disabled', async function () { const provider = new MockInlineCompletionsProvider(); await withAsyncTestCodeEditorAndInlineCompletionsModel('', @@ -366,100 +268,158 @@ suite('Inline Completions', () => { ); }); - test('Forward stability', async function () { - // The user types the text as suggested and the provider is forward-stable - const provider = new MockInlineCompletionsProvider(); - await withAsyncTestCodeEditorAndInlineCompletionsModel('', - { fakeClock: true, provider }, - async ({ editor, editorViewModel, model, context }) => { - provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); - context.keyboardType('foo'); - model.trigger(); - await timeout(1000); - assert.deepStrictEqual(provider.getAndClearCallHistory(), [ - { position: '(1,4)', text: 'foo', triggerKind: 0, } - ]); - assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'foo[bar]']); - provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 5) }); - context.keyboardType('b'); - assert.deepStrictEqual(context.currentPrettyViewState, 'foob[ar]'); - await timeout(1000); - assert.deepStrictEqual(provider.getAndClearCallHistory(), [ - { position: '(1,5)', text: 'foob', triggerKind: 0, } - ]); - assert.deepStrictEqual(context.getAndClearViewStates(), ['foob[ar]']); + suite('Forward Stability', () => { + test('Typing agrees', async function () { + // The user types the text as suggested and the provider is forward-stable + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async ({ editor, editorViewModel, model, context }) => { + provider.setReturnValue({ insertText: 'foobar', }); + context.keyboardType('foo'); + model.trigger(); + await timeout(1000); + assert.deepStrictEqual(provider.getAndClearCallHistory(), [ + { position: '(1,4)', text: 'foo', triggerKind: 0, } + ]); + assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'foo[bar]']); - provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 6) }); - context.keyboardType('a'); - assert.deepStrictEqual(context.currentPrettyViewState, 'fooba[r]'); - await timeout(1000); - assert.deepStrictEqual(provider.getAndClearCallHistory(), [ - { position: '(1,6)', text: 'fooba', triggerKind: 0, } - ]); - assert.deepStrictEqual(context.getAndClearViewStates(), ['fooba[r]']); - } - ); - }); + context.keyboardType('b'); + assert.deepStrictEqual(context.getAndClearViewStates(), (["foob[ar]"])); + await timeout(1000); + assert.deepStrictEqual(provider.getAndClearCallHistory(), [ + { position: '(1,5)', text: 'foob', triggerKind: 0, } + ]); + assert.deepStrictEqual(context.getAndClearViewStates(), []); - test('Support forward instability', async function () { - // The user types the text as suggested and the provider reports a different suggestion. + context.keyboardType('a'); + assert.deepStrictEqual(context.getAndClearViewStates(), (["fooba[r]"])); + await timeout(1000); + assert.deepStrictEqual(provider.getAndClearCallHistory(), [ + { position: '(1,6)', text: 'fooba', triggerKind: 0, } + ]); + assert.deepStrictEqual(context.getAndClearViewStates(), []); + } + ); + }); - const provider = new MockInlineCompletionsProvider(); - await withAsyncTestCodeEditorAndInlineCompletionsModel('', - { fakeClock: true, provider }, - async ({ editor, editorViewModel, model, context }) => { - provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); - context.keyboardType('foo'); - model.triggerExplicitly(); - await timeout(100); - assert.deepStrictEqual(provider.getAndClearCallHistory(), [ - { position: '(1,4)', text: 'foo', triggerKind: 1, } - ]); - assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'foo[bar]']); + async function setupScenario({ editor, editorViewModel, model, context, store }: IWithAsyncTestCodeEditorAndInlineCompletionsModel, provider: MockInlineCompletionsProvider): Promise { + assert.deepStrictEqual(context.getAndClearViewStates(), ['']); + provider.setReturnValue({ insertText: 'foo bar' }); + context.keyboardType('f'); + model.triggerExplicitly(); + await timeout(10000); + assert.deepStrictEqual(provider.getAndClearCallHistory(), ([{ position: "(1,2)", triggerKind: 1, text: "f" }])); + assert.deepStrictEqual(context.getAndClearViewStates(), (["f[oo bar]"])); - provider.setReturnValue({ insertText: 'foobaz', range: new Range(1, 1, 1, 5) }); - context.keyboardType('b'); - assert.deepStrictEqual(context.currentPrettyViewState, 'foob[ar]'); - await timeout(100); - // This behavior might change! - assert.deepStrictEqual(provider.getAndClearCallHistory(), [ - { position: '(1,5)', text: 'foob', triggerKind: 0, } - ]); - assert.deepStrictEqual(context.getAndClearViewStates(), ['foob[ar]', 'foob[az]']); - } - ); - }); + provider.setReturnValue({ insertText: 'foo baz' }); + await timeout(10000); + } - test('Support backward instability', async function () { - // The user deletes text and the suggestion changes - const provider = new MockInlineCompletionsProvider(); - await withAsyncTestCodeEditorAndInlineCompletionsModel('', - { fakeClock: true, provider }, - async ({ editor, editorViewModel, model, context }) => { - context.keyboardType('fooba'); + test('Support forward instability', async function () { + // The user types the text as suggested and the provider reports a different suggestion. + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async (ctx) => { + await setupScenario(ctx, provider); - provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 6) }); + ctx.context.keyboardType('o'); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), ['fo[o bar]']); + await timeout(10000); - model.triggerExplicitly(); - await timeout(1000); - assert.deepStrictEqual(provider.getAndClearCallHistory(), [ - { position: '(1,6)', text: 'fooba', triggerKind: 1, } - ]); - assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'fooba[r]']); + assert.deepStrictEqual(provider.getAndClearCallHistory(), [ + { position: '(1,3)', text: 'fo', triggerKind: 0, } + ]); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), ['fo[o baz]']); + } + ); + }); - provider.setReturnValue({ insertText: 'foobaz', range: new Range(1, 1, 1, 5) }); - context.leftDelete(); - await timeout(1000); - assert.deepStrictEqual(provider.getAndClearCallHistory(), [ - { position: '(1,5)', text: 'foob', triggerKind: 0, } - ]); - assert.deepStrictEqual(context.getAndClearViewStates(), [ - 'foob[ar]', - 'foob[az]' - ]); - } - ); + + test('when accepting word by word', async function () { + // The user types the text as suggested and the provider reports a different suggestion. + + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async (ctx) => { + await setupScenario(ctx, provider); + + await ctx.model.acceptNextWord(); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), (["foo[ bar]"])); + + await timeout(10000); + assert.deepStrictEqual(provider.getAndClearCallHistory(), ([{ position: "(1,4)", triggerKind: 0, text: "foo" }])); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), ([])); + + await ctx.model.triggerExplicitly(); // reset to provider truth + await timeout(10000); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), (["foo[ baz]"])); + } + ); + }); + + test('when accepting undo', async function () { + // The user types the text as suggested and the provider reports a different suggestion. + + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async (ctx) => { + await setupScenario(ctx, provider); + + await ctx.model.acceptNextWord(); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), (["foo[ bar]"])); + + await timeout(10000); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), ([])); + assert.deepStrictEqual(provider.getAndClearCallHistory(), ([{ position: "(1,4)", triggerKind: 0, text: "foo" }])); + + await ctx.editor.getModel().undo(); + await timeout(10000); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), (["f[oo bar]"])); + assert.deepStrictEqual(provider.getAndClearCallHistory(), ([{ position: "(1,2)", triggerKind: 0, text: "f" }])); + + await ctx.editor.getModel().redo(); + await timeout(10000); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), (["foo[ bar]"])); + assert.deepStrictEqual(provider.getAndClearCallHistory(), ([{ position: "(1,4)", triggerKind: 0, text: "foo" }])); + } + ); + }); + + test('Support backward instability', async function () { + // The user deletes text and the suggestion changes + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async ({ editor, editorViewModel, model, context }) => { + context.keyboardType('fooba'); + + provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 6) }); + + model.triggerExplicitly(); + await timeout(1000); + assert.deepStrictEqual(provider.getAndClearCallHistory(), [ + { position: '(1,6)', text: 'fooba', triggerKind: 1, } + ]); + assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'fooba[r]']); + + provider.setReturnValue({ insertText: 'foobaz', range: new Range(1, 1, 1, 5) }); + context.leftDelete(); + await timeout(1000); + assert.deepStrictEqual(provider.getAndClearCallHistory(), [ + { position: '(1,5)', text: 'foob', triggerKind: 0, } + ]); + assert.deepStrictEqual(context.getAndClearViewStates(), [ + 'foob[ar]', + 'foob[az]' + ]); + } + ); + }); }); test('No race conditions', async function () { @@ -563,248 +523,199 @@ suite('Inline Completions', () => { } ); }); - - suite('inlineCompletionMultiCursor', () => { - - test('Basic', async function () { - const provider = new MockInlineCompletionsProvider(); - await withAsyncTestCodeEditorAndInlineCompletionsModel('', - { fakeClock: true, provider }, - async ({ editor, editorViewModel, model, context }) => { - context.keyboardType('console\nconsole\n'); - editor.setSelections([ - new Selection(1, 1000, 1, 1000), - new Selection(2, 1000, 2, 1000), - ]); - provider.setReturnValue({ - insertText: 'console.log("hello");', - range: new Range(1, 1, 1, 1000), - }); - model.triggerExplicitly(); - await timeout(1000); - model.accept(editor); - assert.deepStrictEqual( - editor.getValue(), - [ - `console.log("hello");`, - `console.log("hello");`, - `` - ].join('\n') - ); - } - ); - }); - - test('Multi Part', async function () { - const provider = new MockInlineCompletionsProvider(); - await withAsyncTestCodeEditorAndInlineCompletionsModel('', - { fakeClock: true, provider }, - async ({ editor, editorViewModel, model, context }) => { - context.keyboardType('console.log()\nconsole.log\n'); - editor.setSelections([ - new Selection(1, 12, 1, 12), - new Selection(2, 1000, 2, 1000), - ]); - provider.setReturnValue({ - insertText: 'console.log("hello");', - range: new Range(1, 1, 1, 1000), - }); - model.triggerExplicitly(); - await timeout(1000); - model.accept(editor); - assert.deepStrictEqual( - editor.getValue(), - [ - `console.log("hello");`, - `console.log("hello");`, - `` - ].join('\n') - ); - } - ); - }); - - test('Multi Part and Different Cursor Columns', async function () { - const provider = new MockInlineCompletionsProvider(); - await withAsyncTestCodeEditorAndInlineCompletionsModel('', - { fakeClock: true, provider }, - async ({ editor, editorViewModel, model, context }) => { - context.keyboardType('console.log()\nconsole.warn\n'); - editor.setSelections([ - new Selection(1, 12, 1, 12), - new Selection(2, 14, 2, 14), - ]); - provider.setReturnValue({ - insertText: 'console.log("hello");', - range: new Range(1, 1, 1, 1000), - }); - model.triggerExplicitly(); - await timeout(1000); - model.accept(editor); - assert.deepStrictEqual( - editor.getValue(), - [ - `console.log("hello");`, - `console.warn("hello");`, - `` - ].join('\n') - ); - } - ); - }); - - async function acceptNextWord(model: InlineCompletionsModel, editor: ITestCodeEditor, timesToAccept: number = 1): Promise { - for (let i = 0; i < timesToAccept; i++) { - model.triggerExplicitly(); - await timeout(1000); - await model.acceptNextWord(editor); - } - } - - test('Basic Partial Completion', async function () { - const provider = new MockInlineCompletionsProvider(); - await withAsyncTestCodeEditorAndInlineCompletionsModel('', - { fakeClock: true, provider }, - async ({ editor, editorViewModel, model, context }) => { - context.keyboardType('let\nlet\n'); - editor.setSelections([ - new Selection(1, 1000, 1, 1000), - new Selection(2, 1000, 2, 1000), - ]); - - provider.setReturnValue({ - insertText: `let a = 'some word'; `, - range: new Range(1, 1, 1, 1000), - }); - - await acceptNextWord(model, editor, 2); - - assert.deepStrictEqual( - editor.getValue(), - [ - `let a`, - `let a`, - `` - ].join('\n') - ); - } - ); - }); - - test('Partial Multi-Part Completion', async function () { - const provider = new MockInlineCompletionsProvider(); - await withAsyncTestCodeEditorAndInlineCompletionsModel('', - { fakeClock: true, provider }, - async ({ editor, editorViewModel, model, context }) => { - context.keyboardType('for ()\nfor \n'); - editor.setSelections([ - new Selection(1, 5, 1, 5), - new Selection(2, 1000, 2, 1000), - ]); - - provider.setReturnValue({ - insertText: `for (let i = 0; i < 10; i++) {`, - range: new Range(1, 1, 1, 1000), - }); - - model.triggerExplicitly(); - await timeout(1000); - - await acceptNextWord(model, editor, 3); - - assert.deepStrictEqual( - editor.getValue(), - [ - `for (let i)`, - `for (let i`, - `` - ].join('\n') - ); - } - ); - }); - - test('Partial Mutli-Part and Different Cursor Columns Completion', async function () { - const provider = new MockInlineCompletionsProvider(); - await withAsyncTestCodeEditorAndInlineCompletionsModel('', - { fakeClock: true, provider }, - async ({ editor, editorViewModel, model, context }) => { - context.keyboardType(`console.log()\nconsole.warnnnn\n`); - editor.setSelections([ - new Selection(1, 12, 1, 12), - new Selection(2, 16, 2, 16), - ]); - - provider.setReturnValue({ - insertText: `console.log("hello" + " " + "world");`, - range: new Range(1, 1, 1, 1000), - }); - - model.triggerExplicitly(); - await timeout(1000); - - await acceptNextWord(model, editor, 4); - - assert.deepStrictEqual( - editor.getValue(), - [ - `console.log("hello" + )`, - `console.warnnnn("hello" + `, - `` - ].join('\n') - ); - } - ); - }); - }); }); -async function withAsyncTestCodeEditorAndInlineCompletionsModel( - text: string, - options: TestCodeEditorInstantiationOptions & { provider?: InlineCompletionsProvider; fakeClock?: boolean }, - callback: (args: { editor: ITestCodeEditor; editorViewModel: ViewModel; model: InlineCompletionsModel; context: GhostTextContext }) => Promise -): Promise { - return await runWithFakedTimers({ - useFakeTimers: options.fakeClock, - }, async () => { - const disposableStore = new DisposableStore(); +suite('Multi Cursor Support', () => { + ensureNoDisposablesAreLeakedInTestSuite(); - try { - if (options.provider) { - const languageFeaturesService = new LanguageFeaturesService(); - if (!options.serviceCollection) { - options.serviceCollection = new ServiceCollection(); - } - options.serviceCollection.set(ILanguageFeaturesService, languageFeaturesService); - options.serviceCollection.set(IAccessibilitySignalService, { - playSignal: async () => { }, - isSoundEnabled(signal: unknown) { return false; }, - } as any); - const d = languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, options.provider); - disposableStore.add(d); + test('Basic', async function () { + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async ({ editor, editorViewModel, model, context }) => { + context.keyboardType('console\nconsole\n'); + editor.setSelections([ + new Selection(1, 1000, 1, 1000), + new Selection(2, 1000, 2, 1000), + ]); + provider.setReturnValue({ + insertText: 'console.log("hello");', + range: new Range(1, 1, 1, 1000), + }); + model.triggerExplicitly(); + await timeout(1000); + model.accept(editor); + assert.deepStrictEqual( + editor.getValue(), + [ + `console.log("hello");`, + `console.log("hello");`, + `` + ].join('\n') + ); } - - let result: T; - await withAsyncTestCodeEditor(text, options, async (editor, editorViewModel, instantiationService) => { - const controller = instantiationService.createInstance(InlineCompletionsController, editor); - const model = controller.model.get()!; - const context = new GhostTextContext(model, editor); - try { - result = await callback({ editor, editorViewModel, model, context }); - } finally { - context.dispose(); - model.dispose(); - controller.dispose(); - } - }); - - if (options.provider instanceof MockInlineCompletionsProvider) { - options.provider.assertNotCalledTwiceWithin50ms(); - } - - return result!; - } finally { - disposableStore.dispose(); - } + ); }); -} + + test('Multi Part', async function () { + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async ({ editor, editorViewModel, model, context }) => { + context.keyboardType('console.log()\nconsole.log\n'); + editor.setSelections([ + new Selection(1, 12, 1, 12), + new Selection(2, 1000, 2, 1000), + ]); + provider.setReturnValue({ + insertText: 'console.log("hello");', + range: new Range(1, 1, 1, 1000), + }); + model.triggerExplicitly(); + await timeout(1000); + model.accept(editor); + assert.deepStrictEqual( + editor.getValue(), + [ + `console.log("hello");`, + `console.log("hello");`, + `` + ].join('\n') + ); + } + ); + }); + + test('Multi Part and Different Cursor Columns', async function () { + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async ({ editor, editorViewModel, model, context }) => { + context.keyboardType('console.log()\nconsole.warn\n'); + editor.setSelections([ + new Selection(1, 12, 1, 12), + new Selection(2, 14, 2, 14), + ]); + provider.setReturnValue({ + insertText: 'console.log("hello");', + range: new Range(1, 1, 1, 1000), + }); + model.triggerExplicitly(); + await timeout(1000); + model.accept(editor); + assert.deepStrictEqual( + editor.getValue(), + [ + `console.log("hello");`, + `console.warn("hello");`, + `` + ].join('\n') + ); + } + ); + }); + + async function acceptNextWord(model: InlineCompletionsModel, editor: ITestCodeEditor, timesToAccept: number = 1): Promise { + for (let i = 0; i < timesToAccept; i++) { + model.triggerExplicitly(); + await timeout(1000); + await model.acceptNextWord(); + } + } + + test('Basic Partial Completion', async function () { + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async ({ editor, editorViewModel, model, context }) => { + context.keyboardType('let\nlet\n'); + editor.setSelections([ + new Selection(1, 1000, 1, 1000), + new Selection(2, 1000, 2, 1000), + ]); + + provider.setReturnValue({ + insertText: `let a = 'some word'; `, + range: new Range(1, 1, 1, 1000), + }); + + await acceptNextWord(model, editor, 2); + + assert.deepStrictEqual( + editor.getValue(), + [ + `let a`, + `let a`, + `` + ].join('\n') + ); + } + ); + }); + + test('Partial Multi-Part Completion', async function () { + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async ({ editor, editorViewModel, model, context }) => { + context.keyboardType('for ()\nfor \n'); + editor.setSelections([ + new Selection(1, 5, 1, 5), + new Selection(2, 1000, 2, 1000), + ]); + + provider.setReturnValue({ + insertText: `for (let i = 0; i < 10; i++) {`, + range: new Range(1, 1, 1, 1000), + }); + + model.triggerExplicitly(); + await timeout(1000); + + await acceptNextWord(model, editor, 3); + + assert.deepStrictEqual( + editor.getValue(), + [ + `for (let i)`, + `for (let i`, + `` + ].join('\n') + ); + } + ); + }); + + test('Partial Mutli-Part and Different Cursor Columns Completion', async function () { + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider }, + async ({ editor, editorViewModel, model, context }) => { + context.keyboardType(`console.log()\nconsole.warnnnn\n`); + editor.setSelections([ + new Selection(1, 12, 1, 12), + new Selection(2, 16, 2, 16), + ]); + + provider.setReturnValue({ + insertText: `console.log("hello" + " " + "world");`, + range: new Range(1, 1, 1, 1000), + }); + + model.triggerExplicitly(); + await timeout(1000); + + await acceptNextWord(model, editor, 4); + + assert.deepStrictEqual( + editor.getValue(), + [ + `console.log("hello" + )`, + `console.warnnnn("hello" + `, + `` + ].join('\n') + ); + } + ); + }); +}); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineEdits.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineEdits.test.ts new file mode 100644 index 00000000000..ea8896ee649 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineEdits.test.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AnnotatedText, InlineEditContext, IWithAsyncTestCodeEditorAndInlineCompletionsModel, MockSearchReplaceCompletionsProvider, withAsyncTestCodeEditorAndInlineCompletionsModel } from './utils.js'; + +suite('Inline Edits', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const val = new AnnotatedText(` +class Point { + constructor(public x: number, public y: number) {} + + getLength2D(): number { + return↓ Math.sqrt(this.x * this.x + this.y * this.y↓); + } +} +`); + + async function runTest(cb: (ctx: IWithAsyncTestCodeEditorAndInlineCompletionsModel, provider: MockSearchReplaceCompletionsProvider, view: InlineEditContext) => Promise): Promise { + const provider = new MockSearchReplaceCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel(val.value, + { fakeClock: true, provider, inlineSuggest: { enabled: true } }, + async (ctx) => { + const view = new InlineEditContext(ctx.model, ctx.editor); + ctx.store.add(view); + await cb(ctx, provider, view); + } + ); + } + + test('Can Accept Inline Edit', async function () { + await runTest(async ({ context, model, editor, editorViewModel }, provider, view) => { + provider.add(`getLength2D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + }`, `getLength3D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + }`); + + await model.trigger(); + await timeout(10000); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + undefined, + "\n\tget❰Length2↦Length3❱D(): numbe...\n...y * this.y❰ + th...his.z❱);\n" + ])); + + model.accept(); + + assert.deepStrictEqual(editor.getValue(), ` +class Point { + constructor(public x: number, public y: number) {} + + getLength3D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + } +} +`); + }); + }); + + test('Can Type Inline Edit', async function () { + await runTest(async ({ context, model, editor, editorViewModel }, provider, view) => { + provider.add(`getLength2D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + }`, `getLength3D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + }`); + await model.trigger(); + await timeout(10000); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + undefined, + "\n\tget❰Length2↦Length3❱D(): numbe...\n...y * this.y❰ + th...his.z❱);\n" + ])); + + editor.setPosition(val.getMarkerPosition(1)); + editorViewModel.type(' + t'); + + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + "\n\tget❰Length2↦Length3❱D(): numbe...\n...this.y + t❰his.z...his.z❱);\n" + ])); + + editorViewModel.type('his.z * this.z'); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + "\n\tget❰Length2↦Length3❱D(): numbe..." + ])); + }); + }); + + test('Inline Edit Stays On Unrelated Edit', async function () { + await runTest(async ({ context, model, editor, editorViewModel }, provider, view) => { + provider.add(`getLength2D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + }`, `getLength3D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + }`); + await model.trigger(); + await timeout(10000); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + undefined, + "\n\tget❰Length2↦Length3❱D(): numbe...\n...y * this.y❰ + th...his.z❱);\n" + ])); + + editor.setPosition(val.getMarkerPosition(0)); + editorViewModel.type('/* */'); + + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + "\n\tget❰Length2↦Length3❱D(): numbe...\n...y * this.y❰ + th...his.z❱);\n" + ])); + + await timeout(10000); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + undefined + ])); + }); + }); +}); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 9cd0d114694..ffbb9897aa6 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -5,14 +5,25 @@ import { timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { CoreEditingCommands, CoreNavigationCommands } from '../../../../browser/coreCommands.js'; import { Position } from '../../../../common/core/position.js'; import { ITextModel } from '../../../../common/model.js'; -import { InlineCompletion, InlineCompletionContext, InlineCompletionsProvider } from '../../../../common/languages.js'; -import { ITestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; +import { InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider } from '../../../../common/languages.js'; +import { ITestCodeEditor, TestCodeEditorInstantiationOptions, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; import { InlineCompletionsModel } from '../../browser/model/inlineCompletionsModel.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, derived } from '../../../../../base/common/observable.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js'; +import { LanguageFeaturesService } from '../../../../common/services/languageFeaturesService.js'; +import { ViewModel } from '../../../../common/viewModel/viewModelImpl.js'; +import { InlineCompletionsController } from '../../browser/controller/inlineCompletionsController.js'; +import { Range } from '../../../../common/core/range.js'; +import { TextEdit } from '../../../../common/core/textEdit.js'; +import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { PositionOffsetTransformer } from '../../../../common/core/positionToOffset.js'; export class MockInlineCompletionsProvider implements InlineCompletionsProvider { private returnValue: InlineCompletion[] = []; @@ -58,7 +69,13 @@ export class MockInlineCompletionsProvider implements InlineCompletionsProvider text: model.getValue() }); const result = new Array(); - result.push(...this.returnValue); + for (const v of this.returnValue) { + const x = { ...v }; + if (!x.range) { + x.range = model.getFullModelRange(); + } + result.push(x); + } if (this.delayMs > 0) { await timeout(this.delayMs); @@ -70,6 +87,66 @@ export class MockInlineCompletionsProvider implements InlineCompletionsProvider handleItemDidShow() { } } +export class MockSearchReplaceCompletionsProvider implements InlineCompletionsProvider { + private _map = new Map(); + + public add(search: string, replace: string): void { + this._map.set(search, replace); + } + + async provideInlineCompletions(model: ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): Promise { + const text = model.getValue(); + for (const [search, replace] of this._map) { + const idx = text.indexOf(search); + // replace idx...idx+text.length with replace + if (idx !== -1) { + const range = Range.fromPositions(model.getPositionAt(idx), model.getPositionAt(idx + search.length)); + return { + items: [ + { range, insertText: replace, isInlineEdit: true } + ] + }; + } + } + return { items: [] }; + } + freeInlineCompletions() { } + handleItemDidShow() { } +} + +export class InlineEditContext extends Disposable { + public readonly prettyViewStates = new Array(); + + constructor(model: InlineCompletionsModel, private readonly editor: ITestCodeEditor) { + super(); + + const edit = derived(reader => { + const state = model.state.read(reader); + return state ? new TextEdit(state.edits) : undefined; + }); + + this._register(autorun(reader => { + /** @description update */ + const e = edit.read(reader); + let view: string | undefined; + + if (e) { + view = e.toString(this.editor.getValue()); + } else { + view = undefined; + } + + this.prettyViewStates.push(view); + })); + } + + public getAndClearViewStates(): (string | undefined)[] { + const arr = [...this.prettyViewStates]; + this.prettyViewStates.length = 0; + return arr; + } +} + export class GhostTextContext extends Disposable { public readonly prettyViewStates = new Array(); private _currentPrettyViewState: string | undefined; @@ -132,3 +209,116 @@ export class GhostTextContext extends Disposable { } } +export interface IWithAsyncTestCodeEditorAndInlineCompletionsModel { + editor: ITestCodeEditor; + editorViewModel: ViewModel; + model: InlineCompletionsModel; + context: GhostTextContext; + store: DisposableStore; +} + +export async function withAsyncTestCodeEditorAndInlineCompletionsModel( + text: string, + options: TestCodeEditorInstantiationOptions & { provider?: InlineCompletionsProvider; fakeClock?: boolean }, + callback: (args: IWithAsyncTestCodeEditorAndInlineCompletionsModel) => Promise): Promise { + return await runWithFakedTimers({ + useFakeTimers: options.fakeClock, + }, async () => { + const disposableStore = new DisposableStore(); + + try { + if (options.provider) { + const languageFeaturesService = new LanguageFeaturesService(); + if (!options.serviceCollection) { + options.serviceCollection = new ServiceCollection(); + } + options.serviceCollection.set(ILanguageFeaturesService, languageFeaturesService); + options.serviceCollection.set(IAccessibilitySignalService, { + playSignal: async () => { }, + isSoundEnabled(signal: unknown) { return false; }, + } as any); + const d = languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, options.provider); + disposableStore.add(d); + } + + let result: T; + await withAsyncTestCodeEditor(text, options, async (editor, editorViewModel, instantiationService) => { + const controller = instantiationService.createInstance(InlineCompletionsController, editor); + controller.testOnlyDisableUi(); + const model = controller.model.get()!; + const context = new GhostTextContext(model, editor); + try { + result = await callback({ editor, editorViewModel, model, context, store: disposableStore }); + } finally { + context.dispose(); + model.dispose(); + controller.dispose(); + } + }); + + if (options.provider instanceof MockInlineCompletionsProvider) { + options.provider.assertNotCalledTwiceWithin50ms(); + } + + return result!; + } finally { + disposableStore.dispose(); + } + }); +} + +export class AnnotatedString { + public readonly value: string; + public readonly markers: { mark: string; idx: number }[]; + + constructor(src: string, annotations: string[] = ['↓']) { + const markers = findMarkers(src, annotations); + this.value = markers.textWithoutMarkers; + this.markers = markers.results; + } + + getMarkerOffset(markerIdx = 0): number { + if (markerIdx >= this.markers.length) { + throw new BugIndicatingError(`Marker index ${markerIdx} out of bounds`); + } + return this.markers[markerIdx].idx; + } +} + +function findMarkers(text: string, markers: string[]): { + results: { mark: string; idx: number }[]; + textWithoutMarkers: string; +} { + const results: { mark: string; idx: number }[] = []; + let textWithoutMarkers = ''; + + markers.sort((a, b) => b.length - a.length); + + let pos = 0; + for (let i = 0; i < text.length;) { + let foundMarker = false; + for (const marker of markers) { + if (text.startsWith(marker, i)) { + results.push({ mark: marker, idx: pos }); + i += marker.length; + foundMarker = true; + break; + } + } + if (!foundMarker) { + textWithoutMarkers += text[i]; + pos++; + i++; + } + } + + return { results, textWithoutMarkers }; +} + +export class AnnotatedText extends AnnotatedString { + private readonly _transformer = new PositionOffsetTransformer(this.value); + + getMarkerPosition(markerIdx = 0): Position { + return this._transformer.getPosition(this.getMarkerOffset(markerIdx)); + } +} diff --git a/src/vs/editor/contrib/links/browser/getLinks.ts b/src/vs/editor/contrib/links/browser/getLinks.ts index be8a31af119..fed24b5b1a8 100644 --- a/src/vs/editor/contrib/links/browser/getLinks.ts +++ b/src/vs/editor/contrib/links/browser/getLinks.ts @@ -74,7 +74,7 @@ export class LinksList { readonly links: Link[]; - private readonly _disposables = new DisposableStore(); + private readonly _disposables: DisposableStore | undefined = new DisposableStore(); constructor(tuples: [ILinksList, LinkProvider][]) { @@ -85,6 +85,7 @@ export class LinksList { links = LinksList._union(links, newLinks); // register disposables if (isDisposable(list)) { + this._disposables ??= new DisposableStore(); this._disposables.add(list); } } @@ -92,7 +93,7 @@ export class LinksList { } dispose(): void { - this._disposables.dispose(); + this._disposables?.dispose(); this.links.length = 0; } diff --git a/src/vs/editor/contrib/links/browser/links.ts b/src/vs/editor/contrib/links/browser/links.ts index a07122f53b4..ef1b67addd1 100644 --- a/src/vs/editor/contrib/links/browser/links.ts +++ b/src/vs/editor/contrib/links/browser/links.ts @@ -287,10 +287,6 @@ export class LinkDetector extends Disposable implements IEditorContribution { return null; } - public getAllLinkOccurrences(): LinkOccurrence[] { - return Object.values(this.currentOccurrences); - } - private isEnabled(mouseEvent: ClickLinkMouseEvent, withKey?: ClickLinkKeyboardEvent | null): boolean { return Boolean( (mouseEvent.target.type === MouseTargetType.CONTENT_TEXT) diff --git a/src/vs/editor/contrib/rename/browser/rename.ts b/src/vs/editor/contrib/rename/browser/rename.ts index 2165ff1e350..4fda88fd913 100644 --- a/src/vs/editor/contrib/rename/browser/rename.ts +++ b/src/vs/editor/contrib/rename/browser/rename.ts @@ -12,6 +12,16 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { assertType } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; +import * as nls from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IEditorProgressService } from '../../../../platform/progress/common/progress.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; import { ICodeEditor } from '../../../browser/editorBrowser.js'; import { EditorAction, EditorCommand, EditorContributionInstantiation, ServicesAccessor, registerEditorAction, registerEditorCommand, registerEditorContribution, registerModelAndPositionCommand } from '../../../browser/editorExtensions.js'; import { IBulkEditService } from '../../../browser/services/bulkEditService.js'; @@ -27,18 +37,7 @@ import { ILanguageFeaturesService } from '../../../common/services/languageFeatu import { ITextResourceConfigurationService } from '../../../common/services/textResourceConfiguration.js'; import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from '../../editorState/browser/editorState.js'; import { MessageController } from '../../message/browser/messageController.js'; -import * as nls from '../../../../nls.js'; -import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; -import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { IEditorProgressService } from '../../../../platform/progress/common/progress.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { CONTEXT_RENAME_INPUT_VISIBLE, NewNameSource, RenameWidget, RenameWidgetResult } from './renameWidget.js'; +import { CONTEXT_RENAME_INPUT_VISIBLE, RenameWidget } from './renameWidget.js'; class RenameSkeleton { @@ -151,7 +150,6 @@ class RenameController implements IEditorContribution { @ILogService private readonly _logService: ILogService, @ITextResourceConfigurationService private readonly _configService: ITextResourceConfigurationService, @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { this._renameWidget = this._disposableStore.add(this._instaService.createInstance(RenameWidget, this.editor, ['acceptRenameInput', 'acceptRenameInputWithPreview'])); } @@ -254,10 +252,6 @@ class RenameController implements IEditorContribution { ); trace('received response from rename input field'); - if (newSymbolNamesProviders.length > 0) { // @ulugbekna: we're interested only in telemetry for rename suggestions currently - this._reportTelemetry(newSymbolNamesProviders.length, model.getLanguageId(), inputFieldResult); - } - // no result, only hint to focus the editor or not if (typeof inputFieldResult === 'boolean') { trace(`returning early - rename input field response - ${inputFieldResult}`); @@ -343,66 +337,6 @@ class RenameController implements IEditorContribution { focusPreviousRenameSuggestion(): void { this._renameWidget.focusPreviousRenameSuggestion(); } - - private _reportTelemetry(nRenameSuggestionProviders: number, languageId: string, inputFieldResult: boolean | RenameWidgetResult) { - type RenameInvokedEvent = - { - kind: 'accepted' | 'cancelled'; - languageId: string; - nRenameSuggestionProviders: number; - - /** provided only if kind = 'accepted' */ - source?: NewNameSource['k']; - /** provided only if kind = 'accepted' */ - nRenameSuggestions?: number; - /** provided only if kind = 'accepted' */ - timeBeforeFirstInputFieldEdit?: number; - /** provided only if kind = 'accepted' */ - wantsPreview?: boolean; - /** provided only if kind = 'accepted' */ - nRenameSuggestionsInvocations?: number; - /** provided only if kind = 'accepted' */ - hadAutomaticRenameSuggestionsInvocation?: boolean; - }; - - type RenameInvokedClassification = { - owner: 'ulugbekna'; - comment: 'A rename operation was invoked.'; - - kind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the rename operation was cancelled or accepted.' }; - languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Document language ID.' }; - nRenameSuggestionProviders: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of rename providers for this document.' }; - - source?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the new name came from the input field or rename suggestions.' }; - nRenameSuggestions?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of rename suggestions user has got' }; - timeBeforeFirstInputFieldEdit?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Milliseconds before user edits the input field for the first time' }; - wantsPreview?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If user wanted preview.' }; - nRenameSuggestionsInvocations?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of times rename suggestions were invoked' }; - hadAutomaticRenameSuggestionsInvocation?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether rename suggestions were invoked automatically' }; - }; - - const value: RenameInvokedEvent = - typeof inputFieldResult === 'boolean' - ? { - kind: 'cancelled', - languageId, - nRenameSuggestionProviders, - } - : { - kind: 'accepted', - languageId, - nRenameSuggestionProviders, - - source: inputFieldResult.stats.source.k, - nRenameSuggestions: inputFieldResult.stats.nRenameSuggestions, - timeBeforeFirstInputFieldEdit: inputFieldResult.stats.timeBeforeFirstInputFieldEdit, - wantsPreview: inputFieldResult.wantsPreview, - nRenameSuggestionsInvocations: inputFieldResult.stats.nRenameSuggestionsInvocations, - hadAutomaticRenameSuggestionsInvocation: inputFieldResult.stats.hadAutomaticRenameSuggestionsInvocation, - }; - - this._telemetryService.publicLog2('renameInvokedEvent', value); - } } // ---- action implementation diff --git a/src/vs/editor/contrib/rename/browser/renameWidget.ts b/src/vs/editor/contrib/rename/browser/renameWidget.ts index baf0f0b88c4..e5a7bc71497 100644 --- a/src/vs/editor/contrib/rename/browser/renameWidget.ts +++ b/src/vs/editor/contrib/rename/browser/renameWidget.ts @@ -181,7 +181,7 @@ export class RenameWidget implements IRenameWidget, IContentWidget, IDisposable } })); - this._disposables.add(_themeService.onDidColorThemeChange(e => this._updateStyles(e.theme))); + this._disposables.add(_themeService.onDidColorThemeChange(this._updateStyles, this)); } dispose(): void { diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts index c407471b340..9f443e037d6 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts @@ -96,6 +96,14 @@ export class StickyScrollController extends Disposable implements IEditorContrib this._widgetState = StickyScrollWidgetState.Empty; const stickyScrollDomNode = this._stickyScrollWidget.getDomNode(); + this._register(this._editor.onDidChangeLineHeight((e) => { + e.changes.forEach((change) => { + const lineNumber = change.lineNumber; + if (this._widgetState.startLineNumbers.includes(lineNumber)) { + this._renderStickyScroll(lineNumber); + } + }); + })); this._register(this._editor.onDidChangeConfiguration(e => { this._readConfigurationChange(e); })); @@ -584,6 +592,10 @@ export class StickyScrollController extends Disposable implements IEditorContrib } findScrollWidgetState(): StickyScrollWidgetState { + if (!this._editor.hasModel()) { + return StickyScrollWidgetState.Empty; + } + const textModel = this._editor.getModel(); const maxNumberStickyLines = Math.min(this._maxStickyLines, this._editor.getOption(EditorOption.stickyScroll).maxLineCount); const scrollTop: number = this._editor.getScrollTop(); let lastLineRelativePosition: number = 0; @@ -596,10 +608,10 @@ export class StickyScrollController extends Disposable implements IEditorContrib for (const range of candidateRanges) { const start = range.startLineNumber; const end = range.endLineNumber; - if (end - start > 0) { + const isValidRange = textModel.isValidRange({ startLineNumber: start, endLineNumber: end, startColumn: 1, endColumn: 1 }); + if (isValidRange && end - start > 0) { const topOfElement = range.top; const bottomOfElement = topOfElement + range.height; - const topOfBeginningLine = this._editor.getTopForLineNumber(start) - scrollTop; const bottomOfEndLine = this._editor.getBottomForLineNumber(end) - scrollTop; if (topOfElement > topOfBeginningLine && topOfElement <= bottomOfEndLine) { diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollModelProvider.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollModelProvider.ts index b313d5490df..7b0800a58c4 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollModelProvider.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollModelProvider.ts @@ -316,7 +316,7 @@ abstract class StickyModelFromCandidateFoldingProvider extends StickyModelCandid constructor(editor: IActiveCodeEditor) { super(editor); - this._foldingLimitReporter = new RangesLimitReporter(editor); + this._foldingLimitReporter = this._register(new RangesLimitReporter(editor)); } protected createStickyModel(token: CancellationToken, model: FoldingRegions): StickyModel { diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollProvider.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollProvider.ts index ff98493b58e..24f9a4c279d 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollProvider.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollProvider.ts @@ -70,12 +70,10 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi private readConfiguration() { this._sessionStore.clear(); - const options = this._editor.getOption(EditorOption.stickyScroll); if (!options.enabled) { return; } - this._sessionStore.add(this._editor.onDidChangeModel(() => { // We should not show an old model for a different file, it will always be wrong. // So we clear the model here immediately and then trigger an update. @@ -103,7 +101,6 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi private updateStickyModelProvider() { this._stickyModelProvider?.dispose(); this._stickyModelProvider = null; - const editor = this._editor; if (editor.hasModel()) { this._stickyModelProvider = new StickyModelProvider( @@ -123,7 +120,6 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi } private async updateStickyModel(token: CancellationToken): Promise { - if (!this._editor.hasModel() || !this._stickyModelProvider || this._editor.getModel().isTooLargeForTokenization()) { this._model = null; return; @@ -133,7 +129,6 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi // the computation was canceled, so do not overwrite the model return; } - this._model = model; } @@ -167,19 +162,20 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi } } const lowerBound = this.updateIndex(binarySearch(childrenStartLines, range.startLineNumber, (a: number, b: number) => { return a - b; })); - const upperBound = this.updateIndex(binarySearch(childrenStartLines, range.startLineNumber + depth, (a: number, b: number) => { return a - b; })); + const upperBound = this.updateIndex(binarySearch(childrenStartLines, range.endLineNumber, (a: number, b: number) => { return a - b; })); for (let i = lowerBound; i <= upperBound; i++) { const child = outlineModel.children[i]; if (!child) { return; } - if (child.range) { - const childStartLine = child.range.startLineNumber; - const childEndLine = child.range.endLineNumber; + const childRange = child.range; + if (childRange) { + const childStartLine = childRange.startLineNumber; + const childEndLine = childRange.endLineNumber; if (range.startLineNumber <= childEndLine + 1 && childStartLine - 1 <= range.endLineNumber && childStartLine !== lastLine) { lastLine = childStartLine; - const lineHeight = this._editor.getOption(EditorOption.lineHeight); + const lineHeight = this._editor.getLineHeightForLineNumber(childStartLine); result.push(new StickyLineCandidate(childStartLine, childEndLine - 1, top, lineHeight)); this.getCandidateStickyLinesIntersectingFromStickyModel(range, child, result, depth + 1, top + lineHeight, childStartLine); } diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts index 9ec68fb92a8..ddda2843eb2 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts @@ -160,7 +160,7 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { if (!state) { return true; } - const futureWidgetHeight = state.startLineNumbers.length * this._lineHeight + state.lastLineRelativePosition; + const futureWidgetHeight = this._getHeightOfLines(state.startLineNumbers, state.lastLineRelativePosition); if (futureWidgetHeight > 0) { this._lastLineRelativePosition = state.lastLineRelativePosition; const lineNumbers = [...state.startLineNumbers]; @@ -228,18 +228,21 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { this._setHeight(0); return; } + let top: number = 0; // For existing sticky lines update the top and z-index for (const stickyLine of this._renderedStickyLines) { - this._updatePosition(stickyLine); + this._updatePosition(stickyLine, top); + top += stickyLine.height; } // For new sticky lines const layoutInfo = this._editor.getLayoutInfo(); const linesToRender = this._lineNumbers.slice(rebuildFromLine); for (const [index, line] of linesToRender.entries()) { - const stickyLine = this._renderChildNode(index + rebuildFromLine, line, foldingModel, layoutInfo); + const stickyLine = this._renderChildNode(index + rebuildFromLine, line, top, foldingModel, layoutInfo); if (!stickyLine) { continue; } + top += stickyLine.height; this._linesDomNode.appendChild(stickyLine.lineDomNode); this._lineNumbersDomNode.appendChild(stickyLine.lineNumberDomNode); this._renderedStickyLines.push(stickyLine); @@ -249,7 +252,7 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { this._useFoldingOpacityTransition(!this._isOnGlyphMargin); } - const widgetHeight = this._lineNumbers.length * this._lineHeight + this._lastLineRelativePosition; + const widgetHeight = top + this._lastLineRelativePosition; this._setHeight(widgetHeight); this._rootDomNode.style.marginLeft = '0px'; @@ -257,6 +260,14 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { this._editor.layoutOverlayWidget(this); } + private _getHeightOfLines(lineNumbers: number[], lastLineRelativePosition: number): number { + let totalHeight = 0; + for (let i = 0; i < lineNumbers.length; i++) { + totalHeight += this._editor.getLineHeightForLineNumber(lineNumbers[i]); + } + return totalHeight + lastLineRelativePosition; + } + private _setHeight(height: number): void { if (this._height === height) { return; @@ -291,7 +302,7 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { })); } - private _renderChildNode(index: number, line: number, foldingModel: FoldingModel | undefined, layoutInfo: EditorLayoutInfo): RenderedStickyLine | undefined { + private _renderChildNode(index: number, line: number, top: number, foldingModel: FoldingModel | undefined, layoutInfo: EditorLayoutInfo): RenderedStickyLine | undefined { const viewModel = this._editor._getViewModel(); if (!viewModel) { return; @@ -307,7 +318,7 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { actualInlineDecorations = []; } - const lineHeight = this._lineHeight; + const lineHeight = this._editor.getLineHeightForLineNumber(line); const renderLineInput: RenderLineInput = new RenderLineInput(true, true, lineRenderingData.content, lineRenderingData.continuesWithWrappedLine, lineRenderingData.isBasicASCII, lineRenderingData.containsRTL, 0, @@ -359,6 +370,7 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { if (foldingIcon) { lineNumberHTMLNode.appendChild(foldingIcon.domNode); foldingIcon.domNode.style.left = `${layoutInfo.lineNumbersWidth + layoutInfo.lineNumbersLeft}px`; + foldingIcon.domNode.style.lineHeight = `${lineHeight}px`; } this._editor.applyFontInfo(lineHTMLNode); @@ -371,10 +383,10 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { lineHTMLNode.style.height = `${lineHeight}px`; const renderedLine = new RenderedStickyLine(index, line, lineHTMLNode, lineNumberHTMLNode, foldingIcon, renderOutput.characterMapping, lineHTMLNode.scrollWidth, lineHeight); - return this._updatePosition(renderedLine); + return this._updatePosition(renderedLine, top); } - private _updatePosition(stickyLine: RenderedStickyLine): RenderedStickyLine { + private _updatePosition(stickyLine: RenderedStickyLine, top: number): RenderedStickyLine { const index = stickyLine.index; const lineHTMLNode = stickyLine.lineDomNode; const lineNumberHTMLNode = stickyLine.lineNumberDomNode; @@ -383,16 +395,15 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { const zIndex = '0'; lineHTMLNode.style.zIndex = zIndex; lineNumberHTMLNode.style.zIndex = zIndex; - const top = `${index * this._lineHeight + this._lastLineRelativePosition + (stickyLine.foldingIcon?.isCollapsed ? 1 : 0)}px`; - lineHTMLNode.style.top = top; - lineNumberHTMLNode.style.top = top; + const updatedTop = `${top + this._lastLineRelativePosition + (stickyLine.foldingIcon?.isCollapsed ? 1 : 0)}px`; + lineHTMLNode.style.top = updatedTop; + lineNumberHTMLNode.style.top = updatedTop; } else { const zIndex = '1'; lineHTMLNode.style.zIndex = zIndex; lineNumberHTMLNode.style.zIndex = zIndex; - const top = `${index * this._lineHeight}px`; - lineHTMLNode.style.top = top; - lineNumberHTMLNode.style.top = top; + lineHTMLNode.style.top = `${top}px`; + lineNumberHTMLNode.style.top = `${top}px`; } return stickyLine; } diff --git a/src/vs/editor/contrib/stickyScroll/test/browser/stickyScroll.test.ts b/src/vs/editor/contrib/stickyScroll/test/browser/stickyScroll.test.ts index 07493f90714..918e06d8aeb 100644 --- a/src/vs/editor/contrib/stickyScroll/test/browser/stickyScroll.test.ts +++ b/src/vs/editor/contrib/stickyScroll/test/browser/stickyScroll.test.ts @@ -150,7 +150,7 @@ suite('Sticky Scroll Tests', () => { await provider.update(); assert.deepStrictEqual(provider.getCandidateStickyLinesIntersecting({ startLineNumber: 1, endLineNumber: 4 }), [new StickyLineCandidate(1, 2, 0, 19)]); assert.deepStrictEqual(provider.getCandidateStickyLinesIntersecting({ startLineNumber: 8, endLineNumber: 10 }), [new StickyLineCandidate(7, 11, 0, 19), new StickyLineCandidate(9, 11, 19, 19), new StickyLineCandidate(10, 10, 38, 19)]); - assert.deepStrictEqual(provider.getCandidateStickyLinesIntersecting({ startLineNumber: 10, endLineNumber: 13 }), [new StickyLineCandidate(7, 11, 0, 19), new StickyLineCandidate(9, 11, 19, 19), new StickyLineCandidate(10, 10, 38, 19)]); + assert.deepStrictEqual(provider.getCandidateStickyLinesIntersecting({ startLineNumber: 10, endLineNumber: 13 }), [new StickyLineCandidate(7, 11, 0, 19), new StickyLineCandidate(9, 11, 19, 19), new StickyLineCandidate(10, 10, 38, 19), new StickyLineCandidate(13, 13, 0, 19)]); provider.dispose(); model.dispose(); diff --git a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts index 8c77c920b40..71ae4e428fe 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts @@ -36,6 +36,7 @@ import { ItemRenderer } from './suggestWidgetRenderer.js'; import { getListStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { status } from '../../../../base/browser/ui/aria/aria.js'; import { CompletionItemKinds } from '../../../common/languages.js'; +import { isWindows } from '../../../../base/common/platform.js'; /** * Suggest widget colors @@ -231,7 +232,7 @@ export class SuggestWidget implements IDisposable { mouseSupport: false, multipleSelectionSupport: false, accessibilityProvider: { - getRole: () => 'option', + getRole: () => isWindows ? 'listitem' : 'option', getWidgetAriaLabel: () => nls.localize('suggest', "Suggest"), getWidgetRole: () => 'listbox', getAriaLabel: (item: CompletionItem) => { @@ -273,7 +274,7 @@ export class SuggestWidget implements IDisposable { const applyStatusBarStyle = () => this.element.domNode.classList.toggle('with-status-bar', this.editor.getOption(EditorOption.suggest).showStatusBar); applyStatusBarStyle(); - this._disposables.add(_themeService.onDidColorThemeChange(t => this._onThemeChange(t.theme))); + this._disposables.add(_themeService.onDidColorThemeChange(t => this._onThemeChange(t))); this._onThemeChange(_themeService.getColorTheme()); this._disposables.add(this._list.onMouseDown(e => this._onListMouseDownOrTap(e))); diff --git a/src/vs/editor/contrib/suggest/test/browser/wordDistance.test.ts b/src/vs/editor/contrib/suggest/test/browser/wordDistance.test.ts index 0057e3cf389..5c50c04166b 100644 --- a/src/vs/editor/contrib/suggest/test/browser/wordDistance.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/wordDistance.test.ts @@ -13,7 +13,7 @@ import { IRange } from '../../../../common/core/range.js'; import { DEFAULT_WORD_REGEXP } from '../../../../common/core/wordHelper.js'; import * as languages from '../../../../common/languages.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; -import { BaseEditorSimpleWorker } from '../../../../common/services/editorSimpleWorker.js'; +import { EditorWorker } from '../../../../common/services/editorWebWorker.js'; import { EditorWorkerService } from '../../../../browser/services/editorWorkerService.js'; import { IModelService } from '../../../../common/services/model.js'; import { ITextResourceConfigurationService } from '../../../../common/services/textResourceConfiguration.js'; @@ -62,7 +62,7 @@ suite('suggest, word distance', function () { const service = new class extends EditorWorkerService { - private _worker = new BaseEditorSimpleWorker(); + private _worker = new EditorWorker(); constructor() { super(null!, modelService, new class extends mock() { }, new NullLogService(), new TestLanguageConfigurationService(), new LanguageFeaturesService()); diff --git a/src/vs/editor/contrib/unicodeHighlighter/browser/bannerController.ts b/src/vs/editor/contrib/unicodeHighlighter/browser/bannerController.ts index d96488677d9..e23a826de08 100644 --- a/src/vs/editor/contrib/unicodeHighlighter/browser/bannerController.ts +++ b/src/vs/editor/contrib/unicodeHighlighter/browser/bannerController.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import './bannerController.css'; +import { localize } from '../../../../nls.js'; import { $, append, clearNode } from '../../../../base/browser/dom.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { Action } from '../../../../base/common/actions.js'; @@ -129,7 +130,7 @@ class Banner extends Disposable { this.actionBar.push(this._register( new Action( 'banner.close', - 'Close Banner', + localize('closeBanner', "Close Banner"), ThemeIcon.asClassName(widgetClose), true, () => { diff --git a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts index 2a65960d983..96820f1eafd 100644 --- a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts +++ b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts @@ -370,7 +370,7 @@ export abstract class ZoneWidget implements IHorizontalSashLayoutProvider { } if (this.options.showFrame) { - const frameThickness = Math.round(lineHeight / 9); + const frameThickness = this.options.frameWidth ?? Math.round(lineHeight / 9); result += 2 * frameThickness; } diff --git a/src/vs/editor/editor.worker.start.ts b/src/vs/editor/editor.worker.start.ts new file mode 100644 index 00000000000..9906249af9d --- /dev/null +++ b/src/vs/editor/editor.worker.start.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { initialize } from '../base/common/worker/webWorkerBootstrap.js'; +import { EditorWorker, IWorkerContext } from './common/services/editorWebWorker.js'; +import { EditorWorkerHost } from './common/services/editorWorkerHost.js'; + +/** + * Used by `monaco-editor` to hook up web worker rpc. + * @skipMangle + * @internal + */ +export function start(client: TClient): IWorkerContext { + const webWorkerServer = initialize(() => new EditorWorker(client)); + const editorWorkerHost = EditorWorkerHost.getChannel(webWorkerServer); + const host = new Proxy({}, { + get(target, prop, receiver) { + if (typeof prop !== 'string') { + throw new Error(`Not supported`); + } + return (...args: any[]) => { + return editorWorkerHost.$fhr(prop, args); + }; + } + }); + + return { + host: host as THost, + getMirrorModels: () => { + return webWorkerServer.requestHandler.getModels(); + } + }; +} diff --git a/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts index 03a2821ce51..0b0da3e8e37 100644 --- a/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts +++ b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts @@ -174,9 +174,11 @@ export class QuickInputEditorContribution implements IEditorContribution { return editor.getContribution(QuickInputEditorContribution.ID); } - readonly widget = new QuickInputEditorWidget(this.editor); + readonly widget: QuickInputEditorWidget; - constructor(private editor: ICodeEditor) { } + constructor(private editor: ICodeEditor) { + this.widget = new QuickInputEditorWidget(this.editor); + } dispose(): void { this.widget.dispose(); diff --git a/src/vs/editor/standalone/browser/standalone-tokens.css b/src/vs/editor/standalone/browser/standalone-tokens.css index 1fc85078f9e..1d65e13d69c 100644 --- a/src/vs/editor/standalone/browser/standalone-tokens.css +++ b/src/vs/editor/standalone/browser/standalone-tokens.css @@ -37,16 +37,16 @@ clip-path: inset(50%); } -.monaco-editor, .monaco-diff-editor .synthetic-focus, -.monaco-editor, .monaco-diff-editor [tabindex="0"]:focus, -.monaco-editor, .monaco-diff-editor [tabindex="-1"]:focus, -.monaco-editor, .monaco-diff-editor button:focus, -.monaco-editor, .monaco-diff-editor input[type=button]:focus, -.monaco-editor, .monaco-diff-editor input[type=checkbox]:focus, -.monaco-editor, .monaco-diff-editor input[type=search]:focus, -.monaco-editor, .monaco-diff-editor input[type=text]:focus, -.monaco-editor, .monaco-diff-editor select:focus, -.monaco-editor, .monaco-diff-editor textarea:focus { +.monaco-editor .synthetic-focus, .monaco-diff-editor .synthetic-focus, +.monaco-editor [tabindex="0"]:focus, .monaco-diff-editor [tabindex="0"]:focus, +.monaco-editor [tabindex="-1"]:focus, .monaco-diff-editor [tabindex="-1"]:focus, +.monaco-editor button:focus, .monaco-diff-editor button:focus, +.monaco-editor input[type=button]:focus, .monaco-diff-editor input[type=button]:focus, +.monaco-editor input[type=checkbox]:focus, .monaco-diff-editor input[type=checkbox]:focus, +.monaco-editor input[type=search]:focus, .monaco-diff-editor input[type=search]:focus, +.monaco-editor input[type=text]:focus, .monaco-diff-editor input[type=text]:focus, +.monaco-editor select:focus, .monaco-diff-editor select:focus, +.monaco-editor textarea:focus, .monaco-diff-editor textarea:focus { outline-width: 1px; outline-style: solid; outline-offset: -1px; diff --git a/src/vs/editor/standalone/browser/standaloneEditor.ts b/src/vs/editor/standalone/browser/standaloneEditor.ts index 92df7c57a64..5b1232115a3 100644 --- a/src/vs/editor/standalone/browser/standaloneEditor.ts +++ b/src/vs/editor/standalone/browser/standaloneEditor.ts @@ -12,7 +12,7 @@ import { FontMeasurements } from '../../browser/config/fontMeasurements.js'; import { ICodeEditor } from '../../browser/editorBrowser.js'; import { EditorCommand, ServicesAccessor } from '../../browser/editorExtensions.js'; import { ICodeEditorService } from '../../browser/services/codeEditorService.js'; -import { IWebWorkerOptions, MonacoWebWorker, createWebWorker as actualCreateWebWorker } from './standaloneWebWorker.js'; +import { IInternalWebWorkerOptions, MonacoWebWorker, createWebWorker as actualCreateWebWorker } from './standaloneWebWorker.js'; import { ApplyUpdateResult, ConfigurationChangedEvent, EditorOptions } from '../../common/config/editorOptions.js'; import { EditorZoom } from '../../common/config/editorZoom.js'; import { BareFontInfo, FontInfo } from '../../common/config/fontInfo.js'; @@ -331,7 +331,7 @@ export function onDidChangeModelLanguage(listener: (e: { readonly model: ITextMo * Create a new web worker that has model syncing capabilities built in. * Specify an AMD module to load that will `create` an object that will be proxied. */ -export function createWebWorker(opts: IWebWorkerOptions): MonacoWebWorker { +export function createWebWorker(opts: IInternalWebWorkerOptions): MonacoWebWorker { return actualCreateWebWorker(StandaloneServices.get(IModelService), opts); } diff --git a/src/vs/editor/standalone/browser/standaloneLanguages.ts b/src/vs/editor/standalone/browser/standaloneLanguages.ts index 849e4dc42d7..2dc93ca30c6 100644 --- a/src/vs/editor/standalone/browser/standaloneLanguages.ts +++ b/src/vs/editor/standalone/browser/standaloneLanguages.ts @@ -812,6 +812,7 @@ export function createMonacoLanguagesAPI(): typeof monaco.languages { NewSymbolNameTriggerKind: standaloneEnums.NewSymbolNameTriggerKind, PartialAcceptTriggerKind: standaloneEnums.PartialAcceptTriggerKind, HoverVerbosityAction: standaloneEnums.HoverVerbosityAction, + InlineCompletionEndOfLifeReasonKind: standaloneEnums.InlineCompletionEndOfLifeReasonKind, // classes FoldingRangeKind: languages.FoldingRangeKind, diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index a19d7603763..c3208cb7b76 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -42,7 +42,7 @@ import { IKeybindingItem, KeybindingsRegistry } from '../../../platform/keybindi import { ResolvedKeybindingItem } from '../../../platform/keybinding/common/resolvedKeybindingItem.js'; import { USLayoutResolvedKeybinding } from '../../../platform/keybinding/common/usLayoutResolvedKeybinding.js'; import { ILabelService, ResourceLabelFormatter, IFormatterChangeEvent, Verbosity } from '../../../platform/label/common/label.js'; -import { INotification, INotificationHandle, INotificationService, IPromptChoice, IPromptOptions, NoOpNotification, IStatusMessageOptions, INotificationSource, INotificationSourceFilter, NotificationsFilter } from '../../../platform/notification/common/notification.js'; +import { INotification, INotificationHandle, INotificationService, IPromptChoice, IPromptOptions, NoOpNotification, IStatusMessageOptions, INotificationSource, INotificationSourceFilter, NotificationsFilter, IStatusHandle } from '../../../platform/notification/common/notification.js'; import { IProgressRunner, IEditorProgressService, IProgressService, IProgress, IProgressCompositeOptions, IProgressDialogOptions, IProgressNotificationOptions, IProgressOptions, IProgressStep, IProgressWindowOptions } from '../../../platform/progress/common/progress.js'; import { ITelemetryService, TelemetryLevel } from '../../../platform/telemetry/common/telemetry.js'; import { ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier, IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceFoldersWillChangeEvent, WorkbenchState, WorkspaceFolder, STANDALONE_EDITOR_WORKSPACE_ID } from '../../../platform/workspace/common/workspace.js'; @@ -98,7 +98,7 @@ import { mainWindow } from '../../../base/browser/window.js'; import { ResourceMap } from '../../../base/common/map.js'; import { ITreeSitterParserService } from '../../common/services/treeSitterParserService.js'; import { StandaloneTreeSitterParserService } from './standaloneTreeSitterService.js'; -import { IWorkerDescriptor } from '../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerDescriptor } from '../../../base/browser/webWorkerFactory.js'; class SimpleModel implements IResolvedTextEditorModel { @@ -350,11 +350,10 @@ export class StandaloneNotificationService implements INotificationService { return StandaloneNotificationService.NO_OP; } - public status(message: string | Error, options?: IStatusMessageOptions): IDisposable { - return Disposable.None; + public status(message: string | Error, options?: IStatusMessageOptions): IStatusHandle { + return { close: () => { } }; } - public setFilter(filter: NotificationsFilter | INotificationSourceFilter): void { } public getFilter(source?: INotificationSource): NotificationsFilter { @@ -1077,8 +1076,7 @@ class StandaloneContextMenuService extends ContextMenuService { } } -export const standaloneEditorWorkerDescriptor: IWorkerDescriptor = { - moduleId: 'vs/editor/common/services/editorSimpleWorker', +const standaloneEditorWorkerDescriptor: IWebWorkerDescriptor = { esmModuleLocation: undefined, label: 'editorWorkerService' }; diff --git a/src/vs/editor/standalone/browser/standaloneThemeService.ts b/src/vs/editor/standalone/browser/standaloneThemeService.ts index 4446b693290..8a697f653ff 100644 --- a/src/vs/editor/standalone/browser/standaloneThemeService.ts +++ b/src/vs/editor/standalone/browser/standaloneThemeService.ts @@ -16,7 +16,7 @@ import { hc_black, hc_light, vs, vs_dark } from '../common/themes.js'; import { IEnvironmentService } from '../../../platform/environment/common/environment.js'; import { Registry } from '../../../platform/registry/common/platform.js'; import { asCssVariableName, ColorIdentifier, Extensions, IColorRegistry } from '../../../platform/theme/common/colorRegistry.js'; -import { Extensions as ThemingExtensions, ICssStyleCollector, IFileIconTheme, IProductIconTheme, IThemingRegistry, ITokenStyle, IThemeChangeEvent } from '../../../platform/theme/common/themeService.js'; +import { Extensions as ThemingExtensions, ICssStyleCollector, IFileIconTheme, IProductIconTheme, IThemingRegistry, ITokenStyle } from '../../../platform/theme/common/themeService.js'; import { IDisposable, Disposable } from '../../../base/common/lifecycle.js'; import { ColorScheme, isDark, isHighContrast } from '../../../platform/theme/common/theme.js'; import { getIconsStyleSheet, UnthemedProductIconTheme } from '../../../platform/theme/browser/iconsStyleSheet.js'; @@ -213,7 +213,7 @@ export class StandaloneThemeService extends Disposable implements IStandaloneThe declare readonly _serviceBrand: undefined; - private readonly _onColorThemeChange = this._register(new Emitter()); + private readonly _onColorThemeChange = this._register(new Emitter()); public readonly onDidColorThemeChange = this._onColorThemeChange.event; private readonly _onFileIconThemeChange = this._register(new Emitter()); @@ -403,7 +403,7 @@ export class StandaloneThemeService extends Disposable implements IStandaloneThe this._updateCSS(); TokenizationRegistry.setColorMap(colorMap); - this._onColorThemeChange.fire({ theme: this._theme }); + this._onColorThemeChange.fire(this._theme); } private _updateCSS(): void { diff --git a/src/vs/editor/standalone/browser/standaloneTreeSitterService.ts b/src/vs/editor/standalone/browser/standaloneTreeSitterService.ts index 307507b5108..f9e0c0bf58b 100644 --- a/src/vs/editor/standalone/browser/standaloneTreeSitterService.ts +++ b/src/vs/editor/standalone/browser/standaloneTreeSitterService.ts @@ -6,7 +6,7 @@ import type * as Parser from '@vscode/tree-sitter-wasm'; import { Event } from '../../../base/common/event.js'; import { ITextModel } from '../../common/model.js'; -import { ITextModelTreeSitter, ITreeSitterParseResult, ITreeSitterParserService, TreeUpdateEvent } from '../../common/services/treeSitterParserService.js'; +import { ITextModelTreeSitter, ITreeSitterParserService, TreeUpdateEvent } from '../../common/services/treeSitterParserService.js'; /** * The monaco build doesn't like the dynamic import of tree sitter in the real service. @@ -32,7 +32,7 @@ export class StandaloneTreeSitterParserService implements ITreeSitterParserServi getOrInitLanguage(_languageId: string): Parser.Language | undefined { return undefined; } - getParseResult(textModel: ITextModel): ITreeSitterParseResult | undefined { + getParseResult(textModel: ITextModel): ITextModelTreeSitter | undefined { return undefined; } } diff --git a/src/vs/editor/standalone/browser/standaloneWebWorker.ts b/src/vs/editor/standalone/browser/standaloneWebWorker.ts index bd557c7bbcb..f8aa5966d51 100644 --- a/src/vs/editor/standalone/browser/standaloneWebWorker.ts +++ b/src/vs/editor/standalone/browser/standaloneWebWorker.ts @@ -3,18 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { getAllMethodNames } from '../../../base/common/objects.js'; import { URI } from '../../../base/common/uri.js'; -import { IWorkerDescriptor } from '../../../base/common/worker/simpleWorker.js'; import { EditorWorkerClient } from '../../browser/services/editorWorkerService.js'; import { IModelService } from '../../common/services/model.js'; -import { standaloneEditorWorkerDescriptor } from './standaloneServices.js'; /** * Create a new web worker that has model syncing capabilities built in. * Specify an AMD module to load that will `create` an object that will be proxied. */ -export function createWebWorker(modelService: IModelService, opts: IWebWorkerOptions): MonacoWebWorker { +export function createWebWorker(modelService: IModelService, opts: IInternalWebWorkerOptions): MonacoWebWorker { return new MonacoWebWorkerImpl(modelService, opts); } @@ -37,20 +34,11 @@ export interface MonacoWebWorker { withSyncedResources(resources: URI[]): Promise; } -export interface IWebWorkerOptions { +export interface IInternalWebWorkerOptions { /** - * The AMD moduleId to load. - * It should export a function `create` that should return the exported proxy. + * The worker. */ - moduleId: string; - /** - * The data to send over when calling create on the module. - */ - createData?: any; - /** - * A label to be used to identify the web worker for debugging purposes. - */ - label?: string; + worker: Worker; /** * An object that can be used by the web worker to make calls back to the main thread. */ @@ -64,22 +52,24 @@ export interface IWebWorkerOptions { class MonacoWebWorkerImpl extends EditorWorkerClient implements MonacoWebWorker { - private readonly _foreignModuleId: string; private readonly _foreignModuleHost: { [method: string]: Function } | null; - private _foreignModuleCreateData: any | null; - private _foreignProxy: Promise | null; + private _foreignProxy: Promise; - constructor(modelService: IModelService, opts: IWebWorkerOptions) { - const workerDescriptor: IWorkerDescriptor = { - moduleId: standaloneEditorWorkerDescriptor.moduleId, - esmModuleLocation: standaloneEditorWorkerDescriptor.esmModuleLocation, - label: opts.label, - }; - super(workerDescriptor, opts.keepIdleModels || false, modelService); - this._foreignModuleId = opts.moduleId; - this._foreignModuleCreateData = opts.createData || null; + constructor(modelService: IModelService, opts: IInternalWebWorkerOptions) { + super(opts.worker, opts.keepIdleModels || false, modelService); this._foreignModuleHost = opts.host || null; - this._foreignProxy = null; + this._foreignProxy = this._getProxy().then(proxy => { + return new Proxy({}, { + get(target, prop, receiver) { + if (typeof prop !== 'string') { + throw new Error(`Not supported`); + } + return (...args: any[]) => { + return proxy.$fmr(prop, args); + }; + } + }) as T; + }); } // foreign host request @@ -95,38 +85,8 @@ class MonacoWebWorkerImpl extends EditorWorkerClient implement } } - private _getForeignProxy(): Promise { - if (!this._foreignProxy) { - this._foreignProxy = this._getProxy().then((proxy) => { - const foreignHostMethods = this._foreignModuleHost ? getAllMethodNames(this._foreignModuleHost) : []; - return proxy.$loadForeignModule(this._foreignModuleId, this._foreignModuleCreateData, foreignHostMethods).then((foreignMethods) => { - this._foreignModuleCreateData = null; - - const proxyMethodRequest = (method: string, args: any[]): Promise => { - return proxy.$fmr(method, args); - }; - - const createProxyMethod = (method: string, proxyMethodRequest: (method: string, args: any[]) => Promise): () => Promise => { - return function () { - const args = Array.prototype.slice.call(arguments, 0); - return proxyMethodRequest(method, args); - }; - }; - - const foreignProxy = {} as any as T; - for (const foreignMethod of foreignMethods) { - (foreignProxy)[foreignMethod] = createProxyMethod(foreignMethod, proxyMethodRequest); - } - - return foreignProxy; - }); - }); - } - return this._foreignProxy; - } - public getProxy(): Promise { - return this._getForeignProxy(); + return this._foreignProxy; } public withSyncedResources(resources: URI[]): Promise { diff --git a/src/vs/editor/standalone/test/browser/standaloneLanguages.test.ts b/src/vs/editor/standalone/test/browser/standaloneLanguages.test.ts index def6b624a18..0fa82cf782b 100644 --- a/src/vs/editor/standalone/test/browser/standaloneLanguages.test.ts +++ b/src/vs/editor/standalone/test/browser/standaloneLanguages.test.ts @@ -17,7 +17,7 @@ import { IStandaloneTheme, IStandaloneThemeData, IStandaloneThemeService } from import { UnthemedProductIconTheme } from '../../../../platform/theme/browser/iconsStyleSheet.js'; import { ColorIdentifier } from '../../../../platform/theme/common/colorRegistry.js'; import { ColorScheme } from '../../../../platform/theme/common/theme.js'; -import { IFileIconTheme, IProductIconTheme, IThemeChangeEvent, ITokenStyle } from '../../../../platform/theme/common/themeService.js'; +import { IColorTheme, IFileIconTheme, IProductIconTheme, ITokenStyle } from '../../../../platform/theme/common/themeService.js'; suite('TokenizationSupport2Adapter', () => { @@ -92,7 +92,7 @@ suite('TokenizationSupport2Adapter', () => { public getProductIconTheme(): IProductIconTheme { return this._builtInProductIconTheme; } - public readonly onDidColorThemeChange = new Emitter().event; + public readonly onDidColorThemeChange = new Emitter().event; public readonly onDidFileIconThemeChange = new Emitter().event; public readonly onDidProductIconThemeChange = new Emitter().event; } diff --git a/src/vs/editor/test/browser/config/editorConfiguration.test.ts b/src/vs/editor/test/browser/config/editorConfiguration.test.ts index 3dcf646314f..ce875cd7037 100644 --- a/src/vs/editor/test/browser/config/editorConfiguration.test.ts +++ b/src/vs/editor/test/browser/config/editorConfiguration.test.ts @@ -66,7 +66,8 @@ suite('Common Editor Config', () => { outerHeight: 100, emptySelectionClipboard: true, pixelRatio: 1, - accessibilitySupport: AccessibilitySupport.Unknown + accessibilitySupport: AccessibilitySupport.Unknown, + editContextSupported: true, }; } } diff --git a/src/vs/editor/test/browser/config/testConfiguration.ts b/src/vs/editor/test/browser/config/testConfiguration.ts index 3c1862c967d..ece0400a78c 100644 --- a/src/vs/editor/test/browser/config/testConfiguration.ts +++ b/src/vs/editor/test/browser/config/testConfiguration.ts @@ -25,7 +25,8 @@ export class TestConfiguration extends EditorConfiguration { outerHeight: envConfig?.outerHeight ?? 100, emptySelectionClipboard: envConfig?.emptySelectionClipboard ?? true, pixelRatio: envConfig?.pixelRatio ?? 1, - accessibilitySupport: envConfig?.accessibilitySupport ?? AccessibilitySupport.Unknown + accessibilitySupport: envConfig?.accessibilitySupport ?? AccessibilitySupport.Unknown, + editContextSupported: true }; } diff --git a/src/vs/editor/test/browser/services/treeSitterParserService.test.ts b/src/vs/editor/test/browser/services/treeSitterParserService.test.ts index 29f3d8ebbd0..a85a5c62bea 100644 --- a/src/vs/editor/test/browser/services/treeSitterParserService.test.ts +++ b/src/vs/editor/test/browser/services/treeSitterParserService.test.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { TextModelTreeSitter, TreeSitterLanguages } from '../../../common/services/treeSitter/treeSitterParserService.js'; import type * as Parser from '@vscode/tree-sitter-wasm'; import { createTextModel } from '../../common/testTextModel.js'; import { timeout } from '../../../../base/common/async.js'; @@ -13,6 +12,9 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { LogService } from '../../../../platform/log/common/logService.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ITreeSitterImporter } from '../../../common/services/treeSitterParserService.js'; +import { TextModelTreeSitter } from '../../../common/services/treeSitter/textModelTreeSitter.js'; +import { TreeSitterLanguages } from '../../../common/services/treeSitter/treeSitterLanguages.js'; +import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; class MockParser implements Parser.Parser { language: Parser.Language | null = null; @@ -167,9 +169,10 @@ suite('TreeSitterParserService', function () { } } - const treeSitterParser: TreeSitterLanguages = store.add(new MockTreeSitterLanguages(treeSitterImporter, {} as any, { isBuilt: false } as any, new Map())); + const mockConfigurationService = new TestConfigurationService(); + const treeSitterLanguages: TreeSitterLanguages = store.add(new MockTreeSitterLanguages(treeSitterImporter, {} as any, { isBuilt: false } as any, mockConfigurationService, new Map())); const textModel = store.add(createTextModel('console.log("Hello, world!");', 'javascript')); - const textModelTreeSitter = store.add(new TextModelTreeSitter(textModel, treeSitterParser, treeSitterImporter, logService, telemetryService)); + const textModelTreeSitter = store.add(new TextModelTreeSitter(textModel, treeSitterLanguages, false, treeSitterImporter, logService, telemetryService, { exists: async () => false } as any)); textModel.setLanguage('typescript'); await timeout(300); assert.strictEqual((textModelTreeSitter.parseResult?.language as MockLanguage).languageId, 'typescript'); diff --git a/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts b/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts index 7bb14f13871..6596c70b875 100644 --- a/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts +++ b/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts @@ -37,11 +37,13 @@ suite("CodeEditorWidget", () => { const derived = derivedHandleChanges( { - createEmptyChangeSummary: () => undefined, - handleChange: (context) => { - const obsName = observableName(context.changedObservable, obsEditor); - log.log(`handle change: ${obsName} ${formatChange(context.change)}`); - return true; + changeTracker: { + createChangeSummary: () => undefined, + handleChange: (context) => { + const obsName = observableName(context.changedObservable, obsEditor); + log.log(`handle change: ${obsName} ${formatChange(context.change)}`); + return true; + }, }, }, (reader) => { diff --git a/src/vs/editor/test/common/codecs/baseToken.test.ts b/src/vs/editor/test/common/codecs/baseToken.test.ts new file mode 100644 index 00000000000..284e0e24732 --- /dev/null +++ b/src/vs/editor/test/common/codecs/baseToken.test.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Range } from '../../../common/core/range.js'; +import { randomInt } from '../../../../base/common/numbers.js'; +import { BaseToken } from '../../../common/codecs/baseToken.js'; +import { assertDefined } from '../../../../base/common/types.js'; +import { randomBoolean } from '../../../../base/test/common/testUtils.js'; +import { NewLine } from '../../../common/codecs/linesCodec/tokens/newLine.js'; +import { CarriageReturn } from '../../../common/codecs/linesCodec/tokens/carriageReturn.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TSimpleToken, WELL_KNOWN_TOKENS } from '../../../common/codecs/simpleCodec/simpleDecoder.js'; +import { ISimpleTokenClass, SimpleToken } from '../../../common/codecs/simpleCodec/tokens/simpleToken.js'; +import { At, Colon, DollarSign, ExclamationMark, Hash, LeftAngleBracket, LeftBracket, LeftCurlyBrace, RightAngleBracket, RightBracket, RightCurlyBrace, Slash, Space, Word } from '../../../common/codecs/simpleCodec/tokens/index.js'; + +/** + * Generates a random {@link Range} object. + * + * @throws if {@link maxNumber} argument is less than `2`, + * is equal to `NaN` or is `infinite`. + */ +const randomRange = ( + maxNumber: number = 1_000, +): Range => { + assert( + maxNumber > 1, + `Max number must be greater than 1, got '${maxNumber}'.`, + ); + + const startLineNumber = randomInt(maxNumber, 1); + const endLineNumber = (randomBoolean() === true) + ? startLineNumber + : randomInt(2 * maxNumber, startLineNumber); + + const startColumnNumber = randomInt(maxNumber, 1); + const endColumnNumber = (randomBoolean() === true) + ? startColumnNumber + 1 + : randomInt(2 * maxNumber, startColumnNumber + 1); + + return new Range( + startLineNumber, + startColumnNumber, + endLineNumber, + endColumnNumber, + ); +}; + +/** + * List of simple tokens to randomly select from + * in the {@link randomSimpleToken} utility. + */ +const TOKENS: readonly ISimpleTokenClass[] = Object.freeze([ + ...WELL_KNOWN_TOKENS, + CarriageReturn, + NewLine, +]); + +/** + * Generates a random {@link SimpleToken} instance. + */ +const randomSimpleToken = (): TSimpleToken => { + const index = randomInt(TOKENS.length - 1); + + const Constructor = TOKENS[index]; + assertDefined( + Constructor, + `Cannot find a constructor object for a well-known token at index '${index}'.`, + ); + + return new Constructor(randomRange()); +}; + +suite('BaseToken', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('• render', () => { + /** + * Note! Range of tokens is ignored by the render method, hence + * we generate random ranges for each token in this test. + */ + test('• a list of tokens', () => { + const tests: readonly [string, BaseToken[]][] = [ + ['/textoftheword$#', [ + new Slash(randomRange()), + new Word(randomRange(), 'textoftheword'), + new DollarSign(randomRange()), + new Hash(randomRange()), + ]], + ['<:👋helou👋:>', [ + new LeftAngleBracket(randomRange()), + new Colon(randomRange()), + new Word(randomRange(), '👋helou👋'), + new Colon(randomRange()), + new RightAngleBracket(randomRange()), + ]], + [' {$#[ !@! ]#$} ', [ + new Space(randomRange()), + new LeftCurlyBrace(randomRange()), + new DollarSign(randomRange()), + new Hash(randomRange()), + new LeftBracket(randomRange()), + new Space(randomRange()), + new ExclamationMark(randomRange()), + new At(randomRange()), + new ExclamationMark(randomRange()), + new Space(randomRange()), + new RightBracket(randomRange()), + new Hash(randomRange()), + new DollarSign(randomRange()), + new RightCurlyBrace(randomRange()), + new Space(randomRange()), + ]], + ]; + + for (const test of tests) { + const [expectedText, tokens] = test; + + assert.strictEqual( + expectedText, + BaseToken.render(tokens), + ); + } + }); + + test('• an empty list of tokens', () => { + assert.strictEqual( + '', + BaseToken.render([]), + `Must correctly render and empty list of tokens.`, + ); + }); + }); + + suite('• fullRange', () => { + suite('• throws', () => { + test('• if empty list provided', () => { + assert.throws(() => { + BaseToken.fullRange([]); + }); + }); + + test('• if start line number of the first token is greater than one of the last token', () => { + assert.throws(() => { + const lastToken = randomSimpleToken(); + + // generate a first token with starting line number that is + // greater than the start line number of the last token + const startLineNumber = lastToken.range.startLineNumber + randomInt(10, 1); + const firstToken = new Colon( + new Range( + startLineNumber, + lastToken.range.startColumn, + startLineNumber, + lastToken.range.startColumn + 1, + ), + ); + + BaseToken.fullRange([ + firstToken, + // tokens in the middle are ignored, so we + // generate random ones to fill the gap + randomSimpleToken(), + randomSimpleToken(), + randomSimpleToken(), + randomSimpleToken(), + randomSimpleToken(), + // - + lastToken, + ]); + }); + }); + + test('• if start line numbers are equal and end of the first token is greater than the start of the last token', () => { + assert.throws(() => { + const firstToken = randomSimpleToken(); + + const lastToken = new Hash( + new Range( + firstToken.range.startLineNumber, + firstToken.range.endColumn - 1, + firstToken.range.startLineNumber + randomInt(10), + firstToken.range.endColumn, + ), + ); + + BaseToken.fullRange([ + firstToken, + // tokens in the middle are ignored, so we + // generate random ones to fill the gap + randomSimpleToken(), + randomSimpleToken(), + randomSimpleToken(), + randomSimpleToken(), + randomSimpleToken(), + // - + lastToken, + ]); + }); + }); + }); + }); +}); diff --git a/src/vs/editor/test/common/codecs/frontMatterDecoder.test.ts b/src/vs/editor/test/common/codecs/frontMatterDecoder.test.ts new file mode 100644 index 00000000000..cc2e893d95a --- /dev/null +++ b/src/vs/editor/test/common/codecs/frontMatterDecoder.test.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../common/core/range.js'; +import { TestDecoder } from '../utils/testDecoder.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { newWriteableStream } from '../../../../base/common/stream.js'; +import { NewLine } from '../../../common/codecs/linesCodec/tokens/newLine.js'; +import { DoubleQuote } from '../../../common/codecs/simpleCodec/tokens/doubleQuote.js'; +import { type TSimpleDecoderToken } from '../../../common/codecs/simpleCodec/simpleDecoder.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { LeftBracket, RightBracket } from '../../../common/codecs/simpleCodec/tokens/brackets.js'; +import { FrontMatterDecoder } from '../../../common/codecs/frontMatterCodec/frontMatterDecoder.js'; +import { ExclamationMark, Quote, Tab, Word, Space, Colon } from '../../../common/codecs/simpleCodec/tokens/index.js'; +import { FrontMatterBoolean, FrontMatterString, FrontMatterArray, FrontMatterRecord, FrontMatterRecordDelimiter, FrontMatterRecordName } from '../../../common/codecs/frontMatterCodec/tokens/index.js'; + +/** + * Front Matter decoder for testing purposes. + */ +export class TestFrontMatterDecoder extends TestDecoder { + constructor() { + const stream = newWriteableStream(null); + const decoder = new FrontMatterDecoder(stream); + + super(stream, decoder); + } +} + +suite('FrontMatterDecoder', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('• produces expected tokens', async () => { + const test = disposables.add( + new TestFrontMatterDecoder(), + ); + + await test.run( + [ + 'just: "write some yaml "', + 'write-some :\t[ \' just\t \', "yaml!", true, , ,]', + 'anotherField \t\t\t : FALSE ', + ], + [ + // first record + new FrontMatterRecord([ + new FrontMatterRecordName([ + new Word(new Range(1, 1, 1, 1 + 4), 'just'), + ]), + new FrontMatterRecordDelimiter([ + new Colon(new Range(1, 5, 1, 6)), + new Space(new Range(1, 6, 1, 7)), + ]), + new FrontMatterString([ + new DoubleQuote(new Range(1, 7, 1, 8)), + new Word(new Range(1, 8, 1, 8 + 5), 'write'), + new Space(new Range(1, 13, 1, 14)), + new Word(new Range(1, 14, 1, 14 + 4), 'some'), + new Space(new Range(1, 18, 1, 19)), + new Word(new Range(1, 19, 1, 19 + 4), 'yaml'), + new Space(new Range(1, 23, 1, 24)), + new DoubleQuote(new Range(1, 24, 1, 25)), + ]), + ]), + new NewLine(new Range(1, 25, 1, 26)), + // second record + new FrontMatterRecord([ + new FrontMatterRecordName([ + new Word(new Range(2, 1, 2, 1 + 10), 'write-some'), + ]), + new FrontMatterRecordDelimiter([ + new Colon(new Range(2, 12, 2, 13)), + new Tab(new Range(2, 13, 2, 14)), + ]), + new FrontMatterArray([ + new LeftBracket(new Range(2, 14, 2, 15)), + new FrontMatterString([ + new Quote(new Range(2, 16, 2, 17)), + new Space(new Range(2, 17, 2, 18)), + new Word(new Range(2, 18, 2, 18 + 4), 'just'), + new Tab(new Range(2, 22, 2, 23)), + new Space(new Range(2, 23, 2, 24)), + new Quote(new Range(2, 24, 2, 25)), + ]), + new FrontMatterString([ + new DoubleQuote(new Range(2, 28, 2, 29)), + new Word(new Range(2, 29, 2, 29 + 4), 'yaml'), + new ExclamationMark(new Range(2, 33, 2, 34)), + new DoubleQuote(new Range(2, 34, 2, 35)), + ]), + new FrontMatterBoolean( + new Range(2, 37, 2, 37 + 4), + true, + ), + new RightBracket(new Range(2, 46, 2, 47)), + ]), + ]), + new NewLine(new Range(2, 47, 2, 48)), + // third record + new FrontMatterRecord([ + new FrontMatterRecordName([ + new Word(new Range(3, 1, 3, 1 + 12), 'anotherField'), + ]), + new FrontMatterRecordDelimiter([ + new Colon(new Range(3, 19, 3, 20)), + new Space(new Range(3, 20, 3, 21)), + ]), + new FrontMatterBoolean( + new Range(3, 22, 3, 22 + 5), + false, + ), + ]), + new Space(new Range(3, 27, 3, 28)), + ]); + }); +}); diff --git a/src/vs/editor/test/common/codecs/linesDecoder.test.ts b/src/vs/editor/test/common/codecs/linesDecoder.test.ts index 019344128bc..82ff987120a 100644 --- a/src/vs/editor/test/common/codecs/linesDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/linesDecoder.test.ts @@ -16,8 +16,8 @@ import { LinesDecoder, TLineToken } from '../../../common/codecs/linesCodec/line import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; /** - * Note! This decoder is also often used to test common logic of abstract {@linkcode BaseDecoder} - * class, because the {@linkcode LinesDecoder} is one of the simplest non-abstract decoders we have. + * Note! This decoder is also often used to test common logic of abstract {@link BaseDecoder} + * class, because the {@link LinesDecoder} is one of the simplest non-abstract decoders we have. */ suite('LinesDecoder', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -26,14 +26,14 @@ suite('LinesDecoder', () => { * Test the core logic with specific method of consuming * tokens that are produced by a lines decoder instance. */ - suite('core logic', () => { + suite('• core logic', () => { testLinesDecoder('async-generator', disposables); testLinesDecoder('consume-all-method', disposables); testLinesDecoder('on-data-event', disposables); }); - suite('settled promise', () => { - test('throws if accessed on not-yet-started decoder instance', () => { + suite('• settled promise', () => { + test('• throws if accessed on not-yet-started decoder instance', () => { const test = disposables.add(new TestLinesDecoder()); assert.throws( @@ -51,8 +51,8 @@ suite('LinesDecoder', () => { }); }); - suite('start', () => { - test('throws if the decoder object is already `disposed`', () => { + suite('• start', () => { + test('• throws if the decoder object is already `disposed`', () => { const test = disposables.add(new TestLinesDecoder()); const { decoder } = test; decoder.dispose(); @@ -63,7 +63,7 @@ suite('LinesDecoder', () => { ); }); - test('throws if the decoder object is already `ended`', async () => { + test('• throws if the decoder object is already `ended`', async () => { const inputStream = newWriteableStream(null); const test = disposables.add(new TestLinesDecoder(inputStream)); const { decoder } = test; @@ -129,8 +129,8 @@ export class TestLinesDecoder extends TestDecoder { } /** - * Common reusable test utility to validate {@linkcode LinesDecoder} logic with - * the provided {@linkcode tokensConsumeMethod} way of consuming decoder-produced tokens. + * Common reusable test utility to validate {@link LinesDecoder} logic with + * the provided {@link tokensConsumeMethod} way of consuming decoder-produced tokens. * * @throws if a test fails, please see thrown error for failure details. * @param tokensConsumeMethod The way to consume tokens produced by the decoder. @@ -141,8 +141,26 @@ function testLinesDecoder( disposables: Pick, ) { suite(tokensConsumeMethod, () => { - suite('produces expected tokens', () => { - test('input starts with line data', async () => { + suite('• produces expected tokens', () => { + test('• input starts with line data', async () => { + const test = disposables.add(new TestLinesDecoder()); + + await test.run( + ' hello world\nhow are you doing?\n\n 😊 \r', + [ + new Line(1, ' hello world'), + new NewLine(new Range(1, 13, 1, 14)), + new Line(2, 'how are you doing?'), + new NewLine(new Range(2, 19, 2, 20)), + new Line(3, ''), + new NewLine(new Range(3, 1, 3, 2)), + new Line(4, ' 😊 '), + new NewLine(new Range(4, 5, 4, 6)), + ], + ); + }); + + test('• standalone \\r is treated as new line', async () => { const test = disposables.add(new TestLinesDecoder()); await test.run( @@ -155,13 +173,13 @@ function testLinesDecoder( new Line(3, ''), new NewLine(new Range(3, 1, 3, 2)), new Line(4, ' 😊 '), - new CarriageReturn(new Range(4, 5, 4, 6)), + new NewLine(new Range(4, 5, 4, 6)), new Line(5, ' '), ], ); }); - test('input starts with a new line', async () => { + test('• input starts with a new line', async () => { const test = disposables.add(new TestLinesDecoder()); await test.run( @@ -184,7 +202,7 @@ function testLinesDecoder( ); }); - test('input starts and ends with multiple new lines', async () => { + test('• input starts and ends with multiple new lines', async () => { const test = disposables.add(new TestLinesDecoder()); await test.run( @@ -211,18 +229,18 @@ function testLinesDecoder( ); }); - test('single carriage return is treated as new line', async () => { + test('• single carriage return is treated as new line', async () => { const test = disposables.add(new TestLinesDecoder()); await test.run( '\r\rhaalo! 💥💥 how\'re you?\r ?!\r\n\r\n ', [ new Line(1, ''), - new CarriageReturn(new Range(1, 1, 1, 2)), + new NewLine(new Range(1, 1, 1, 2)), new Line(2, ''), - new CarriageReturn(new Range(2, 1, 2, 2)), + new NewLine(new Range(2, 1, 2, 2)), new Line(3, 'haalo! 💥💥 how\'re you?'), - new CarriageReturn(new Range(3, 24, 3, 25)), + new NewLine(new Range(3, 24, 3, 25)), new Line(4, ' ?!'), new CarriageReturn(new Range(4, 4, 4, 5)), new NewLine(new Range(4, 5, 4, 6)), diff --git a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts index d0ea73d7fa2..2219068bef3 100644 --- a/src/vs/editor/test/common/codecs/markdownDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/markdownDecoder.test.ts @@ -12,6 +12,7 @@ import { Tab } from '../../../common/codecs/simpleCodec/tokens/tab.js'; import { Word } from '../../../common/codecs/simpleCodec/tokens/word.js'; import { Dash } from '../../../common/codecs/simpleCodec/tokens/dash.js'; import { Space } from '../../../common/codecs/simpleCodec/tokens/space.js'; +import { Slash } from '../../../common/codecs/simpleCodec/tokens/slash.js'; import { NewLine } from '../../../common/codecs/linesCodec/tokens/newLine.js'; import { FormFeed } from '../../../common/codecs/simpleCodec/tokens/formFeed.js'; import { VerticalTab } from '../../../common/codecs/simpleCodec/tokens/verticalTab.js'; @@ -195,9 +196,15 @@ suite('MarkdownDecoder', () => { new Space(new Range(1, 2, 1, 3)), new RightBracket(new Range(1, 3, 1, 4)), new LeftParenthesis(new Range(1, 4, 1, 5)), - new Word(new Range(1, 5, 1, 5 + 11), './real/file'), + new Word(new Range(1, 5, 1, 5 + 1), '.'), + new Slash(new Range(1, 6, 1, 7)), + new Word(new Range(1, 7, 1, 7 + 4), 'real'), + new Slash(new Range(1, 11, 1, 12)), + new Word(new Range(1, 12, 1, 12 + 4), 'file'), new Space(new Range(1, 16, 1, 17)), - new Word(new Range(1, 17, 1, 17 + 17), 'path/file⇧name.md'), + new Word(new Range(1, 17, 1, 17 + 4), 'path'), + new Slash(new Range(1, 21, 1, 22)), + new Word(new Range(1, 22, 1, 22 + 12), 'file⇧name.md'), new NewLine(new Range(1, 34, 1, 35)), // `2nd` line new LeftBracket(new Range(2, 1, 2, 2)), @@ -207,22 +214,26 @@ suite('MarkdownDecoder', () => { new RightBracket(new Range(2, 11, 2, 12)), new Space(new Range(2, 12, 2, 13)), new LeftParenthesis(new Range(2, 13, 2, 14)), - new Word(new Range(2, 14, 2, 14 + 6), './file'), + new Word(new Range(2, 14, 2, 14 + 1), '.'), + new Slash(new Range(2, 15, 2, 16)), + new Word(new Range(2, 16, 2, 16 + 4), 'file'), new Space(new Range(2, 20, 2, 21)), - new Word(new Range(2, 21, 2, 21 + 13), 'path/name.txt'), + new Word(new Range(2, 21, 2, 21 + 4), 'path'), + new Slash(new Range(2, 25, 2, 26)), + new Word(new Range(2, 26, 2, 26 + 8), 'name.txt'), new RightParenthesis(new Range(2, 34, 2, 35)), ], ); }); suite('• stop characters inside caption/reference (new lines)', () => { - for (const stopCharacter of [CarriageReturn, NewLine]) { + for (const StopCharacter of [CarriageReturn, NewLine]) { let characterName = ''; - if (stopCharacter === CarriageReturn) { + if (StopCharacter === CarriageReturn) { characterName = '\\r'; } - if (stopCharacter === NewLine) { + if (StopCharacter === NewLine) { characterName = '\\n'; } @@ -238,11 +249,11 @@ suite('MarkdownDecoder', () => { const inputLines = [ // stop character inside link caption - `[haa${stopCharacter.symbol}loů](./real/💁/name.txt)`, + `[haa${StopCharacter.symbol}loů](./real/💁/name.txt)`, // stop character inside link reference - `[ref text](/etc/pat${stopCharacter.symbol}h/to/file.md)`, + `[ref text](/etc/pat${StopCharacter.symbol}h/to/file.md)`, // stop character between line caption and link reference is disallowed - `[text]${stopCharacter.symbol}(/etc/ path/file.md)`, + `[text]${StopCharacter.symbol}(/etc/ path/main.mdc)`, ]; @@ -252,11 +263,17 @@ suite('MarkdownDecoder', () => { // `1st` input line new LeftBracket(new Range(1, 1, 1, 2)), new Word(new Range(1, 2, 1, 2 + 3), 'haa'), - new stopCharacter(new Range(1, 5, 1, 6)), // <- stop character + new NewLine(new Range(1, 5, 1, 6)), // a single CR token is treated as a `new line` new Word(new Range(2, 1, 2, 1 + 3), 'loů'), new RightBracket(new Range(2, 4, 2, 5)), new LeftParenthesis(new Range(2, 5, 2, 6)), - new Word(new Range(2, 6, 2, 6 + 18), './real/💁/name.txt'), + new Word(new Range(2, 6, 2, 6 + 1), '.'), + new Slash(new Range(2, 7, 2, 8)), + new Word(new Range(2, 8, 2, 8 + 4), 'real'), + new Slash(new Range(2, 12, 2, 13)), + new Word(new Range(2, 13, 2, 13 + 2), '💁'), + new Slash(new Range(2, 15, 2, 16)), + new Word(new Range(2, 16, 2, 16 + 8), 'name.txt'), new RightParenthesis(new Range(2, 24, 2, 25)), new NewLine(new Range(2, 25, 2, 26)), // `2nd` input line @@ -266,21 +283,32 @@ suite('MarkdownDecoder', () => { new Word(new Range(3, 6, 3, 6 + 4), 'text'), new RightBracket(new Range(3, 10, 3, 11)), new LeftParenthesis(new Range(3, 11, 3, 12)), - new Word(new Range(3, 12, 3, 12 + 8), '/etc/pat'), - new stopCharacter(new Range(3, 20, 3, 21)), // <- stop character - new Word(new Range(4, 1, 4, 1 + 12), 'h/to/file.md'), + new Slash(new Range(3, 12, 3, 13)), + new Word(new Range(3, 13, 3, 13 + 3), 'etc'), + new Slash(new Range(3, 16, 3, 17)), + new Word(new Range(3, 17, 3, 17 + 3), 'pat'), + new NewLine(new Range(3, 20, 3, 21)), // a single CR token is treated as a `new line` + new Word(new Range(4, 1, 4, 1 + 1), 'h'), + new Slash(new Range(4, 2, 4, 3)), + new Word(new Range(4, 3, 4, 3 + 2), 'to'), + new Slash(new Range(4, 5, 4, 6)), + new Word(new Range(4, 6, 4, 6 + 7), 'file.md'), new RightParenthesis(new Range(4, 13, 4, 14)), new NewLine(new Range(4, 14, 4, 15)), // `3nd` input line new LeftBracket(new Range(5, 1, 5, 2)), new Word(new Range(5, 2, 5, 2 + 4), 'text'), new RightBracket(new Range(5, 6, 5, 7)), - new stopCharacter(new Range(5, 7, 5, 8)), // <- stop character + new NewLine(new Range(5, 7, 5, 8)), // a single CR token is treated as a `new line` new LeftParenthesis(new Range(6, 1, 6, 2)), - new Word(new Range(6, 2, 6, 2 + 5), '/etc/'), + new Slash(new Range(6, 2, 6, 3)), + new Word(new Range(6, 3, 6, 3 + 3), 'etc'), + new Slash(new Range(6, 6, 6, 7)), new Space(new Range(6, 7, 6, 8)), - new Word(new Range(6, 8, 6, 8 + 12), 'path/file.md'), - new RightParenthesis(new Range(6, 20, 6, 21)), + new Word(new Range(6, 8, 6, 8 + 4), 'path'), + new Slash(new Range(6, 12, 6, 13)), + new Word(new Range(6, 13, 6, 13 + 8), 'main.mdc'), + new RightParenthesis(new Range(6, 21, 6, 22)), ], ); }); @@ -291,13 +319,13 @@ suite('MarkdownDecoder', () => { * Same as above but these stop characters do not move the caret to the next line. */ suite('• stop characters inside caption/reference (same line)', () => { - for (const stopCharacter of [VerticalTab, FormFeed]) { + for (const StopCharacter of [VerticalTab, FormFeed]) { let characterName = ''; - if (stopCharacter === VerticalTab) { + if (StopCharacter === VerticalTab) { characterName = '\\v'; } - if (stopCharacter === FormFeed) { + if (StopCharacter === FormFeed) { characterName = '\\f'; } @@ -313,11 +341,11 @@ suite('MarkdownDecoder', () => { const inputLines = [ // stop character inside link caption - `[haa${stopCharacter.symbol}loů](./real/💁/name.txt)`, + `[haa${StopCharacter.symbol}loů](./real/💁/name.txt)`, // stop character inside link reference - `[ref text](/etc/pat${stopCharacter.symbol}h/to/file.md)`, + `[ref text](/etc/pat${StopCharacter.symbol}h/to/file.md)`, // stop character between line caption and link reference is disallowed - `[text]${stopCharacter.symbol}(/etc/ path/file.md)`, + `[text]${StopCharacter.symbol}(/etc/ path/file.md)`, ]; @@ -327,11 +355,17 @@ suite('MarkdownDecoder', () => { // `1st` input line new LeftBracket(new Range(1, 1, 1, 2)), new Word(new Range(1, 2, 1, 2 + 3), 'haa'), - new stopCharacter(new Range(1, 5, 1, 6)), // <- stop character + new StopCharacter(new Range(1, 5, 1, 6)), // <- stop character new Word(new Range(1, 6, 1, 6 + 3), 'loů'), new RightBracket(new Range(1, 9, 1, 10)), new LeftParenthesis(new Range(1, 10, 1, 11)), - new Word(new Range(1, 11, 1, 11 + 18), './real/💁/name.txt'), + new Word(new Range(1, 11, 1, 11 + 1), '.'), + new Slash(new Range(1, 12, 1, 13)), + new Word(new Range(1, 13, 1, 13 + 4), 'real'), + new Slash(new Range(1, 17, 1, 18)), + new Word(new Range(1, 18, 1, 18 + 2), '💁'), + new Slash(new Range(1, 20, 1, 21)), + new Word(new Range(1, 21, 1, 21 + 8), 'name.txt'), new RightParenthesis(new Range(1, 29, 1, 30)), new NewLine(new Range(1, 30, 1, 31)), // `2nd` input line @@ -341,20 +375,31 @@ suite('MarkdownDecoder', () => { new Word(new Range(2, 6, 2, 6 + 4), 'text'), new RightBracket(new Range(2, 10, 2, 11)), new LeftParenthesis(new Range(2, 11, 2, 12)), - new Word(new Range(2, 12, 2, 12 + 8), '/etc/pat'), - new stopCharacter(new Range(2, 20, 2, 21)), // <- stop character - new Word(new Range(2, 21, 2, 21 + 12), 'h/to/file.md'), + new Slash(new Range(2, 12, 2, 13)), + new Word(new Range(2, 13, 2, 13 + 3), 'etc'), + new Slash(new Range(2, 16, 2, 17)), + new Word(new Range(2, 17, 2, 17 + 3), 'pat'), + new StopCharacter(new Range(2, 20, 2, 21)), // <- stop character + new Word(new Range(2, 21, 2, 21 + 1), 'h'), + new Slash(new Range(2, 22, 2, 23)), + new Word(new Range(2, 23, 2, 23 + 2), 'to'), + new Slash(new Range(2, 25, 2, 26)), + new Word(new Range(2, 26, 2, 26 + 7), 'file.md'), new RightParenthesis(new Range(2, 33, 2, 34)), new NewLine(new Range(2, 34, 2, 35)), // `3nd` input line new LeftBracket(new Range(3, 1, 3, 2)), new Word(new Range(3, 2, 3, 2 + 4), 'text'), new RightBracket(new Range(3, 6, 3, 7)), - new stopCharacter(new Range(3, 7, 3, 8)), // <- stop character + new StopCharacter(new Range(3, 7, 3, 8)), // <- stop character new LeftParenthesis(new Range(3, 8, 3, 9)), - new Word(new Range(3, 9, 3, 9 + 5), '/etc/'), + new Slash(new Range(3, 9, 3, 10)), + new Word(new Range(3, 10, 3, 10 + 3), 'etc'), + new Slash(new Range(3, 13, 3, 14)), new Space(new Range(3, 14, 3, 15)), - new Word(new Range(3, 15, 3, 15 + 12), 'path/file.md'), + new Word(new Range(3, 15, 3, 15 + 4), 'path'), + new Slash(new Range(3, 19, 3, 20)), + new Word(new Range(3, 20, 3, 20 + 7), 'file.md'), new RightParenthesis(new Range(3, 27, 3, 28)), ], ); @@ -461,7 +506,7 @@ suite('MarkdownDecoder', () => { // space between caption and reference is disallowed '\f![link text] (./file path/name.jpg)', // new line inside the link reference - '\v![ ](./file\npath/name.jpg )', + '\v![ ](./file\npath/name.jpeg )', ]; await test.run( @@ -473,9 +518,15 @@ suite('MarkdownDecoder', () => { new Space(new Range(1, 3, 1, 4)), new RightBracket(new Range(1, 4, 1, 5)), new LeftParenthesis(new Range(1, 5, 1, 6)), - new Word(new Range(1, 6, 1, 6 + 11), './real/file'), + new Word(new Range(1, 6, 1, 6 + 1), '.'), + new Slash(new Range(1, 7, 1, 8)), + new Word(new Range(1, 8, 1, 8 + 4), 'real'), + new Slash(new Range(1, 12, 1, 13)), + new Word(new Range(1, 13, 1, 13 + 4), 'file'), new Space(new Range(1, 17, 1, 18)), - new Word(new Range(1, 18, 1, 18 + 19), 'path/file★name.webp'), + new Word(new Range(1, 18, 1, 18 + 4), 'path'), + new Slash(new Range(1, 22, 1, 23)), + new Word(new Range(1, 23, 1, 23 + 14), 'file★name.webp'), new NewLine(new Range(1, 37, 1, 38)), // `2nd` line new FormFeed(new Range(2, 1, 2, 2)), @@ -487,9 +538,13 @@ suite('MarkdownDecoder', () => { new RightBracket(new Range(2, 13, 2, 14)), new Space(new Range(2, 14, 2, 15)), new LeftParenthesis(new Range(2, 15, 2, 16)), - new Word(new Range(2, 16, 2, 16 + 6), './file'), + new Word(new Range(2, 16, 2, 16 + 1), '.'), + new Slash(new Range(2, 17, 2, 18)), + new Word(new Range(2, 18, 2, 18 + 4), 'file'), new Space(new Range(2, 22, 2, 23)), - new Word(new Range(2, 23, 2, 23 + 13), 'path/name.jpg'), + new Word(new Range(2, 23, 2, 23 + 4), 'path'), + new Slash(new Range(2, 27, 2, 28)), + new Word(new Range(2, 28, 2, 28 + 8), 'name.jpg'), new RightParenthesis(new Range(2, 36, 2, 37)), new NewLine(new Range(2, 37, 2, 38)), // `3rd` line @@ -499,23 +554,27 @@ suite('MarkdownDecoder', () => { new Space(new Range(3, 4, 3, 5)), new RightBracket(new Range(3, 5, 3, 6)), new LeftParenthesis(new Range(3, 6, 3, 7)), - new Word(new Range(3, 7, 3, 7 + 6), './file'), + new Word(new Range(3, 7, 3, 7 + 1), '.'), + new Slash(new Range(3, 8, 3, 9)), + new Word(new Range(3, 9, 3, 9 + 4), 'file'), new NewLine(new Range(3, 13, 3, 14)), - new Word(new Range(4, 1, 4, 1 + 13), 'path/name.jpg'), - new Space(new Range(4, 14, 4, 15)), - new RightParenthesis(new Range(4, 15, 4, 16)), + new Word(new Range(4, 1, 4, 1 + 4), 'path'), + new Slash(new Range(4, 5, 4, 6)), + new Word(new Range(4, 6, 4, 6 + 9), 'name.jpeg'), + new Space(new Range(4, 15, 4, 16)), + new RightParenthesis(new Range(4, 16, 4, 17)), ], ); }); suite('• stop characters inside caption/reference (new lines)', () => { - for (const stopCharacter of [CarriageReturn, NewLine]) { + for (const StopCharacter of [CarriageReturn, NewLine]) { let characterName = ''; - if (stopCharacter === CarriageReturn) { + if (StopCharacter === CarriageReturn) { characterName = '\\r'; } - if (stopCharacter === NewLine) { + if (StopCharacter === NewLine) { characterName = '\\n'; } @@ -531,11 +590,11 @@ suite('MarkdownDecoder', () => { const inputLines = [ // stop character inside link caption - `![haa${stopCharacter.symbol}loů](./real/💁/name.png)`, + `![haa${StopCharacter.symbol}loů](./real/💁/name.png)`, // stop character inside link reference - `![ref text](/etc/pat${stopCharacter.symbol}h/to/file.webp)`, + `![ref text](/etc/pat${StopCharacter.symbol}h/to/file.webp)`, // stop character between line caption and link reference is disallowed - `![text]${stopCharacter.symbol}(/etc/ path/file.jpeg)`, + `![text]${StopCharacter.symbol}(/etc/ path/file.jpeg)`, ]; @@ -546,11 +605,17 @@ suite('MarkdownDecoder', () => { new ExclamationMark(new Range(1, 1, 1, 2)), new LeftBracket(new Range(1, 2, 1, 3)), new Word(new Range(1, 3, 1, 3 + 3), 'haa'), - new stopCharacter(new Range(1, 6, 1, 7)), // <- stop character + new NewLine(new Range(1, 6, 1, 7)), // a single CR token is treated as a `new line` new Word(new Range(2, 1, 2, 1 + 3), 'loů'), new RightBracket(new Range(2, 4, 2, 5)), new LeftParenthesis(new Range(2, 5, 2, 6)), - new Word(new Range(2, 6, 2, 6 + 18), './real/💁/name.png'), + new Word(new Range(2, 6, 2, 6 + 1), '.'), + new Slash(new Range(2, 7, 2, 8)), + new Word(new Range(2, 8, 2, 8 + 4), 'real'), + new Slash(new Range(2, 12, 2, 13)), + new Word(new Range(2, 13, 2, 13 + 2), '💁'), + new Slash(new Range(2, 15, 2, 16)), + new Word(new Range(2, 16, 2, 16 + 8), 'name.png'), new RightParenthesis(new Range(2, 24, 2, 25)), new NewLine(new Range(2, 25, 2, 26)), // `2nd` input line @@ -561,9 +626,16 @@ suite('MarkdownDecoder', () => { new Word(new Range(3, 7, 3, 7 + 4), 'text'), new RightBracket(new Range(3, 11, 3, 12)), new LeftParenthesis(new Range(3, 12, 3, 13)), - new Word(new Range(3, 13, 3, 13 + 8), '/etc/pat'), - new stopCharacter(new Range(3, 21, 3, 22)), // <- stop character - new Word(new Range(4, 1, 4, 1 + 14), 'h/to/file.webp'), + new Slash(new Range(3, 13, 3, 14)), + new Word(new Range(3, 14, 3, 14 + 3), 'etc'), + new Slash(new Range(3, 17, 3, 18)), + new Word(new Range(3, 18, 3, 18 + 3), 'pat'), + new NewLine(new Range(3, 21, 3, 22)), // a single CR token is treated as a `new line` + new Word(new Range(4, 1, 4, 1 + 1), 'h'), + new Slash(new Range(4, 2, 4, 3)), + new Word(new Range(4, 3, 4, 3 + 2), 'to'), + new Slash(new Range(4, 5, 4, 6)), + new Word(new Range(4, 6, 4, 6 + 9), 'file.webp'), new RightParenthesis(new Range(4, 15, 4, 16)), new NewLine(new Range(4, 16, 4, 17)), // `3nd` input line @@ -571,11 +643,15 @@ suite('MarkdownDecoder', () => { new LeftBracket(new Range(5, 2, 5, 3)), new Word(new Range(5, 3, 5, 3 + 4), 'text'), new RightBracket(new Range(5, 7, 5, 8)), - new stopCharacter(new Range(5, 8, 5, 9)), // <- stop character + new NewLine(new Range(5, 8, 5, 9)), // a single CR token is treated as a `new line` new LeftParenthesis(new Range(6, 1, 6, 2)), - new Word(new Range(6, 2, 6, 2 + 5), '/etc/'), + new Slash(new Range(6, 2, 6, 3)), + new Word(new Range(6, 3, 6, 3 + 3), 'etc'), + new Slash(new Range(6, 6, 6, 7)), new Space(new Range(6, 7, 6, 8)), - new Word(new Range(6, 8, 6, 8 + 14), 'path/file.jpeg'), + new Word(new Range(6, 8, 6, 8 + 4), 'path'), + new Slash(new Range(6, 12, 6, 13)), + new Word(new Range(6, 13, 6, 13 + 9), 'file.jpeg'), new RightParenthesis(new Range(6, 22, 6, 23)), ], ); @@ -628,7 +704,13 @@ suite('MarkdownDecoder', () => { new Word(new Range(1, 7, 1, 7 + 3), 'loů'), new RightBracket(new Range(1, 10, 1, 11)), new LeftParenthesis(new Range(1, 11, 1, 12)), - new Word(new Range(1, 12, 1, 12 + 14), './real/💁/name'), + new Word(new Range(1, 12, 1, 12 + 1), '.'), + new Slash(new Range(1, 13, 1, 14)), + new Word(new Range(1, 14, 1, 14 + 4), 'real'), + new Slash(new Range(1, 18, 1, 19)), + new Word(new Range(1, 19, 1, 19 + 2), '💁'), + new Slash(new Range(1, 21, 1, 22)), + new Word(new Range(1, 22, 1, 22 + 4), 'name'), new RightParenthesis(new Range(1, 26, 1, 27)), new NewLine(new Range(1, 27, 1, 28)), // `2nd` input line @@ -639,9 +721,16 @@ suite('MarkdownDecoder', () => { new Word(new Range(2, 7, 2, 7 + 4), 'text'), new RightBracket(new Range(2, 11, 2, 12)), new LeftParenthesis(new Range(2, 12, 2, 13)), - new Word(new Range(2, 13, 2, 13 + 8), '/etc/pat'), + new Slash(new Range(2, 13, 2, 14)), + new Word(new Range(2, 14, 2, 14 + 3), 'etc'), + new Slash(new Range(2, 17, 2, 18)), + new Word(new Range(2, 18, 2, 18 + 3), 'pat'), new stopCharacter(new Range(2, 21, 2, 22)), // <- stop character - new Word(new Range(2, 22, 2, 22 + 14), 'h/to/file.webp'), + new Word(new Range(2, 22, 2, 22 + 1), 'h'), + new Slash(new Range(2, 23, 2, 24)), + new Word(new Range(2, 24, 2, 24 + 2), 'to'), + new Slash(new Range(2, 26, 2, 27)), + new Word(new Range(2, 27, 2, 27 + 9), 'file.webp'), new RightParenthesis(new Range(2, 36, 2, 37)), new NewLine(new Range(2, 37, 2, 38)), // `3nd` input line @@ -651,9 +740,13 @@ suite('MarkdownDecoder', () => { new RightBracket(new Range(3, 7, 3, 8)), new stopCharacter(new Range(3, 8, 3, 9)), // <- stop character new LeftParenthesis(new Range(3, 9, 3, 10)), - new Word(new Range(3, 10, 3, 10 + 5), '/etc/'), + new Slash(new Range(3, 10, 3, 11)), + new Word(new Range(3, 11, 3, 11 + 3), 'etc'), + new Slash(new Range(3, 14, 3, 15)), new Space(new Range(3, 15, 3, 16)), - new Word(new Range(3, 16, 3, 16 + 14), 'path/image.gif'), + new Word(new Range(3, 16, 3, 16 + 4), 'path'), + new Slash(new Range(3, 20, 3, 21)), + new Word(new Range(3, 21, 3, 21 + 9), 'image.gif'), new RightParenthesis(new Range(3, 30, 3, 31)), ], ); diff --git a/src/vs/editor/test/common/codecs/simpleDecoder.test.ts b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts index 16ae699708c..7ebb92e68a0 100644 --- a/src/vs/editor/test/common/codecs/simpleDecoder.test.ts +++ b/src/vs/editor/test/common/codecs/simpleDecoder.test.ts @@ -3,25 +3,39 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TestDecoder } from '../utils/testDecoder.js'; import { Range } from '../../../common/core/range.js'; +import { TestDecoder } from '../utils/testDecoder.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { newWriteableStream } from '../../../../base/common/stream.js'; -import { Tab } from '../../../common/codecs/simpleCodec/tokens/tab.js'; -import { Hash } from '../../../common/codecs/simpleCodec/tokens/hash.js'; -import { Word } from '../../../common/codecs/simpleCodec/tokens/word.js'; -import { Dash } from '../../../common/codecs/simpleCodec/tokens/dash.js'; -import { Space } from '../../../common/codecs/simpleCodec/tokens/space.js'; import { NewLine } from '../../../common/codecs/linesCodec/tokens/newLine.js'; -import { FormFeed } from '../../../common/codecs/simpleCodec/tokens/formFeed.js'; -import { VerticalTab } from '../../../common/codecs/simpleCodec/tokens/verticalTab.js'; import { CarriageReturn } from '../../../common/codecs/linesCodec/tokens/carriageReturn.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { SimpleDecoder, TSimpleToken } from '../../../common/codecs/simpleCodec/simpleDecoder.js'; -import { LeftBracket, RightBracket } from '../../../common/codecs/simpleCodec/tokens/brackets.js'; -import { LeftParenthesis, RightParenthesis } from '../../../common/codecs/simpleCodec/tokens/parentheses.js'; -import { LeftAngleBracket, RightAngleBracket } from '../../../common/codecs/simpleCodec/tokens/angleBrackets.js'; -import { ExclamationMark } from '../../../common/codecs/simpleCodec/tokens/exclamationMark.js'; +import { SimpleDecoder, TSimpleDecoderToken } from '../../../common/codecs/simpleCodec/simpleDecoder.js'; +import { + At, + Tab, + Word, + Hash, + Dash, + Colon, + Slash, + Space, + Quote, + FormFeed, + DollarSign, + DoubleQuote, + VerticalTab, + LeftBracket, + RightBracket, + LeftCurlyBrace, + RightCurlyBrace, + ExclamationMark, + LeftParenthesis, + RightParenthesis, + LeftAngleBracket, + RightAngleBracket, + Comma, +} from '../../../common/codecs/simpleCodec/tokens/index.js'; /** * A reusable test utility that asserts that a `SimpleDecoder` instance @@ -45,7 +59,7 @@ import { ExclamationMark } from '../../../common/codecs/simpleCodec/tokens/excla * ], * ); */ -export class TestSimpleDecoder extends TestDecoder { +export class TestSimpleDecoder extends TestDecoder { constructor() { const stream = newWriteableStream(null); const decoder = new SimpleDecoder(stream); @@ -57,13 +71,22 @@ export class TestSimpleDecoder extends TestDecoder suite('SimpleDecoder', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('produces expected tokens', async () => { + test('produces expected tokens #1', async () => { const test = testDisposables.add( new TestSimpleDecoder(), ); await test.run( - ' hello world!\nhow are\t you?\v\n\n (test) [!@#$%^🦄&*_+=-]\f \n\t\t🤗❤ \t\n hey\v-\tthere\r\n\r\n', + [ + ' hello world!', + 'how are\t "you?"\v', + '', + ' (test) [!@#$:%^🦄&*_+=,-,]\f ', + '\t\t🤗❤ \t', + ' hey\v-\tthere\r', + ' @workspace@legomushroom', + '\'my\' ${text} /run', + ], [ // first line new Space(new Range(1, 1, 1, 2)), @@ -78,9 +101,11 @@ suite('SimpleDecoder', () => { new Word(new Range(2, 5, 2, 8), 'are'), new Tab(new Range(2, 8, 2, 9)), new Space(new Range(2, 9, 2, 10)), - new Word(new Range(2, 10, 2, 14), 'you?'), - new VerticalTab(new Range(2, 14, 2, 15)), - new NewLine(new Range(2, 15, 2, 16)), + new DoubleQuote(new Range(2, 10, 2, 11)), + new Word(new Range(2, 11, 2, 11 + 4), 'you?'), + new DoubleQuote(new Range(2, 15, 2, 16)), + new VerticalTab(new Range(2, 16, 2, 17)), + new NewLine(new Range(2, 17, 2, 18)), // third line new NewLine(new Range(3, 1, 3, 2)), // fourth line @@ -94,15 +119,19 @@ suite('SimpleDecoder', () => { new Space(new Range(4, 11, 4, 12)), new LeftBracket(new Range(4, 12, 4, 13)), new ExclamationMark(new Range(4, 13, 4, 14)), - new Word(new Range(4, 14, 4, 15), '@'), + new At(new Range(4, 14, 4, 15)), new Hash(new Range(4, 15, 4, 16)), - new Word(new Range(4, 16, 4, 16 + 10), '$%^🦄&*_+='), - new Dash(new Range(4, 26, 4, 27)), - new RightBracket(new Range(4, 27, 4, 28)), - new FormFeed(new Range(4, 28, 4, 29)), - new Space(new Range(4, 29, 4, 30)), - new Space(new Range(4, 30, 4, 31)), - new NewLine(new Range(4, 31, 4, 32)), + new DollarSign(new Range(4, 16, 4, 17)), + new Colon(new Range(4, 17, 4, 18)), + new Word(new Range(4, 18, 4, 18 + 9), '%^🦄&*_+='), + new Comma(new Range(4, 27, 4, 28)), + new Dash(new Range(4, 28, 4, 29)), + new Comma(new Range(4, 29, 4, 30)), + new RightBracket(new Range(4, 30, 4, 31)), + new FormFeed(new Range(4, 31, 4, 32)), + new Space(new Range(4, 32, 4, 33)), + new Space(new Range(4, 33, 4, 34)), + new NewLine(new Range(4, 34, 4, 35)), // fifth line new Tab(new Range(5, 1, 5, 2)), new LeftAngleBracket(new Range(5, 2, 5, 3)), @@ -125,8 +154,84 @@ suite('SimpleDecoder', () => { new CarriageReturn(new Range(6, 13, 6, 14)), new NewLine(new Range(6, 14, 6, 15)), // seventh line - new CarriageReturn(new Range(7, 1, 7, 2)), - new NewLine(new Range(7, 2, 7, 3)), + new Space(new Range(7, 1, 7, 2)), + new At(new Range(7, 2, 7, 3)), + new Word(new Range(7, 3, 7, 12), 'workspace'), + new At(new Range(7, 12, 7, 13)), + new Word(new Range(7, 13, 7, 25), 'legomushroom'), + new NewLine(new Range(7, 25, 7, 26)), + // eighth line + new Quote(new Range(8, 1, 8, 2)), + new Word(new Range(8, 2, 8, 2 + 2), 'my'), + new Quote(new Range(8, 4, 8, 5)), + new Space(new Range(8, 5, 8, 6)), + new DollarSign(new Range(8, 6, 8, 7)), + new LeftCurlyBrace(new Range(8, 7, 8, 8)), + new Word(new Range(8, 8, 8, 8 + 4), 'text'), + new RightCurlyBrace(new Range(8, 12, 8, 13)), + new Space(new Range(8, 13, 8, 14)), + new Slash(new Range(8, 14, 8, 15)), + new Word(new Range(8, 15, 8, 15 + 3), 'run'), + ], + ); + }); + + test('produces expected tokens #2', async () => { + const test = testDisposables.add( + new TestSimpleDecoder(), + ); + + await test.run( + [ + 'your command is /catch', + '\t\t/command1/command2 ', + ' /cmd#var ', + '/test@github\t\t', + '/update\r', + '', + ], + [ + // first line + new Word(new Range(1, 1, 1, 5), 'your'), + new Space(new Range(1, 5, 1, 6)), + new Word(new Range(1, 6, 1, 6 + 7), 'command'), + new Space(new Range(1, 13, 1, 14)), + new Word(new Range(1, 14, 1, 14 + 2), 'is'), + new Space(new Range(1, 16, 1, 17)), + new Slash(new Range(1, 17, 1, 18)), + new Word(new Range(1, 18, 1, 18 + 5), 'catch'), + new NewLine(new Range(1, 23, 1, 24)), + // second line + new Tab(new Range(2, 1, 2, 2)), + new Tab(new Range(2, 2, 2, 3)), + new Slash(new Range(2, 3, 2, 4)), + new Word(new Range(2, 4, 2, 4 + 8), 'command1'), + new Slash(new Range(2, 12, 2, 13)), + new Word(new Range(2, 13, 2, 13 + 8), 'command2'), + new Space(new Range(2, 21, 2, 22)), + new NewLine(new Range(2, 22, 2, 23)), + // third line + new Space(new Range(3, 1, 3, 2)), + new Space(new Range(3, 2, 3, 3)), + new Slash(new Range(3, 3, 3, 4)), + new Word(new Range(3, 4, 3, 4 + 3), 'cmd'), + new Hash(new Range(3, 7, 3, 8)), + new Word(new Range(3, 8, 3, 8 + 3), 'var'), + new Space(new Range(3, 11, 3, 12)), + new NewLine(new Range(3, 12, 3, 13)), + // fourth line + new Slash(new Range(4, 1, 4, 2)), + new Word(new Range(4, 2, 4, 2 + 4), 'test'), + new At(new Range(4, 6, 4, 7)), + new Word(new Range(4, 7, 4, 7 + 6), 'github'), + new Tab(new Range(4, 13, 4, 14)), + new Tab(new Range(4, 14, 4, 15)), + new NewLine(new Range(4, 15, 4, 16)), + // fifth line + new Slash(new Range(5, 1, 5, 2)), + new Word(new Range(5, 2, 5, 2 + 6), 'update'), + new CarriageReturn(new Range(5, 8, 5, 9)), + new NewLine(new Range(5, 9, 5, 10)), ], ); }); diff --git a/src/vs/editor/test/common/services/editorSimpleWorker.test.ts b/src/vs/editor/test/common/services/editorWebWorker.test.ts similarity index 98% rename from src/vs/editor/test/common/services/editorSimpleWorker.test.ts rename to src/vs/editor/test/common/services/editorWebWorker.test.ts index 1cc3d022d55..f2f33476c16 100644 --- a/src/vs/editor/test/common/services/editorSimpleWorker.test.ts +++ b/src/vs/editor/test/common/services/editorWebWorker.test.ts @@ -8,14 +8,14 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { Position } from '../../../common/core/position.js'; import { IRange, Range } from '../../../common/core/range.js'; import { TextEdit } from '../../../common/languages.js'; -import { BaseEditorSimpleWorker } from '../../../common/services/editorSimpleWorker.js'; +import { EditorWorker } from '../../../common/services/editorWebWorker.js'; import { ICommonModel } from '../../../common/services/textModelSync/textModelSync.impl.js'; -suite('EditorSimpleWorker', () => { +suite('EditorWebWorker', () => { ensureNoDisposablesAreLeakedInTestSuite(); - class WorkerWithModels extends BaseEditorSimpleWorker { + class WorkerWithModels extends EditorWorker { getModel(uri: string) { return this._getModel(uri); diff --git a/src/vs/editor/test/common/services/testTreeSitterService.ts b/src/vs/editor/test/common/services/testTreeSitterService.ts index 9926a17e41c..2d67b5abce3 100644 --- a/src/vs/editor/test/common/services/testTreeSitterService.ts +++ b/src/vs/editor/test/common/services/testTreeSitterService.ts @@ -6,7 +6,7 @@ import type * as Parser from '@vscode/tree-sitter-wasm'; import { Event } from '../../../../base/common/event.js'; import { ITextModel } from '../../../common/model.js'; -import { ITreeSitterParserService, ITreeSitterParseResult, ITextModelTreeSitter, TreeUpdateEvent } from '../../../common/services/treeSitterParserService.js'; +import { ITreeSitterParserService, ITextModelTreeSitter, TreeUpdateEvent } from '../../../common/services/treeSitterParserService.js'; export class TestTreeSitterParserService implements ITreeSitterParserService { getLanguage(languageId: string): Promise { @@ -30,7 +30,7 @@ export class TestTreeSitterParserService implements ITreeSitterParserService { waitForLanguage(languageId: string): Promise { throw new Error('Method not implemented.'); } - getParseResult(textModel: ITextModel): ITreeSitterParseResult | undefined { + getParseResult(textModel: ITextModel): ITextModelTreeSitter | undefined { throw new Error('Method not implemented.'); } diff --git a/src/vs/editor/test/common/utils/testDecoder.ts b/src/vs/editor/test/common/utils/testDecoder.ts index 78ce39a310b..410606fbffc 100644 --- a/src/vs/editor/test/common/utils/testDecoder.ts +++ b/src/vs/editor/test/common/utils/testDecoder.ts @@ -102,45 +102,8 @@ export class TestDecoder> extends // initiate the data sending flow this.sendData(inputData); - // consume the decoder tokens based on specified - // (or randomly generated) tokens consume method - const receivedTokens: T[] = []; - switch (tokensConsumeMethod) { - // test the `async iterator` code path - case 'async-generator': { - for await (const token of this.decoder) { - if (token === null) { - break; - } - - receivedTokens.push(token); - } - - break; - } - // test the `.consumeAll()` method code path - case 'consume-all-method': { - receivedTokens.push(...(await this.decoder.consumeAll())); - break; - } - // test the `.onData()` event consume flow - case 'on-data-event': { - this.decoder.onData((token) => { - receivedTokens.push(token); - }); - - this.decoder.start(); - - // in this case we also test the `settled` promise of the decoder - await this.decoder.settled; - - break; - } - // ensure that the switch block is exhaustive - default: { - throw new Error(`Unknown consume method '${tokensConsumeMethod}'.`); - } - } + // receive tokens from the decoder stream + const receivedTokens = await this.receiveTokens(tokensConsumeMethod); // validate the received tokens this.validateReceivedTokens( @@ -191,6 +154,55 @@ export class TestDecoder> extends } } + /** + * Receive all tokens from the decoder stream using the specified consume method. + */ + public async receiveTokens( + tokensConsumeMethod: TTokensConsumeMethod = this.randomTokensConsumeMethod(), + ): Promise { + // consume the decoder tokens based on specified + // (or randomly generated) tokens consume method + const receivedTokens: T[] = []; + switch (tokensConsumeMethod) { + // test the `async iterator` code path + case 'async-generator': { + for await (const token of this.decoder) { + if (token === null) { + break; + } + + receivedTokens.push(token); + } + + break; + } + // test the `.consumeAll()` method code path + case 'consume-all-method': { + receivedTokens.push(...(await this.decoder.consumeAll())); + break; + } + // test the `.onData()` event consume flow + case 'on-data-event': { + this.decoder.onData((token) => { + receivedTokens.push(token); + }); + + this.decoder.start(); + + // in this case we also test the `settled` promise of the decoder + await this.decoder.settled; + + break; + } + // ensure that the switch block is exhaustive + default: { + throw new Error(`Unknown consume method '${tokensConsumeMethod}'.`); + } + } + + return receivedTokens; + } + /** * Validate that received tokens list is equal to the expected one. */ diff --git a/src/vs/editor/test/common/viewLayout/lineHeights.test.ts b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts new file mode 100644 index 00000000000..0047a4014ba --- /dev/null +++ b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts @@ -0,0 +1,268 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { LineHeightsManager } from '../../../common/viewLayout/lineHeights.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +suite('Editor ViewLayout - LineHeightsManager', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('default line height is used when no custom heights exist', () => { + const manager = new LineHeightsManager(10, []); + + // Check individual line heights + assert.strictEqual(manager.heightForLineNumber(1), 10); + assert.strictEqual(manager.heightForLineNumber(5), 10); + assert.strictEqual(manager.heightForLineNumber(100), 10); + + // Check accumulated heights + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(1), 10); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(5), 50); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(10), 100); + }); + + test('can change default line height', () => { + const manager = new LineHeightsManager(10, []); + manager.defaultLineHeight = 20; + + // Check individual line heights + assert.strictEqual(manager.heightForLineNumber(1), 20); + assert.strictEqual(manager.heightForLineNumber(5), 20); + + // Check accumulated heights + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(1), 20); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(5), 100); + }); + + test('can add single custom line height', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); + manager.commit(); + + // Check individual line heights + assert.strictEqual(manager.heightForLineNumber(1), 10); + assert.strictEqual(manager.heightForLineNumber(2), 10); + assert.strictEqual(manager.heightForLineNumber(3), 20); + assert.strictEqual(manager.heightForLineNumber(4), 10); + + // Check accumulated heights + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(1), 10); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(2), 20); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(3), 40); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(4), 50); + }); + + test('can add multiple custom line heights', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 2, 2, 15); + manager.insertOrChangeCustomLineHeight('dec2', 4, 4, 25); + manager.commit(); + + // Check individual line heights + assert.strictEqual(manager.heightForLineNumber(1), 10); + assert.strictEqual(manager.heightForLineNumber(2), 15); + assert.strictEqual(manager.heightForLineNumber(3), 10); + assert.strictEqual(manager.heightForLineNumber(4), 25); + assert.strictEqual(manager.heightForLineNumber(5), 10); + + // Check accumulated heights + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(1), 10); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(2), 25); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(3), 35); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(4), 60); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(5), 70); + }); + + test('can add range of custom line heights', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 2, 4, 15); + manager.commit(); + + // Check individual line heights + assert.strictEqual(manager.heightForLineNumber(1), 10); + assert.strictEqual(manager.heightForLineNumber(2), 15); + assert.strictEqual(manager.heightForLineNumber(3), 15); + assert.strictEqual(manager.heightForLineNumber(4), 15); + assert.strictEqual(manager.heightForLineNumber(5), 10); + + // Check accumulated heights + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(1), 10); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(2), 25); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(3), 40); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(4), 55); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(5), 65); + }); + + test('can change existing custom line height', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); + manager.commit(); + assert.strictEqual(manager.heightForLineNumber(3), 20); + + manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 30); + manager.commit(); + assert.strictEqual(manager.heightForLineNumber(3), 30); + + // Check accumulated heights after change + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(3), 50); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(4), 60); + }); + + test('can remove custom line height', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); + manager.commit(); + assert.strictEqual(manager.heightForLineNumber(3), 20); + + manager.removeCustomLineHeight('dec1'); + manager.commit(); + assert.strictEqual(manager.heightForLineNumber(3), 10); + + // Check accumulated heights after removal + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(3), 30); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(4), 40); + }); + + test('handles overlapping custom line heights (last one wins)', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 3, 5, 20); + manager.insertOrChangeCustomLineHeight('dec2', 4, 6, 30); + manager.commit(); + + assert.strictEqual(manager.heightForLineNumber(2), 10); + assert.strictEqual(manager.heightForLineNumber(3), 20); + assert.strictEqual(manager.heightForLineNumber(4), 30); + assert.strictEqual(manager.heightForLineNumber(5), 30); + assert.strictEqual(manager.heightForLineNumber(6), 30); + assert.strictEqual(manager.heightForLineNumber(7), 10); + }); + + test('handles deleting lines before custom line heights', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 10, 12, 20); + manager.commit(); + + manager.onLinesDeleted(5, 7); // Delete lines 5-7 + + assert.strictEqual(manager.heightForLineNumber(7), 20); // Was line 10 + assert.strictEqual(manager.heightForLineNumber(8), 20); // Was line 11 + assert.strictEqual(manager.heightForLineNumber(9), 20); // Was line 12 + assert.strictEqual(manager.heightForLineNumber(10), 10); + }); + + test('handles deleting lines overlapping with custom line heights', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 5, 10, 20); + manager.commit(); + + manager.onLinesDeleted(7, 12); // Delete lines 7-12, including part of decoration + + assert.strictEqual(manager.heightForLineNumber(5), 20); + assert.strictEqual(manager.heightForLineNumber(6), 20); + assert.strictEqual(manager.heightForLineNumber(7), 10); + }); + + test('handles deleting lines containing custom line heights completely', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); + manager.commit(); + + manager.onLinesDeleted(4, 8); // Delete lines 4-8, completely contains decoration + + // The decoration collapses to a single line which matches the behavior in the text buffer + assert.strictEqual(manager.heightForLineNumber(3), 10); + assert.strictEqual(manager.heightForLineNumber(4), 20); + assert.strictEqual(manager.heightForLineNumber(5), 10); + }); + + test('handles inserting lines before custom line heights', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); + manager.commit(); + + manager.onLinesInserted(3, 4); // Insert 2 lines at line 3 + + assert.strictEqual(manager.heightForLineNumber(5), 10); + assert.strictEqual(manager.heightForLineNumber(6), 10); + assert.strictEqual(manager.heightForLineNumber(7), 20); // Was line 5 + assert.strictEqual(manager.heightForLineNumber(8), 20); // Was line 6 + assert.strictEqual(manager.heightForLineNumber(9), 20); // Was line 7 + }); + + test('handles inserting lines inside custom line heights range', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); + manager.commit(); + + manager.onLinesInserted(6, 7); // Insert 2 lines at line 6 + + assert.strictEqual(manager.heightForLineNumber(5), 20); + assert.strictEqual(manager.heightForLineNumber(6), 20); + assert.strictEqual(manager.heightForLineNumber(7), 20); + assert.strictEqual(manager.heightForLineNumber(8), 20); + assert.strictEqual(manager.heightForLineNumber(9), 20); + }); + + test('changing decoration id maintains custom line height', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); + manager.commit(); + + manager.removeCustomLineHeight('dec1'); + manager.insertOrChangeCustomLineHeight('dec2', 5, 7, 20); + manager.commit(); + + assert.strictEqual(manager.heightForLineNumber(5), 20); + assert.strictEqual(manager.heightForLineNumber(6), 20); + assert.strictEqual(manager.heightForLineNumber(7), 20); + }); + + test('accumulates heights correctly with complex setup', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 15); + manager.insertOrChangeCustomLineHeight('dec2', 5, 7, 20); + manager.insertOrChangeCustomLineHeight('dec3', 10, 10, 30); + manager.commit(); + + // Check accumulated heights + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(1), 10); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(2), 20); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(3), 35); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(4), 45); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(5), 65); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(7), 105); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(9), 125); + assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(10), 155); + }); + + test('partial deletion with multiple lines for the same decoration ID', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('decSame', 5, 5, 20); + manager.insertOrChangeCustomLineHeight('decSame', 6, 6, 25); + manager.commit(); + + // Delete one line that partially intersects the same decoration + manager.onLinesDeleted(6, 6); + + // Check individual line heights + assert.strictEqual(manager.heightForLineNumber(5), 20); + assert.strictEqual(manager.heightForLineNumber(6), 10); + }); + + test('overlapping decorations use maximum line height', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('decA', 3, 5, 40); + manager.insertOrChangeCustomLineHeight('decB', 4, 6, 30); + manager.commit(); + + // Check individual line heights + assert.strictEqual(manager.heightForLineNumber(3), 40); + assert.strictEqual(manager.heightForLineNumber(4), 40); + assert.strictEqual(manager.heightForLineNumber(5), 40); + assert.strictEqual(manager.heightForLineNumber(6), 30); + }); +}); diff --git a/src/vs/editor/test/common/viewLayout/linesLayout.test.ts b/src/vs/editor/test/common/viewLayout/linesLayout.test.ts index 087c24e457b..7bf20a78d84 100644 --- a/src/vs/editor/test/common/viewLayout/linesLayout.test.ts +++ b/src/vs/editor/test/common/viewLayout/linesLayout.test.ts @@ -33,7 +33,7 @@ suite('Editor ViewLayout - LinesLayout', () => { test('LinesLayout 1', () => { // Start off with 10 lines - const linesLayout = new LinesLayout(10, 10, 0, 0); + const linesLayout = new LinesLayout(10, 10, 0, 0, []); // lines: [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] // whitespace: - @@ -142,7 +142,7 @@ suite('Editor ViewLayout - LinesLayout', () => { test('LinesLayout 2', () => { // Start off with 10 lines and one whitespace after line 2, of height 5 - const linesLayout = new LinesLayout(10, 1, 0, 0); + const linesLayout = new LinesLayout(10, 1, 0, 0, []); const a = insertWhitespace(linesLayout, 2, 0, 5, 0); // 10 lines @@ -239,7 +239,7 @@ suite('Editor ViewLayout - LinesLayout', () => { test('LinesLayout Padding', () => { // Start off with 10 lines - const linesLayout = new LinesLayout(10, 10, 15, 20); + const linesLayout = new LinesLayout(10, 10, 15, 20, []); // lines: [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] // whitespace: - @@ -333,7 +333,7 @@ suite('Editor ViewLayout - LinesLayout', () => { }); test('LinesLayout getLineNumberAtOrAfterVerticalOffset', () => { - const linesLayout = new LinesLayout(10, 1, 0, 0); + const linesLayout = new LinesLayout(10, 1, 0, 0, []); insertWhitespace(linesLayout, 6, 0, 10, 0); // 10 lines @@ -382,7 +382,7 @@ suite('Editor ViewLayout - LinesLayout', () => { }); test('LinesLayout getCenteredLineInViewport', () => { - const linesLayout = new LinesLayout(10, 1, 0, 0); + const linesLayout = new LinesLayout(10, 1, 0, 0, []); insertWhitespace(linesLayout, 6, 0, 10, 0); // 10 lines @@ -465,7 +465,7 @@ suite('Editor ViewLayout - LinesLayout', () => { }); test('LinesLayout getLinesViewportData 1', () => { - const linesLayout = new LinesLayout(10, 10, 0, 0); + const linesLayout = new LinesLayout(10, 10, 0, 0, []); insertWhitespace(linesLayout, 6, 0, 100, 0); // 10 lines @@ -598,7 +598,7 @@ suite('Editor ViewLayout - LinesLayout', () => { }); test('LinesLayout getLinesViewportData 2 & getWhitespaceViewportData', () => { - const linesLayout = new LinesLayout(10, 10, 0, 0); + const linesLayout = new LinesLayout(10, 10, 0, 0, []); const a = insertWhitespace(linesLayout, 6, 0, 100, 0); const b = insertWhitespace(linesLayout, 7, 0, 50, 0); @@ -669,7 +669,7 @@ suite('Editor ViewLayout - LinesLayout', () => { }); test('LinesLayout getWhitespaceAtVerticalOffset', () => { - const linesLayout = new LinesLayout(10, 10, 0, 0); + const linesLayout = new LinesLayout(10, 10, 0, 0, []); const a = insertWhitespace(linesLayout, 6, 0, 100, 0); const b = insertWhitespace(linesLayout, 7, 0, 50, 0); @@ -712,7 +712,7 @@ suite('Editor ViewLayout - LinesLayout', () => { test('LinesLayout', () => { - const linesLayout = new LinesLayout(100, 20, 0, 0); + const linesLayout = new LinesLayout(100, 20, 0, 0, []); // Insert a whitespace after line number 2, of height 10 const a = insertWhitespace(linesLayout, 2, 0, 10, 0); @@ -1063,7 +1063,7 @@ suite('Editor ViewLayout - LinesLayout', () => { }); test('LinesLayout changeWhitespaceAfterLineNumber & getFirstWhitespaceIndexAfterLineNumber', () => { - const linesLayout = new LinesLayout(100, 20, 0, 0); + const linesLayout = new LinesLayout(100, 20, 0, 0, []); const a = insertWhitespace(linesLayout, 0, 0, 1, 0); const b = insertWhitespace(linesLayout, 7, 0, 1, 0); @@ -1187,7 +1187,7 @@ suite('Editor ViewLayout - LinesLayout', () => { }); test('LinesLayout Bug', () => { - const linesLayout = new LinesLayout(100, 20, 0, 0); + const linesLayout = new LinesLayout(100, 20, 0, 0, []); const a = insertWhitespace(linesLayout, 0, 0, 1, 0); const b = insertWhitespace(linesLayout, 7, 0, 1, 0); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 8fcb5444911..a5ce6e74f66 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -518,6 +518,7 @@ declare namespace monaco { readonly altKey: boolean; readonly metaKey: boolean; readonly timestamp: number; + readonly defaultPrevented: boolean; preventDefault(): void; stopPropagation(): void; } @@ -1099,7 +1100,7 @@ declare namespace monaco.editor { * Create a new web worker that has model syncing capabilities built in. * Specify an AMD module to load that will `create` an object that will be proxied. */ - export function createWebWorker(opts: IWebWorkerOptions): MonacoWebWorker; + export function createWebWorker(opts: IInternalWebWorkerOptions): MonacoWebWorker; /** * Colorize the contents of `domNode` using attribute `data-lang`. @@ -1218,20 +1219,11 @@ declare namespace monaco.editor { withSyncedResources(resources: Uri[]): Promise; } - export interface IWebWorkerOptions { + export interface IInternalWebWorkerOptions { /** - * The AMD moduleId to load. - * It should export a function `create` that should return the exported proxy. + * The worker. */ - moduleId: string; - /** - * The data to send over when calling create on the module. - */ - createData?: any; - /** - * A label to be used to identify the web worker for debugging purposes. - */ - label?: string; + worker: Worker; /** * An object that can be used by the web worker to make calls back to the main thread. */ @@ -1752,6 +1744,10 @@ declare namespace monaco.editor { * with the specified {@link IModelDecorationGlyphMarginOptions} in the glyph margin. */ glyphMargin?: IModelDecorationGlyphMarginOptions | null; + /** + * If set, the decoration will override the line height of the lines it spans. + */ + lineHeight?: number | null; /** * If set, the decoration will be rendered in the lines decorations with this CSS class name. */ @@ -2286,6 +2282,11 @@ declare namespace monaco.editor { * @param ownerId If set, it will ignore decorations belonging to other owners. */ getInjectedTextDecorations(ownerId?: number): IModelDecoration[]; + /** + * Gets all the decorations that contain custom line heights. + * @param ownerId If set, it will ignore decorations belonging to other owners. + */ + getCustomLineHeightsDecorations(ownerId?: number): IModelDecoration[]; /** * Normalize a string containing whitespace according to indentation rules (converts to spaces or to tabs). */ @@ -6108,6 +6109,10 @@ declare namespace monaco.editor { * Get the vertical position (top offset) for the position w.r.t. to the first line. */ getTopForPosition(lineNumber: number, column: number): number; + /** + * Get the line height for the line number. + */ + getLineHeightForLineNumber(lineNumber: number): number; /** * Write the screen reader content to be the current selection */ @@ -6920,12 +6925,15 @@ declare namespace monaco.languages { export interface SyntaxNode { startIndex: number; endIndex: number; + startPosition: IPosition; + endPosition: IPosition; } export interface QueryCapture { name: string; text?: string; node: SyntaxNode; + encodedLanguageId: number; } /** @@ -7313,6 +7321,7 @@ declare namespace monaco.languages { readonly showInlineEditMenu?: boolean; readonly showRange?: IRange; readonly warning?: InlineCompletionWarning; + readonly displayLocation?: InlineCompletionDisplayLocation; } export interface InlineCompletionWarning { @@ -7320,6 +7329,11 @@ declare namespace monaco.languages { icon?: IconPath; } + export interface InlineCompletionDisplayLocation { + range: IRange; + label: string; + } + /** * TODO: add `| Uri | { light: Uri; dark: Uri }`. */ @@ -7352,11 +7366,20 @@ declare namespace monaco.languages { * @param acceptedCharacters Deprecated. Use `info.acceptedCharacters` instead. */ handlePartialAccept?(completions: T, item: T['items'][number], acceptedCharacters: number, info: PartialAcceptInfo): void; + /** + * @deprecated Use `handleEndOfLifetime` instead. + */ handleRejection?(completions: T, item: T['items'][number]): void; + /** + * Is called when an inline completion item is no longer being used. + * Provides a reason of why it is not used anymore. + */ + handleEndOfLifetime?(completions: T, item: T['items'][number], reason: InlineCompletionEndOfLifeReason): void; /** * Will be called when a completions list is no longer in use and can be garbage-collected. */ freeInlineCompletions(completions: T): void; + onDidChangeInlineCompletions?: IEvent; /** * Only used for {@link yieldsToGroupIds}. * Multiple providers can have the same group id. @@ -7372,6 +7395,22 @@ declare namespace monaco.languages { toString?(): string; } + export enum InlineCompletionEndOfLifeReasonKind { + Accepted = 0, + Rejected = 1, + Ignored = 2 + } + + export type InlineCompletionEndOfLifeReason = { + kind: InlineCompletionEndOfLifeReasonKind.Accepted; + } | { + kind: InlineCompletionEndOfLifeReasonKind.Rejected; + } | { + kind: InlineCompletionEndOfLifeReasonKind.Ignored; + supersededBy?: TInlineCompletion; + userTypingDisagreed: boolean; + }; + export interface CodeAction { title: string; command?: Command; diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index c435fa39e24..1c8cb6a7df3 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -21,6 +21,7 @@ export const enum AccessibleViewProviderId { MergeEditor = 'mergeEditor', PanelChat = 'panelChat', InlineChat = 'inlineChat', + AgentChat = 'agentChat', QuickChat = 'quickChat', InlineCompletions = 'inlineCompletions', KeybindingsEditor = 'keybindingsEditor', diff --git a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts index 3357330e6cd..4f21111492e 100644 --- a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts +++ b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts @@ -14,7 +14,7 @@ export class TestAccessibilityService implements IAccessibilityService { onDidChangeReducedMotion = Event.None; isScreenReaderOptimized(): boolean { return false; } - isMotionReduced(): boolean { return false; } + isMotionReduced(): boolean { return true; } alwaysUnderlineAccessKeys(): Promise { return Promise.resolve(false); } setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void { } getAccessibilitySupport(): AccessibilitySupport { return AccessibilitySupport.Unknown; } diff --git a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts index 0e919686c6a..128c3fe2064 100644 --- a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts +++ b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts @@ -316,6 +316,10 @@ export class Sound { public static readonly voiceRecordingStopped = Sound.register({ fileName: 'voiceRecordingStopped.mp3' }); public static readonly progress = Sound.register({ fileName: 'progress.mp3' }); public static readonly chatEditModifiedFile = Sound.register({ fileName: 'chatEditModifiedFile.mp3' }); + public static readonly editsKept = Sound.register({ fileName: 'editsKept.mp3' }); + public static readonly editsUndone = Sound.register({ fileName: 'editsUndone.mp3' }); + public static readonly nextEditSuggestion = Sound.register({ fileName: 'nextEditSuggestion.mp3' }); + public static readonly terminalCommandSucceeded = Sound.register({ fileName: 'terminalCommandSucceeded.mp3' }); private constructor(public readonly fileName: string) { } } @@ -432,7 +436,13 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.lineHasInlineSuggestion', settingsKey: 'accessibility.signals.lineHasInlineSuggestion', }); - + public static readonly nextEditSuggestion = AccessibilitySignal.register({ + name: localize('accessibilitySignals.nextEditSuggestion.name', 'Next Edit Suggestion on Line'), + sound: Sound.nextEditSuggestion, + legacySoundSettingsKey: 'audioCues.nextEditSuggestion', + settingsKey: 'accessibility.signals.nextEditSuggestion', + announcementMessage: localize('accessibility.signals.nextEditSuggestion', 'Next Edit Suggestion'), + }); public static readonly terminalQuickFix = AccessibilitySignal.register({ name: localize('accessibilitySignals.terminalQuickFix.name', 'Terminal Quick Fix'), sound: Sound.quickFixes, @@ -489,7 +499,7 @@ export class AccessibilitySignal { public static readonly terminalCommandSucceeded = AccessibilitySignal.register({ name: localize('accessibilitySignals.terminalCommandSucceeded', 'Terminal Command Succeeded'), - sound: Sound.success, + sound: Sound.terminalCommandSucceeded, announcementMessage: localize('accessibility.signals.terminalCommandSucceeded', 'Command Succeeded'), settingsKey: 'accessibility.signals.terminalCommandSucceeded', }); @@ -638,4 +648,18 @@ export class AccessibilitySignal { legacySoundSettingsKey: 'audioCues.voiceRecordingStopped', settingsKey: 'accessibility.signals.voiceRecordingStopped' }); + + public static readonly editsKept = AccessibilitySignal.register({ + name: localize('accessibilitySignals.editsKept', 'Edits Kept'), + sound: Sound.editsKept, + announcementMessage: localize('accessibility.signals.editsKept', 'Edits Kept'), + settingsKey: 'accessibility.signals.editsKept', + }); + + public static readonly editsUndone = AccessibilitySignal.register({ + name: localize('accessibilitySignals.editsUndone', 'Undo Edits'), + sound: Sound.editsUndone, + announcementMessage: localize('accessibility.signals.editsUndone', 'Edits Undone'), + settingsKey: 'accessibility.signals.editsUndone', + }); } diff --git a/src/vs/platform/accessibilitySignal/browser/media/editsKept.mp3 b/src/vs/platform/accessibilitySignal/browser/media/editsKept.mp3 new file mode 100644 index 00000000000..241e9f4330a Binary files /dev/null and b/src/vs/platform/accessibilitySignal/browser/media/editsKept.mp3 differ diff --git a/src/vs/platform/accessibilitySignal/browser/media/editsUndone.mp3 b/src/vs/platform/accessibilitySignal/browser/media/editsUndone.mp3 new file mode 100644 index 00000000000..7a4a16f24fa Binary files /dev/null and b/src/vs/platform/accessibilitySignal/browser/media/editsUndone.mp3 differ diff --git a/src/vs/platform/accessibilitySignal/browser/media/nextEditSuggestion.mp3 b/src/vs/platform/accessibilitySignal/browser/media/nextEditSuggestion.mp3 new file mode 100644 index 00000000000..ae39e3aaf41 Binary files /dev/null and b/src/vs/platform/accessibilitySignal/browser/media/nextEditSuggestion.mp3 differ diff --git a/src/vs/platform/accessibilitySignal/browser/media/save.mp3 b/src/vs/platform/accessibilitySignal/browser/media/save.mp3 index 68a9cc83565..147f81af550 100644 Binary files a/src/vs/platform/accessibilitySignal/browser/media/save.mp3 and b/src/vs/platform/accessibilitySignal/browser/media/save.mp3 differ diff --git a/src/vs/platform/accessibilitySignal/browser/media/terminalCommandSucceeded.mp3 b/src/vs/platform/accessibilitySignal/browser/media/terminalCommandSucceeded.mp3 new file mode 100644 index 00000000000..68a9cc83565 Binary files /dev/null and b/src/vs/platform/accessibilitySignal/browser/media/terminalCommandSucceeded.mp3 differ diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index bebab4ec9a4..4205ba69d1e 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -18,6 +18,7 @@ import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IKeybindingService } from '../../keybinding/common/keybinding.js'; import { defaultListStyles } from '../../theme/browser/defaultStyles.js'; import { asCssVariable } from '../../theme/common/colorRegistry.js'; +import { ILayoutService } from '../../layout/browser/layoutService.js'; export const acceptSelectedActionCommand = 'acceptSelectedCodeAction'; export const previewSelectedActionCommand = 'previewSelectedCodeAction'; @@ -35,15 +36,18 @@ export interface IActionListItem { readonly group?: { kind?: any; icon?: ThemeIcon; title: string }; readonly disabled?: boolean; readonly label?: string; + readonly description?: string; readonly keybinding?: ResolvedKeybinding; canPreview?: boolean | undefined; readonly hideIcon?: boolean; + readonly tooltip?: string; } interface IActionMenuTemplateData { readonly container: HTMLElement; readonly icon: HTMLElement; readonly text: HTMLElement; + readonly description?: HTMLElement; readonly keybinding: KeybindingLabel; } @@ -71,7 +75,7 @@ class HeaderRenderer implements IListRenderer, IHeaderTemp } renderElement(element: IActionListItem, _index: number, templateData: IHeaderTemplateData): void { - templateData.text.textContent = element.group?.title ?? ''; + templateData.text.textContent = element.group?.title ?? element.label ?? ''; } disposeTemplate(_templateData: IHeaderTemplateData): void { @@ -99,9 +103,13 @@ class ActionItemRenderer implements IListRenderer, IAction text.className = 'title'; container.append(text); + const description = document.createElement('span'); + description.className = 'description'; + container.append(description); + const keybinding = new KeybindingLabel(container, OS); - return { container, icon, text, keybinding }; + return { container, icon, text, description, keybinding }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -123,13 +131,23 @@ class ActionItemRenderer implements IListRenderer, IAction data.text.textContent = stripNewlines(element.label); + if (element.description) { + data.description!.textContent = stripNewlines(element.description); + data.description!.style.display = 'inline'; + } else { + data.description!.textContent = ''; + data.description!.style.display = 'none'; + } + data.keybinding.set(element.keybinding); dom.setVisibility(!!element.keybinding, data.keybinding.element); const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel(); const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel(); data.container.classList.toggle('option-disabled', element.disabled); - if (element.disabled) { + if (element.tooltip) { + data.container.title = element.tooltip; + } else if (element.disabled) { data.container.title = element.label; } else if (actionTitle && previewTitle) { if (this._supportsPreview && element.canPreview) { @@ -182,10 +200,10 @@ export class ActionList extends Disposable { items: readonly IActionListItem[], private readonly _delegate: IActionListDelegate, @IContextViewService private readonly _contextViewService: IContextViewService, - @IKeybindingService private readonly _keybindingService: IKeybindingService + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @ILayoutService private readonly _layoutService: ILayoutService, ) { super(); - this.domNode = document.createElement('div'); this.domNode.classList.add('actionList'); const virtualDelegate: IListVirtualDelegate> = { @@ -270,7 +288,7 @@ export class ActionList extends Disposable { } const maxVhPrecentage = 0.7; - const height = Math.min(heightWithHeaders, this.domNode.ownerDocument.body.clientHeight * maxVhPrecentage); + const height = Math.min(heightWithHeaders, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage); this._list.layout(height, maxWidth); this.domNode.style.height = `${height}px`; diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index d205c7ab791..9f454528d91 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -168,3 +168,9 @@ /* The important gives this rule precedence over the hover rule. */ background: var(--vscode-actionBar-toggledBackground) !important; } + +.action-widget .monaco-list .monaco-list-row .description { + opacity: 0.7; + margin-left: 0.5em; + font-size: 0.9em; +} diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 450126a0f27..43179ddc230 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -18,6 +18,7 @@ import { InstantiationType, registerSingleton } from '../../instantiation/common import { createDecorator, IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../keybinding/common/keybindingsRegistry.js'; import { inputActiveOptionBackground, registerColor } from '../../theme/common/colorRegistry.js'; +import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; registerColor( 'actionBar.toggledBackground', @@ -34,7 +35,7 @@ export const IActionWidgetService = createDecorator('actio export interface IActionWidgetService { readonly _serviceBrand: undefined; - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[]): void; + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[]): void; hide(didCancel?: boolean): void; @@ -58,7 +59,7 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { super(); } - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[]): void { + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[]): void { const visibleContext = ActionWidgetContextKeys.Visible.bindTo(this._contextKeyService); const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate); diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts new file mode 100644 index 00000000000..ce519ebe9be --- /dev/null +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IActionWidgetService } from './actionWidget.js'; +import { IAction } from '../../../base/common/actions.js'; +import { BaseDropdown, IActionProvider, IBaseDropdownOptions } from '../../../base/browser/ui/dropdown/dropdown.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from './actionList.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { getActiveElement, isHTMLElement } from '../../../base/browser/dom.js'; +import { IKeybindingService } from '../../keybinding/common/keybinding.js'; + +export interface IActionWidgetDropdownAction extends IAction { + category?: { label: string; order: number }; + description?: string; +} + +// TODO @lramos15 - Should we just make IActionProvider templated? +export interface IActionWidgetDropdownActionProvider { + getActions(): IActionWidgetDropdownAction[]; +} + +export interface IActionWidgetDropdownOptions extends IBaseDropdownOptions { + // These are the actions that are shown in the action widget split up by category + readonly actions?: IActionWidgetDropdownAction[]; + readonly actionProvider?: IActionWidgetDropdownActionProvider; + + // These actions are those shown at the bottom of the action widget + readonly actionBarActions?: IAction[]; + readonly actionBarActionProvider?: IActionProvider; +} + +/** + * Action widget dropdown is a dropdown that uses the action widget under the hood to simulate a native dropdown menu + * The benefits of this include non native features such as headers, descriptions, icons, and button bar + */ +export class ActionWidgetDropdown extends BaseDropdown { + constructor( + container: HTMLElement, + private readonly _options: IActionWidgetDropdownOptions, + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + ) { + super(container, _options); + } + + override show(): void { + const actionBarActions = this._options.actionBarActions ?? this._options.actionBarActionProvider?.getActions() ?? []; + const actions = this._options.actions ?? this._options.actionProvider?.getActions() ?? []; + const actionWidgetItems: IActionListItem[] = []; + + const actionsByCategory = new Map(); + for (const action of actions) { + let category = action.category; + if (!category) { + category = { label: '', order: Number.MAX_SAFE_INTEGER }; + } + if (!actionsByCategory.has(category.label)) { + actionsByCategory.set(category.label, []); + } + actionsByCategory.get(category.label)!.push(action); + } + + // Sort categories by order + const sortedCategories = Array.from(actionsByCategory.entries()) + .sort((a, b) => { + const aOrder = a[1][0]?.category?.order ?? Number.MAX_SAFE_INTEGER; + const bOrder = b[1][0]?.category?.order ?? Number.MAX_SAFE_INTEGER; + return aOrder - bOrder; + }); + + for (const [categoryLabel, categoryActions] of sortedCategories) { + + if (categoryLabel) { + // Push headers for each category + actionWidgetItems.push({ + label: categoryLabel, + kind: ActionListItemKind.Header, + canPreview: false, + disabled: false, + hideIcon: false, + }); + } + // Push actions for each category + for (const action of categoryActions) { + actionWidgetItems.push({ + item: action, + tooltip: action.tooltip, + description: action.description, + kind: ActionListItemKind.Action, + canPreview: false, + group: { title: '', icon: ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, + disabled: false, + hideIcon: false, + label: action.label, + keybinding: this.keybindingService.lookupKeybinding(action.id) + }); + } + } + + const previouslyFocusedElement = getActiveElement(); + + const actionWidgetDelegate: IActionListDelegate = { + onSelect: (action, preview) => { + action.run(); + this.actionWidgetService.hide(); + }, + onHide: () => { + if (isHTMLElement(previouslyFocusedElement)) { + previouslyFocusedElement.focus(); + } + } + }; + + this.actionWidgetService.show( + this._options.label ?? '', + false, + actionWidgetItems, + actionWidgetDelegate, + this.element, + undefined, + actionBarActions + ); + } +} diff --git a/src/vs/platform/actions/browser/actionWidgetDropdownActionViewItem.ts b/src/vs/platform/actions/browser/actionWidgetDropdownActionViewItem.ts new file mode 100644 index 00000000000..bd0d57848a8 --- /dev/null +++ b/src/vs/platform/actions/browser/actionWidgetDropdownActionViewItem.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append } from '../../../base/browser/dom.js'; +import { BaseActionViewItem } from '../../../base/browser/ui/actionbar/actionViewItems.js'; +import { ILabelRenderer } from '../../../base/browser/ui/dropdown/dropdown.js'; +import { getBaseLayerHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegate2.js'; +import { getDefaultHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IAction } from '../../../base/common/actions.js'; +import { IDisposable } from '../../../base/common/lifecycle.js'; +import { IActionWidgetService } from '../../actionWidget/browser/actionWidget.js'; +import { ActionWidgetDropdown, IActionWidgetDropdownOptions } from '../../actionWidget/browser/actionWidgetDropdown.js'; +import { IContextKeyService } from '../../contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../keybinding/common/keybinding.js'; + +/** + * Action view item for the custom action widget dropdown widget. + * Very closely based off of `DropdownMenuActionViewItem`, would be good to have some code re-use in the future + */ +export class ActionWidgetDropdownActionViewItem extends BaseActionViewItem { + private actionWidgetDropdown: ActionWidgetDropdown | undefined; + private actionItem: HTMLElement | null = null; + constructor( + action: IAction, + private readonly actionWidgetOptions: Omit, + @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + ) { + super(undefined, action); + } + + override render(container: HTMLElement): void { + this.actionItem = container; + + const labelRenderer: ILabelRenderer = (el: HTMLElement): IDisposable | null => { + this.element = append(el, $('a.action-label')); + return this.renderLabel(this.element); + }; + + this.actionWidgetDropdown = this._register(new ActionWidgetDropdown(container, { ...this.actionWidgetOptions, labelRenderer }, this._actionWidgetService, this._keybindingService)); + this._register(this.actionWidgetDropdown.onDidChangeVisibility(visible => { + this.element?.setAttribute('aria-expanded', `${visible}`); + })); + + this.updateTooltip(); + this.updateEnabled(); + } + + protected renderLabel(element: HTMLElement): IDisposable | null { + // todo@aeschli: remove codicon, should come through `this.options.classNames` + element.classList.add('codicon'); + + if (this._action.label) { + this._register(getBaseLayerHoverDelegate().setupManagedHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('mouse'), element, this._action.label)); + } + + return null; + } + + protected setAriaLabelAttributes(element: HTMLElement): void { + element.setAttribute('role', 'button'); + element.setAttribute('aria-haspopup', 'true'); + element.setAttribute('aria-expanded', 'false'); + element.ariaLabel = this._action.label || ''; + } + + protected override getTooltip() { + const keybinding = this._keybindingService.lookupKeybinding(this.action.id, this._contextKeyService); + const keybindingLabel = keybinding && keybinding.getLabel(); + + const tooltip = this.action.tooltip ?? this.action.label; + return keybindingLabel + ? `${tooltip} (${keybindingLabel})` + : tooltip; + } + + show(): void { + this.actionWidgetDropdown?.show(); + } + + protected override updateEnabled(): void { + const disabled = !this.action.enabled; + this.actionItem?.classList.toggle('disabled', disabled); + this.element?.classList.toggle('disabled', disabled); + } + +} diff --git a/src/vs/platform/actions/browser/buttonbar.ts b/src/vs/platform/actions/browser/buttonbar.ts index e477d3ddbdd..1e29718f04a 100644 --- a/src/vs/platform/actions/browser/buttonbar.ts +++ b/src/vs/platform/actions/browser/buttonbar.ts @@ -85,7 +85,13 @@ export class WorkbenchButtonBar extends ButtonBar { const actionOrSubmenu = actions[i]; let action: IAction; let btn: IButton; - + let tooltip: string = ''; + const kb = actionOrSubmenu instanceof SubmenuAction ? '' : this._keybindingService.lookupKeybinding(actionOrSubmenu.id); + if (kb) { + tooltip = localize('labelWithKeybinding', "{0} ({1})", actionOrSubmenu.tooltip || actionOrSubmenu.label, kb.getLabel()); + } else { + tooltip = actionOrSubmenu.tooltip || actionOrSubmenu.label; + } if (actionOrSubmenu instanceof SubmenuAction && actionOrSubmenu.actions.length > 0) { const [first, ...rest] = actionOrSubmenu.actions; action = first; @@ -94,14 +100,14 @@ export class WorkbenchButtonBar extends ButtonBar { actionRunner: this._actionRunner, actions: rest, contextMenuProvider: this._contextMenuService, - ariaLabel: action.label, + ariaLabel: tooltip, supportIcons: true, }); } else { action = actionOrSubmenu; btn = this.addButton({ secondary: conifgProvider(action, i)?.isSecondary ?? secondary, - ariaLabel: action.label, + ariaLabel: tooltip, supportIcons: true, }); } @@ -128,13 +134,7 @@ export class WorkbenchButtonBar extends ButtonBar { btn.element.classList.add(...action.class.split(' ')); } } - const kb = this._keybindingService.lookupKeybinding(action.id); - let tooltip: string; - if (kb) { - tooltip = localize('labelWithKeybinding', "{0} ({1})", action.tooltip || action.label, kb.getLabel()); - } else { - tooltip = action.tooltip || action.label; - } + this._updateStore.add(this._hoverService.setupManagedHover(hoverDelegate, btn.element, tooltip)); this._updateStore.add(btn.onDidClick(async () => { this._actionRunner.run(action); diff --git a/src/vs/platform/actions/browser/dropdownWithPrimaryActionViewItem.ts b/src/vs/platform/actions/browser/dropdownWithPrimaryActionViewItem.ts index bbdc7e37240..d0884dbcfa9 100644 --- a/src/vs/platform/actions/browser/dropdownWithPrimaryActionViewItem.ts +++ b/src/vs/platform/actions/browser/dropdownWithPrimaryActionViewItem.ts @@ -92,6 +92,9 @@ export class DropdownWithPrimaryActionViewItem extends BaseActionViewItem { this._dropdownContainer = DOM.$('.dropdown-action-container'); this._dropdown.render(DOM.append(this._container, this._dropdownContainer)); this._register(DOM.addDisposableListener(primaryContainer, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (!this.action.enabled) { + return; + } const event = new StandardKeyboardEvent(e); if (event.equals(KeyCode.RightArrow)) { this._primaryAction.element!.tabIndex = -1; @@ -100,6 +103,9 @@ export class DropdownWithPrimaryActionViewItem extends BaseActionViewItem { } })); this._register(DOM.addDisposableListener(this._dropdownContainer, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (!this.action.enabled) { + return; + } const event = new StandardKeyboardEvent(e); if (event.equals(KeyCode.LeftArrow)) { this._primaryAction.element!.tabIndex = 0; diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index eee25b6d57a..c143e4ee1e2 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -60,6 +60,7 @@ export class MenuId { static readonly DebugWatchContext = new MenuId('DebugWatchContext'); static readonly DebugToolBar = new MenuId('DebugToolBar'); static readonly DebugToolBarStop = new MenuId('DebugToolBarStop'); + static readonly DebugDisassemblyContext = new MenuId('DebugDisassemblyContext'); static readonly DebugCallStackToolbar = new MenuId('DebugCallStackToolbar'); static readonly DebugCreateConfiguration = new MenuId('DebugCreateConfiguration'); static readonly EditorContext = new MenuId('EditorContext'); @@ -127,6 +128,7 @@ export class MenuId { static readonly SCMHistoryItemContext = new MenuId('SCMHistoryItemContext'); static readonly SCMHistoryItemHover = new MenuId('SCMHistoryItemHover'); static readonly SCMHistoryItemRefContext = new MenuId('SCMHistoryItemRefContext'); + static readonly SCMQuickDiffDecorations = new MenuId('SCMQuickDiffDecorations'); static readonly SCMTitle = new MenuId('SCMTitle'); static readonly SearchContext = new MenuId('SearchContext'); static readonly SearchActionMenu = new MenuId('SearchActionContext'); @@ -602,7 +604,7 @@ interface IBaseAction2Options extends IAction2CommonOptions { f1?: false; } -interface ICommandPaletteOptions extends IAction2CommonOptions { +export interface ICommandPaletteOptions extends IAction2CommonOptions { /** * The title of the command that will be displayed in the command palette after the category. diff --git a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows.ts b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows.ts index 7384c57f4f4..a67d76690ab 100644 --- a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows.ts +++ b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows.ts @@ -17,6 +17,7 @@ export interface IAuxiliaryWindowsMainService { readonly onDidMaximizeWindow: Event; readonly onDidUnmaximizeWindow: Event; readonly onDidChangeFullScreen: Event<{ window: IAuxiliaryWindow; fullscreen: boolean }>; + readonly onDidChangeAlwaysOnTop: Event<{ window: IAuxiliaryWindow; alwaysOnTop: boolean }>; readonly onDidTriggerSystemContextMenu: Event<{ readonly window: IAuxiliaryWindow; readonly x: number; readonly y: number }>; createWindow(details: HandlerDetails): BrowserWindowConstructorOptions; diff --git a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.ts b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.ts index 3ef1cb28108..12754b8b00a 100644 --- a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.ts +++ b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.ts @@ -28,6 +28,9 @@ export class AuxiliaryWindowsMainService extends Disposable implements IAuxiliar private readonly _onDidChangeFullScreen = this._register(new Emitter<{ window: IAuxiliaryWindow; fullscreen: boolean }>()); readonly onDidChangeFullScreen = this._onDidChangeFullScreen.event; + private readonly _onDidChangeAlwaysOnTop = this._register(new Emitter<{ window: IAuxiliaryWindow; alwaysOnTop: boolean }>()); + readonly onDidChangeAlwaysOnTop = this._onDidChangeAlwaysOnTop.event; + private readonly _onDidTriggerSystemContextMenu = this._register(new Emitter<{ window: IAuxiliaryWindow; x: number; y: number }>()); readonly onDidTriggerSystemContextMenu = this._onDidTriggerSystemContextMenu.event; @@ -126,6 +129,9 @@ export class AuxiliaryWindowsMainService extends Disposable implements IAuxiliar case 'window-native-titlebar': overrides.forceNativeTitlebar = true; break; + case 'window-always-on-top': + overrides.alwaysOnTop = true; + break; } } @@ -148,6 +154,7 @@ export class AuxiliaryWindowsMainService extends Disposable implements IAuxiliar disposables.add(auxiliaryWindow.onDidUnmaximize(() => this._onDidUnmaximizeWindow.fire(auxiliaryWindow))); disposables.add(auxiliaryWindow.onDidEnterFullScreen(() => this._onDidChangeFullScreen.fire({ window: auxiliaryWindow, fullscreen: true }))); disposables.add(auxiliaryWindow.onDidLeaveFullScreen(() => this._onDidChangeFullScreen.fire({ window: auxiliaryWindow, fullscreen: false }))); + disposables.add(auxiliaryWindow.onDidChangeAlwaysOnTop(alwaysOnTop => this._onDidChangeAlwaysOnTop.fire({ window: auxiliaryWindow, alwaysOnTop }))); disposables.add(auxiliaryWindow.onDidTriggerSystemContextMenu(({ x, y }) => this._onDidTriggerSystemContextMenu.fire({ window: auxiliaryWindow, x, y }))); Event.once(auxiliaryWindow.onDidClose)(() => disposables.dispose()); diff --git a/src/vs/platform/clipboard/browser/clipboardService.ts b/src/vs/platform/clipboard/browser/clipboardService.ts index 787d3dac4f8..2167555b24e 100644 --- a/src/vs/platform/clipboard/browser/clipboardService.ts +++ b/src/vs/platform/clipboard/browser/clipboardService.ts @@ -45,6 +45,10 @@ export class BrowserClipboardService extends Disposable implements IClipboardSer }, { window: mainWindow, disposables: this._store })); } + triggerPaste(): Promise | undefined { + return undefined; + } + async readImage(): Promise { try { const clipboardItems = await navigator.clipboard.read(); diff --git a/src/vs/platform/clipboard/common/clipboardService.ts b/src/vs/platform/clipboard/common/clipboardService.ts index cef3ff82105..be69b5219aa 100644 --- a/src/vs/platform/clipboard/common/clipboardService.ts +++ b/src/vs/platform/clipboard/common/clipboardService.ts @@ -12,6 +12,11 @@ export interface IClipboardService { readonly _serviceBrand: undefined; + /** + * Trigger the paste. Returns undefined if the paste was not triggered or a promise that resolves on paste end. + */ + triggerPaste(targetWindowId: number): Promise | undefined; + /** * Writes text to the system clipboard. */ diff --git a/src/vs/platform/clipboard/test/common/testClipboardService.ts b/src/vs/platform/clipboard/test/common/testClipboardService.ts index 23a20010dd3..261755d0d61 100644 --- a/src/vs/platform/clipboard/test/common/testClipboardService.ts +++ b/src/vs/platform/clipboard/test/common/testClipboardService.ts @@ -15,6 +15,10 @@ export class TestClipboardService implements IClipboardService { private text: string | undefined = undefined; + triggerPaste(): Promise | undefined { + return Promise.resolve(); + } + async writeText(text: string, type?: string): Promise { this.text = text; } diff --git a/src/vs/platform/configuration/common/configurationModels.ts b/src/vs/platform/configuration/common/configurationModels.ts index 4e220cc465d..edf1a427527 100644 --- a/src/vs/platform/configuration/common/configurationModels.ts +++ b/src/vs/platform/configuration/common/configurationModels.ts @@ -727,7 +727,7 @@ export class Configuration { } getValue(section: string | undefined, overrides: IConfigurationOverrides, workspace: Workspace | undefined): any { - const consolidateConfigurationModel = this.getConsolidatedConfigurationModel(overrides, workspace); + const consolidateConfigurationModel = this.getConsolidatedConfigurationModel(section, overrides, workspace); return consolidateConfigurationModel.getValue(section); } @@ -755,7 +755,7 @@ export class Configuration { } inspect(key: string, overrides: IConfigurationOverrides, workspace: Workspace | undefined): IConfigurationValue { - const consolidateConfigurationModel = this.getConsolidatedConfigurationModel(overrides, workspace); + const consolidateConfigurationModel = this.getConsolidatedConfigurationModel(key, overrides, workspace); const folderConfigurationModel = this.getFolderConfigurationModelForResource(overrides.resource, workspace); const memoryConfigurationModel = overrides.resource ? this._memoryConfigurationByResource.get(overrides.resource) || this._memoryConfiguration : this._memoryConfiguration; const overrideIdentifiers = new Set(); @@ -970,12 +970,12 @@ export class Configuration { return this._folderConfigurations; } - private getConsolidatedConfigurationModel(overrides: IConfigurationOverrides, workspace: Workspace | undefined): ConfigurationModel { + private getConsolidatedConfigurationModel(section: string | undefined, overrides: IConfigurationOverrides, workspace: Workspace | undefined): ConfigurationModel { let configurationModel = this.getConsolidatedConfigurationModelForResource(overrides, workspace); if (overrides.overrideIdentifier) { configurationModel = configurationModel.override(overrides.overrideIdentifier); } - if (!this._policyConfiguration.isEmpty()) { + if (!this._policyConfiguration.isEmpty() && this._policyConfiguration.getValue(section) !== undefined) { // clone by merging configurationModel = configurationModel.merge(); for (const key of this._policyConfiguration.keys) { diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index 70a1c98a370..4bfc7cbf6b8 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -11,8 +11,9 @@ import * as types from '../../../base/common/types.js'; import * as nls from '../../../nls.js'; import { getLanguageTagSettingPlainKey } from './configuration.js'; import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../jsonschemas/common/jsonContributionRegistry.js'; -import { PolicyName } from '../../policy/common/policy.js'; import { Registry } from '../../registry/common/platform.js'; +import { IPolicy, PolicyName } from '../../../base/common/policy.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; export enum EditPresentationTypes { Multiline = 'multilineText', @@ -35,7 +36,7 @@ export interface IConfigurationRegistry { /** * Register a configuration to the registry. */ - registerConfiguration(configuration: IConfigurationNode): void; + registerConfiguration(configuration: IConfigurationNode): IConfigurationNode; /** * Register multiple configurations to the registry. @@ -155,23 +156,6 @@ export const enum ConfigurationScope { MACHINE_OVERRIDABLE, } -export interface IPolicy { - - /** - * The policy name. - */ - readonly name: PolicyName; - - /** - * The Code version in which this policy was introduced. - */ - readonly minimumVersion: `${number}.${number}`; - - /** - * The policy description (optional). - */ - readonly description?: string; -} export interface IConfigurationPropertySchema extends IJSONSchema { @@ -289,7 +273,7 @@ export const configurationDefaultsSchemaId = 'vscode://schemas/settings/configur const contributionRegistry = Registry.as(JSONExtensions.JSONContribution); -class ConfigurationRegistry implements IConfigurationRegistry { +class ConfigurationRegistry extends Disposable implements IConfigurationRegistry { private readonly registeredConfigurationDefaults: IConfigurationDefaults[] = []; private readonly configurationDefaultsOverrides: Map; @@ -301,13 +285,14 @@ class ConfigurationRegistry implements IConfigurationRegistry { private readonly resourceLanguageSettingsSchema: IJSONSchema; private readonly overrideIdentifiers = new Set(); - private readonly _onDidSchemaChange = new Emitter(); + private readonly _onDidSchemaChange = this._register(new Emitter()); readonly onDidSchemaChange: Event = this._onDidSchemaChange.event; - private readonly _onDidUpdateConfiguration = new Emitter<{ properties: ReadonlySet; defaultsOverrides?: boolean }>(); + private readonly _onDidUpdateConfiguration = this._register(new Emitter<{ properties: ReadonlySet; defaultsOverrides?: boolean }>()); readonly onDidUpdateConfiguration = this._onDidUpdateConfiguration.event; constructor() { + super(); this.configurationDefaultsOverrides = new Map(); this.defaultLanguageConfigurationOverridesNode = { id: 'defaultOverrides', @@ -330,8 +315,9 @@ class ConfigurationRegistry implements IConfigurationRegistry { this.registerOverridePropertyPatternKey(); } - public registerConfiguration(configuration: IConfigurationNode, validate: boolean = true): void { + public registerConfiguration(configuration: IConfigurationNode, validate: boolean = true): IConfigurationNode { this.registerConfigurations([configuration], validate); + return configuration; } public registerConfigurations(configurations: IConfigurationNode[], validate: boolean = true): void { @@ -671,25 +657,28 @@ class ConfigurationRegistry implements IConfigurationRegistry { property.restricted = types.isUndefinedOrNull(property.restricted) ? !!restrictedProperties?.includes(key) : property.restricted; } - // Add to properties maps - // Property is included by default if 'included' is unspecified - if (properties[key].hasOwnProperty('included') && !properties[key].included) { + const excluded = properties[key].hasOwnProperty('included') && !properties[key].included; + const policyName = properties[key].policy?.name; + + if (excluded) { this.excludedConfigurationProperties[key] = properties[key]; + if (policyName) { + this.policyConfigurations.set(policyName, key); + bucket.add(key); + } delete properties[key]; - continue; } else { + bucket.add(key); + if (policyName) { + this.policyConfigurations.set(policyName, key); + } this.configurationProperties[key] = properties[key]; - if (properties[key].policy?.name) { - this.policyConfigurations.set(properties[key].policy!.name, key); + if (!properties[key].deprecationMessage && properties[key].markdownDeprecationMessage) { + // If not set, default deprecationMessage to the markdown source + properties[key].deprecationMessage = properties[key].markdownDeprecationMessage; } } - if (!properties[key].deprecationMessage && properties[key].markdownDeprecationMessage) { - // If not set, default deprecationMessage to the markdown source - properties[key].deprecationMessage = properties[key].markdownDeprecationMessage; - } - - bucket.add(key); } } const subNodes = configuration.allOf; diff --git a/src/vs/platform/configuration/common/configurations.ts b/src/vs/platform/configuration/common/configurations.ts index 785523cd54b..2dea3c6ce98 100644 --- a/src/vs/platform/configuration/common/configurations.ts +++ b/src/vs/platform/configuration/common/configurations.ts @@ -7,28 +7,30 @@ import { coalesce } from '../../../base/common/arrays.js'; import { IStringDictionary } from '../../../base/common/collections.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { equals } from '../../../base/common/objects.js'; +import { deepClone, equals } from '../../../base/common/objects.js'; import { isEmptyObject, isString } from '../../../base/common/types.js'; import { ConfigurationModel } from './configurationModels.js'; import { Extensions, IConfigurationRegistry, IRegisteredConfigurationPropertySchema } from './configurationRegistry.js'; import { ILogService, NullLogService } from '../../log/common/log.js'; -import { IPolicyService, PolicyDefinition, PolicyName } from '../../policy/common/policy.js'; +import { IPolicyService, PolicyDefinition } from '../../policy/common/policy.js'; import { Registry } from '../../registry/common/platform.js'; import { getErrorMessage } from '../../../base/common/errors.js'; import * as json from '../../../base/common/json.js'; +import { PolicyName } from '../../../base/common/policy.js'; export class DefaultConfiguration extends Disposable { private readonly _onDidChangeConfiguration = this._register(new Emitter<{ defaults: ConfigurationModel; properties: string[] }>()); readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; - private _configurationModel = ConfigurationModel.createEmptyModel(this.logService); + private _configurationModel: ConfigurationModel; get configurationModel(): ConfigurationModel { return this._configurationModel; } constructor(private readonly logService: ILogService) { super(); + this._configurationModel = ConfigurationModel.createEmptyModel(logService); } async initialize(): Promise { @@ -65,7 +67,7 @@ export class DefaultConfiguration extends Disposable { if (defaultOverrideValue !== undefined) { this._configurationModel.setValue(key, defaultOverrideValue); } else if (propertySchema) { - this._configurationModel.setValue(key, propertySchema.default); + this._configurationModel.setValue(key, deepClone(propertySchema.default)); } else { this._configurationModel.removeValue(key); } @@ -91,7 +93,9 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat private readonly _onDidChangeConfiguration = this._register(new Emitter()); readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; - private _configurationModel = ConfigurationModel.createEmptyModel(this.logService); + private readonly configurationRegistry: IConfigurationRegistry; + + private _configurationModel: ConfigurationModel; get configurationModel() { return this._configurationModel; } constructor( @@ -100,11 +104,15 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat @ILogService private readonly logService: ILogService ) { super(); + this._configurationModel = ConfigurationModel.createEmptyModel(this.logService); + this.configurationRegistry = Registry.as(Extensions.Configuration); } async initialize(): Promise { this.logService.trace('PolicyConfiguration#initialize'); + this.update(await this.updatePolicyDefinitions(this.defaultConfiguration.configurationModel.keys), false); + this.update(await this.updatePolicyDefinitions(Object.keys(this.configurationRegistry.getExcludedConfigurationProperties())), false); this._register(this.policyService.onDidChange(policyNames => this.onDidChangePolicies(policyNames))); this._register(this.defaultConfiguration.onDidChangeConfiguration(async ({ properties }) => this.update(await this.updatePolicyDefinitions(properties), true))); return this._configurationModel; @@ -114,10 +122,11 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat this.logService.trace('PolicyConfiguration#updatePolicyDefinitions', properties); const policyDefinitions: IStringDictionary = {}; const keys: string[] = []; - const configurationProperties = Registry.as(Extensions.Configuration).getConfigurationProperties(); + const configurationProperties = this.configurationRegistry.getConfigurationProperties(); + const excludedConfigurationProperties = this.configurationRegistry.getExcludedConfigurationProperties(); for (const key of properties) { - const config = configurationProperties[key]; + const config = configurationProperties[key] ?? excludedConfigurationProperties[key]; if (!config) { // Config is removed. So add it to the list if in case it was registered as policy before keys.push(key); @@ -128,8 +137,13 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat this.logService.warn(`Policy ${config.policy.name} has unsupported type ${config.type}`); continue; } + const { defaultValue, previewFeature } = config.policy; keys.push(key); - policyDefinitions[config.policy.name] = { type: config.type === 'number' ? 'number' : config.type === 'boolean' ? 'boolean' : 'string' }; + policyDefinitions[config.policy.name] = { + type: config.type === 'number' ? 'number' : config.type === 'boolean' ? 'boolean' : 'string', + previewFeature, + defaultValue, + }; } } @@ -142,19 +156,20 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat private onDidChangePolicies(policyNames: readonly PolicyName[]): void { this.logService.trace('PolicyConfiguration#onDidChangePolicies', policyNames); - const policyConfigurations = Registry.as(Extensions.Configuration).getPolicyConfigurations(); + const policyConfigurations = this.configurationRegistry.getPolicyConfigurations(); const keys = coalesce(policyNames.map(policyName => policyConfigurations.get(policyName))); this.update(keys, true); } private update(keys: string[], trigger: boolean): void { this.logService.trace('PolicyConfiguration#update', keys); - const configurationProperties = Registry.as(Extensions.Configuration).getConfigurationProperties(); + const configurationProperties = this.configurationRegistry.getConfigurationProperties(); + const excludedConfigurationProperties = this.configurationRegistry.getExcludedConfigurationProperties(); const changed: [string, any][] = []; const wasEmpty = this._configurationModel.isEmpty(); for (const key of keys) { - const proprety = configurationProperties[key]; + const proprety = configurationProperties[key] ?? excludedConfigurationProperties[key]; const policyName = proprety?.policy?.name; if (policyName) { let policyValue = this.policyService.getPolicyValue(policyName); diff --git a/src/vs/platform/configuration/test/common/policyConfiguration.test.ts b/src/vs/platform/configuration/test/common/policyConfiguration.test.ts index ee69975cd24..ac30e8b5050 100644 --- a/src/vs/platform/configuration/test/common/policyConfiguration.test.ts +++ b/src/vs/platform/configuration/test/common/policyConfiguration.test.ts @@ -66,6 +66,23 @@ suite('PolicyConfiguration', () => { minimumVersion: '1.0.0', } }, + 'policy.booleanSetting': { + 'type': 'boolean', + 'default': true, + policy: { + name: 'PolicyBooleanSetting', + minimumVersion: '1.0.0', + } + }, + 'policy.internalSetting': { + 'type': 'string', + 'default': 'defaultInternalValue', + included: false, + policy: { + name: 'PolicyInternalSetting', + minimumVersion: '1.0.0', + } + }, 'nonPolicy.setting': { 'type': 'boolean', 'default': true @@ -150,6 +167,24 @@ suite('PolicyConfiguration', () => { assert.deepStrictEqual(acutal.getValue('policy.arraySetting'), [1]); }); + test('initialize: with boolean type policy as false', async () => { + await fileService.writeFile(policyFile, VSBuffer.fromString(JSON.stringify({ 'PolicyBooleanSetting': false }))); + + await testObject.initialize(); + const acutal = testObject.configurationModel; + + assert.deepStrictEqual(acutal.getValue('policy.booleanSetting'), false); + }); + + test('initialize: with boolean type policy as true', async () => { + await fileService.writeFile(policyFile, VSBuffer.fromString(JSON.stringify({ 'PolicyBooleanSetting': true }))); + + await testObject.initialize(); + const acutal = testObject.configurationModel; + + assert.deepStrictEqual(acutal.getValue('policy.booleanSetting'), true); + }); + test('initialize: with object type policy ignores policy if value is not valid', async () => { await fileService.writeFile(policyFile, VSBuffer.fromString(JSON.stringify({ 'PolicyObjectSetting': '{"a": "b", "hello": }' }))); @@ -263,4 +298,18 @@ suite('PolicyConfiguration', () => { assert.deepStrictEqual(acutal.overrides, []); }); + test('initialize: with internal policies', async () => { + await fileService.writeFile(policyFile, VSBuffer.fromString(JSON.stringify({ 'PolicyInternalSetting': 'internalValue' }))); + + await testObject.initialize(); + const acutal = testObject.configurationModel; + + assert.strictEqual(acutal.getValue('policy.settingA'), undefined); + assert.strictEqual(acutal.getValue('policy.settingB'), undefined); + assert.strictEqual(acutal.getValue('policy.internalSetting'), 'internalValue'); + assert.strictEqual(acutal.getValue('nonPolicy.setting'), undefined); + assert.deepStrictEqual(acutal.keys, ['policy.internalSetting']); + assert.deepStrictEqual(acutal.overrides, []); + }); + }); diff --git a/src/vs/platform/contextview/browser/contextMenuHandler.ts b/src/vs/platform/contextview/browser/contextMenuHandler.ts index 03666b22807..b0f447fd1d3 100644 --- a/src/vs/platform/contextview/browser/contextMenuHandler.ts +++ b/src/vs/platform/contextview/browser/contextMenuHandler.ts @@ -55,7 +55,7 @@ export class ContextMenuHandler { canRelayout: false, anchorAlignment: delegate.anchorAlignment, anchorAxisAlignment: delegate.anchorAxisAlignment, - + layer: delegate.layer, render: (container) => { this.lastContainer = container; const className = delegate.getMenuClassName ? delegate.getMenuClassName() : ''; diff --git a/src/vs/platform/cssDev/node/cssDevService.ts b/src/vs/platform/cssDev/node/cssDevService.ts index 5aa1efa6e39..5bbc93b3b28 100644 --- a/src/vs/platform/cssDev/node/cssDevService.ts +++ b/src/vs/platform/cssDev/node/cssDevService.ts @@ -49,21 +49,23 @@ export class CSSDevelopmentService implements ICSSDevelopmentService { const sw = StopWatch.create(); - const chunks: string[][] = []; - const decoder = new TextDecoder(); + const chunks: Buffer[] = []; const basePath = FileAccess.asFileUri('').fsPath; const process = spawn(rg.rgPath, ['-g', '**/*.css', '--files', '--no-ignore', basePath], {}); process.stdout.on('data', data => { - const chunk = decoder.decode(data, { stream: true }); - chunks.push(chunk.split('\n').filter(Boolean)); + chunks.push(data); }); process.on('error', err => { this.logService.error('[CSS_DEV] FAILED to compute CSS data', err); resolve([]); }); process.on('close', () => { - const result = chunks.flat().map(path => relative(basePath, path).replace(/\\/g, '/')).filter(Boolean).sort(); + const data = Buffer.concat(chunks).toString('utf8'); + const result = data.split('\n').filter(Boolean).map(path => relative(basePath, path).replace(/\\/g, '/')).filter(Boolean).sort(); + if (result.some(path => path.indexOf('vs/') !== 0)) { + this.logService.error(`[CSS_DEV] Detected invalid paths in css modules, raw output: ${data}`); + } resolve(result); this.logService.info(`[CSS_DEV] DONE, ${result.length} css modules (${Math.round(sw.elapsed())}ms)`); }); diff --git a/src/vs/platform/diagnostics/electron-main/diagnosticsMainService.ts b/src/vs/platform/diagnostics/electron-main/diagnosticsMainService.ts index 39ef93b4509..6a9e1fc437a 100644 --- a/src/vs/platform/diagnostics/electron-main/diagnosticsMainService.ts +++ b/src/vs/platform/diagnostics/electron-main/diagnosticsMainService.ts @@ -10,7 +10,7 @@ import { URI } from '../../../base/common/uri.js'; import { IDiagnosticInfo, IDiagnosticInfoOptions, IMainProcessDiagnostics, IProcessDiagnostics, IRemoteDiagnosticError, IRemoteDiagnosticInfo, IWindowDiagnostics } from '../common/diagnostics.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ICodeWindow } from '../../window/electron-main/window.js'; -import { IWindowsMainService } from '../../windows/electron-main/windows.js'; +import { getAllWindowsExcludingOffscreen, IWindowsMainService } from '../../windows/electron-main/windows.js'; import { isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { IWorkspacesManagementMainService } from '../../workspaces/electron-main/workspacesManagementMainService.js'; import { assertIsDefined } from '../../../base/common/types.js'; @@ -80,7 +80,7 @@ export class DiagnosticsMainService implements IDiagnosticsMainService { this.logService.trace('Received request for main process info from other instance.'); const windows: IWindowDiagnostics[] = []; - for (const window of BrowserWindow.getAllWindows()) { + for (const window of getAllWindowsExcludingOffscreen()) { const codeWindow = this.windowsMainService.getWindowById(window.id); if (codeWindow) { windows.push(await this.codeWindowToInfo(codeWindow)); diff --git a/src/vs/platform/dialogs/common/dialogs.ts b/src/vs/platform/dialogs/common/dialogs.ts index bb9270b9c28..b0420f5c5a8 100644 --- a/src/vs/platform/dialogs/common/dialogs.ts +++ b/src/vs/platform/dialogs/common/dialogs.ts @@ -283,7 +283,8 @@ export interface ICustomDialogOptions { export interface ICustomDialogMarkdown { readonly markdown: IMarkdownString; readonly classes?: string[]; - readonly dismissOnLinkClick?: boolean; + /** Custom link handler for markdown content, see {@link IContentActionHandler}. Defaults to {@link openLinkFromMarkdown}. */ + actionHandler?(link: string): Promise; } /** diff --git a/src/vs/platform/dnd/browser/dnd.ts b/src/vs/platform/dnd/browser/dnd.ts index f00c4d08a73..d59b32bd10d 100644 --- a/src/vs/platform/dnd/browser/dnd.ts +++ b/src/vs/platform/dnd/browser/dnd.ts @@ -33,6 +33,7 @@ export const CodeDataTransfers = { FILES: 'CodeFiles', SYMBOLS: 'application/vnd.code.symbols', MARKERS: 'application/vnd.code.diagnostics', + NOTEBOOK_CELL_OUTPUT: 'notebook-cell-output', }; export interface IDraggedResourceEditorInput extends IBaseTextResourceEditorInput { @@ -416,6 +417,10 @@ export interface DocumentSymbolTransferData { kind: number; } +export interface NotebookCellOutputTransferData { + outputId: string; +} + function setDataAsJSON(e: DragEvent, kind: string, data: unknown) { e.dataTransfer?.setData(kind, JSON.stringify(data)); } @@ -451,6 +456,10 @@ export function fillInMarkersDragData(markerData: MarkerTransferData[], e: DragE setDataAsJSON(e, CodeDataTransfers.MARKERS, markerData); } +export function extractNotebookCellOutputDropData(e: DragEvent): NotebookCellOutputTransferData | undefined { + return getDataAsJSON(e, CodeDataTransfers.NOTEBOOK_CELL_OUTPUT, undefined); +} + /** * A helper to get access to Electrons `webUtils.getPathForFile` function * in a safe way without crashing the application when running in the web. diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index 0138721fb1d..7c856237f67 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -250,11 +250,6 @@ export interface IEditorOptions { */ inactive?: boolean; - /** - * Will not show an error in case opening the editor fails and thus allows to show a custom error - * message as needed. By default, an error will be presented as notification if opening was not possible. - */ - /** * In case of an error opening the editor, will not present this error to the user (e.g. by showing * a generic placeholder in the editor area). So it is up to the caller to provide error information @@ -303,6 +298,14 @@ export interface IEditorOptions { * not turn transient. */ transient?: boolean; + + /** + * A hint that the editor should have compact chrome when showing if possible. + * + * Note: this currently is only working if AUX_GROUP is specified as target to + * open the editor in a floating window. + */ + compact?: boolean; } export interface ITextEditorSelection { diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 1efc1b5de45..e4522f6492e 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -121,8 +121,9 @@ export interface NativeParsedArgs { 'profile-temp'?: boolean; 'disable-chromium-sandbox'?: boolean; sandbox?: boolean; - 'enable-coi'?: boolean; + 'unresponsive-sample-interval'?: string; + 'unresponsive-sample-period'?: string; // chromium command line args: https://electronjs.org/docs/all#supported-chrome-command-line-switches 'no-proxy-server'?: boolean; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 0025e4b5973..7ea314341a3 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -104,7 +104,7 @@ export const OPTIONS: OptionDescriptions> = { 'update-extensions': { type: 'boolean', cat: 'e', description: localize('updateExtensions', "Update the installed extensions.") }, 'enable-proposed-api': { type: 'string[]', allowEmptyValue: true, cat: 'e', args: 'ext-id', description: localize('experimentalApis', "Enables proposed API features for extensions. Can receive one or more extension IDs to enable individually.") }, - 'add-mcp': { type: 'string[]', cat: 'o', args: 'json', description: localize('addMcp', "Adds a Model Context Protocol server definition to the user profile, or workspace or folder when used with --mcp-workspace. Accepts JSON input in the form '{\"name\":\"server-name\",\"command\":...}'") }, + 'add-mcp': { type: 'string[]', cat: 'o', args: 'json', description: localize('addMcp', "Adds a Model Context Protocol server definition to the user profile. Accepts JSON input in the form '{\"name\":\"server-name\",\"command\":...}'") }, 'version': { type: 'boolean', cat: 't', alias: 'v', description: localize('version', "Print version.") }, 'verbose': { type: 'boolean', cat: 't', global: true, description: localize('verbose', "Print verbose output (implies --wait).") }, @@ -182,8 +182,9 @@ export const OPTIONS: OptionDescriptions> = { '__enable-file-policy': { type: 'boolean' }, 'editSessionId': { type: 'string' }, 'continueOn': { type: 'string' }, - 'enable-coi': { type: 'boolean' }, + 'unresponsive-sample-interval': { type: 'string' }, + 'unresponsive-sample-period': { type: 'string' }, // chromium flags 'no-proxy-server': { type: 'boolean' }, diff --git a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts index f0f8be2593a..1507e3476ad 100644 --- a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts +++ b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts @@ -98,7 +98,7 @@ export abstract class CommontExtensionManagementService extends Disposable imple abstract installGalleryExtensions(extensions: InstallExtensionInfo[]): Promise; abstract uninstall(extension: ILocalExtension, options?: UninstallOptions): Promise; abstract uninstallExtensions(extensions: UninstallExtensionInfo[]): Promise; - abstract toggleAppliationScope(extension: ILocalExtension, fromProfileLocation: URI): Promise; + abstract toggleApplicationScope(extension: ILocalExtension, fromProfileLocation: URI): Promise; abstract getExtensionsControlManifest(): Promise; abstract resetPinnedStateForAllUserExtensions(pinned: boolean): Promise; abstract registerParticipant(pariticipant: IExtensionManagementParticipant): void; @@ -204,7 +204,7 @@ export abstract class AbstractExtensionManagementService extends CommontExtensio return this.uninstallExtensions([{ extension, options }]); } - async toggleAppliationScope(extension: ILocalExtension, fromProfileLocation: URI): Promise { + async toggleApplicationScope(extension: ILocalExtension, fromProfileLocation: URI): Promise { if (isApplicationScopedExtension(extension.manifest) || extension.isBuiltin) { return extension; } @@ -661,7 +661,7 @@ export abstract class AbstractExtensionManagementService extends CommontExtensio throw new ExtensionManagementError(nls.localize('incompatibleAPI', "Can't install '{0}' extension. {1}", extension.displayName ?? extension.identifier.id, incompatibleApiProposalsMessages[0]), ExtensionManagementErrorCode.IncompatibleApi); } /** If no compatible release version is found, check if the extension has a release version or not and throw relevant error */ - if (!installPreRelease && extension.properties.isPreReleaseVersion && (await this.galleryService.getExtensions([extension.identifier], CancellationToken.None))[0]) { + if (!installPreRelease && extension.hasPreReleaseVersion && extension.properties.isPreReleaseVersion && (await this.galleryService.getExtensions([extension.identifier], CancellationToken.None))[0]) { throw new ExtensionManagementError(nls.localize('notFoundReleaseExtension', "Can't install release version of '{0}' extension because it has no release version.", extension.displayName ?? extension.identifier.id), ExtensionManagementErrorCode.ReleaseVersionNotFound); } throw new ExtensionManagementError(nls.localize('notFoundCompatibleDependency', "Can't install '{0}' extension because it is not compatible with the current version of {1} (version {2}).", extension.identifier.id, this.productService.nameLong, this.productService.version), ExtensionManagementErrorCode.Incompatible); diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts b/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts index e1d36350659..0999bc27635 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts @@ -14,6 +14,8 @@ export const enum ExtensionGalleryResourceType { PublisherViewUri = 'PublisherViewUriTemplate', ExtensionDetailsViewUri = 'ExtensionDetailsViewUriTemplate', ExtensionRatingViewUri = 'ExtensionRatingViewUriTemplate', + ExtensionResourceUri = 'ExtensionResourceUriTemplate', + ContactSupportUri = 'ContactSupportUri', } export const enum Flag { @@ -53,7 +55,12 @@ export interface IExtensionGalleryManifest { readonly flags?: readonly ExtensionQueryCapabilityValue[]; }; readonly signing?: { - readonly allRepositorySigned: boolean; + readonly allPublicRepositorySigned: boolean; + readonly allPrivateRepositorySigned?: boolean; + }; + readonly extensions?: { + readonly includePublicExtensions?: boolean; + readonly includePrivateExtensions?: boolean; }; }; } @@ -67,3 +74,20 @@ export interface IExtensionGalleryManifestService { isEnabled(): boolean; getExtensionGalleryManifest(): Promise; } + +export function getExtensionGalleryManifestResourceUri(manifest: IExtensionGalleryManifest, type: string): string | undefined { + const [name, version] = type.split('/'); + for (const resource of manifest.resources) { + const [r, v] = resource.type.split('/'); + if (r !== name) { + continue; + } + if (!version || v === version) { + return resource.id; + } + break; + } + return undefined; +} + +export const ExtensionGalleryServiceUrlConfigKey = 'extensions.gallery.serviceUrl'; diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts index d608bae653d..70e34e808be 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts @@ -68,15 +68,22 @@ export class ExtensionGalleryManifestService extends Disposable implements IExte if (extensionsGallery.itemUrl) { resources.push({ - id: `${extensionsGallery.itemUrl}/?itemName={publisher}.{name}`, + id: `${extensionsGallery.itemUrl}?itemName={publisher}.{name}`, type: ExtensionGalleryResourceType.ExtensionDetailsViewUri }); resources.push({ - id: `${extensionsGallery.itemUrl}/?itemName={publisher}.{name}&ssr=false#review-details`, + id: `${extensionsGallery.itemUrl}?itemName={publisher}.{name}&ssr=false#review-details`, type: ExtensionGalleryResourceType.ExtensionRatingViewUri }); } + if (extensionsGallery.resourceUrlTemplate) { + resources.push({ + id: extensionsGallery.resourceUrlTemplate, + type: ExtensionGalleryResourceType.ExtensionResourceUri + }); + } + const filtering = [ { name: FilterType.Tag, @@ -216,7 +223,7 @@ export class ExtensionGalleryManifestService extends Disposable implements IExte flags, }, signing: { - allRepositorySigned: true, + allPublicRepositorySigned: true, } } }; diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index 4ff64b25d5f..7dbac890425 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -5,6 +5,7 @@ import { distinct } from '../../../base/common/arrays.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; +import * as semver from '../../../base/common/semver/semver.js'; import { IStringDictionary } from '../../../base/common/collections.js'; import { CancellationError, getErrorMessage, isCancellationError } from '../../../base/common/errors.js'; import { IPager } from '../../../base/common/paging.js'; @@ -15,7 +16,7 @@ import { URI } from '../../../base/common/uri.js'; import { IHeaders, IRequestContext, IRequestOptions, isOfflineError } from '../../../base/parts/request/common/request.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; -import { getTargetPlatform, IExtensionGalleryService, IExtensionIdentifier, IExtensionInfo, IGalleryExtension, IGalleryExtensionAsset, IGalleryExtensionAssets, IGalleryExtensionVersion, InstallOperation, IQueryOptions, IExtensionsControlManifest, isNotWebExtensionInWebTargetPlatform, isTargetPlatformCompatible, ITranslation, SortOrder, StatisticType, toTargetPlatform, WEB_EXTENSION_TAG, IExtensionQueryOptions, IDeprecationInfo, ISearchPrefferedResults, ExtensionGalleryError, ExtensionGalleryErrorCode, IProductVersion, UseUnpkgResourceApiConfigKey, IAllowedExtensionsService, EXTENSION_IDENTIFIER_REGEX, SortBy, FilterType } from './extensionManagement.js'; +import { getTargetPlatform, IExtensionGalleryService, IExtensionIdentifier, IExtensionInfo, IGalleryExtension, IGalleryExtensionAsset, IGalleryExtensionAssets, IGalleryExtensionVersion, InstallOperation, IQueryOptions, IExtensionsControlManifest, isNotWebExtensionInWebTargetPlatform, isTargetPlatformCompatible, ITranslation, SortOrder, StatisticType, toTargetPlatform, WEB_EXTENSION_TAG, IExtensionQueryOptions, IDeprecationInfo, ISearchPrefferedResults, ExtensionGalleryError, ExtensionGalleryErrorCode, IProductVersion, UseUnpkgResourceApiConfigKey, IAllowedExtensionsService, EXTENSION_IDENTIFIER_REGEX, SortBy, FilterType, MaliciousExtensionInfo } from './extensionManagement.js'; import { adoptToGalleryExtensionId, areSameExtensions, getGalleryExtensionId, getGalleryExtensionTelemetryData } from './extensionManagementUtil.js'; import { IExtensionManifest, TargetPlatform } from '../../extensions/common/extensions.js'; import { areApiProposalsCompatible, isEngineValid } from '../../extensions/common/extensionValidator.js'; @@ -29,10 +30,14 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import { format2 } from '../../../base/common/strings.js'; import { IAssignmentService } from '../../assignment/common/assignment.js'; -import { ExtensionGalleryResourceType, Flag, IExtensionGalleryManifest, IExtensionGalleryManifestService } from './extensionGalleryManifest.js'; +import { ExtensionGalleryResourceType, Flag, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifest, IExtensionGalleryManifestService } from './extensionGalleryManifest.js'; +import { TelemetryTrustedValue } from '../../telemetry/common/telemetryUtils.js'; const CURRENT_TARGET_PLATFORM = isWeb ? TargetPlatform.WEB : getTargetPlatform(platform, arch); -const ACTIVITY_HEADER_NAME = 'X-Market-Search-Activity-Id'; +const SEARCH_ACTIVITY_HEADER_NAME = 'X-Market-Search-Activity-Id'; +const ACTIVITY_HEADER_NAME = 'Activityid'; +const SERVER_HEADER_NAME = 'Server'; +const END_END_ID_HEADER_NAME = 'X-Vss-E2eid'; interface IRawGalleryExtensionFile { readonly assetType: string; @@ -65,6 +70,7 @@ interface IRawGalleryExtensionPublisher { readonly publisherName: string; readonly domain?: string | null; readonly isDomainVerified?: boolean; + readonly linkType?: string; } interface IRawGalleryExtension { @@ -81,6 +87,8 @@ interface IRawGalleryExtension { readonly lastUpdated: string; readonly categories: string[] | undefined; readonly flags: string; + readonly linkType?: string; + readonly ratingLinkType?: string; } interface IRawGalleryExtensionsResult { @@ -164,7 +172,7 @@ type GalleryServiceQueryClassification = { readonly sortOrder: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'sort order option passed in the query' }; readonly pageNumber: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'requested page number in the query' }; readonly duration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; 'isMeasurement': true; comment: 'amount of time taken by the query request' }; - readonly success: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'whether the query reques is success or not' }; + readonly success: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'whether the query request is success or not' }; readonly requestBodySize: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'size of the request body' }; readonly responseBodySize?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'size of the response body' }; readonly statusCode?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'status code of the response' }; @@ -172,6 +180,9 @@ type GalleryServiceQueryClassification = { readonly count?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'total number of extensions matching the query' }; readonly source?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'source that requested this query, eg., recommendations, viewlet' }; readonly searchTextLength?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'length of the search text in the query' }; + readonly server?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'server that handled the query' }; + readonly endToEndId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'end to end operation id' }; + readonly activityId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'activity id' }; }; type QueryTelemetryData = { @@ -192,6 +203,9 @@ type GalleryServiceQueryEvent = QueryTelemetryData & { readonly statusCode?: string; readonly errorCode?: string; readonly count?: string; + readonly server?: TelemetryTrustedValue; + readonly endToEndId?: TelemetryTrustedValue; + readonly activityId?: TelemetryTrustedValue; }; type GalleryServiceAdditionalQueryClassification = { @@ -340,6 +354,14 @@ function isPreReleaseVersion(version: IRawGalleryExtensionVersion): boolean { return values.length > 0 && values[0].value === 'true'; } +function hasPreReleaseForExtension(id: string, productService: IProductService): boolean | undefined { + return productService.extensionProperties?.[id.toLowerCase()]?.hasPrereleaseVersion; +} + +function getExcludeVersionRangeForExtension(id: string, productService: IProductService): string | undefined { + return productService.extensionProperties?.[id.toLowerCase()]?.excludeVersionRange; +} + function isPrivateExtension(version: IRawGalleryExtensionVersion): boolean { const values = version.properties ? version.properties.filter(p => p.key === PropertyType.Private) : []; return values.length > 0 && values[0].value === 'true'; @@ -429,24 +451,10 @@ function setTelemetry(extension: IGalleryExtension, index: number, querySource?: "queryActivityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ - extension.telemetryData = { index, querySource, queryActivityId: extension.queryContext?.[ACTIVITY_HEADER_NAME] }; + extension.telemetryData = { index, querySource, queryActivityId: extension.queryContext?.[SEARCH_ACTIVITY_HEADER_NAME] }; } -function getExtensionGalleryManifestResourceUri(manifest: IExtensionGalleryManifest, type: ExtensionGalleryResourceType, version?: string): string | undefined { - for (const resource of manifest.resources) { - const [r, v] = resource.type.split('/'); - if (r !== type) { - continue; - } - if (!version || v === version) { - return resource.id; - } - break; - } - return undefined; -} - -function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGalleryExtensionVersion, allTargetPlatforms: TargetPlatform[], extensionGalleryManifest: IExtensionGalleryManifest, queryContext?: IStringDictionary): IGalleryExtension { +function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGalleryExtensionVersion, allTargetPlatforms: TargetPlatform[], extensionGalleryManifest: IExtensionGalleryManifest, productService: IProductService, queryContext?: IStringDictionary): IGalleryExtension { const latestVersion = galleryExtension.versions[0]; const assets: IGalleryExtensionAssets = { manifest: getVersionAsset(version, AssetType.Manifest), @@ -460,14 +468,15 @@ function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGaller coreTranslations: getCoreTranslationAssets(version) }; - const detailsViewUri = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionDetailsViewUri); - const publisherViewUri = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.PublisherViewUri); - const ratingViewUri = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionRatingViewUri); + const detailsViewUri = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, galleryExtension.linkType ?? ExtensionGalleryResourceType.ExtensionDetailsViewUri); + const publisherViewUri = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, galleryExtension.publisher.linkType ?? ExtensionGalleryResourceType.PublisherViewUri); + const ratingViewUri = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, galleryExtension.ratingLinkType ?? ExtensionGalleryResourceType.ExtensionRatingViewUri); + const id = getGalleryExtensionId(galleryExtension.publisher.publisherName, galleryExtension.extensionName); return { type: 'gallery', identifier: { - id: getGalleryExtensionId(galleryExtension.publisher.publisherName, galleryExtension.extensionName), + id, uuid: galleryExtension.extensionId }, name: galleryExtension.extensionName, @@ -498,7 +507,7 @@ function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGaller isPreReleaseVersion: isPreReleaseVersion(version), executesCode: executesCode(version) }, - hasPreReleaseVersion: isPreReleaseVersion(latestVersion), + hasPreReleaseVersion: hasPreReleaseForExtension(id, productService) ?? isPreReleaseVersion(latestVersion), hasReleaseVersion: true, private: isPrivateExtension(latestVersion), preview: getIsPreview(galleryExtension.flags), @@ -513,6 +522,7 @@ function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGaller interface IRawExtensionsControlManifest { malicious: string[]; + learnMoreLinks?: IStringDictionary; migrateToPreRelease?: IStringDictionary<{ id: string; displayName: string; @@ -529,7 +539,6 @@ interface IRawExtensionsControlManifest { additionalInfo?: string; }>; search?: ISearchPrefferedResults[]; - extensionsEnabledWithPreRelease?: string[]; } export abstract class AbstractExtensionGalleryService implements IExtensionGalleryService { @@ -772,6 +781,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle extension: string; preRelease: boolean; compatible: boolean; + fromFallback: boolean; }, { owner: 'sandy081'; @@ -779,10 +789,12 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Extension id' }; preRelease: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Get pre-release version' }; compatible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Get compatible version' }; + fromFallback: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'From fallback' }; }>('galleryService:fallbacktoquery', { extension: extensionInfo.id, preRelease: !!extensionInfo.preRelease, - compatible: !!options.compatible + compatible: !!options.compatible, + fromFallback: !!resourceApi.fallback }); toQuery.push(extensionInfo); } @@ -820,7 +832,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle }, allTargetPlatforms); if (rawGalleryExtensionVersion) { - return toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest); + return toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, this.productService); } return null; @@ -851,87 +863,73 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle } async isExtensionCompatible(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform, productVersion: IProductVersion = { version: this.productService.version, date: this.productService.date }): Promise { - if (this.allowedExtensionsService.isAllowed(extension) !== true) { - return false; - } - - if (!isTargetPlatformCompatible(extension.properties.targetPlatform, extension.allTargetPlatforms, targetPlatform)) { - return false; - } - - if (!includePreRelease && extension.properties.isPreReleaseVersion) { - // Pre-releases are not allowed when include pre-release flag is not set - return false; - } - - let engine = extension.properties.engine; - if (!engine) { - const manifest = await this.getManifest(extension, CancellationToken.None); - if (!manifest) { - throw new Error('Manifest was not found'); - } - engine = manifest.engines.vscode; - } - - if (!isEngineValid(engine, productVersion.version, productVersion.date)) { - return false; - } - - if (!this.areApiProposalsCompatible(extension.identifier, extension.properties.enabledApiProposals)) { - return false; - } - - return true; - } - - private areApiProposalsCompatible(extensionIdentifier: IExtensionIdentifier, enabledApiProposals: string[] | undefined): boolean { - if (!enabledApiProposals) { - return true; - } - if (!this.extensionsEnabledWithApiProposalVersion.includes(extensionIdentifier.id.toLowerCase())) { - return true; - } - return areApiProposalsCompatible(enabledApiProposals); + return this.isValidVersion( + { + id: extension.identifier.id, + version: extension.version, + isPreReleaseVersion: extension.properties.isPreReleaseVersion, + targetPlatform: extension.properties.targetPlatform, + manifestAsset: extension.assets.manifest, + engine: extension.properties.engine, + enabledApiProposals: extension.properties.enabledApiProposals + }, + { + targetPlatform, + compatible: true, + productVersion, + version: includePreRelease ? VersionKind.Latest : VersionKind.Release + }, + extension.publisherDisplayName, + extension.allTargetPlatforms + ); } private async isValidVersion( - extension: string, - rawGalleryExtensionVersion: IRawGalleryExtensionVersion, + extension: { id: string; version: string; isPreReleaseVersion: boolean; targetPlatform: TargetPlatform; manifestAsset: IGalleryExtensionAsset | null; engine: string | undefined; enabledApiProposals: string[] | undefined }, { targetPlatform, compatible, productVersion, version }: ExtensionVersionCriteria, publisherDisplayName: string, allTargetPlatforms: TargetPlatform[] ): Promise { + const hasPreRelease = hasPreReleaseForExtension(extension.id, this.productService); + const excludeVersionRange = getExcludeVersionRangeForExtension(extension.id, this.productService); + + if (extension.isPreReleaseVersion && hasPreRelease === false /* Skip if hasPreRelease is not defined for this extension */) { + return false; + } + + if (excludeVersionRange && semver.satisfies(extension.version, excludeVersionRange)) { + return false; + } + // Specific version if (isString(version)) { - if (rawGalleryExtensionVersion.version !== version) { + if (extension.version !== version) { return false; } } // Prerelease or release version kind else if (version === VersionKind.Release || version === VersionKind.Prerelease) { - if (isPreReleaseVersion(rawGalleryExtensionVersion) !== (version === VersionKind.Prerelease)) { + if (extension.isPreReleaseVersion !== (version === VersionKind.Prerelease)) { return false; } } - const targetPlatformForExtension = getTargetPlatformForExtensionVersion(rawGalleryExtensionVersion); - if (!isTargetPlatformCompatible(targetPlatformForExtension, allTargetPlatforms, targetPlatform)) { + if (!isTargetPlatformCompatible(extension.targetPlatform, allTargetPlatforms, targetPlatform)) { return false; } if (compatible) { - if (this.allowedExtensionsService.isAllowed({ id: extension, publisherDisplayName, version: rawGalleryExtensionVersion.version, prerelease: isPreReleaseVersion(rawGalleryExtensionVersion), targetPlatform: targetPlatformForExtension }) !== true) { + if (this.allowedExtensionsService.isAllowed({ id: extension.id, publisherDisplayName, version: extension.version, prerelease: extension.isPreReleaseVersion, targetPlatform: extension.targetPlatform }) !== true) { return false; } - try { - const engine = await this.getEngine(extension, rawGalleryExtensionVersion); - if (!isEngineValid(engine, productVersion.version, productVersion.date)) { - return false; - } - } catch (error) { - this.logService.error(`Error while getting the engine for the version ${rawGalleryExtensionVersion.version}.`, getErrorMessage(error)); + + if (!this.areApiProposalsCompatible(extension.id, extension.enabledApiProposals)) { + return false; + } + + if (!(await this.isEngineValid(extension.id, extension.version, extension.engine, extension.manifestAsset, productVersion))) { return false; } } @@ -939,6 +937,52 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return true; } + private areApiProposalsCompatible(extensionId: string, enabledApiProposals: string[] | undefined): boolean { + if (!enabledApiProposals) { + return true; + } + if (!this.extensionsEnabledWithApiProposalVersion.includes(extensionId.toLowerCase())) { + return true; + } + return areApiProposalsCompatible(enabledApiProposals); + } + + private async isEngineValid(extensionId: string, version: string, engine: string | undefined, manifestAsset: IGalleryExtensionAsset | null, productVersion: IProductVersion): Promise { + if (!engine) { + if (!manifestAsset) { + this.logService.error(`Missing engine and manifest asset for the extension ${extensionId} with version ${version}`); + return false; + } + try { + type GalleryServiceEngineFallbackClassification = { + owner: 'sandy081'; + comment: 'Fallback request when engine is not found in properties of an extension version'; + extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'extension name' }; + extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'version' }; + }; + type GalleryServiceEngineFallbackEvent = { + extension: string; + extensionVersion: string; + }; + this.telemetryService.publicLog2('galleryService:engineFallback', { extension: extensionId, extensionVersion: version }); + + const headers = { 'Accept-Encoding': 'gzip' }; + const context = await this.getAsset(extensionId, manifestAsset, AssetType.Manifest, version, { headers }); + const manifest = await asJson(context); + if (!manifest) { + this.logService.error(`Manifest was not found for the extension ${extensionId} with version ${version}`); + return false; + } + engine = manifest.engines.vscode; + } catch (error) { + this.logService.error(`Error while getting the engine for the version ${version}.`, getErrorMessage(error)); + return false; + } + } + + return isEngineValid(engine, productVersion.version, productVersion.date); + } + async query(options: IQueryOptions, token: CancellationToken): Promise> { const extensionGalleryManifest = await this.extensionGalleryManifestService.getExtensionGalleryManifest(); @@ -1076,7 +1120,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle allTargetPlatforms ); if (rawGalleryExtensionVersion) { - extensions.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, context)); + extensions.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, this.productService, context)); } } return { extensions, total }; @@ -1110,7 +1154,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle }, allTargetPlatforms ); - const extension = rawGalleryExtensionVersion ? toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, context) : null; + const extension = rawGalleryExtensionVersion ? toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, this.productService, context) : null; if (!extension /** Need all versions if the extension is a pre-release version but * - the query is to look for a release version or @@ -1210,7 +1254,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle allTargetPlatforms ); if (rawGalleryExtensionVersion) { - extensions.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, context)); + extensions.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, this.productService, context)); } } @@ -1230,15 +1274,19 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle for (let index = 0; index < rawGalleryExtensionVersions.length; index++) { const rawGalleryExtensionVersion = rawGalleryExtensionVersions[index]; if (await this.isValidVersion( - extensionIdentifier.id, - rawGalleryExtensionVersion, + { + id: extensionIdentifier.id, + version: rawGalleryExtensionVersion.version, + isPreReleaseVersion: isPreReleaseVersion(rawGalleryExtensionVersion), + targetPlatform: getTargetPlatformForExtensionVersion(rawGalleryExtensionVersion), + engine: getEngine(rawGalleryExtensionVersion), + manifestAsset: getVersionAsset(rawGalleryExtensionVersion, AssetType.Manifest), + enabledApiProposals: getEnabledApiProposals(rawGalleryExtensionVersion) + }, criteria, rawGalleryExtension.publisher.displayName, allTargetPlatforms) ) { - if (criteria.compatible && !this.areApiProposalsCompatible(extensionIdentifier, getEnabledApiProposals(rawGalleryExtensionVersion))) { - continue; - } return rawGalleryExtensionVersion; } if (version && rawGalleryExtensionVersion.version === version) { @@ -1339,7 +1387,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle galleryExtensions, total, context: context.res.headers['activityid'] ? { - [ACTIVITY_HEADER_NAME]: context.res.headers['activityid'] + [SEARCH_ACTIVITY_HEADER_NAME]: context.res.headers['activityid'] } : {} }; } @@ -1373,15 +1421,25 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle responseBodySize: context?.res.headers['Content-Length'], statusCode: context ? String(context.res.statusCode) : undefined, errorCode, - count: String(total) + count: String(total), + server: this.getHeaderValue(context?.res.headers, SERVER_HEADER_NAME), + activityId: this.getHeaderValue(context?.res.headers, ACTIVITY_HEADER_NAME), + endToEndId: this.getHeaderValue(context?.res.headers, END_END_ID_HEADER_NAME), }); } } + private getHeaderValue(headers: IHeaders | undefined, name: string): TelemetryTrustedValue | undefined { + const headerValue = headers?.[name.toLowerCase()]; + const value = Array.isArray(headerValue) ? headerValue[0] : headerValue; + return value ? new TelemetryTrustedValue(value) : undefined; + } + private async getLatestRawGalleryExtension(extension: string, uri: URI, token: CancellationToken): Promise { let errorCode: string | undefined; const stopWatch = new StopWatch(); + let context; try { const commonHeaders = await this.commonHeadersPromise; const headers = { @@ -1391,7 +1449,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle 'Accept-Encoding': 'gzip', }; - const context = await this.requestService.request({ + context = await this.requestService.request({ type: 'GET', url: uri.toString(true), headers, @@ -1437,14 +1495,28 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the extension' }; duration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Duration in ms for the query' }; errorCode?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The error code in case of error' }; + server?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The server of the end point' }; + activityId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The activity ID of the request' }; + endToEndId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The end-to-end ID of the request' }; }; type GalleryServiceGetLatestEvent = { extension: string; host: string; duration: number; errorCode?: string; + server?: TelemetryTrustedValue; + activityId?: TelemetryTrustedValue; + endToEndId?: TelemetryTrustedValue; }; - this.telemetryService.publicLog2('galleryService:getLatest', { extension, host: uri.authority, duration: stopWatch.elapsed(), errorCode }); + this.telemetryService.publicLog2('galleryService:getLatest', { + extension, + host: uri.authority, + duration: stopWatch.elapsed(), + errorCode, + server: this.getHeaderValue(context?.res.headers, SERVER_HEADER_NAME), + activityId: this.getHeaderValue(context?.res.headers, ACTIVITY_HEADER_NAME), + endToEndId: this.getHeaderValue(context?.res.headers, END_END_ID_HEADER_NAME), + }); } } @@ -1493,7 +1565,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle fallbackUri: `${extension.assets.download.fallbackUri}${URI.parse(extension.assets.download.fallbackUri).query ? '&' : '?'}${operationParam}=true` } : extension.assets.download; - const headers: IHeaders | undefined = extension.queryContext?.[ACTIVITY_HEADER_NAME] ? { [ACTIVITY_HEADER_NAME]: extension.queryContext[ACTIVITY_HEADER_NAME] } : undefined; + const headers: IHeaders | undefined = extension.queryContext?.[SEARCH_ACTIVITY_HEADER_NAME] ? { [SEARCH_ACTIVITY_HEADER_NAME]: extension.queryContext[SEARCH_ACTIVITY_HEADER_NAME] } : undefined; const context = await this.getAsset(extension.identifier.id, downloadAsset, AssetType.VSIX, extension.version, headers ? { headers } : undefined); try { @@ -1560,16 +1632,6 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return null; } - private async getManifestFromRawExtensionVersion(extension: string, rawExtensionVersion: IRawGalleryExtensionVersion, token: CancellationToken): Promise { - const manifestAsset = getVersionAsset(rawExtensionVersion, AssetType.Manifest); - if (!manifestAsset) { - throw new Error('Manifest was not found'); - } - const headers = { 'Accept-Encoding': 'gzip' }; - const context = await this.getAsset(extension, manifestAsset, AssetType.Manifest, rawExtensionVersion.version, { headers }); - return await asJson(context); - } - async getCoreTranslation(extension: IGalleryExtension, languageId: string): Promise { const asset = extension.assets.coreTranslations.filter(t => t[0] === languageId.toUpperCase())[0]; if (asset) { @@ -1615,14 +1677,21 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return []; } - const validVersions: IRawGalleryExtensionVersion[] = []; + const compatibleVersions: IRawGalleryExtensionVersion[] = []; const productVersion = { version: this.productService.version, date: this.productService.date }; await Promise.all(galleryExtensions[0].versions.map(async (version) => { try { if ( (await this.isValidVersion( - extensionIdentifier.id, - version, + { + id: extensionIdentifier.id, + version: version.version, + isPreReleaseVersion: isPreReleaseVersion(version), + targetPlatform: getTargetPlatformForExtensionVersion(version), + engine: getEngine(version), + manifestAsset: getVersionAsset(version, AssetType.Manifest), + enabledApiProposals: getEnabledApiProposals(version) + }, { compatible: true, productVersion, @@ -1631,16 +1700,15 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle }, galleryExtensions[0].publisher.displayName, allTargetPlatforms)) - && this.areApiProposalsCompatible(extensionIdentifier, getEnabledApiProposals(version)) ) { - validVersions.push(version); + compatibleVersions.push(version); } } catch (error) { /* Ignore error and skip version */ } })); const result: IGalleryExtensionVersion[] = []; const seen = new Set(); - for (const version of sortExtensionVersions(validVersions, targetPlatform)) { + for (const version of sortExtensionVersions(compatibleVersions, targetPlatform)) { if (!seen.has(version.version)) { seen.add(version.version); result.push({ version: version.version, date: version.lastUpdated, isPreReleaseVersion: isPreReleaseVersion(version) }); @@ -1660,8 +1728,9 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const fallbackUrl = asset.fallbackUri; const firstOptions = { ...options, url }; + let context; try { - const context = await this.requestService.request(firstOptions, token); + context = await this.requestService.request(firstOptions, token); if (context.res.statusCode === 200) { return context; } @@ -1680,43 +1749,34 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle assetType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'asset that failed' }; message: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'error message' }; extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'version' }; + readonly server?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'server that handled the query' }; + readonly endToEndId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'end to end operation id' }; + readonly activityId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'activity id' }; }; type GalleryServiceCDNFallbackEvent = { extension: string; assetType: string; message: string; extensionVersion: string; + server?: TelemetryTrustedValue; + endToEndId?: TelemetryTrustedValue; + activityId?: TelemetryTrustedValue; }; - this.telemetryService.publicLog2('galleryService:cdnFallback', { extension, assetType, message, extensionVersion }); + this.telemetryService.publicLog2('galleryService:cdnFallback', { + extension, + assetType, + message, + extensionVersion, + server: this.getHeaderValue(context?.res.headers, SERVER_HEADER_NAME), + activityId: this.getHeaderValue(context?.res.headers, ACTIVITY_HEADER_NAME), + endToEndId: this.getHeaderValue(context?.res.headers, END_END_ID_HEADER_NAME), + }); const fallbackOptions = { ...options, url: fallbackUrl }; return this.requestService.request(fallbackOptions, token); } } - private async getEngine(extension: string, rawExtensionVersion: IRawGalleryExtensionVersion): Promise { - let engine = getEngine(rawExtensionVersion); - if (!engine) { - type GalleryServiceEngineFallbackClassification = { - owner: 'sandy081'; - comment: 'Fallback request when engine is not found in properties of an extension version'; - extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'extension name' }; - extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'version' }; - }; - type GalleryServiceEngineFallbackEvent = { - extension: string; - extensionVersion: string; - }; - this.telemetryService.publicLog2('galleryService:engineFallback', { extension, extensionVersion: rawExtensionVersion.version }); - const manifest = await this.getManifestFromRawExtensionVersion(extension, rawExtensionVersion, CancellationToken.None); - if (!manifest) { - throw new Error('Manifest was not found'); - } - engine = manifest.engines.vscode; - } - return engine; - } - async getExtensionsControlManifest(): Promise { if (!this.isEnabled()) { throw new Error('No extension gallery service configured.'); @@ -1737,17 +1797,16 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle } const result = await asJson(context); - const malicious: Array = []; + const malicious: Array = []; const deprecated: IStringDictionary = {}; const search: ISearchPrefferedResults[] = []; - const extensionsEnabledWithPreRelease: string[] = []; if (result) { for (const id of result.malicious) { - if (EXTENSION_IDENTIFIER_REGEX.test(id)) { - malicious.push({ id }); - } else { - malicious.push(id); + if (!isString(id)) { + continue; } + const publisherOrExtension = EXTENSION_IDENTIFIER_REGEX.test(id) ? { id } : id; + malicious.push({ extensionOrPublisher: publisherOrExtension, learnMoreLink: result.learnMoreLinks?.[id] }); } if (result.migrateToPreRelease) { for (const [unsupportedPreReleaseExtensionId, preReleaseExtensionInfo] of Object.entries(result.migrateToPreRelease)) { @@ -1776,14 +1835,9 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle search.push(s); } } - if (Array.isArray(result.extensionsEnabledWithPreRelease)) { - for (const id of result.extensionsEnabledWithPreRelease) { - extensionsEnabledWithPreRelease.push(id.toLowerCase()); - } - } } - return { malicious, deprecated, search, extensionsEnabledWithPreRelease }; + return { malicious, deprecated, search }; } } diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index ab30c7ca90c..e53143f1b84 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -10,10 +10,13 @@ import { IMarkdownString } from '../../../base/common/htmlContent.js'; import { IPager } from '../../../base/common/paging.js'; import { Platform } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; -import { localize2 } from '../../../nls.js'; +import { localize, localize2 } from '../../../nls.js'; +import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; import { ExtensionType, IExtension, IExtensionManifest, TargetPlatform } from '../../extensions/common/extensions.js'; import { FileOperationError, FileOperationResult, IFileService, IFileStat } from '../../files/common/files.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { Registry } from '../../registry/common/platform.js'; +import { IExtensionGalleryManifest } from './extensionGalleryManifest.js'; export const EXTENSION_IDENTIFIER_PATTERN = '^([a-z0-9A-Z][a-z0-9-A-Z]*)\\.([a-z0-9A-Z][a-z0-9-A-Z]*)$'; export const EXTENSION_IDENTIFIER_REGEX = new RegExp(EXTENSION_IDENTIFIER_PATTERN); @@ -345,11 +348,15 @@ export interface ISearchPrefferedResults { readonly preferredResults?: string[]; } +export type MaliciousExtensionInfo = { + readonly extensionOrPublisher: IExtensionIdentifier | string; + readonly learnMoreLink?: string; +}; + export interface IExtensionsControlManifest { - readonly malicious: ReadonlyArray; + readonly malicious: ReadonlyArray; readonly deprecated: IStringDictionary; readonly search: ISearchPrefferedResults[]; - readonly extensionsEnabledWithPreRelease?: string[]; } export const enum InstallOperation { @@ -467,6 +474,7 @@ export class ExtensionGalleryError extends Error { } export const enum ExtensionManagementErrorCode { + NotFound = 'NotFound', Unsupported = 'Unsupported', Deprecated = 'Deprecated', Malicious = 'Malicious', @@ -604,7 +612,7 @@ export interface IExtensionManagementService { installExtensionsFromProfile(extensions: IExtensionIdentifier[], fromProfileLocation: URI, toProfileLocation: URI): Promise; uninstall(extension: ILocalExtension, options?: UninstallOptions): Promise; uninstallExtensions(extensions: UninstallExtensionInfo[]): Promise; - toggleAppliationScope(extension: ILocalExtension, fromProfileLocation: URI): Promise; + toggleApplicationScope(extension: ILocalExtension, fromProfileLocation: URI): Promise; getInstalled(type?: ExtensionType, profileLocation?: URI, productVersion?: IProductVersion): Promise; getExtensionsControlManifest(): Promise; copyExtensions(fromProfileLocation: URI, toProfileLocation: URI): Promise; @@ -695,3 +703,86 @@ export const ExtensionsLocalizedLabel = localize2('extensions', "Extensions"); export const PreferencesLocalizedLabel = localize2('preferences', 'Preferences'); export const UseUnpkgResourceApiConfigKey = 'extensions.gallery.useUnpkgResourceApi'; export const AllowedExtensionsConfigKey = 'extensions.allowed'; +export const VerifyExtensionSignatureConfigKey = 'extensions.verifySignature'; + +Registry.as(Extensions.Configuration) + .registerConfiguration({ + id: 'extensions', + order: 30, + title: localize('extensionsConfigurationTitle', "Extensions"), + type: 'object', + properties: { + [AllowedExtensionsConfigKey]: { + // Note: Type is set only to object because to support policies generation during build time, where single type is expected. + type: 'object', + markdownDescription: localize('extensions.allowed', "Specify a list of extensions that are allowed to use. This helps maintain a secure and consistent development environment by restricting the use of unauthorized extensions. For more information on how to configure this setting, please visit the [Configure Allowed Extensions](https://code.visualstudio.com/docs/setup/enterprise#_configure-allowed-extensions) section."), + default: '*', + defaultSnippets: [{ + body: {}, + description: localize('extensions.allowed.none', "No extensions are allowed."), + }, { + body: { + '*': true + }, + description: localize('extensions.allowed.all', "All extensions are allowed."), + }], + scope: ConfigurationScope.APPLICATION, + policy: { + name: 'AllowedExtensions', + minimumVersion: '1.96', + description: localize('extensions.allowed.policy', "Specify a list of extensions that are allowed to use. This helps maintain a secure and consistent development environment by restricting the use of unauthorized extensions. More information: https://code.visualstudio.com/docs/setup/enterprise#_configure-allowed-extensions"), + }, + additionalProperties: false, + patternProperties: { + '([a-z0-9A-Z][a-z0-9-A-Z]*)\\.([a-z0-9A-Z][a-z0-9-A-Z]*)$': { + anyOf: [ + { + type: ['boolean', 'string'], + enum: [true, false, 'stable'], + description: localize('extensions.allow.description', "Allow or disallow the extension."), + enumDescriptions: [ + localize('extensions.allowed.enable.desc', "Extension is allowed."), + localize('extensions.allowed.disable.desc', "Extension is not allowed."), + localize('extensions.allowed.disable.stable.desc', "Allow only stable versions of the extension."), + ], + }, + { + type: 'array', + items: { + type: 'string', + }, + description: localize('extensions.allow.version.description', "Allow or disallow specific versions of the extension. To specifcy a platform specific version, use the format `platform@1.2.3`, e.g. `win32-x64@1.2.3`. Supported platforms are `win32-x64`, `win32-arm64`, `linux-x64`, `linux-arm64`, `linux-armhf`, `alpine-x64`, `alpine-arm64`, `darwin-x64`, `darwin-arm64`"), + }, + ] + }, + '([a-z0-9A-Z][a-z0-9-A-Z]*)$': { + type: ['boolean', 'string'], + enum: [true, false, 'stable'], + description: localize('extension.publisher.allow.description', "Allow or disallow all extensions from the publisher."), + enumDescriptions: [ + localize('extensions.publisher.allowed.enable.desc', "All extensions from the publisher are allowed."), + localize('extensions.publisher.allowed.disable.desc', "All extensions from the publisher are not allowed."), + localize('extensions.publisher.allowed.disable.stable.desc', "Allow only stable versions of the extensions from the publisher."), + ], + }, + '\\*': { + type: 'boolean', + enum: [true, false], + description: localize('extensions.allow.all.description', "Allow or disallow all extensions."), + enumDescriptions: [ + localize('extensions.allow.all.enable', "Allow all extensions."), + localize('extensions.allow.all.disable', "Disallow all extensions.") + ], + } + } + } + } + }); + +export function shouldRequireRepositorySignatureFor(isPrivate: boolean, galleryManifest: IExtensionGalleryManifest | null): boolean { + if (isPrivate) { + return galleryManifest?.capabilities.signing?.allPrivateRepositorySigned === true; + } + return galleryManifest?.capabilities.signing?.allPublicRepositorySigned === true; +} + diff --git a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts index 69ba77c8c84..a40e4319815 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts @@ -170,7 +170,8 @@ export class ExtensionManagementCLI { } private async installGalleryExtensions(installExtensionInfos: InstallGalleryExtensionInfo[], installed: ILocalExtension[], force: boolean): Promise { - installExtensionInfos = installExtensionInfos.filter(({ id, version }) => { + installExtensionInfos = installExtensionInfos.filter(installExtensionInfo => { + const { id, version, installOptions } = installExtensionInfo; const installedExtension = installed.find(i => areSameExtensions(i.identifier, { id })); if (installedExtension) { if (!force && (!version || (version === 'prerelease' && installedExtension.preRelease))) { @@ -181,6 +182,9 @@ export class ExtensionManagementCLI { this.logger.info(localize('alreadyInstalled', "Extension '{0}' is already installed.", `${id}@${version}`)); return false; } + if (installedExtension.preRelease && version !== 'prerelease') { + installOptions.preRelease = false; + } } return true; }); diff --git a/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts b/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts index e20ce088e42..4e30ac2c3a3 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts @@ -148,8 +148,8 @@ export class ExtensionManagementChannel implements IServerChannel { const extensions = await this.service.getInstalled(args[0], transformIncomingURI(args[1], uriTransformer), args[2]); return extensions.map(e => transformOutgoingExtension(e, uriTransformer)); } - case 'toggleAppliationScope': { - const extension = await this.service.toggleAppliationScope(transformIncomingExtension(args[0], uriTransformer), transformIncomingURI(args[1], uriTransformer)); + case 'toggleApplicationScope': { + const extension = await this.service.toggleApplicationScope(transformIncomingExtension(args[0], uriTransformer), transformIncomingURI(args[1], uriTransformer)); return transformOutgoingExtension(extension, uriTransformer); } case 'copyExtensions': { @@ -310,8 +310,8 @@ export class ExtensionManagementChannelClient extends CommontExtensionManagement return this.channel.call('resetPinnedStateForAllUserExtensions', [pinned]); } - toggleAppliationScope(local: ILocalExtension, fromProfileLocation: URI): Promise { - return this.channel.call('toggleAppliationScope', [local, fromProfileLocation]) + toggleApplicationScope(local: ILocalExtension, fromProfileLocation: URI): Promise { + return this.channel.call('toggleApplicationScope', [local, fromProfileLocation]) .then(extension => transformIncomingExtension(extension, null)); } diff --git a/src/vs/platform/extensionManagement/common/extensionManagementUtil.ts b/src/vs/platform/extensionManagement/common/extensionManagementUtil.ts index ece6b624163..c7d04e5ebc5 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementUtil.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementUtil.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { compareIgnoreCase } from '../../../base/common/strings.js'; -import { IExtensionIdentifier, IGalleryExtension, ILocalExtension, getTargetPlatform } from './extensionManagement.js'; +import { IExtensionIdentifier, IGalleryExtension, ILocalExtension, MaliciousExtensionInfo, getTargetPlatform } from './extensionManagement.js'; import { ExtensionIdentifier, IExtension, TargetPlatform, UNDEFINED_PUBLISHER } from '../../extensions/common/extensions.js'; import { IFileService } from '../../files/common/files.js'; import { isLinux, platform } from '../../../base/common/platform.js'; @@ -194,15 +194,19 @@ async function isAlpineLinux(fileService: IFileService, logService: ILogService) export async function computeTargetPlatform(fileService: IFileService, logService: ILogService): Promise { const alpineLinux = await isAlpineLinux(fileService, logService); const targetPlatform = getTargetPlatform(alpineLinux ? 'alpine' : platform, arch); - logService.debug('ComputeTargetPlatform:', targetPlatform); + logService.info('ComputeTargetPlatform:', targetPlatform); return targetPlatform; } -export function isMalicious(identifier: IExtensionIdentifier, malicious: ReadonlyArray): boolean { - return malicious.some(publisherOrIdentifier => { - if (isString(publisherOrIdentifier)) { - return compareIgnoreCase(identifier.id.split('.')[0], publisherOrIdentifier) === 0; +export function isMalicious(identifier: IExtensionIdentifier, malicious: ReadonlyArray): boolean { + return findMatchingMaliciousEntry(identifier, malicious) !== undefined; +} + +export function findMatchingMaliciousEntry(identifier: IExtensionIdentifier, malicious: ReadonlyArray): MaliciousExtensionInfo | undefined { + return malicious.find(({ extensionOrPublisher }) => { + if (isString(extensionOrPublisher)) { + return compareIgnoreCase(identifier.id.split('.')[0], extensionOrPublisher) === 0; } - return areSameExtensions(identifier, publisherOrIdentifier); + return areSameExtensions(identifier, extensionOrPublisher); }); } diff --git a/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts b/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts index 16ba453fd05..4e920ab4ee2 100644 --- a/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts +++ b/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts @@ -177,7 +177,7 @@ export abstract class AbstractExtensionsProfileScannerService extends Disposable await this.withProfileExtensions(profileLocation, profileExtensions => { const result: IScannedProfileExtension[] = []; for (const profileExtension of profileExtensions) { - const extension = extensions.find(([e]) => areSameExtensions(e.identifier, profileExtension.identifier) && e.manifest.version === profileExtension.version); + const extension = extensions.find(([e]) => areSameExtensions({ id: e.identifier.id }, { id: profileExtension.identifier.id }) && e.manifest.version === profileExtension.version); if (extension) { profileExtension.metadata = { ...profileExtension.metadata, ...extension[1] }; updatedExtensions.push(profileExtension); diff --git a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts index ddcacaf37b2..a15e92a42ab 100644 --- a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts +++ b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts @@ -36,6 +36,7 @@ import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; import { localizeManifest } from './extensionNls.js'; export type ManifestMetadata = Partial<{ + targetPlatform: TargetPlatform; installedTimestamp: number; size: number; }>; @@ -154,15 +155,15 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem private readonly _onDidChangeCache = this._register(new Emitter()); readonly onDidChangeCache = this._onDidChangeCache.event; - private readonly systemExtensionsCachedScanner = this._register(this.instantiationService.createInstance(CachedExtensionsScanner, this.currentProfile)); - private readonly userExtensionsCachedScanner = this._register(this.instantiationService.createInstance(CachedExtensionsScanner, this.currentProfile)); - private readonly extensionsScanner = this._register(this.instantiationService.createInstance(ExtensionsScanner)); + private readonly systemExtensionsCachedScanner: CachedExtensionsScanner; + private readonly userExtensionsCachedScanner: CachedExtensionsScanner; + private readonly extensionsScanner: ExtensionsScanner; constructor( readonly systemExtensionsLocation: URI, readonly userExtensionsLocation: URI, private readonly extensionsControlLocation: URI, - private readonly currentProfile: IUserDataProfile, + currentProfile: IUserDataProfile, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, @IExtensionsProfileScannerService protected readonly extensionsProfileScannerService: IExtensionsProfileScannerService, @IFileService protected readonly fileService: IFileService, @@ -174,6 +175,10 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem ) { super(); + this.systemExtensionsCachedScanner = this._register(this.instantiationService.createInstance(CachedExtensionsScanner, currentProfile)); + this.userExtensionsCachedScanner = this._register(this.instantiationService.createInstance(CachedExtensionsScanner, currentProfile)); + this.extensionsScanner = this._register(this.instantiationService.createInstance(ExtensionsScanner)); + this._register(this.systemExtensionsCachedScanner.onDidChangeCache(() => this._onDidChangeCache.fire(ExtensionType.System))); this._register(this.userExtensionsCachedScanner.onDidChangeCache(() => this._onDidChangeCache.fire(ExtensionType.User))); } @@ -673,6 +678,7 @@ class ExtensionsScanner extends Disposable { metadata = { installedTimestamp: manifest.__metadata.installedTimestamp, size: manifest.__metadata.size, + targetPlatform: manifest.__metadata.targetPlatform, }; } diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 8d137c47d4e..0a50a2edaf4 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -34,6 +34,8 @@ import { ExtensionSignatureVerificationCode, computeSize, IAllowedExtensionsService, + VerifyExtensionSignatureConfigKey, + shouldRequireRepositorySignatureFor, } from '../common/extensionManagement.js'; import { areSameExtensions, computeTargetPlatform, ExtensionKey, getGalleryExtensionId, groupByExtension } from '../common/extensionManagementUtil.js'; import { IExtensionsProfileScannerService, IScannedProfileExtension } from '../common/extensionsProfileScannerService.js'; @@ -53,7 +55,6 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; import { IUserDataProfilesService } from '../../userDataProfile/common/userDataProfile.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { isLinux } from '../../../base/common/platform.js'; import { IExtensionGalleryManifestService } from '../common/extensionGalleryManifest.js'; export const INativeServerExtensionManagementService = refineServiceDecorator(IExtensionManagementService); @@ -330,18 +331,17 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi private async downloadExtension(extension: IGalleryExtension, operation: InstallOperation, verifySignature: boolean, clientTargetPlatform?: TargetPlatform): Promise<{ readonly location: URI; readonly verificationStatus: ExtensionSignatureVerificationCode | undefined }> { if (verifySignature) { - const value = this.configurationService.getValue('extensions.verifySignature'); + const value = this.configurationService.getValue(VerifyExtensionSignatureConfigKey); verifySignature = isBoolean(value) ? value : true; } const { location, verificationStatus } = await this.extensionsDownloader.download(extension, operation, verifySignature, clientTargetPlatform); - const shouldRequireSignature = (await this.extensionGalleryManifestService.getExtensionGalleryManifest())?.capabilities.signing?.allRepositorySigned; + const shouldRequireSignature = shouldRequireRepositorySignatureFor(extension.private, await this.extensionGalleryManifestService.getExtensionGalleryManifest()); if ( verificationStatus !== ExtensionSignatureVerificationCode.Success && !(verificationStatus === ExtensionSignatureVerificationCode.NotSigned && !shouldRequireSignature) && verifySignature && this.environmentService.isBuilt - && !(isLinux && this.productService.quality === 'stable') ) { try { await this.extensionsDownloader.delete(location); @@ -638,7 +638,7 @@ export class ExtensionsScanner extends Disposable { throw fromExtractError(e); } - const metadata: ManifestMetadata = { installedTimestamp: Date.now() }; + const metadata: ManifestMetadata = { installedTimestamp: Date.now(), targetPlatform: extensionKey.targetPlatform }; try { metadata.size = await computeSize(tempLocation, this.fileService); } catch (error) { @@ -703,7 +703,13 @@ export class ExtensionsScanner extends Disposable { } async setExtensionsForRemoval(...extensions: IExtension[]): Promise { - const extensionKeys: ExtensionKey[] = extensions.map(e => ExtensionKey.create(e)); + const extensionsToRemove = []; + for (const extension of extensions) { + if (await this.fileService.exists(extension.location)) { + extensionsToRemove.push(extension); + } + } + const extensionKeys: ExtensionKey[] = extensionsToRemove.map(e => ExtensionKey.create(e)); await this.withRemovedExtensions(removedExtensions => extensionKeys.forEach(extensionKey => { removedExtensions[extensionKey.toString()] = true; diff --git a/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts b/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts index ea0a4661df0..b4729bbd940 100644 --- a/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts +++ b/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts @@ -300,9 +300,10 @@ suite('NativeExtensionsScanerService Test', () => { test('scan single extension with manifest metadata retains manifest metadata', async () => { const manifest: Partial = anExtensionManifest({ 'name': 'name', 'publisher': 'pub' }); + const expectedMetadata = { size: 12345, installedTimestamp: 1234567890, targetPlatform: TargetPlatform.DARWIN_ARM64 }; const extensionLocation = await aUserExtension({ ...manifest, - __metadata: { size: 12345, installedTimestamp: 1234567890 } + __metadata: expectedMetadata }); const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); @@ -315,7 +316,7 @@ suite('NativeExtensionsScanerService Test', () => { assert.deepStrictEqual(actual!.type, ExtensionType.User); assert.deepStrictEqual(actual!.isValid, true); assert.deepStrictEqual(actual!.validations, []); - assert.deepStrictEqual(actual!.metadata, { size: 12345, installedTimestamp: 1234567890 }); + assert.deepStrictEqual(actual!.metadata, expectedMetadata); assert.deepStrictEqual(actual!.manifest, manifest); }); diff --git a/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts b/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts index 974a3061346..921674ef18e 100644 --- a/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts +++ b/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts @@ -13,6 +13,7 @@ import { IEnvironmentService } from '../../environment/common/environment.js'; import { ILogService } from '../../log/common/log.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { AbstractExtensionResourceLoaderService, IExtensionResourceLoaderService } from '../common/extensionResourceLoader.js'; +import { IExtensionGalleryManifestService } from '../../extensionManagement/common/extensionGalleryManifest.js'; class ExtensionResourceLoaderService extends AbstractExtensionResourceLoaderService { @@ -24,9 +25,10 @@ class ExtensionResourceLoaderService extends AbstractExtensionResourceLoaderServ @IProductService productService: IProductService, @IEnvironmentService environmentService: IEnvironmentService, @IConfigurationService configurationService: IConfigurationService, - @ILogService private readonly _logService: ILogService, + @IExtensionGalleryManifestService extensionGalleryManifestService: IExtensionGalleryManifestService, + @ILogService logService: ILogService, ) { - super(fileService, storageService, productService, environmentService, configurationService); + super(fileService, storageService, productService, environmentService, configurationService, extensionGalleryManifestService, logService); } async readExtensionResource(uri: URI): Promise { @@ -38,7 +40,7 @@ class ExtensionResourceLoaderService extends AbstractExtensionResourceLoaderServ } const requestInit: RequestInit = {}; - if (this.isExtensionGalleryResource(uri)) { + if (await this.isExtensionGalleryResource(uri)) { requestInit.headers = await this.getExtensionGalleryRequestHeaders(); requestInit.mode = 'cors'; /* set mode to cors so that above headers are always passed */ } diff --git a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts index e73ec41aba9..28a25db97f0 100644 --- a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts +++ b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts @@ -17,6 +17,9 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel, supportsTelemetry } from '../../telemetry/common/telemetryUtils.js'; import { RemoteAuthorities } from '../../../base/common/network.js'; import { TargetPlatform } from '../../extensions/common/extensions.js'; +import { ExtensionGalleryResourceType, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifest, IExtensionGalleryManifestService } from '../../extensionManagement/common/extensionGalleryManifest.js'; +import { ILogService } from '../../log/common/log.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; const WEB_EXTENSION_RESOURCE_END_POINT_SEGMENT = '/web-extension-resource/'; @@ -36,17 +39,17 @@ export interface IExtensionResourceLoaderService { /** * Returns whether the gallery provides extension resources. */ - readonly supportsExtensionGalleryResources: boolean; + supportsExtensionGalleryResources(): Promise; /** * Return true if the given URI is a extension gallery resource. */ - isExtensionGalleryResource(uri: URI): boolean; + isExtensionGalleryResource(uri: URI): Promise; /** * Computes the URL of a extension gallery resource. Returns `undefined` if gallery does not provide extension resources. */ - getExtensionGalleryResourceURL(galleryExtension: { publisher: string; name: string; version: string; targetPlatform?: TargetPlatform }, path?: string): URI | undefined; + getExtensionGalleryResourceURL(galleryExtension: { publisher: string; name: string; version: string; targetPlatform?: TargetPlatform }, path?: string): Promise; } export function migratePlatformSpecificExtensionGalleryResourceURL(resource: URI, targetPlatform: TargetPlatform): URI | undefined { @@ -61,12 +64,14 @@ export function migratePlatformSpecificExtensionGalleryResourceURL(resource: URI return resource.with({ query: null, path: paths.join('/') }); } -export abstract class AbstractExtensionResourceLoaderService implements IExtensionResourceLoaderService { +export abstract class AbstractExtensionResourceLoaderService extends Disposable implements IExtensionResourceLoaderService { readonly _serviceBrand: undefined; - private readonly _extensionGalleryResourceUrlTemplate: string | undefined; - private readonly _extensionGalleryAuthority: string | undefined; + private readonly _initPromise: Promise; + + private _extensionGalleryResourceUrlTemplate: string | undefined; + private _extensionGalleryAuthority: string | undefined; constructor( protected readonly _fileService: IFileService, @@ -74,18 +79,35 @@ export abstract class AbstractExtensionResourceLoaderService implements IExtensi private readonly _productService: IProductService, private readonly _environmentService: IEnvironmentService, private readonly _configurationService: IConfigurationService, + private readonly _extensionGalleryManifestService: IExtensionGalleryManifestService, + protected readonly _logService: ILogService, ) { - if (_productService.extensionsGallery) { - this._extensionGalleryResourceUrlTemplate = _productService.extensionsGallery.resourceUrlTemplate; - this._extensionGalleryAuthority = this._extensionGalleryResourceUrlTemplate ? this._getExtensionGalleryAuthority(URI.parse(this._extensionGalleryResourceUrlTemplate)) : undefined; + super(); + this._initPromise = this._init(); + } + + private async _init(): Promise { + try { + const manifest = await this._extensionGalleryManifestService.getExtensionGalleryManifest(); + this.resolve(manifest); + this._register(this._extensionGalleryManifestService.onDidChangeExtensionGalleryManifest(() => this.resolve(manifest))); + } catch (error) { + this._logService.error(error); } } - public get supportsExtensionGalleryResources(): boolean { + private resolve(manifest: IExtensionGalleryManifest | null): void { + this._extensionGalleryResourceUrlTemplate = manifest ? getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ExtensionResourceUri) : undefined; + this._extensionGalleryAuthority = this._extensionGalleryResourceUrlTemplate ? this._getExtensionGalleryAuthority(URI.parse(this._extensionGalleryResourceUrlTemplate)) : undefined; + } + + public async supportsExtensionGalleryResources(): Promise { + await this._initPromise; return this._extensionGalleryResourceUrlTemplate !== undefined; } - public getExtensionGalleryResourceURL({ publisher, name, version, targetPlatform }: { publisher: string; name: string; version: string; targetPlatform?: TargetPlatform }, path?: string): URI | undefined { + public async getExtensionGalleryResourceURL({ publisher, name, version, targetPlatform }: { publisher: string; name: string; version: string; targetPlatform?: TargetPlatform }, path?: string): Promise { + await this._initPromise; if (this._extensionGalleryResourceUrlTemplate) { const uri = URI.parse(format2(this._extensionGalleryResourceUrlTemplate, { publisher, @@ -105,7 +127,8 @@ export abstract class AbstractExtensionResourceLoaderService implements IExtensi public abstract readExtensionResource(uri: URI): Promise; - isExtensionGalleryResource(uri: URI): boolean { + async isExtensionGalleryResource(uri: URI): Promise { + await this._initPromise; return !!this._extensionGalleryAuthority && this._extensionGalleryAuthority === this._getExtensionGalleryAuthority(uri); } diff --git a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts index 72fccbcc52c..e360b843193 100644 --- a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts +++ b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts @@ -13,6 +13,8 @@ import { IEnvironmentService } from '../../environment/common/environment.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { AbstractExtensionResourceLoaderService, IExtensionResourceLoaderService } from './extensionResourceLoader.js'; +import { IExtensionGalleryManifestService } from '../../extensionManagement/common/extensionGalleryManifest.js'; +import { ILogService } from '../../log/common/log.js'; export class ExtensionResourceLoaderService extends AbstractExtensionResourceLoaderService { @@ -22,13 +24,15 @@ export class ExtensionResourceLoaderService extends AbstractExtensionResourceLoa @IProductService productService: IProductService, @IEnvironmentService environmentService: IEnvironmentService, @IConfigurationService configurationService: IConfigurationService, + @IExtensionGalleryManifestService extensionGalleryManifestService: IExtensionGalleryManifestService, @IRequestService private readonly _requestService: IRequestService, + @ILogService logService: ILogService, ) { - super(fileService, storageService, productService, environmentService, configurationService); + super(fileService, storageService, productService, environmentService, configurationService, extensionGalleryManifestService, logService); } async readExtensionResource(uri: URI): Promise { - if (this.isExtensionGalleryResource(uri)) { + if (await this.isExtensionGalleryResource(uri)) { const headers = await this.getExtensionGalleryRequestHeaders(); const requestContext = await this._requestService.request({ url: uri.toString(), headers }, CancellationToken.None); return (await asTextOrError(requestContext)) || ''; diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index b38ba82a1d9..8e68cbe06d1 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -280,6 +280,7 @@ export interface IRelaxedExtensionManifest { engines: { readonly vscode: string }; description?: string; main?: string; + type?: string; browser?: string; preview?: boolean; // For now this only supports pointing to l10n bundle files diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 6f7a0ff8516..bef924d6d9b 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -12,6 +12,9 @@ const _allApiProposals = { aiRelatedInformation: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.aiRelatedInformation.d.ts', }, + aiSettingsSearch: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.aiSettingsSearch.d.ts', + }, aiTextSearchProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.aiTextSearchProvider.d.ts', version: 2 @@ -33,7 +36,7 @@ const _allApiProposals = { }, chatParticipantPrivate: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts', - version: 6 + version: 9 }, chatProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatProvider.d.ts', @@ -44,6 +47,9 @@ const _allApiProposals = { chatReferenceDiagnostic: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatReferenceDiagnostic.d.ts', }, + chatStatusItem: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts', + }, chatTab: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatTab.d.ts', }, @@ -151,7 +157,7 @@ const _allApiProposals = { }, defaultChatParticipant: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.defaultChatParticipant.d.ts', - version: 3 + version: 4 }, diffCommand: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.diffCommand.d.ts', @@ -174,9 +180,6 @@ const _allApiProposals = { embeddings: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.embeddings.d.ts', }, - envExtractUri: { - proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.envExtractUri.d.ts', - }, extensionRuntime: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.extensionRuntime.d.ts', }, @@ -226,12 +229,13 @@ const _allApiProposals = { languageModelCapabilities: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageModelCapabilities.d.ts', }, + languageModelDataPart: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageModelDataPart.d.ts', + version: 2 + }, languageModelSystem: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageModelSystem.d.ts', }, - languageModelToolsForAgent: { - proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageModelToolsForAgent.d.ts', - }, languageStatusText: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageStatusText.d.ts', }, @@ -373,9 +377,6 @@ const _allApiProposals = { testRelatedCode: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testRelatedCode.d.ts', }, - textDocumentEncoding: { - proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.textDocumentEncoding.d.ts', - }, textEditorDiffInformation: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts', }, @@ -394,6 +395,9 @@ const _allApiProposals = { tokenInformation: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.tokenInformation.d.ts', }, + toolProgress: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.toolProgress.d.ts', + }, treeViewActiveItem: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.treeViewActiveItem.d.ts', }, diff --git a/src/vs/platform/files/common/diskFileSystemProvider.ts b/src/vs/platform/files/common/diskFileSystemProvider.ts index ab382fef438..0cbcc333af8 100644 --- a/src/vs/platform/files/common/diskFileSystemProvider.ts +++ b/src/vs/platform/files/common/diskFileSystemProvider.ts @@ -63,12 +63,26 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen return this.watchNonRecursive(resource, opts); } + private getRefreshWatchersDelay(count: number): number { + if (count > 200) { + // If there are many requests to refresh, start to throttle + // the refresh to reduce pressure. We see potentially thousands + // of requests coming in on startup repeatedly so we take it easy. + return 500; + } + + // By default, use a short delay to keep watchers updating fast but still + // with a delay so that we can efficiently deduplicate requests or reuse + // existing watchers. + return 0; + } + //#region File Watching (universal) private universalWatcher: AbstractUniversalWatcherClient | undefined; private readonly universalWatchRequests: IUniversalWatchRequest[] = []; - private readonly universalWatchRequestDelayer = this._register(new ThrottledDelayer(0)); + private readonly universalWatchRequestDelayer = this._register(new ThrottledDelayer(this.getRefreshWatchersDelay(this.universalWatchRequests.length))); private watchUniversal(resource: URI, opts: IWatchOptions): IDisposable { const request = this.toWatchRequest(resource, opts); @@ -114,12 +128,9 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen } private refreshUniversalWatchers(): void { - - // Buffer requests for universal watching to decide on right watcher - // that supports potentially watching more than one path at once this.universalWatchRequestDelayer.trigger(() => { return this.doRefreshUniversalWatchers(); - }).catch(error => onUnexpectedError(error)); + }, this.getRefreshWatchersDelay(this.universalWatchRequests.length)).catch(error => onUnexpectedError(error)); } private doRefreshUniversalWatchers(): Promise { @@ -155,7 +166,7 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen private nonRecursiveWatcher: AbstractNonRecursiveWatcherClient | undefined; private readonly nonRecursiveWatchRequests: INonRecursiveWatchRequest[] = []; - private readonly nonRecursiveWatchRequestDelayer = this._register(new ThrottledDelayer(0)); + private readonly nonRecursiveWatchRequestDelayer = this._register(new ThrottledDelayer(this.getRefreshWatchersDelay(this.nonRecursiveWatchRequests.length))); private watchNonRecursive(resource: URI, opts: IWatchOptions): IDisposable { @@ -184,12 +195,9 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen } private refreshNonRecursiveWatchers(): void { - - // Buffer requests for nonrecursive watching to decide on right watcher - // that supports potentially watching more than one path at once this.nonRecursiveWatchRequestDelayer.trigger(() => { return this.doRefreshNonRecursiveWatchers(); - }).catch(error => onUnexpectedError(error)); + }, this.getRefreshWatchersDelay(this.nonRecursiveWatchRequests.length)).catch(error => onUnexpectedError(error)); } private doRefreshNonRecursiveWatchers(): Promise { diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts index 6b40b9d4ba3..47d1f2664f0 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts @@ -9,6 +9,8 @@ import { BaseWatcher } from '../baseWatcher.js'; import { isLinux } from '../../../../../base/common/platform.js'; import { INonRecursiveWatchRequest, INonRecursiveWatcher, IRecursiveWatcherWithSubscribe } from '../../../common/watcher.js'; import { NodeJSFileWatcherLibrary } from './nodejsWatcherLib.js'; +import { ThrottledWorker } from '../../../../../base/common/async.js'; +import { MutableDisposable } from '../../../../../base/common/lifecycle.js'; export interface INodeJSWatcherInstance { @@ -30,6 +32,8 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { private readonly _watchers = new Map(); get watchers() { return this._watchers.values(); } + private readonly worker = this._register(new MutableDisposable>()); + constructor(protected readonly recursiveWatcher: IRecursiveWatcherWithSubscribe | undefined) { super(); } @@ -61,15 +65,36 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { this.trace(`Request to stop watching: ${Array.from(watchersToStop).map(watcher => this.requestToString(watcher.request)).join(',')}`); } + // Stop the worker + this.worker.clear(); + // Stop watching as instructed for (const watcher of watchersToStop) { this.stopWatching(watcher); } // Start watching as instructed - for (const request of requestsToStart) { - this.startWatching(request); - } + this.createWatchWorker().work(requestsToStart); + } + + private createWatchWorker(): ThrottledWorker { + + // We see very large amount of non-recursive file watcher requests + // in large workspaces. To prevent the overhead of starting thousands + // of watchers at once, we use a throttled worker to distribute this + // work over time. + + this.worker.value = new ThrottledWorker({ + maxWorkChunkSize: 100, // only start 100 watchers at once before... + throttleDelay: 100, // ...resting for 100ms until we start watchers again... + maxBufferedWork: Number.MAX_VALUE // ...and never refuse any work. + }, requests => { + for (const request of requests) { + this.startWatching(request); + } + }); + + return this.worker.value; } private requestToWatcherKey(request: INonRecursiveWatchRequest): string | number { diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts index 3d92b0e3361..26a54be46ff 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts @@ -190,6 +190,10 @@ export class NodeJSFileWatcherLibrary extends Disposable { private async doWatchWithNodeJS(isDirectory: boolean, disposables: DisposableStore): Promise { const realPath = await this.realPath.value; + if (this.cts.token.isCancellationRequested) { + return; + } + // macOS: watching samba shares can crash VSCode so we do // a simple check for the file path pointing to /Volumes // (https://github.com/microsoft/vscode/issues/106879) @@ -429,10 +433,12 @@ export class NodeJSFileWatcherLibrary extends Disposable { } }); } catch (error) { - if (!cts.token.isCancellationRequested) { - this.error(`Failed to watch ${realPath} for changes using fs.watch() (${error.toString()})`); + if (cts.token.isCancellationRequested) { + return; } + this.error(`Failed to watch ${realPath} for changes using fs.watch() (${error.toString()})`); + this.notifyWatchFailed(); } } diff --git a/src/vs/platform/jsonschemas/common/jsonContributionRegistry.ts b/src/vs/platform/jsonschemas/common/jsonContributionRegistry.ts index 72b231f4e03..5f37ab3c587 100644 --- a/src/vs/platform/jsonschemas/common/jsonContributionRegistry.ts +++ b/src/vs/platform/jsonschemas/common/jsonContributionRegistry.ts @@ -5,6 +5,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { getCompressedContent, IJSONSchema } from '../../../base/common/jsonSchema.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import * as platform from '../../registry/common/platform.js'; export const Extensions = { @@ -18,12 +19,14 @@ export interface ISchemaContributions { export interface IJSONContributionRegistry { readonly onDidChangeSchema: Event; + readonly onDidChangeSchemaAssociations: Event; /** * Register a schema to the registry. */ - registerSchema(uri: string, unresolvedSchemaContent: IJSONSchema): void; + registerSchema(uri: string, unresolvedSchemaContent: IJSONSchema, store?: DisposableStore): void; + registerSchemaAssociation(uri: string, glob: string): IDisposable; /** * Notifies all listeners that the content of the given schema has changed. @@ -36,6 +39,8 @@ export interface IJSONContributionRegistry { */ getSchemaContributions(): ISchemaContributions; + getSchemaAssociations(): { [uri: string]: string[] }; + /** * Gets the (compressed) content of the schema with the given schema ID (if any) * @param uri The id of the schema @@ -60,20 +65,53 @@ function normalizeId(id: string) { -class JSONContributionRegistry implements IJSONContributionRegistry { +class JSONContributionRegistry extends Disposable implements IJSONContributionRegistry { - private schemasById: { [id: string]: IJSONSchema }; + private readonly schemasById: { [id: string]: IJSONSchema } = {}; + private readonly schemaAssociations: { [uri: string]: string[] } = {}; - private readonly _onDidChangeSchema = new Emitter(); + private readonly _onDidChangeSchema = this._register(new Emitter()); readonly onDidChangeSchema: Event = this._onDidChangeSchema.event; - constructor() { - this.schemasById = {}; + private readonly _onDidChangeSchemaAssociations = this._register(new Emitter()); + readonly onDidChangeSchemaAssociations: Event = this._onDidChangeSchemaAssociations.event; + + public registerSchema(uri: string, unresolvedSchemaContent: IJSONSchema, store?: DisposableStore): void { + const normalizedUri = normalizeId(uri); + this.schemasById[normalizedUri] = unresolvedSchemaContent; + this._onDidChangeSchema.fire(uri); + + if (store) { + store.add(toDisposable(() => { + delete this.schemasById[normalizedUri]; + this._onDidChangeSchema.fire(uri); + })); + } } - public registerSchema(uri: string, unresolvedSchemaContent: IJSONSchema): void { - this.schemasById[normalizeId(uri)] = unresolvedSchemaContent; - this._onDidChangeSchema.fire(uri); + public registerSchemaAssociation(uri: string, glob: string): IDisposable { + const normalizedUri = normalizeId(uri); + if (!this.schemaAssociations[normalizedUri]) { + this.schemaAssociations[normalizedUri] = []; + } + if (!this.schemaAssociations[normalizedUri].includes(glob)) { + this.schemaAssociations[normalizedUri].push(glob); + this._onDidChangeSchemaAssociations.fire(); + } + + return toDisposable(() => { + const associations = this.schemaAssociations[normalizedUri]; + if (associations) { + const index = associations.indexOf(glob); + if (index !== -1) { + associations.splice(index, 1); + if (associations.length === 0) { + delete this.schemaAssociations[normalizedUri]; + } + this._onDidChangeSchemaAssociations.fire(); + } + } + }); } public notifySchemaChanged(uri: string): void { @@ -95,6 +133,10 @@ class JSONContributionRegistry implements IJSONContributionRegistry { return !!this.schemasById[uri]; } + public getSchemaAssociations(): { [uri: string]: string[] } { + return this.schemaAssociations; + } + } const jsonContributionRegistry = new JSONContributionRegistry(); diff --git a/src/vs/platform/keybinding/common/abstractKeybindingService.ts b/src/vs/platform/keybinding/common/abstractKeybindingService.ts index f3e1e2b854d..ee26e7b40b9 100644 --- a/src/vs/platform/keybinding/common/abstractKeybindingService.ts +++ b/src/vs/platform/keybinding/common/abstractKeybindingService.ts @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { IME } from '../../../base/common/ime.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { Keybinding, ResolvedChord, ResolvedKeybinding, SingleModifierChord } from '../../../base/common/keybindings.js'; -import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; import * as nls from '../../../nls.js'; import { ICommandService } from '../../commands/common/commands.js'; @@ -20,7 +20,7 @@ import { IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } fro import { ResolutionResult, KeybindingResolver, ResultKind, NoMatchingKb } from './keybindingResolver.js'; import { ResolvedKeybindingItem } from './resolvedKeybindingItem.js'; import { ILogService } from '../../log/common/log.js'; -import { INotificationService } from '../../notification/common/notification.js'; +import { INotificationService, IStatusHandle } from '../../notification/common/notification.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; interface CurrentChord { @@ -49,7 +49,7 @@ export abstract class AbstractKeybindingService extends Disposable implements IK private _currentChords: CurrentChord[]; private _currentChordChecker: IntervalTimer; - private _currentChordStatusMessage: IDisposable | null; + private _currentChordStatusMessage: IStatusHandle | null; private _ignoreSingleModifiers: KeybindingModifierSet; private _currentSingleModifier: SingleModifierChord | null; private _currentSingleModifierClearTimeout: TimeoutTimer; @@ -203,7 +203,7 @@ export abstract class AbstractKeybindingService extends Disposable implements IK private _leaveChordMode(): void { if (this._currentChordStatusMessage) { - this._currentChordStatusMessage.dispose(); + this._currentChordStatusMessage.close(); this._currentChordStatusMessage = null; } this._currentChordChecker.cancel(); diff --git a/src/vs/platform/keybinding/test/common/abstractKeybindingService.test.ts b/src/vs/platform/keybinding/test/common/abstractKeybindingService.test.ts index 4a5f4c83407..04af18c812b 100644 --- a/src/vs/platform/keybinding/test/common/abstractKeybindingService.test.ts +++ b/src/vs/platform/keybinding/test/common/abstractKeybindingService.test.ts @@ -184,7 +184,7 @@ suite('AbstractKeybindingService', () => { status(message: string, options?: IStatusMessageOptions) { statusMessageCalls!.push(message); return { - dispose: () => { + close: () => { statusMessageCallsDisposed!.push(message); } }; diff --git a/src/vs/platform/languagePacks/browser/languagePacks.ts b/src/vs/platform/languagePacks/browser/languagePacks.ts index af82787b888..b0b64c697d9 100644 --- a/src/vs/platform/languagePacks/browser/languagePacks.ts +++ b/src/vs/platform/languagePacks/browser/languagePacks.ts @@ -56,7 +56,7 @@ export class WebLanguagePacksService extends LanguagePackBaseService { } // get the resource uri and return it - const uri = this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ + const uri = await this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ // If translation is defined then manifest should have been defined. name: manifest!.name, publisher: manifest!.publisher, diff --git a/src/vs/platform/lifecycle/electron-main/lifecycleMainService.ts b/src/vs/platform/lifecycle/electron-main/lifecycleMainService.ts index 0016baac80f..4912966c79c 100644 --- a/src/vs/platform/lifecycle/electron-main/lifecycleMainService.ts +++ b/src/vs/platform/lifecycle/electron-main/lifecycleMainService.ts @@ -19,6 +19,7 @@ import { ICodeWindow, LoadReason, UnloadReason } from '../../window/electron-mai import { ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; +import { getAllWindowsExcludingOffscreen } from '../../windows/electron-main/windows.js'; export const ILifecycleMainService = createDecorator('lifecycleMainService'); @@ -727,7 +728,7 @@ export class LifecycleMainService extends Disposable implements ILifecycleMainSe // to a participant within the window. this is not wanted when we // are asked to kill the application. (async () => { - for (const window of electron.BrowserWindow.getAllWindows()) { + for (const window of getAllWindowsExcludingOffscreen()) { if (window && !window.isDestroyed()) { let whenWindowClosed: Promise; if (window.webContents && !window.webContents.isDestroyed()) { diff --git a/src/vs/platform/log/common/log.ts b/src/vs/platform/log/common/log.ts index b5780a16134..65bcdfe440c 100644 --- a/src/vs/platform/log/common/log.ts +++ b/src/vs/platform/log/common/log.ts @@ -57,6 +57,10 @@ export interface ILogger extends IDisposable { flush(): void; } +export function canLog(loggerLevel: LogLevel, messageLevel: LogLevel): boolean { + return loggerLevel !== LogLevel.Off && loggerLevel <= messageLevel; +} + export function log(logger: ILogger, level: LogLevel, message: string): void { switch (level) { case LogLevel.Trace: logger.trace(message); break; @@ -236,7 +240,7 @@ export interface ILoggerService { /** * Deregister the logger for the given resource. */ - deregisterLogger(resource: URI): void; + deregisterLogger(idOrResource: URI | string): void; /** * Get all registered loggers @@ -267,7 +271,7 @@ export abstract class AbstractLogger extends Disposable implements ILogger { } protected checkLogLevel(level: LogLevel): boolean { - return this.level !== LogLevel.Off && this.level <= level; + return canLog(this.level, level); } protected canLog(level: LogLevel): boolean { @@ -701,7 +705,8 @@ export abstract class AbstractLoggerService extends Disposable implements ILogge } } - deregisterLogger(resource: URI): void { + deregisterLogger(idOrResource: URI | string): void { + const resource = this.toResource(idOrResource); const existing = this._loggers.get(resource); if (existing) { if (existing.logger) { diff --git a/src/vs/platform/mcp/common/mcpManagementCli.ts b/src/vs/platform/mcp/common/mcpManagementCli.ts index f25efca6999..cecdfaa07a4 100644 --- a/src/vs/platform/mcp/common/mcpManagementCli.ts +++ b/src/vs/platform/mcp/common/mcpManagementCli.ts @@ -5,9 +5,9 @@ import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogger } from '../../log/common/log.js'; -import { IMcpConfiguration, IMcpConfigurationSSE, IMcpConfigurationStdio } from './mcpPlatformTypes.js'; +import { IMcpConfiguration, IMcpConfigurationHTTP, IMcpConfigurationStdio, McpConfigurationServer } from './mcpPlatformTypes.js'; -type ValidatedConfig = { name: string; config: IMcpConfigurationStdio | IMcpConfigurationSSE }; +type ValidatedConfig = { name: string; config: IMcpConfigurationStdio | IMcpConfigurationHTTP }; export class McpManagementCli { constructor( @@ -35,7 +35,7 @@ export class McpManagementCli { } private validateConfiguration(config: string): ValidatedConfig { - let parsed: (IMcpConfigurationStdio | IMcpConfigurationSSE) & { name: string }; + let parsed: McpConfigurationServer & { name: string }; try { parsed = JSON.parse(config); } catch (e) { @@ -51,7 +51,7 @@ export class McpManagementCli { } const { name, ...rest } = parsed; - return { name, config: rest as IMcpConfigurationStdio | IMcpConfigurationSSE }; + return { name, config: rest as IMcpConfigurationStdio | IMcpConfigurationHTTP }; } } diff --git a/src/vs/platform/mcp/common/mcpPlatformTypes.ts b/src/vs/platform/mcp/common/mcpPlatformTypes.ts index 47df96028fb..078c70306cf 100644 --- a/src/vs/platform/mcp/common/mcpPlatformTypes.ts +++ b/src/vs/platform/mcp/common/mcpPlatformTypes.ts @@ -7,20 +7,21 @@ export interface IMcpConfiguration { inputs?: unknown[]; /** @deprecated Only for rough cross-compat with other formats */ mcpServers?: Record; - servers?: Record; + servers?: Record; } -export type McpConfigurationServer = IMcpConfigurationStdio | IMcpConfigurationSSE; +export type McpConfigurationServer = IMcpConfigurationStdio | IMcpConfigurationHTTP; export interface IMcpConfigurationStdio { type?: 'stdio'; command: string; args?: readonly string[]; env?: Record; + envFile?: string; } -export interface IMcpConfigurationSSE { - type: 'sse'; +export interface IMcpConfigurationHTTP { + type?: 'http'; url: string; headers?: Record; } diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 4d60c1d9f1b..f2268dd36fe 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { VSBuffer } from '../../../base/common/buffer.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { URI } from '../../../base/common/uri.js'; import { MessageBoxOptions, MessageBoxReturnValue, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from '../../../base/parts/sandbox/common/electronTypes.js'; @@ -38,6 +39,12 @@ export interface INativeHostOptions { readonly targetWindowId?: number; } +export interface IElementData { + readonly outerHTML: string; + readonly computedStyle: string; + readonly bounds: IRectangle; +} + export interface ICommonNativeHostService { readonly _serviceBrand: undefined; @@ -55,6 +62,7 @@ export interface ICommonNativeHostService { readonly onDidBlurMainWindow: Event; readonly onDidChangeWindowFullScreen: Event<{ windowId: number; fullscreen: boolean }>; + readonly onDidChangeWindowAlwaysOnTop: Event<{ windowId: number; alwaysOnTop: boolean }>; readonly onDidFocusMainOrAuxiliaryWindow: Event; readonly onDidBlurMainOrAuxiliaryWindow: Event; @@ -92,6 +100,10 @@ export interface ICommonNativeHostService { moveWindowTop(options?: INativeHostOptions): Promise; positionWindow(position: IRectangle, options?: INativeHostOptions): Promise; + isWindowAlwaysOnTop(options?: INativeHostOptions): Promise; + toggleWindowAlwaysOnTop(options?: INativeHostOptions): Promise; + setWindowAlwaysOnTop(alwaysOnTop: boolean, options?: INativeHostOptions): Promise; + /** * Only supported on Windows and macOS. Updates the window controls to match the title bar size. * @@ -143,13 +155,15 @@ export interface ICommonNativeHostService { hasWSLFeatureInstalled(): Promise; // Screenshots - getScreenshot(): Promise; + getScreenshot(rect?: IRectangle): Promise; + getElementData(offsetX: number, offsetY: number, token: CancellationToken, cancellationId?: number): Promise; // Process getProcessId(): Promise; killProcess(pid: number, code: string): Promise; // Clipboard + triggerPaste(options?: INativeHostOptions): Promise; readClipboardText(type?: 'selection' | 'clipboard'): Promise; writeClipboardText(text: string, type?: 'selection' | 'clipboard'): Promise; readClipboardFindText(): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 6324e98e290..223ffbdfc1f 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -28,7 +28,7 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; -import { ICommonNativeHostService, INativeHostOptions, IOSProperties, IOSStatistics } from '../common/native.js'; +import { ICommonNativeHostService, IElementData, INativeHostOptions, IOSProperties, IOSStatistics } from '../common/native.js'; import { IProductService } from '../../product/common/productService.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; import { IThemeMainService } from '../../theme/electron-main/themeMainService.js'; @@ -48,11 +48,18 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; import { randomPath } from '../../../base/common/extpath.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; export interface INativeHostMainService extends AddFirstParameterToFunctions /* only methods, not events */, number | undefined /* window ID */> { } export const INativeHostMainService = createDecorator('nativeHostMainService'); +interface NodeDataResponse { + outerHTML: string; + computedStyle: string; + bounds: IRectangle; +} + export class NativeHostMainService extends Disposable implements INativeHostMainService { declare readonly _serviceBrand: undefined; @@ -97,6 +104,11 @@ export class NativeHostMainService extends Disposable implements INativeHostMain Event.map(this.auxiliaryWindowsMainService.onDidChangeFullScreen, e => ({ windowId: e.window.id, fullscreen: e.fullscreen })) ); + this.onDidChangeWindowAlwaysOnTop = Event.any( + Event.None, // always on top is unsupported in main windows currently + Event.map(this.auxiliaryWindowsMainService.onDidChangeAlwaysOnTop, e => ({ windowId: e.window.id, alwaysOnTop: e.alwaysOnTop })) + ); + this.onDidBlurMainWindow = Event.filter(Event.fromNodeEventEmitter(app, 'browser-window-blur', (event, window: BrowserWindow) => window.id), windowId => !!this.windowsMainService.getWindowById(windowId)); this.onDidFocusMainWindow = Event.any( Event.map(Event.filter(Event.map(this.windowsMainService.onDidChangeWindowsCount, () => this.windowsMainService.getLastActiveWindow()), window => !!window), window => window!.id), @@ -154,6 +166,8 @@ export class NativeHostMainService extends Disposable implements INativeHostMain readonly onDidBlurMainOrAuxiliaryWindow: Event; readonly onDidFocusMainOrAuxiliaryWindow: Event; + readonly onDidChangeWindowAlwaysOnTop: Event<{ readonly windowId: number; readonly alwaysOnTop: boolean }>; + readonly onDidResumeOS: Event; readonly onDidChangeColorScheme: Event; @@ -304,6 +318,21 @@ export class NativeHostMainService extends Disposable implements INativeHostMain window?.win?.moveTop(); } + async isWindowAlwaysOnTop(windowId: number | undefined, options?: INativeHostOptions): Promise { + const window = this.windowById(options?.targetWindowId, windowId); + return window?.win?.isAlwaysOnTop() ?? false; + } + + async toggleWindowAlwaysOnTop(windowId: number | undefined, options?: INativeHostOptions): Promise { + const window = this.windowById(options?.targetWindowId, windowId); + window?.win?.setAlwaysOnTop(!window.win.isAlwaysOnTop()); + } + + async setWindowAlwaysOnTop(windowId: number | undefined, alwaysOnTop: boolean, options?: INativeHostOptions): Promise { + const window = this.windowById(options?.targetWindowId, windowId); + window?.win?.setAlwaysOnTop(alwaysOnTop); + } + async positionWindow(windowId: number | undefined, position: IRectangle, options?: INativeHostOptions): Promise { const window = this.windowById(options?.targetWindowId, windowId); if (window?.win) { @@ -720,11 +749,303 @@ export class NativeHostMainService extends Disposable implements INativeHostMain //#region Screenshots - async getScreenshot(windowId: number | undefined, options?: INativeHostOptions): Promise { + async getScreenshot(windowId: number | undefined, rect?: IRectangle, options?: INativeHostOptions): Promise { const window = this.windowById(options?.targetWindowId, windowId); - const captured = await window?.win?.webContents.capturePage(); + const captured = await window?.win?.webContents.capturePage(rect); - return captured?.toJPEG(95); + const buf = captured?.toJPEG(95); + return buf && VSBuffer.wrap(buf); + } + + async getElementData(windowId: number | undefined, offsetX: number = 0, offsetY: number = 0, token: CancellationToken, cancellationId?: number): Promise { + const window = this.windowById(windowId, windowId); + if (!window?.win) { + return undefined; + } + + // Find the simple browser webview + const allWebContents = webContents.getAllWebContents(); + const simpleBrowserWebview = allWebContents.find(webContent => webContent.getTitle().includes('Simple Browser')); + + if (!simpleBrowserWebview) { + return undefined; + } + + const debuggers = simpleBrowserWebview.debugger; + debuggers.attach(); + + const { targetInfos } = await debuggers.sendCommand('Target.getTargets'); + let resultId: string | undefined = undefined; + let target: typeof targetInfos[number] | undefined = undefined; + let targetSessionId: number | undefined = undefined; + try { + // find parent id and extract id + const matchingTarget = targetInfos.find((targetInfo: { url: string }) => { + const url = new URL(targetInfo.url); + return url.searchParams.get('parentId') === window?.id.toString(); + }); + + if (matchingTarget) { + const url = new URL(matchingTarget.url); + resultId = url.searchParams.get('id')!; + } + + // use id to grab simple browser target + if (resultId) { + target = targetInfos.find((targetInfo: { url: string }) => { + const url = new URL(targetInfo.url); + return url.searchParams.get('id') === resultId && url.searchParams.get('vscodeBrowserReqId')!; + }); + } + + const { sessionId } = await debuggers.sendCommand('Target.attachToTarget', { + targetId: target.targetId, + flatten: true, + }); + + targetSessionId = sessionId; + + await debuggers.sendCommand('DOM.enable', {}, sessionId); + await debuggers.sendCommand('CSS.enable', {}, sessionId); + await debuggers.sendCommand('Overlay.enable', {}, sessionId); + await debuggers.sendCommand('Debugger.enable', {}, sessionId); + await debuggers.sendCommand('Runtime.enable', {}, sessionId); + + await debuggers.sendCommand('Runtime.evaluate', { + expression: `(function() { + const style = document.createElement('style'); + style.id = '__pseudoBlocker__'; + style.textContent = '*::before, *::after { pointer-events: none !important; }'; + document.head.appendChild(style); + })();`, + }, sessionId); + + // slightly changed default CDP debugger inspect colors + await debuggers.sendCommand('Overlay.setInspectMode', { + mode: 'searchForNode', + highlightConfig: { + showInfo: true, + showRulers: false, + showStyles: true, + showAccessibilityInfo: true, + showExtensionLines: false, + contrastAlgorithm: 'aa', + contentColor: { r: 173, g: 216, b: 255, a: 0.8 }, + paddingColor: { r: 150, g: 200, b: 255, a: 0.5 }, + borderColor: { r: 120, g: 180, b: 255, a: 0.7 }, + marginColor: { r: 200, g: 220, b: 255, a: 0.4 }, + eventTargetColor: { r: 130, g: 160, b: 255, a: 0.8 }, + shapeColor: { r: 130, g: 160, b: 255, a: 0.8 }, + shapeMarginColor: { r: 130, g: 160, b: 255, a: 0.5 }, + gridHighlightConfig: { + rowGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, + rowHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + columnGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, + columnHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + rowLineColor: { r: 120, g: 180, b: 255 }, + columnLineColor: { r: 120, g: 180, b: 255 }, + rowLineDash: true, + columnLineDash: true + }, + flexContainerHighlightConfig: { + containerBorder: { + color: { r: 120, g: 180, b: 255 }, + pattern: 'solid' + }, + itemSeparator: { + color: { r: 140, g: 190, b: 255 }, + pattern: 'solid' + }, + lineSeparator: { + color: { r: 140, g: 190, b: 255 }, + pattern: 'solid' + }, + mainDistributedSpace: { + hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + fillColor: { r: 140, g: 190, b: 255, a: 0.4 } + }, + crossDistributedSpace: { + hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + fillColor: { r: 140, g: 190, b: 255, a: 0.4 } + }, + rowGapSpace: { + hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + fillColor: { r: 140, g: 190, b: 255, a: 0.4 } + }, + columnGapSpace: { + hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + fillColor: { r: 140, g: 190, b: 255, a: 0.4 } + } + }, + flexItemHighlightConfig: { + baseSizeBox: { + hatchColor: { r: 130, g: 170, b: 255, a: 0.6 } + }, + baseSizeBorder: { + color: { r: 120, g: 180, b: 255 }, + pattern: 'solid' + }, + flexibilityArrow: { + color: { r: 130, g: 190, b: 255 } + } + }, + }, + }, sessionId); + } catch (e) { + debuggers.detach(); + throw new Error('No target found', e); + } + + if (!targetSessionId) { + debuggers.detach(); + throw new Error('No target session id found'); + } + + const nodeData = await this.getNodeData(targetSessionId, debuggers, window.win, cancellationId); + debuggers.detach(); + + const zoomFactor = simpleBrowserWebview.getZoomFactor(); + const scaledBounds = { + x: (nodeData.bounds.x + offsetX) * zoomFactor, + y: (nodeData.bounds.y + offsetY) * zoomFactor, + width: nodeData.bounds.width * zoomFactor, + height: nodeData.bounds.height * zoomFactor + }; + + return { outerHTML: nodeData.outerHTML, computedStyle: nodeData.computedStyle, bounds: scaledBounds }; + } + + async getNodeData(sessionId: number, debuggers: any, window: BrowserWindow, cancellationId?: number): Promise { + return new Promise((resolve, reject) => { + const onMessage = async (event: any, method: string, params: { backendNodeId: number }) => { + if (method === 'Overlay.inspectNodeRequested') { + debuggers.off('message', onMessage); + await debuggers.sendCommand('Runtime.evaluate', { + expression: `(() => { + const style = document.getElementById('__pseudoBlocker__'); + if (style) style.remove(); + })();`, + }, sessionId); + + const backendNodeId = params?.backendNodeId; + if (!backendNodeId) { + throw new Error('Missing backendNodeId in inspectNodeRequested event'); + } + + try { + await debuggers.sendCommand('DOM.getDocument', {}, sessionId); + const { nodeIds } = await debuggers.sendCommand('DOM.pushNodesByBackendIdsToFrontend', { backendNodeIds: [backendNodeId] }, sessionId); + if (!nodeIds || nodeIds.length === 0) { + throw new Error('Failed to get node IDs.'); + } + const nodeId = nodeIds[0]; + + const { model } = await debuggers.sendCommand('DOM.getBoxModel', { nodeId }, sessionId); + if (!model) { + throw new Error('Failed to get box model.'); + } + + const margin = model.margin; + const x = margin[0]; + const y = margin[1] + 32.4 + 35; // 32.4 is height of the title bar, 35 is height of the tab bar + const width = margin[2] - margin[0]; + const height = margin[5] - margin[1]; + + const matched = await debuggers.sendCommand('CSS.getMatchedStylesForNode', { nodeId }, sessionId); + if (!matched) { + throw new Error('Failed to get matched css.'); + } + + const formatted = this.formatMatchedStyles(matched); + const { outerHTML } = await debuggers.sendCommand('DOM.getOuterHTML', { nodeId }, sessionId); + if (!outerHTML) { + throw new Error('Failed to get outerHTML.'); + } + + resolve({ + outerHTML, + computedStyle: formatted, + bounds: { x, y, width, height } + }); + } catch (err) { + debuggers.off('message', onMessage); + debuggers.detach(); + reject(err); + + } + } + }; + + window.webContents.on('ipc-message', async (event, channel, closedCancellationId) => { + if (channel === `vscode:cancelElementSelection${cancellationId}`) { + if (cancellationId !== closedCancellationId) { + return; + } + debuggers.off('message', onMessage); + if (debuggers.isAttached()) { + debuggers.detach(); + } + window.webContents.removeAllListeners('ipc-message'); + } + }); + + debuggers.on('message', onMessage); + }); + } + + formatMatchedStyles(matched: any): string { + const lines: string[] = []; + + // inline + if (matched.inlineStyle?.cssProperties?.length) { + lines.push('/* Inline style */'); + lines.push('element {'); + for (const prop of matched.inlineStyle.cssProperties) { + if (prop.name && prop.value) { + lines.push(` ${prop.name}: ${prop.value};`); + } + } + lines.push('}\n'); + } + + // matched + if (matched.matchedCSSRules?.length) { + for (const ruleEntry of matched.matchedCSSRules) { + const rule = ruleEntry.rule; + const selectors = rule.selectorList.selectors.map((s: any) => s.text).join(', '); + lines.push(`/* Matched Rule from ${rule.origin} */`); + lines.push(`${selectors} {`); + for (const prop of rule.style.cssProperties) { + if (prop.name && prop.value) { + lines.push(` ${prop.name}: ${prop.value};`); + } + } + lines.push('}\n'); + } + } + + // inherited rules + if (matched.inherited?.length) { + let level = 1; + for (const inherited of matched.inherited) { + const rules = inherited.matchedCSSRules || []; + for (const ruleEntry of rules) { + const rule = ruleEntry.rule; + const selectors = rule.selectorList.selectors.map((s: any) => s.text).join(', '); + lines.push(`/* Inherited from ancestor level ${level} (${rule.origin}) */`); + lines.push(`${selectors} {`); + for (const prop of rule.style.cssProperties) { + if (prop.name && prop.value) { + lines.push(` ${prop.name}: ${prop.value};`); + } + } + lines.push('}\n'); + } + level++; + } + } + + return '\n' + lines.join('\n'); } //#endregion @@ -750,6 +1071,11 @@ export class NativeHostMainService extends Disposable implements INativeHostMain return clipboard.readText(type); } + async triggerPaste(windowId: number | undefined, options?: INativeHostOptions): Promise { + const window = this.windowById(options?.targetWindowId, windowId); + return window?.win?.webContents.paste() ?? Promise.resolve(); + } + async readImage(): Promise { return clipboard.readImage().toPNG(); } diff --git a/src/vs/platform/notification/common/notification.ts b/src/vs/platform/notification/common/notification.ts index f573e788fb3..a3b59ab038e 100644 --- a/src/vs/platform/notification/common/notification.ts +++ b/src/vs/platform/notification/common/notification.ts @@ -5,7 +5,6 @@ import { IAction } from '../../../base/common/actions.js'; import { Event } from '../../../base/common/event.js'; -import { IDisposable } from '../../../base/common/lifecycle.js'; import BaseSeverity from '../../../base/common/severity.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; @@ -22,6 +21,11 @@ export enum NotificationPriority { */ DEFAULT, + /** + * Optional priority: notification might only be visible from the notifications center. + */ + OPTIONAL, + /** * Silent priority: notification will only be visible from the notifications center. */ @@ -268,6 +272,14 @@ export interface INotificationHandle { close(): void; } +export interface IStatusHandle { + + /** + * Hide the status message. + */ + close(): void; +} + interface IBasePromptChoice { /** @@ -445,9 +457,9 @@ export interface INotificationService { * @param message the message to show as status * @param options provides some optional configuration options * - * @returns a disposable to hide the status message + * @returns a handle to hide the status message */ - status(message: NotificationMessage, options?: IStatusMessageOptions): IDisposable; + status(message: NotificationMessage, options?: IStatusMessageOptions): IStatusHandle; } export class NoOpNotification implements INotificationHandle { diff --git a/src/vs/platform/notification/test/common/testNotificationService.ts b/src/vs/platform/notification/test/common/testNotificationService.ts index f016e186cf5..80def17f244 100644 --- a/src/vs/platform/notification/test/common/testNotificationService.ts +++ b/src/vs/platform/notification/test/common/testNotificationService.ts @@ -4,8 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; -import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; -import { INotification, INotificationHandle, INotificationService, INotificationSource, INotificationSourceFilter, IPromptChoice, IPromptOptions, IStatusMessageOptions, NoOpNotification, NotificationsFilter, Severity } from '../../common/notification.js'; +import { INotification, INotificationHandle, INotificationService, INotificationSource, INotificationSourceFilter, IPromptChoice, IPromptOptions, IStatusHandle, IStatusMessageOptions, NoOpNotification, NotificationsFilter, Severity } from '../../common/notification.js'; export class TestNotificationService implements INotificationService { @@ -39,8 +38,10 @@ export class TestNotificationService implements INotificationService { return TestNotificationService.NO_OP; } - status(message: string | Error, options?: IStatusMessageOptions): IDisposable { - return Disposable.None; + status(message: string | Error, options?: IStatusMessageOptions): IStatusHandle { + return { + close: () => { } + }; } setFilter(): void { } diff --git a/src/vs/platform/observable/common/observableMemento.ts b/src/vs/platform/observable/common/observableMemento.ts index cf38e2fb80b..f901636f2b2 100644 --- a/src/vs/platform/observable/common/observableMemento.ts +++ b/src/vs/platform/observable/common/observableMemento.ts @@ -5,7 +5,9 @@ import { strictEquals } from '../../../base/common/equals.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +// eslint-disable-next-line local/code-no-deep-import-of-internal import { ObservableValue } from '../../../base/common/observableInternal/base.js'; +// eslint-disable-next-line local/code-no-deep-import-of-internal import { DebugNameData } from '../../../base/common/observableInternal/debugName.js'; import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; diff --git a/src/vs/platform/policy/common/filePolicyService.ts b/src/vs/platform/policy/common/filePolicyService.ts index 40d13159b9a..78c1860c918 100644 --- a/src/vs/platform/policy/common/filePolicyService.ts +++ b/src/vs/platform/policy/common/filePolicyService.ts @@ -6,11 +6,12 @@ import { ThrottledDelayer } from '../../../base/common/async.js'; import { Event } from '../../../base/common/event.js'; import { Iterable } from '../../../base/common/iterator.js'; +import { PolicyName } from '../../../base/common/policy.js'; import { isObject } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { FileOperationError, FileOperationResult, IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { AbstractPolicyService, IPolicyService, PolicyName, PolicyValue } from './policy.js'; +import { AbstractPolicyService, IPolicyService, PolicyValue } from './policy.js'; function keysDiff(a: Map, b: Map): string[] { const result: string[] = []; diff --git a/src/vs/platform/policy/common/policy.ts b/src/vs/platform/policy/common/policy.ts index b7313ff03cc..80b042416b0 100644 --- a/src/vs/platform/policy/common/policy.ts +++ b/src/vs/platform/policy/common/policy.ts @@ -7,11 +7,11 @@ import { IStringDictionary } from '../../../base/common/collections.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Iterable } from '../../../base/common/iterator.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { PolicyName } from '../../../base/common/policy.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -export type PolicyName = string; export type PolicyValue = string | number | boolean; -export type PolicyDefinition = { type: 'string' | 'number' | 'boolean' }; +export type PolicyDefinition = { type: 'string' | 'number' | 'boolean'; previewFeature?: boolean; defaultValue?: string | number | boolean }; export const IPolicyService = createDecorator('policy'); @@ -22,12 +22,13 @@ export interface IPolicyService { updatePolicyDefinitions(policyDefinitions: IStringDictionary): Promise>; getPolicyValue(name: PolicyName): PolicyValue | undefined; serialize(): IStringDictionary<{ definition: PolicyDefinition; value: PolicyValue }> | undefined; + readonly policyDefinitions: IStringDictionary; } export abstract class AbstractPolicyService extends Disposable implements IPolicyService { readonly _serviceBrand: undefined; - protected policyDefinitions: IStringDictionary = {}; + public policyDefinitions: IStringDictionary = {}; protected policies = new Map(); protected readonly _onDidChange = this._register(new Emitter()); @@ -38,7 +39,7 @@ export abstract class AbstractPolicyService extends Disposable implements IPolic this.policyDefinitions = { ...policyDefinitions, ...this.policyDefinitions }; if (size !== Object.keys(this.policyDefinitions).length) { - await this._updatePolicyDefinitions(policyDefinitions); + await this._updatePolicyDefinitions(this.policyDefinitions); } return Iterable.reduce(this.policies.entries(), (r, [name, value]) => ({ ...r, [name]: value }), {}); @@ -61,4 +62,5 @@ export class NullPolicyService implements IPolicyService { async updatePolicyDefinitions() { return {}; } getPolicyValue() { return undefined; } serialize() { return undefined; } + policyDefinitions: IStringDictionary = {}; } diff --git a/src/vs/platform/policy/common/policyIpc.ts b/src/vs/platform/policy/common/policyIpc.ts index 803f140ae2d..f0d59caf66f 100644 --- a/src/vs/platform/policy/common/policyIpc.ts +++ b/src/vs/platform/policy/common/policyIpc.ts @@ -6,8 +6,10 @@ import { IStringDictionary } from '../../../base/common/collections.js'; import { Event } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { PolicyName } from '../../../base/common/policy.js'; import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { AbstractPolicyService, IPolicyService, PolicyDefinition, PolicyName, PolicyValue } from './policy.js'; +import { AbstractPolicyService, IPolicyService, PolicyDefinition, PolicyValue } from './policy.js'; + export class PolicyChannel implements IServerChannel { diff --git a/src/vs/platform/process/electron-main/processMainService.ts b/src/vs/platform/process/electron-main/processMainService.ts index d2f6bbfd98a..df2af9b7a85 100644 --- a/src/vs/platform/process/electron-main/processMainService.ts +++ b/src/vs/platform/process/electron-main/processMainService.ts @@ -33,7 +33,6 @@ interface IBrowserWindowOptions { backgroundColor: string | undefined; title: string; zoomLevel: number; - alwaysOnTop: boolean; } type IStrictWindowState = Required>; @@ -145,8 +144,7 @@ export class ProcessMainService implements IProcessMainService { this.processExplorerWindow = this.createBrowserWindow(position, processExplorerWindowConfigUrl, { backgroundColor: data.styles.backgroundColor, title: localize('processExplorer', "Process Explorer"), - zoomLevel: data.zoomLevel, - alwaysOnTop: true + zoomLevel: data.zoomLevel }, 'process-explorer'); // Store into config object URL @@ -349,7 +347,7 @@ export class ProcessMainService implements IProcessMainService { zoomFactor: zoomLevelToZoomFactor(options.zoomLevel), sandbox: true }, - alwaysOnTop: options.alwaysOnTop, + alwaysOnTop: true, experimentalDarkMode: true }; const window = new BrowserWindow(browserWindowOptions); diff --git a/src/vs/platform/profiling/common/profilingTelemetrySpec.ts b/src/vs/platform/profiling/common/profilingTelemetrySpec.ts index d6f8fd92b77..81807f9d8b7 100644 --- a/src/vs/platform/profiling/common/profilingTelemetrySpec.ts +++ b/src/vs/platform/profiling/common/profilingTelemetrySpec.ts @@ -67,7 +67,17 @@ class PerformanceError extends Error { readonly selfTime: number; constructor(data: SampleData) { - super(`PerfSampleError: by ${data.source} in ${data.sample.location}`); + // Since the stacks are available via the sample + // we can avoid collecting them when constructing the error. + if (Error.hasOwnProperty('stackTraceLimit')) { + const Err = Error as any as { stackTraceLimit: number }; // For the monaco editor checks. + const stackTraceLimit = Err.stackTraceLimit; + Err.stackTraceLimit = 0; + super(`PerfSampleError: by ${data.source} in ${data.sample.location}`); + Err.stackTraceLimit = stackTraceLimit; + } else { + super(`PerfSampleError: by ${data.source} in ${data.sample.location}`); + } this.name = 'PerfSampleError'; this.selfTime = data.sample.selfTime; diff --git a/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorker.ts b/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorker.ts index 50acc3353a5..897cdeffc89 100644 --- a/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorker.ts +++ b/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorker.ts @@ -6,20 +6,16 @@ import { basename } from '../../../base/common/path.js'; import { TernarySearchTree } from '../../../base/common/ternarySearchTree.js'; import { URI } from '../../../base/common/uri.js'; -import { IRequestHandler, IWorkerServer } from '../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerServerRequestHandler } from '../../../base/common/worker/webWorker.js'; import { IV8Profile, Utils } from '../common/profiling.js'; import { IProfileModel, BottomUpSample, buildModel, BottomUpNode, processNode, CdpCallFrame } from '../common/profilingModel.js'; import { BottomUpAnalysis, IProfileAnalysisWorker, ProfilingOutput } from './profileAnalysisWorkerService.js'; -/** - * Defines the worker entry point. Must be exported and named `create`. - * @skipMangle - */ -export function create(workerServer: IWorkerServer): IRequestHandler { +export function create(): IWebWorkerServerRequestHandler { return new ProfileAnalysisWorker(); } -class ProfileAnalysisWorker implements IRequestHandler, IProfileAnalysisWorker { +class ProfileAnalysisWorker implements IWebWorkerServerRequestHandler, IProfileAnalysisWorker { _requestHandlerBrand: any; diff --git a/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorkerMain.ts b/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorkerMain.ts index bab4ec0dc5d..cd676f8a895 100644 --- a/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorkerMain.ts +++ b/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorkerMain.ts @@ -4,6 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { create } from './profileAnalysisWorker.js'; -import { bootstrapSimpleWorker } from '../../../base/common/worker/simpleWorkerBootstrap.js'; +import { bootstrapWebWorker } from '../../../base/common/worker/webWorkerBootstrap.js'; -bootstrapSimpleWorker(create); +bootstrapWebWorker(create); diff --git a/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorkerService.ts b/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorkerService.ts index 164bafa0242..b53efdadc1a 100644 --- a/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorkerService.ts +++ b/src/vs/platform/profiling/electron-sandbox/profileAnalysisWorkerService.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ -import { createWebWorker } from '../../../base/browser/defaultWorkerFactory.js'; +import { createWebWorker } from '../../../base/browser/webWorkerFactory.js'; import { URI } from '../../../base/common/uri.js'; -import { Proxied } from '../../../base/common/worker/simpleWorker.js'; +import { Proxied } from '../../../base/common/worker/webWorker.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; @@ -14,6 +14,7 @@ import { IV8Profile } from '../common/profiling.js'; import { BottomUpSample } from '../common/profilingModel.js'; import { reportSample } from '../common/profilingTelemetrySpec.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { FileAccess } from '../../../base/common/network.js'; export const enum ProfilingOutput { Failure, @@ -48,7 +49,7 @@ class ProfileAnalysisWorkerService implements IProfileAnalysisWorkerService { private async _withWorker(callback: (worker: Proxied) => Promise): Promise { const worker = createWebWorker( - 'vs/platform/profiling/electron-sandbox/profileAnalysisWorker', + FileAccess.asBrowserUri('vs/platform/profiling/electron-sandbox/profileAnalysisWorkerMain.js'), 'CpuProfileAnalysisWorker' ); diff --git a/src/vs/platform/prompts/common/config.ts b/src/vs/platform/prompts/common/config.ts index d448c2f0df7..bfef2fdcf59 100644 --- a/src/vs/platform/prompts/common/config.ts +++ b/src/vs/platform/prompts/common/config.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { ContextKeyExpr } from '../../contextkey/common/contextkey.js'; -import { IConfigurationService } from '../../configuration/common/configuration.js'; +import type { IConfigurationService } from '../../configuration/common/configuration.js'; +import { CONFIG_KEY, PROMPT_DEFAULT_SOURCE_FOLDER, INSTRUCTIONS_LOCATIONS_CONFIG_KEY, PROMPT_LOCATIONS_CONFIG_KEY, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER } from './constants.js'; /** * Configuration helper for the `reusable prompts` feature. - * @see {@link CONFIG_KEY} and {@link LOCATIONS_CONFIG_KEY}. + * @see {@link CONFIG_KEY}, {@link PROMPT_LOCATIONS_CONFIG_KEY} and {@link INSTRUCTIONS_LOCATIONS_CONFIG_KEY}. * * ### Functions * @@ -27,21 +28,9 @@ import { IConfigurationService } from '../../configuration/common/configuration. * - current root folder (if a single folder is open) */ export namespace PromptsConfig { - /** - * Configuration key for the `reusable prompts` feature - * (also known as `prompt files`, `prompt instructions`, etc.). - */ - export const CONFIG_KEY: string = 'chat.promptFiles'; - - /** - * Configuration key for the locations of reusable prompt files. - */ - export const LOCATIONS_CONFIG_KEY: string = 'chat.promptFilesLocations'; - - /** - * Default reusable prompt files source folder. - */ - export const DEFAULT_SOURCE_FOLDER = '.github/prompts'; + export const KEY = CONFIG_KEY; + export const PROMPT_LOCATIONS_KEY = PROMPT_LOCATIONS_CONFIG_KEY; + export const INSTRUCTIONS_LOCATION_KEY = INSTRUCTIONS_LOCATIONS_CONFIG_KEY; /** * Checks if the feature is enabled. @@ -62,12 +51,14 @@ export namespace PromptsConfig { /** * Get value of the `reusable prompt locations` configuration setting. - * @see {@link LOCATIONS_CONFIG_KEY}. + * @see {@link PROMPT_LOCATIONS_CONFIG_KEY} or {@link INSTRUCTIONS_LOCATIONS_CONFIG_KEY}. */ export const getLocationsValue = ( configService: IConfigurationService, + type: 'instructions' | 'prompt' ): Record | undefined => { - const configValue = configService.getValue(LOCATIONS_CONFIG_KEY); + const key = type === 'instructions' ? INSTRUCTIONS_LOCATIONS_CONFIG_KEY : PROMPT_LOCATIONS_CONFIG_KEY; + const configValue = configService.getValue(key); if (configValue === undefined || configValue === null || Array.isArray(configValue)) { return undefined; @@ -97,26 +88,28 @@ export namespace PromptsConfig { /** * Gets list of source folders for prompt files. - * Defaults to {@link DEFAULT_SOURCE_FOLDER}. + * Defaults to {@link PROMPT_DEFAULT_SOURCE_FOLDER} or {@link INSTRUCTIONS_DEFAULT_SOURCE_FOLDER}. */ export const promptSourceFolders = ( configService: IConfigurationService, + type: 'instructions' | 'prompt' ): string[] => { - const value = getLocationsValue(configService); + const value = getLocationsValue(configService, type); + const defaultSourceFolder = type === 'instructions' ? INSTRUCTIONS_DEFAULT_SOURCE_FOLDER : PROMPT_DEFAULT_SOURCE_FOLDER; // note! the `value &&` part handles the `undefined`, `null`, and `false` cases if (value && (typeof value === 'object')) { const paths: string[] = []; // if the default source folder is not explicitly disabled, add it - if (value[DEFAULT_SOURCE_FOLDER] !== false) { - paths.push(DEFAULT_SOURCE_FOLDER); + if (value[defaultSourceFolder] !== false) { + paths.push(defaultSourceFolder); } // copy all the enabled paths to the result list for (const [path, enabled] of Object.entries(value)) { // we already added the default source folder, so skip it - if ((enabled === false) || (path === DEFAULT_SOURCE_FOLDER)) { + if ((enabled === false) || (path === defaultSourceFolder)) { continue; } diff --git a/src/vs/platform/prompts/common/constants.ts b/src/vs/platform/prompts/common/constants.ts index a7ea766894b..b4102539986 100644 --- a/src/vs/platform/prompts/common/constants.ts +++ b/src/vs/platform/prompts/common/constants.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../base/common/uri.js'; -import { assert } from '../../../base/common/assert.js'; import { basename } from '../../../base/common/path.js'; /** @@ -13,29 +12,102 @@ import { basename } from '../../../base/common/path.js'; export const PROMPT_FILE_EXTENSION = '.prompt.md'; /** - * Check if provided path is a prompt file. + * File extension for the reusable instruction files. */ -export const isPromptFile = ( +export const INSTRUCTION_FILE_EXTENSION = '.instructions.md'; + +/** + * Copilot custom instructions file name. + */ +export const COPILOT_CUSTOM_INSTRUCTIONS_FILENAME = 'copilot-instructions.md'; + +/** + * Configuration key for the `reusable prompts` feature + * (also known as `prompt files`, `prompt instructions`, etc.). + */ +export const CONFIG_KEY: string = 'chat.promptFiles'; + +/** + * Configuration key for the locations of reusable prompt files. + */ +export const PROMPT_LOCATIONS_CONFIG_KEY: string = 'chat.promptFilesLocations'; + +/** + * Configuration key for the locations of instructions files. + */ +export const INSTRUCTIONS_LOCATIONS_CONFIG_KEY: string = 'chat.instructionsFilesLocations'; + +/** + * Default reusable prompt files source folder. + */ +export const PROMPT_DEFAULT_SOURCE_FOLDER = '.github/prompts'; + +/** + * Default reusable prompt files source folder. + */ +export const INSTRUCTIONS_DEFAULT_SOURCE_FOLDER = '.github/instructions'; + +/** + * Gets the prompt file type from the provided path. + */ +export function getPromptFileType(fileUri: URI): 'instructions' | 'prompt' | undefined { + const filename = basename(fileUri.path); + + if (filename.endsWith(PROMPT_FILE_EXTENSION)) { + return 'prompt'; + } + + if (filename.endsWith(INSTRUCTION_FILE_EXTENSION) || (filename === COPILOT_CUSTOM_INSTRUCTIONS_FILENAME)) { + return 'instructions'; + } + + return undefined; +} + +/** + * Check if provided URI points to a file that with prompt file extension. + */ +export function isPromptOrInstructionsFile(fileUri: URI): boolean { + return getPromptFileType(fileUri) !== undefined; +} + + +export function getPromptFileExtension(type: 'instructions' | 'prompt'): string { + return type === 'instructions' ? INSTRUCTION_FILE_EXTENSION : PROMPT_FILE_EXTENSION; +} + +/** + * Check whether provided URI belongs to an `untitled` document. + */ +export const isUntitled = ( fileUri: URI, ): boolean => { - return fileUri - .path - .endsWith(PROMPT_FILE_EXTENSION); + return fileUri.scheme === 'untitled'; }; /** * Gets clean prompt name without file extension. - * - * @throws If provided path is not a prompt file - * (does not end with {@link PROMPT_FILE_EXTENSION}). */ export const getCleanPromptName = ( fileUri: URI, ): string => { - assert( - isPromptFile(fileUri), - `Provided path '${fileUri.fsPath}' is not a prompt file.`, - ); + const fileName = basename(fileUri.path); - return basename(fileUri.path, PROMPT_FILE_EXTENSION); + if (fileName.endsWith(PROMPT_FILE_EXTENSION)) { + return basename(fileUri.path, PROMPT_FILE_EXTENSION); + } + + if (fileName.endsWith(INSTRUCTION_FILE_EXTENSION)) { + return basename(fileUri.path, INSTRUCTION_FILE_EXTENSION); + } + + if (fileName === COPILOT_CUSTOM_INSTRUCTIONS_FILENAME) { + return basename(fileUri.path, '.md'); + } + + // because we now rely on the `prompt` language ID that can be explicitly + // set for any document in the editor, any file can be a "prompt" file, so + // to account for that, we return the full file name including the file + // extension for all other cases + return basename(fileUri.path); }; diff --git a/src/vs/platform/prompts/test/common/config.test.ts b/src/vs/platform/prompts/test/common/config.test.ts index d79145e6588..5c6e6fd5b0a 100644 --- a/src/vs/platform/prompts/test/common/config.test.ts +++ b/src/vs/platform/prompts/test/common/config.test.ts @@ -22,7 +22,7 @@ const createMock = (value: T): IConfigurationService => { ); assert( - [PromptsConfig.CONFIG_KEY, PromptsConfig.LOCATIONS_CONFIG_KEY].includes(key), + [PromptsConfig.KEY, PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY].includes(key), `Unsupported configuration key '${key}'.`, ); @@ -39,7 +39,7 @@ suite('PromptsConfig', () => { const configService = createMock(undefined); assert.strictEqual( - PromptsConfig.getLocationsValue(configService), + PromptsConfig.getLocationsValue(configService, 'prompt'), undefined, 'Must read correct value.', ); @@ -49,7 +49,7 @@ suite('PromptsConfig', () => { const configService = createMock(null); assert.strictEqual( - PromptsConfig.getLocationsValue(configService), + PromptsConfig.getLocationsValue(configService, 'prompt'), undefined, 'Must read correct value.', ); @@ -58,7 +58,7 @@ suite('PromptsConfig', () => { suite('• object', () => { test('• empty', () => { assert.deepStrictEqual( - PromptsConfig.getLocationsValue(createMock({})), + PromptsConfig.getLocationsValue(createMock({}), 'prompt'), {}, 'Must read correct value.', ); @@ -79,7 +79,7 @@ suite('PromptsConfig', () => { 'some/folder.with.dots/another.file': true, '/var/logs/app.01.05.error': true, './.tempfile': true, - })), + }), 'prompt'), { '/root/.bashrc': true, '../../folder/.hidden-folder/config.xml': true, @@ -123,7 +123,7 @@ suite('PromptsConfig', () => { '\f\f': true, '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': randomInt(Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER), - })), + }), 'prompt'), { '../assets/img/logo.v2.png': true, '/mnt/storage/video.archive/episode.01.mkv': false, @@ -150,7 +150,7 @@ suite('PromptsConfig', () => { '/var/data/datafile.2025-02-05.json': '\n', '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': randomInt(Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER), - })), + }), 'prompt'), { '/mnt/storage/video.archive/episode.01.mkv': false, }, @@ -165,7 +165,7 @@ suite('PromptsConfig', () => { const configService = createMock(undefined); assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(configService), + PromptsConfig.promptSourceFolders(configService, 'prompt'), [], 'Must read correct value.', ); @@ -175,7 +175,7 @@ suite('PromptsConfig', () => { const configService = createMock(null); assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(configService), + PromptsConfig.promptSourceFolders(configService, 'prompt'), [], 'Must read correct value.', ); @@ -184,7 +184,7 @@ suite('PromptsConfig', () => { suite('object', () => { test('empty', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({})), + PromptsConfig.promptSourceFolders(createMock({}), 'prompt'), ['.github/prompts'], 'Must read correct value.', ); @@ -206,7 +206,7 @@ suite('PromptsConfig', () => { '/var/logs/app.01.05.error': true, '.GitHub/prompts': true, './.tempfile': true, - })), + }), 'prompt'), [ '.github/prompts', '/root/.bashrc', @@ -254,7 +254,7 @@ suite('PromptsConfig', () => { '\f\f': true, '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': randomInt(Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER), - })), + }), 'prompt'), [ '.github/prompts', '../assets/img/logo.v2.png', @@ -282,7 +282,7 @@ suite('PromptsConfig', () => { '/var/data/datafile.2025-02-05.json': '\n', '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': randomInt(Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER), - })), + }), 'prompt'), [ '.github/prompts', ], @@ -317,7 +317,7 @@ suite('PromptsConfig', () => { '\f\f': true, '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': randomInt(Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER), - })), + }), 'prompt'), [ '../assets/img/logo.v2.png', '../.local/bin/script.sh', diff --git a/src/vs/platform/prompts/test/common/constants.test.ts b/src/vs/platform/prompts/test/common/constants.test.ts index 376f8318c40..3dcd0927f98 100644 --- a/src/vs/platform/prompts/test/common/constants.test.ts +++ b/src/vs/platform/prompts/test/common/constants.test.ts @@ -5,8 +5,8 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; -import { getCleanPromptName, isPromptFile } from '../../common/constants.js'; import { randomInt } from '../../../../base/common/numbers.js'; +import { getCleanPromptName, isPromptOrInstructionsFile } from '../../common/constants.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -30,54 +30,59 @@ suite('Prompt Constants', () => { getCleanPromptName(URI.file(`./${expectedPromptName}.prompt.md`)), expectedPromptName, ); - }); - test('• throws if not a prompt file URI provided', () => { - assert.throws(() => { - getCleanPromptName(URI.file('/path/to/default.prompt.md1')); - }); + assert.strictEqual( + getCleanPromptName(URI.file('.github/copilot-instructions.md')), + 'copilot-instructions', + ); - assert.throws(() => { - getCleanPromptName(URI.file('./some.md')); - }); + assert.strictEqual( + getCleanPromptName(URI.file('/etc/prompts/my-prompt')), + 'my-prompt', + ); + assert.strictEqual( + getCleanPromptName(URI.file('../some-folder/frequent.txt')), + 'frequent.txt', + ); - assert.throws(() => { - getCleanPromptName(URI.file('../some-folder/frequent.txt')); - }); - - assert.throws(() => { - getCleanPromptName(URI.file('/etc/prompts/my-prompt')); - }); + assert.strictEqual( + getCleanPromptName(URI.parse('untitled:Untitled-1')), + 'Untitled-1', + ); }); }); - suite('• isPromptFile', () => { + suite('• isPromptOrInstructionsFile', () => { test('• returns `true` for prompt files', () => { assert( - isPromptFile(URI.file('/path/to/my-prompt.prompt.md')), + isPromptOrInstructionsFile(URI.file('/path/to/my-prompt.prompt.md')), ); assert( - isPromptFile(URI.file('../common.prompt.md')), + isPromptOrInstructionsFile(URI.file('../common.prompt.md')), ); assert( - isPromptFile(URI.file(`./some-${randomInt(1000)}.prompt.md`)), + isPromptOrInstructionsFile(URI.file(`./some-${randomInt(1000)}.prompt.md`)), + ); + + assert( + isPromptOrInstructionsFile(URI.file('.github/copilot-instructions.md')), ); }); test('• returns `false` for non-prompt files', () => { assert( - !isPromptFile(URI.file('/path/to/my-prompt.prompt.md1')), + !isPromptOrInstructionsFile(URI.file('/path/to/my-prompt.prompt.md1')), ); assert( - !isPromptFile(URI.file('../common.md')), + !isPromptOrInstructionsFile(URI.file('../common.md')), ); assert( - !isPromptFile(URI.file(`./some-${randomInt(1000)}.txt`)), + !isPromptOrInstructionsFile(URI.file(`./some-${randomInt(1000)}.txt`)), ); }); }); diff --git a/src/vs/platform/prompts/test/common/utils/mock.ts b/src/vs/platform/prompts/test/common/utils/mock.ts index a03543c6118..bb5f7ff7714 100644 --- a/src/vs/platform/prompts/test/common/utils/mock.ts +++ b/src/vs/platform/prompts/test/common/utils/mock.ts @@ -11,7 +11,7 @@ import { assertOneOf } from '../../../../../base/common/types.js'; * If you need to mock an `Service`, please use {@link mockService} * instead which provides better type safety guarantees for the case. * - * @throws Reading non-overidden property or function + * @throws Reading non-overridden property or function * on `TObject` throws an error. */ export function mockObject( @@ -53,7 +53,7 @@ type TAnyService = { * Same as more generic {@link mockObject} utility, but with * the service constraint on the `TService` type. * - * @throws Reading non-overidden property or function + * @throws Reading non-overridden property or function * on `TService` throws an error. */ export function mockService( diff --git a/src/vs/platform/protocol/electron-main/protocolMainService.ts b/src/vs/platform/protocol/electron-main/protocolMainService.ts index 2f0c61b8a81..f5c2e3a9dd7 100644 --- a/src/vs/platform/protocol/electron-main/protocolMainService.ts +++ b/src/vs/platform/protocol/electron-main/protocolMainService.ts @@ -5,7 +5,7 @@ import { session } from 'electron'; import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { COI, FileAccess, Schemas, CacheControlheaders } from '../../../base/common/network.js'; +import { COI, FileAccess, Schemas, CacheControlheaders, DocumentPolicyheaders } from '../../../base/common/network.js'; import { basename, extname, normalize } from '../../../base/common/path.js'; import { isLinux } from '../../../base/common/platform.js'; import { TernarySearchTree } from '../../../base/common/ternarySearchTree.js'; @@ -93,10 +93,10 @@ export class ProtocolMainService extends Disposable implements IProtocolMainServ private handleResourceRequest(request: Electron.ProtocolRequest, callback: ProtocolCallback): void { const path = this.requestToNormalizedFilePath(request); + const pathBasename = basename(path); let headers: Record | undefined; if (this.environmentService.crossOriginIsolated) { - const pathBasename = basename(path); if (pathBasename === 'workbench.html' || pathBasename === 'workbench-dev.html') { headers = COI.CoopAndCoep; } else { @@ -113,6 +113,16 @@ export class ProtocolMainService extends Disposable implements IProtocolMainServ }; } + // Document-policy header is needed for collecting + // JavaScript callstacks via https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental + // until https://github.com/electron/electron/issues/45356 is resolved. + if (pathBasename === 'workbench.html' || pathBasename === 'workbench-dev.html') { + headers = { + ...headers, + ...DocumentPolicyheaders + }; + } + // first check by validRoots if (this.validRoots.findSubstr(path)) { return callback({ path, headers }); diff --git a/src/vs/platform/quickinput/browser/commandsQuickAccess.ts b/src/vs/platform/quickinput/browser/commandsQuickAccess.ts index 513641b4da5..d2c32d03774 100644 --- a/src/vs/platform/quickinput/browser/commandsQuickAccess.ts +++ b/src/vs/platform/quickinput/browser/commandsQuickAccess.ts @@ -49,13 +49,13 @@ export abstract class AbstractCommandsQuickAccessProvider extends PickerQuickAcc private static WORD_FILTER = or(matchesPrefix, matchesWords, matchesContiguousSubString); - private readonly commandsHistory = this._register(this.instantiationService.createInstance(CommandsHistory)); + private readonly commandsHistory: CommandsHistory; protected override readonly options: ICommandsQuickAccessOptions; constructor( options: ICommandsQuickAccessOptions, - @IInstantiationService private readonly instantiationService: IInstantiationService, + @IInstantiationService instantiationService: IInstantiationService, @IKeybindingService protected readonly keybindingService: IKeybindingService, @ICommandService private readonly commandService: ICommandService, @ITelemetryService private readonly telemetryService: ITelemetryService, @@ -63,6 +63,8 @@ export abstract class AbstractCommandsQuickAccessProvider extends PickerQuickAcc ) { super(AbstractCommandsQuickAccessProvider.PREFIX, options); + this.commandsHistory = this._register(instantiationService.createInstance(CommandsHistory)); + this.options = options; } diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 24394ac42cb..31eb809d758 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -167,7 +167,8 @@ } .quick-input-widget.hidden-input .quick-input-list { - margin-top: 4px; /* reduce margins when input box hidden */ + margin-top: 4px; + /* reduce margins when input box hidden */ padding-bottom: 4px; } @@ -188,6 +189,10 @@ padding: 0 6px; } +.quick-input-list .quick-input-list-entry.indented { + padding-left: 1.3em; +} + .quick-input-list .quick-input-list-entry.quick-input-list-separator-border { border-top-width: 1px; border-top-style: solid; @@ -208,9 +213,9 @@ flex: 1; } -.quick-input-list .quick-input-list-checkbox { +.quick-input-widget .monaco-checkbox { + margin-right: 0; align-self: center; - margin: 0; } .quick-input-list .quick-input-list-icon { @@ -235,17 +240,6 @@ margin-left: 5px; } -.quick-input-widget.show-checkboxes .quick-input-list .quick-input-list-rows { - margin-left: 10px; -} - -.quick-input-widget .quick-input-list .quick-input-list-checkbox { - display: none; -} -.quick-input-widget.show-checkboxes .quick-input-list .quick-input-list-checkbox { - display: inline; -} - .quick-input-list .quick-input-list-rows > .quick-input-list-row { display: flex; align-items: center; @@ -253,7 +247,8 @@ .quick-input-list .quick-input-list-rows > .quick-input-list-row .monaco-icon-label, .quick-input-list .quick-input-list-rows > .quick-input-list-row .monaco-icon-label .monaco-icon-label-container > .monaco-icon-name-container { - flex: 1; /* make sure the icon label grows within the row */ + flex: 1; + /* make sure the icon label grows within the row */ } .quick-input-list .quick-input-list-rows > .quick-input-list-row .codicon[class*='codicon-'] { @@ -265,7 +260,8 @@ } .quick-input-list .quick-input-list-entry .quick-input-list-entry-keybinding { - margin-right: 8px; /* separate from the separator label or scrollbar if any */ + margin-right: 8px; + /* separate from the separator label or scrollbar if any */ } .quick-input-list .quick-input-list-label-meta { @@ -288,7 +284,8 @@ } .quick-input-list .quick-input-list-entry .quick-input-list-separator { - margin-right: 4px; /* separate from keybindings or actions */ + margin-right: 4px; + /* separate from keybindings or actions */ } .quick-input-list .quick-input-list-entry-action-bar { @@ -315,7 +312,8 @@ } .quick-input-list .quick-input-list-entry-action-bar { - margin-right: 4px; /* separate from scrollbar */ + margin-right: 4px; + /* separate from scrollbar */ } .quick-input-list .quick-input-list-entry .quick-input-list-entry-action-bar .action-label.always-visible, @@ -331,6 +329,7 @@ .quick-input-list .monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator { color: inherit } + .quick-input-list .monaco-list-row.focused .monaco-keybinding-key { background: none; } diff --git a/src/vs/platform/quickinput/browser/quickInput.ts b/src/vs/platform/quickinput/browser/quickInput.ts index 2324a1e3a76..60370bfdee3 100644 --- a/src/vs/platform/quickinput/browser/quickInput.ts +++ b/src/vs/platform/quickinput/browser/quickInput.ts @@ -13,7 +13,7 @@ import { IInputBoxStyles } from '../../../base/browser/ui/inputbox/inputBox.js'; import { IKeybindingLabelStyles } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListStyles } from '../../../base/browser/ui/list/listWidget.js'; import { IProgressBarStyles, ProgressBar } from '../../../base/browser/ui/progressbar/progressbar.js'; -import { IToggleStyles, Toggle } from '../../../base/browser/ui/toggle/toggle.js'; +import { Checkbox, IToggleStyles, Toggle } from '../../../base/browser/ui/toggle/toggle.js'; import { equals } from '../../../base/common/arrays.js'; import { TimeoutTimer } from '../../../base/common/async.js'; import { Codicon } from '../../../base/common/codicons.js'; @@ -103,7 +103,7 @@ export interface QuickInputUI { widget: HTMLElement; rightActionBar: ActionBar; inlineActionBar: ActionBar; - checkAll: HTMLInputElement; + checkAll: Checkbox; inputContainer: HTMLElement; filterContainer: HTMLElement; inputBox: QuickInputBox; @@ -912,7 +912,7 @@ export class QuickPick { - if (this.canSelectMany) { + if (this.canSelectMany && !selectedItems.some(i => i.pickable === false)) { if (selectedItems.length) { this.ui.list.setSelectedElements([]); } @@ -1057,6 +1057,9 @@ export class QuickPickdom.append(headerContainer, $('input.quick-input-check-all')); - checkAll.type = 'checkbox'; - checkAll.setAttribute('aria-label', localize('quickInput.checkAll', "Toggle all checkboxes")); - this._register(dom.addStandardDisposableListener(checkAll, dom.EventType.CHANGE, e => { + const checkAll = this._register(new Checkbox(localize('quickInput.checkAll', "Toggle all checkboxes"), false, { ...defaultCheckboxStyles, size: 15 })); + dom.append(headerContainer, checkAll.domNode); + this._register(checkAll.onChange(() => { const checked = checkAll.checked; list.setAllVisibleChecked(checked); })); - this._register(dom.addDisposableListener(checkAll, dom.EventType.CLICK, e => { + this._register(dom.addDisposableListener(checkAll.domNode, dom.EventType.CLICK, e => { if (e.x || e.y) { // Avoid 'click' triggered by 'space'... inputBox.setFocus(); } @@ -688,7 +689,7 @@ export class QuickInputController extends Disposable { ui.title.style.display = visibilities.title ? '' : 'none'; ui.description1.style.display = visibilities.description && (visibilities.inputBox || visibilities.checkAll) ? '' : 'none'; ui.description2.style.display = visibilities.description && !(visibilities.inputBox || visibilities.checkAll) ? '' : 'none'; - ui.checkAll.style.display = visibilities.checkAll ? '' : 'none'; + ui.checkAll.domNode.style.display = visibilities.checkAll ? '' : 'none'; ui.inputContainer.style.display = visibilities.inputBox ? '' : 'none'; ui.filterContainer.style.display = visibilities.inputBox ? '' : 'none'; ui.visibleCountContainer.style.display = visibilities.visibleCount ? '' : 'none'; @@ -706,16 +707,21 @@ export class QuickInputController extends Disposable { private setEnabled(enabled: boolean) { if (enabled !== this.enabled) { this.enabled = enabled; - for (const item of this.getUI().leftActionBar.viewItems) { + const ui = this.getUI(); + for (const item of ui.leftActionBar.viewItems) { (item as ActionViewItem).action.enabled = enabled; } - for (const item of this.getUI().rightActionBar.viewItems) { + for (const item of ui.rightActionBar.viewItems) { (item as ActionViewItem).action.enabled = enabled; } - this.getUI().checkAll.disabled = !enabled; - this.getUI().inputBox.enabled = enabled; - this.getUI().ok.enabled = enabled; - this.getUI().list.enabled = enabled; + if (enabled) { + ui.checkAll.enable(); + } else { + ui.checkAll.disable(); + } + ui.inputBox.enabled = enabled; + ui.ok.enabled = enabled; + ui.list.enabled = enabled; } } diff --git a/src/vs/platform/quickinput/browser/quickInputTree.ts b/src/vs/platform/quickinput/browser/quickInputTree.ts index 94f0488fc4e..bce6f79c693 100644 --- a/src/vs/platform/quickinput/browser/quickInputTree.ts +++ b/src/vs/platform/quickinput/browser/quickInputTree.ts @@ -3,44 +3,46 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as dom from '../../../base/browser/dom.js'; import * as cssJs from '../../../base/browser/cssValue.js'; -import { Emitter, Event, EventBufferer, IValueWithChangeEvent } from '../../../base/common/event.js'; -import { IHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegate.js'; -import { IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; -import { IObjectTreeElement, ITreeNode, ITreeRenderer, TreeVisibility } from '../../../base/browser/ui/tree/tree.js'; -import { localize } from '../../../nls.js'; -import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { WorkbenchObjectTree } from '../../list/browser/listService.js'; -import { IThemeService } from '../../theme/common/themeService.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator, IQuickPickSeparatorButtonEvent, QuickPickItem, QuickPickFocus } from '../common/quickInput.js'; -import { IMarkdownString } from '../../../base/common/htmlContent.js'; -import { IMatch } from '../../../base/common/filters.js'; -import { IListAccessibilityProvider, IListStyles } from '../../../base/browser/ui/list/listWidget.js'; -import { AriaRole } from '../../../base/browser/ui/aria/aria.js'; +import * as dom from '../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; -import { KeyCode } from '../../../base/common/keyCodes.js'; -import { OS } from '../../../base/common/platform.js'; -import { memoize } from '../../../base/common/decorators.js'; +import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { AriaRole } from '../../../base/browser/ui/aria/aria.js'; +import type { IHoverWidget, IManagedHoverTooltipMarkdownString } from '../../../base/browser/ui/hover/hover.js'; +import { IHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegate.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; import { IIconLabelValueOptions, IconLabel } from '../../../base/browser/ui/iconLabel/iconLabel.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; -import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; -import { isDark } from '../../theme/common/theme.js'; -import { URI } from '../../../base/common/uri.js'; -import { quickInputButtonToAction } from './quickInputUtils.js'; -import { Lazy } from '../../../base/common/lazy.js'; -import { IParsedLabelWithIcons, getCodiconAriaLabel, matchesFuzzyIconAware, parseLabelWithIcons } from '../../../base/common/iconLabels.js'; -import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; -import { compareAnything } from '../../../base/common/comparers.js'; -import { escape, ltrim } from '../../../base/common/strings.js'; +import { IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; +import { IListAccessibilityProvider, IListStyles } from '../../../base/browser/ui/list/listWidget.js'; +import { Checkbox } from '../../../base/browser/ui/toggle/toggle.js'; import { RenderIndentGuides } from '../../../base/browser/ui/tree/abstractTree.js'; -import { ThrottledDelayer } from '../../../base/common/async.js'; -import { isCancellationError } from '../../../base/common/errors.js'; -import type { IHoverWidget, IManagedHoverTooltipMarkdownString } from '../../../base/browser/ui/hover/hover.js'; -import { IAccessibilityService } from '../../accessibility/common/accessibility.js'; -import { observableValue, observableValueOpts, transaction } from '../../../base/common/observable.js'; +import { IObjectTreeElement, ITreeNode, ITreeRenderer, TreeVisibility } from '../../../base/browser/ui/tree/tree.js'; import { equals } from '../../../base/common/arrays.js'; +import { ThrottledDelayer } from '../../../base/common/async.js'; +import { compareAnything } from '../../../base/common/comparers.js'; +import { memoize } from '../../../base/common/decorators.js'; +import { isCancellationError } from '../../../base/common/errors.js'; +import { Emitter, Event, EventBufferer, IValueWithChangeEvent } from '../../../base/common/event.js'; +import { IMatch } from '../../../base/common/filters.js'; +import { IMarkdownString } from '../../../base/common/htmlContent.js'; +import { IParsedLabelWithIcons, getCodiconAriaLabel, matchesFuzzyIconAware, parseLabelWithIcons } from '../../../base/common/iconLabels.js'; +import { KeyCode } from '../../../base/common/keyCodes.js'; +import { Lazy } from '../../../base/common/lazy.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { observableValue, observableValueOpts, transaction } from '../../../base/common/observable.js'; +import { OS } from '../../../base/common/platform.js'; +import { escape, ltrim } from '../../../base/common/strings.js'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { IAccessibilityService } from '../../accessibility/common/accessibility.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { WorkbenchObjectTree } from '../../list/browser/listService.js'; +import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; +import { isDark } from '../../theme/common/theme.js'; +import { IThemeService } from '../../theme/common/themeService.js'; +import { IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator, IQuickPickSeparatorButtonEvent, QuickPickFocus, QuickPickItem } from '../common/quickInput.js'; +import { quickInputButtonToAction } from './quickInputUtils.js'; const $ = dom.$; @@ -67,8 +69,9 @@ interface IQuickPickElement extends IQuickInputItemLazyParts { interface IQuickInputItemTemplateData { entry: HTMLDivElement; - checkbox: HTMLInputElement; + checkbox: MutableDisposable; icon: HTMLDivElement; + outerLabel: HTMLElement; label: IconLabel; keybinding: KeybindingLabel; detail: IconLabel; @@ -320,7 +323,7 @@ abstract class BaseQuickInputListRenderer implement abstract templateId: string; constructor( - private readonly hoverDelegate: IHoverDelegate | undefined + private readonly hoverDelegate: IHoverDelegate | undefined, ) { } // TODO: only do the common stuff here and have a subclass handle their specific stuff @@ -332,13 +335,16 @@ abstract class BaseQuickInputListRenderer implement // Checkbox const label = dom.append(data.entry, $('label.quick-input-list-label')); + data.outerLabel = label; + data.checkbox = data.toDisposeTemplate.add(new MutableDisposable()); data.toDisposeTemplate.add(dom.addStandardDisposableListener(label, dom.EventType.CLICK, e => { - if (!data.checkbox.offsetParent) { // If checkbox not visible: - e.preventDefault(); // Prevent toggle of checkbox when it is immediately shown afterwards. #91740 + // `label` elements with role=checkboxes don't automatically toggle them like normal elements + if (data.checkbox.value && !e.defaultPrevented && data.checkbox.value.enabled) { + const checked = !data.checkbox.value.checked; + data.checkbox.value.checked = checked; + (data.element as QuickPickItemElement).checked = checked; } })); - data.checkbox = dom.append(label, $('input.quick-input-list-checkbox')); - data.checkbox.type = 'checkbox'; // Rows const rows = dom.append(label, $('.quick-input-list-rows')); @@ -402,14 +408,31 @@ class QuickPickItemElementRenderer extends BaseQuickInputListRenderer { - (data.element as QuickPickItemElement).checked = data.checkbox.checked; - })); + let checkbox = data.checkbox.value; + if (!checkbox) { + checkbox = new Checkbox(element.saneLabel, element.checked, { ...defaultCheckboxStyles, size: 15 }); + data.checkbox.value = checkbox; + data.outerLabel.prepend(checkbox.domNode); + } else { + checkbox.setTitle(element.saneLabel); + } - return data; + if (element.checkboxDisabled) { + checkbox.disable(); + } else { + checkbox.enable(); + } + + checkbox.checked = element.checked; + data.toDisposeElement.add(element.onChecked(checked => checkbox.checked = checked)); + data.toDisposeElement.add(checkbox.onChange(() => element.checked = checkbox.checked)); } renderElement(node: ITreeNode, index: number, data: IQuickInputItemTemplateData): void { @@ -418,9 +441,10 @@ class QuickPickItemElementRenderer extends BaseQuickInputListRenderer data.checkbox.checked = checked)); - data.checkbox.disabled = element.checkboxDisabled; + element.element.classList.toggle('indented', Boolean(mainItem.indented)); + element.element.classList.toggle('not-pickable', element.item.pickable === false); + + this.ensureCheckbox(element, data); const { labelHighlights, descriptionHighlights, detailHighlights } = element; @@ -554,12 +578,6 @@ class QuickPickSeparatorElementRenderer extends BaseQuickInputListRenderer, index: number, data: IQuickInputItemTemplateData): void { const element = node.element; data.element = element; @@ -1048,7 +1066,7 @@ export class QuickInputTree extends Disposable { setAllVisibleChecked(checked: boolean) { this._elementCheckedEventBufferer.bufferEvents(() => { this._itemElements.forEach(element => { - if (!element.hidden && !element.checkboxDisabled) { + if (!element.hidden && !element.checkboxDisabled && element.item.pickable !== false) { // Would fire an event if we didn't beffer the events element.checked = checked; } @@ -1086,7 +1104,7 @@ export class QuickInputTree extends Disposable { } const qpi = new QuickPickItemElement( index, - this._hasCheckboxes, + this._hasCheckboxes && item.pickable !== false, e => this._onButtonTriggered.fire(e), this._elementChecked, item, @@ -1520,7 +1538,7 @@ export class QuickInputTree extends Disposable { private _allVisibleChecked(elements: QuickPickItemElement[], whenNoneVisible = true) { for (let i = 0, n = elements.length; i < n; i++) { const element = elements[i]; - if (!element.hidden) { + if (!element.hidden && element.item.pickable !== false) { if (!element.checked) { return false; } else { diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 15932d2c831..f0d3e10fa34 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -51,6 +51,9 @@ export interface IQuickPickItem { */ disabled?: boolean; alwaysShow?: boolean; + indented?: boolean; + /** Defauls to true with `IQuickPick.canSelectMany`, can be false to disable picks for a single item */ + pickable?: boolean; } export interface IQuickPickSeparator { diff --git a/src/vs/platform/registry/common/platform.ts b/src/vs/platform/registry/common/platform.ts index b4a9bdd775e..9beaaf7c25c 100644 --- a/src/vs/platform/registry/common/platform.ts +++ b/src/vs/platform/registry/common/platform.ts @@ -48,6 +48,16 @@ class RegistryImpl implements IRegistry { public as(id: string): any { return this.data.get(id) || null; } + + public dispose() { + this.data.forEach((value) => { + if (Types.isFunction(value.dispose)) { + value.dispose(); + } + }); + this.data.clear(); + } + } export const Registry: IRegistry = new RegistryImpl(); diff --git a/src/vs/platform/telemetry/common/telemetryService.ts b/src/vs/platform/telemetry/common/telemetryService.ts index a41dd881471..4c72d65da4b 100644 --- a/src/vs/platform/telemetry/common/telemetryService.ts +++ b/src/vs/platform/telemetry/common/telemetryService.ts @@ -227,12 +227,12 @@ configurationRegistry.registerConfiguration({ description: localize('telemetry.telemetryLevel.policyDescription', "Controls the level of telemetry."), } }, - 'telemetry.disableFeedback': { + 'telemetry.feedback.enabled': { type: 'boolean', - default: false, - description: localize('telemetry.disableFeedback', "Disable feedback options."), + default: true, + description: localize('telemetry.feedback.enabled', "Enable feedback mechanisms such as the issue reporter, surveys, and feedback options in features like Copilot Chat."), policy: { - name: 'DisableFeedback', + name: 'EnableFeedback', minimumVersion: '1.99', } }, diff --git a/src/vs/platform/telemetry/common/telemetryUtils.ts b/src/vs/platform/telemetry/common/telemetryUtils.ts index 0b853b8bf40..73a75d7160c 100644 --- a/src/vs/platform/telemetry/common/telemetryUtils.ts +++ b/src/vs/platform/telemetry/common/telemetryUtils.ts @@ -338,7 +338,7 @@ function removePropertiesWithPossibleUserInfo(property: string): string { { label: 'Generic Secret', regex: /(key|token|sig|secret|signature|password|passwd|pwd|android:value)[^a-zA-Z0-9]/i }, { label: 'CLI Credentials', regex: /((login|psexec|(certutil|psexec)\.exe).{1,50}(\s-u(ser(name)?)?\s+.{3,100})?\s-(admin|user|vm|root)?p(ass(word)?)?\s+["']?[^$\-\/\s]|(^|[\s\r\n\\])net(\.exe)?.{1,5}(user\s+|share\s+\/user:| user -? secrets ? set) \s + [^ $\s \/])/ }, { label: 'Microsoft Entra ID', regex: /eyJ(?:0eXAiOiJKV1Qi|hbGci|[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.)/ }, - { label: 'Email', regex: /@[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+/ } // Regex which matches @*.site + { label: 'Email', regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/ } ]; // Check for common user data in the telemetry events diff --git a/src/vs/platform/telemetry/electron-main/errorTelemetry.ts b/src/vs/platform/telemetry/electron-main/errorTelemetry.ts new file mode 100644 index 00000000000..e093fda1474 --- /dev/null +++ b/src/vs/platform/telemetry/electron-main/errorTelemetry.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isSigPipeError, onUnexpectedError, setUnexpectedErrorHandler } from '../../../base/common/errors.js'; +import BaseErrorTelemetry from '../common/errorTelemetry.js'; +import { ITelemetryService } from '../common/telemetry.js'; +import { ILogService } from '../../../platform/log/common/log.js'; + +export default class ErrorTelemetry extends BaseErrorTelemetry { + constructor( + private readonly logService: ILogService, + @ITelemetryService telemetryService: ITelemetryService + ) { + super(telemetryService); + } + + protected override installErrorListeners(): void { + // We handle uncaught exceptions here to prevent electron from opening a dialog to the user + setUnexpectedErrorHandler(error => this.onUnexpectedError(error)); + + process.on('uncaughtException', error => { + if (!isSigPipeError(error)) { + onUnexpectedError(error); + } + }); + + process.on('unhandledRejection', (reason: unknown) => onUnexpectedError(reason)); + } + + private onUnexpectedError(error: Error): void { + this.logService.error(`[uncaught exception in main]: ${error}`); + if (error.stack) { + this.logService.error(error.stack); + } + } +} diff --git a/src/vs/platform/terminal/common/capabilities/capabilities.ts b/src/vs/platform/terminal/common/capabilities/capabilities.ts index 17b2429ff8f..505e1f8ce46 100644 --- a/src/vs/platform/terminal/common/capabilities/capabilities.ts +++ b/src/vs/platform/terminal/common/capabilities/capabilities.ts @@ -215,12 +215,14 @@ export interface ICommandDetectionCapability { /** The current cwd at the cursor's position. */ readonly cwd: string | undefined; readonly hasRichCommandDetection: boolean; + readonly promptType: string | undefined; readonly currentCommand: ICurrentPartialCommand | undefined; readonly onCommandStarted: Event; readonly onCommandFinished: Event; readonly onCommandExecuted: Event; readonly onCommandInvalidated: Event; readonly onCurrentCommandInvalidated: Event; + readonly onPromptTypeChanged: Event; readonly onSetRichCommandDetection: Event; setContinuationPrompt(value: string): void; setPromptTerminator(value: string, lastPromptLine: string): void; @@ -242,6 +244,7 @@ export interface ICommandDetectionCapability { handleCommandExecuted(options?: IHandleCommandOptions): void; handleCommandFinished(exitCode?: number, options?: IHandleCommandOptions): void; setHasRichCommandDetection(value: boolean): void; + setPromptType(value: string): void; /** * Set the command line explicitly. * @param commandLine The command line being set. diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 4af8f79dc28..e8ec4847a4d 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -10,6 +10,7 @@ import type { ITerminalCommand } from '../capabilities.js'; import { throttle } from '../../../../../base/common/decorators.js'; import type { Terminal, IMarker, IBufferCell, IBufferLine, IBuffer } from '@xterm/headless'; +import { PosixShellType, TerminalShellType } from '../../terminal.js'; const enum PromptInputState { Unknown = 0, @@ -38,6 +39,8 @@ export interface IPromptInputModel extends IPromptInputModelState { * empty (as opposed to '|'). */ getCombinedString(emptyStringWhenEmpty?: boolean): string; + + setShellType(shellType?: TerminalShellType): void; } export interface IPromptInputModelState { @@ -79,6 +82,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { private _commandStartX: number = 0; private _lastPromptLine: string | undefined; private _continuationPrompt: string | undefined; + private _shellType: TerminalShellType | undefined; private _lastUserInput: string = ''; @@ -135,6 +139,10 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { } } + setShellType(shellType: TerminalShellType): void { + this._shellType = shellType; + } + setContinuationPrompt(value: string): void { this._continuationPrompt = value; this._sync(); @@ -265,50 +273,71 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { return; } - const commandStartY = this._commandStartMarker?.line; + let commandStartY = this._commandStartMarker?.line; if (commandStartY === undefined) { return; } const buffer = this._xterm.buffer.active; let line = buffer.getLine(commandStartY); - const commandLine = line?.translateToString(true, this._commandStartX); - if (!line || commandLine === undefined) { + const absoluteCursorY = buffer.baseY + buffer.cursorY; + let cursorIndex: number | undefined; + + let commandLine = line?.translateToString(true, this._commandStartX); + if (this._shellType === PosixShellType.Fish && (!line || !commandLine)) { + commandStartY += 1; + line = buffer.getLine(commandStartY); + if (line) { + commandLine = line.translateToString(true); + cursorIndex = absoluteCursorY === commandStartY ? buffer.cursorX : commandLine?.trimEnd().length; + } + } + if (line === undefined || commandLine === undefined) { this._logService.trace(`PromptInputModel#_sync: no line`); return; } - const absoluteCursorY = buffer.baseY + buffer.cursorY; let value = commandLine; let ghostTextIndex = -1; - let cursorIndex: number; - if (absoluteCursorY === commandStartY) { - cursorIndex = this._getRelativeCursorIndex(this._commandStartX, buffer, line); - } else { - cursorIndex = commandLine.trimEnd().length; - } - - // Detect ghost text by looking for italic or dim text in or after the cursor and - // non-italic/dim text in the cell closest non-whitespace cell before the cursor - if (absoluteCursorY === commandStartY && buffer.cursorX > 1) { - // Ghost text in pwsh only appears to happen on the cursor line - ghostTextIndex = this._scanForGhostText(buffer, line, cursorIndex); + if (cursorIndex === undefined) { + if (absoluteCursorY === commandStartY) { + cursorIndex = this._getRelativeCursorIndex(this._commandStartX, buffer, line); + } else { + cursorIndex = commandLine.trimEnd().length; + } } // From command start line to cursor line for (let y = commandStartY + 1; y <= absoluteCursorY; y++) { - line = buffer.getLine(y); - const lineText = line?.translateToString(true); - if (lineText && line) { - // Check if the line wrapped without a new line (continuation) - if (line.isWrapped) { - value += lineText; - const relativeCursorIndex = this._getRelativeCursorIndex(0, buffer, line); + const nextLine = buffer.getLine(y); + const lineText = nextLine?.translateToString(true); + if (lineText && nextLine) { + // Check if the line wrapped without a new line (continuation) or + // we're on the last line and the continuation prompt is not present, so we need to add the value + if (nextLine.isWrapped || (absoluteCursorY === y && this._continuationPrompt && !this._lineContainsContinuationPrompt(lineText))) { + value += `${lineText}`; + const relativeCursorIndex = this._getRelativeCursorIndex(0, buffer, nextLine); if (absoluteCursorY === y) { cursorIndex += relativeCursorIndex; } else { cursorIndex += lineText.length; } + } else if (this._shellType === PosixShellType.Fish) { + if (value.endsWith('\\')) { + // Trim off the trailing backslash + value = value.substring(0, value.length - 1); + value += `${lineText.trim()}`; + cursorIndex += lineText.trim().length - 1; + } else { + if (/^ {6,}/.test(lineText)) { + // Was likely a new line + value += `\n${lineText.trim()}`; + cursorIndex += lineText.trim().length + 1; + } else { + value += lineText; + cursorIndex += lineText.length; + } + } } // Verify continuation prompt if we have it, if this line doesn't have it then the // user likely just pressed enter. @@ -316,35 +345,27 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { const trimmedLineText = this._trimContinuationPrompt(lineText); value += `\n${trimmedLineText}`; if (absoluteCursorY === y) { - const continuationCellWidth = this._getContinuationPromptCellWidth(line, lineText); - const relativeCursorIndex = this._getRelativeCursorIndex(continuationCellWidth, buffer, line); + const continuationCellWidth = this._getContinuationPromptCellWidth(nextLine, lineText); + const relativeCursorIndex = this._getRelativeCursorIndex(continuationCellWidth, buffer, nextLine); cursorIndex += relativeCursorIndex + 1; } else { cursorIndex += trimmedLineText.length + 1; } - } else if (absoluteCursorY === y && this._continuationPrompt && !this._lineContainsContinuationPrompt(lineText)) { - // We're on the last line and the continuation prompt is not present, so we need to add the value - value += `${lineText}`; - const relativeCursorIndex = this._getRelativeCursorIndex(0, buffer, line); - if (absoluteCursorY === y) { - cursorIndex += relativeCursorIndex; - } else { - cursorIndex += lineText.length; - } - break; } } } // Below cursor line for (let y = absoluteCursorY + 1; y < buffer.baseY + this._xterm.rows; y++) { - line = buffer.getLine(y); - const lineText = line?.translateToString(true); - if (lineText && line) { - if (this._continuationPrompt === undefined || this._lineContainsContinuationPrompt(lineText)) { + const belowCursorLine = buffer.getLine(y); + const lineText = belowCursorLine?.translateToString(true); + if (lineText && belowCursorLine) { + if (this._shellType === PosixShellType.Fish) { + value += `${lineText}`; + } else if (this._continuationPrompt === undefined || this._lineContainsContinuationPrompt(lineText)) { value += `\n${this._trimContinuationPrompt(lineText)}`; } else { - break; + value += lineText; } } else { break; @@ -415,6 +436,8 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { value = valueLines.map(e => e.trimEnd()).join('\n') + ' '.repeat(trailingWhitespace); } + ghostTextIndex = this._scanForGhostText(buffer, line, cursorIndex); + if (this._value !== value || this._cursorIndex !== cursorIndex || this._ghostTextIndex !== ghostTextIndex) { this._value = value; this._cursorIndex = cursorIndex; @@ -429,7 +452,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { /** * Detect ghost text by looking for italic or dim text in or after the cursor and - * non-italic/dim text in the cell closest non-whitespace cell before the cursor. + * non-italic/dim text in the first non-whitespace cell following command start and before the cursor. */ private _scanForGhostText(buffer: IBuffer, line: IBufferLine, cursorIndex: number): number { if (!this.value.trim().length) { @@ -529,7 +552,11 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { } } // Calculate the ghost text start index - ghostTextIndex = positionsWithGhostStyle[0] - this._commandStartX; + if (buffer.baseY + buffer.cursorY === this._commandStartMarker?.line) { + ghostTextIndex = positionsWithGhostStyle[0] - this._commandStartX; + } else { + ghostTextIndex = positionsWithGhostStyle[0]; + } } // Ensure no earlier cells in the line match `lastNonWhitespaceCell`'s style, diff --git a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts index 7237d1270d0..3bdc360144a 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts @@ -28,13 +28,15 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe protected _commands: TerminalCommand[] = []; private _cwd: string | undefined; private _promptTerminator: string | undefined; - private _currentCommand: PartialTerminalCommand = new PartialTerminalCommand(this._terminal); + private _currentCommand: PartialTerminalCommand; private _commandMarkers: IMarker[] = []; private _dimensions: ITerminalDimensions; private __isCommandStorageDisabled: boolean = false; private _handleCommandStartOptions?: IHandleCommandOptions; private _hasRichCommandDetection: boolean = false; get hasRichCommandDetection() { return this._hasRichCommandDetection; } + private _promptType: string | undefined; + get promptType(): string | undefined { return this._promptType; } private _ptyHeuristicsHooks: ICommandDetectionHeuristicsHooks; private readonly _ptyHeuristics: MandatoryMutableDisposable; @@ -73,6 +75,8 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe readonly onCommandInvalidated = this._onCommandInvalidated.event; private readonly _onCurrentCommandInvalidated = this._register(new Emitter()); readonly onCurrentCommandInvalidated = this._onCurrentCommandInvalidated.event; + private readonly _onPromptTypeChanged = this._register(new Emitter()); + readonly onPromptTypeChanged = this._onPromptTypeChanged.event; private readonly _onSetRichCommandDetection = this._register(new Emitter()); readonly onSetRichCommandDetection = this._onSetRichCommandDetection.event; @@ -81,7 +85,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe @ILogService private readonly _logService: ILogService ) { super(); - + this._currentCommand = new PartialTerminalCommand(this._terminal); this._promptInputModel = this._register(new PromptInputModel(this._terminal, this.onCommandStarted, this.onCommandStartChanged, this.onCommandExecuted, this._logService)); // Pull command line from the buffer if it was not set explicitly @@ -232,6 +236,11 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe this._onSetRichCommandDetection.fire(value); } + setPromptType(value: string): void { + this._promptType = value; + this._onPromptTypeChanged.fire(value); + } + setIsCommandStorageDisabled(): void { this.__isCommandStorageDisabled = true; } @@ -338,22 +347,20 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe this._ptyHeuristics.value.handleCommandStart(options); } - handleGenericCommand(options?: IHandleCommandOptions): void { - if (options?.markProperties?.disableCommandStorage) { - this.setIsCommandStorageDisabled(); - } - this.handlePromptStart(options); - this.handleCommandStart(options); - this.handleCommandExecuted(options); - this.handleCommandFinished(undefined, options); - } - handleCommandExecuted(options?: IHandleCommandOptions): void { this._ptyHeuristics.value.handleCommandExecuted(options); this._currentCommand.markExecutedTime(); } handleCommandFinished(exitCode: number | undefined, options?: IHandleCommandOptions): void { + // Command executed may not have happened yet, if not handle it now so the expected events + // properly propagate. This may cause the output to show up in the computed command line, + // but the command line confidence will be low in the extension host for example and + // therefore cannot be trusted anyway. + if (!this._currentCommand.commandExecutedMarker) { + this.handleCommandExecuted(); + } + this._currentCommand.markFinishedTime(); this._ptyHeuristics.value.preHandleCommandFinished?.(); diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 3f326233dc7..a380ed4089e 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -251,7 +251,8 @@ export const enum ProcessPropertyType { ResolvedShellLaunchConfig = 'resolvedShellLaunchConfig', OverrideDimensions = 'overrideDimensions', FailedShellIntegrationActivation = 'failedShellIntegrationActivation', - UsedShellIntegrationInjection = 'usedShellIntegrationInjection' + UsedShellIntegrationInjection = 'usedShellIntegrationInjection', + ShellIntegrationInjectionFailureReason = 'shellIntegrationInjectionFailureReason', } export interface IProcessProperty { @@ -270,6 +271,7 @@ export interface IProcessPropertyMap { [ProcessPropertyType.OverrideDimensions]: ITerminalDimensionsOverride | undefined; [ProcessPropertyType.FailedShellIntegrationActivation]: boolean | undefined; [ProcessPropertyType.UsedShellIntegrationInjection]: boolean | undefined; + [ProcessPropertyType.ShellIntegrationInjectionFailureReason]: ShellIntegrationInjectionFailureReason | undefined; } export interface IFixedTerminalDimensions { @@ -975,6 +977,51 @@ export const enum ShellIntegrationStatus { VSCode } + +export const enum ShellIntegrationInjectionFailureReason { + /** + * The setting is disabled. + */ + InjectionSettingDisabled = 'injectionSettingDisabled', + /** + * There is no executable (so there's no way to determine how to inject). + */ + NoExecutable = 'noExecutable', + /** + * It's a feature terminal (tasks, debug), unless it's explicitly being forced. + */ + FeatureTerminal = 'featureTerminal', + /** + * The ignoreShellIntegration flag is passed (eg. relaunching without shell integration). + */ + IgnoreShellIntegrationFlag = 'ignoreShellIntegrationFlag', + /** + * Shell integration doesn't work with winpty. + */ + Winpty = 'winpty', + /** + * We're conservative whether we inject when we don't recognize the arguments used for the + * shell as we would prefer launching one without shell integration than breaking their profile. + */ + UnsupportedArgs = 'unsupportedArgs', + /** + * The shell doesn't have built-in shell integration. Note that this doesn't mean the shell + * won't have shell integration in the end. + */ + UnsupportedShell = 'unsupportedShell', + + + /** + * For zsh, we failed to set the sticky bit on the shell integration script folder. + */ + FailedToSetStickyBit = 'failedToSetStickyBit', + + /** + * For zsh, we failed to create a temp directory for the shell integration script. + */ + FailedToCreateTmpDir = 'failedToCreateTmpDir', +} + export enum TerminalExitReason { Unknown = 0, Shutdown = 1, diff --git a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts index f17dbb5be08..49ff56819b1 100644 --- a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts +++ b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts @@ -583,6 +583,10 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati this._updatePromptTerminator(sanitizedValue); return true; } + case 'PromptType': { + this._createOrGetCommandDetection(this._terminal).setPromptType(value); + return true; + } case 'Task': { this._createOrGetBufferMarkDetection(this._terminal); this.capabilities.get(TerminalCapability.CommandDetection)?.setIsCommandStorageDisabled(); diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index 840334c8d4c..87edbb57a7b 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -78,7 +78,7 @@ export class PtyService extends Disposable implements IPtyService { // #region Pty service contribution RPC calls - private readonly _autoRepliesContribution = new AutoRepliesPtyServiceContribution(this._logService); + private readonly _autoRepliesContribution: AutoRepliesPtyServiceContribution; @traceRpc async installAutoReply(match: string, reply: string) { await this._autoRepliesContribution.installAutoReply(match, reply); @@ -90,9 +90,7 @@ export class PtyService extends Disposable implements IPtyService { // #endregion - private readonly _contributions: IPtyServiceContribution[] = [ - this._autoRepliesContribution - ]; + private readonly _contributions: IPtyServiceContribution[]; private _lastPtyId: number = 0; @@ -148,6 +146,11 @@ export class PtyService extends Disposable implements IPtyService { this._detachInstanceRequestStore = this._register(new RequestStore(undefined, this._logService)); this._detachInstanceRequestStore.onCreateRequest(this._onDidRequestDetach.fire, this._onDidRequestDetach); + + this._autoRepliesContribution = new AutoRepliesPtyServiceContribution(this._logService); + + this._contributions = [this._autoRepliesContribution]; + } @traceRpc diff --git a/src/vs/platform/terminal/node/terminalEnvironment.ts b/src/vs/platform/terminal/node/terminalEnvironment.ts index bacf1a64a74..2acd35de838 100644 --- a/src/vs/platform/terminal/node/terminalEnvironment.ts +++ b/src/vs/platform/terminal/node/terminalEnvironment.ts @@ -11,10 +11,12 @@ import * as process from '../../../base/common/process.js'; import { format } from '../../../base/common/strings.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; -import { IShellLaunchConfig, ITerminalEnvironment, ITerminalProcessOptions } from '../common/terminal.js'; +import { IShellLaunchConfig, ITerminalEnvironment, ITerminalProcessOptions, ShellIntegrationInjectionFailureReason } from '../common/terminal.js'; import { EnvironmentVariableMutatorType } from '../common/environmentVariable.js'; import { deserializeEnvironmentVariableCollections } from '../common/environmentVariableShared.js'; import { MergedEnvironmentVariableCollection } from '../common/environmentVariableCollection.js'; +import { chmod, realpathSync, mkdirSync } from 'fs'; +import { promisify } from 'util'; export function getWindowsBuildNumber(): number { const osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release()); @@ -26,59 +28,68 @@ export function getWindowsBuildNumber(): number { } export interface IShellIntegrationConfigInjection { + readonly type: 'injection'; /** * A new set of arguments to use. */ - newArgs: string[] | undefined; + readonly newArgs: string[] | undefined; /** * An optional environment to mixing to the real environment. */ - envMixin?: IProcessEnvironment; + readonly envMixin?: IProcessEnvironment; /** * An optional array of files to copy from `source` to `dest`. */ - filesToCopy?: { + readonly filesToCopy?: { source: string; dest: string; }[]; } +export interface IShellIntegrationInjectionFailure { + readonly type: 'failure'; + readonly reason: ShellIntegrationInjectionFailureReason; +} + /** * For a given shell launch config, returns arguments to replace and an optional environment to * mixin to the SLC's environment to enable shell integration. This must be run within the context * that creates the process to ensure accuracy. Returns undefined if shell integration cannot be * enabled. */ -export function getShellIntegrationInjection( +export async function getShellIntegrationInjection( shellLaunchConfig: IShellLaunchConfig, options: ITerminalProcessOptions, env: ITerminalEnvironment | undefined, logService: ILogService, - productService: IProductService -): IShellIntegrationConfigInjection | undefined { - // Conditionally disable shell integration arg injection - // - The global setting is disabled - // - There is no executable (not sure what script to run) - // - The terminal is used by a feature like tasks or debugging - const useWinpty = isWindows && (!options.windowsEnableConpty || getWindowsBuildNumber() < 18309); - if ( - // The global setting is disabled - !options.shellIntegration.enabled || - // There is no executable (so there's no way to determine how to inject) - !shellLaunchConfig.executable || - // It's a feature terminal (tasks, debug), unless it's explicitly being forced - (shellLaunchConfig.isFeatureTerminal && !shellLaunchConfig.forceShellIntegration) || - // The ignoreShellIntegration flag is passed (eg. relaunching without shell integration) - shellLaunchConfig.ignoreShellIntegration || - // Winpty is unsupported - useWinpty - ) { - return undefined; + productService: IProductService, + skipStickyBit: boolean = false +): Promise { + // The global setting is disabled + if (!options.shellIntegration.enabled) { + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.InjectionSettingDisabled }; + } + // There is no executable (so there's no way to determine how to inject) + if (!shellLaunchConfig.executable) { + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.NoExecutable }; + } + // It's a feature terminal (tasks, debug), unless it's explicitly being forced + if (shellLaunchConfig.isFeatureTerminal && !shellLaunchConfig.forceShellIntegration) { + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.FeatureTerminal }; + } + // The ignoreShellIntegration flag is passed (eg. relaunching without shell integration) + if (shellLaunchConfig.ignoreShellIntegration) { + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.IgnoreShellIntegrationFlag }; + } + // Shell integration doesn't work with winpty + if (isWindows && (!options.windowsEnableConpty || getWindowsBuildNumber() < 18309)) { + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.Winpty }; } const originalArgs = shellLaunchConfig.args; const shell = process.platform === 'win32' ? path.basename(shellLaunchConfig.executable).toLowerCase() : path.basename(shellLaunchConfig.executable); const appRoot = path.dirname(FileAccess.asFileUri('').fsPath); + const type = 'injection'; let newArgs: string[] | undefined; const envMixin: IProcessEnvironment = { 'VSCODE_INJECTION': '1' @@ -87,14 +98,16 @@ export function getShellIntegrationInjection( if (options.shellIntegration.nonce) { envMixin['VSCODE_NONCE'] = options.shellIntegration.nonce; } + // Temporarily pass list of hardcoded env vars for shell env api + const scopedDownShellEnvs = ['PATH', 'VIRTUAL_ENV', 'HOME', 'SHELL', 'PWD']; if (shellLaunchConfig.shellIntegrationEnvironmentReporting) { if (isWindows) { - const enableWindowsEnvReporting = options.windowsUseConptyDll || options.windowsEnableConpty && getWindowsBuildNumber() >= 22631; + const enableWindowsEnvReporting = options.windowsUseConptyDll || options.windowsEnableConpty && getWindowsBuildNumber() >= 22631 && shell !== 'bash.exe'; if (enableWindowsEnvReporting) { - envMixin['VSCODE_SHELL_ENV_REPORTING'] = '1'; + envMixin['VSCODE_SHELL_ENV_REPORTING'] = scopedDownShellEnvs.join(','); } } else { - envMixin['VSCODE_SHELL_ENV_REPORTING'] = '1'; + envMixin['VSCODE_SHELL_ENV_REPORTING'] = scopedDownShellEnvs.join(','); } } // Windows @@ -106,7 +119,7 @@ export function getShellIntegrationInjection( newArgs = shellIntegrationArgs.get(ShellIntegrationExecutable.WindowsPwshLogin); } if (!newArgs) { - return undefined; + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedArgs }; } newArgs = [...newArgs]; // Shallow clone the array to avoid setting the default array newArgs[newArgs.length - 1] = format(newArgs[newArgs.length - 1], appRoot, ''); @@ -114,7 +127,7 @@ export function getShellIntegrationInjection( if (options.shellIntegration.suggestEnabled) { envMixin['VSCODE_SUGGEST'] = '1'; } - return { newArgs, envMixin }; + return { type, newArgs, envMixin }; } else if (shell === 'bash.exe') { if (!originalArgs || originalArgs.length === 0) { newArgs = shellIntegrationArgs.get(ShellIntegrationExecutable.Bash); @@ -124,15 +137,15 @@ export function getShellIntegrationInjection( newArgs = shellIntegrationArgs.get(ShellIntegrationExecutable.Bash); } if (!newArgs) { - return undefined; + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedArgs }; } newArgs = [...newArgs]; // Shallow clone the array to avoid setting the default array newArgs[newArgs.length - 1] = format(newArgs[newArgs.length - 1], appRoot); envMixin['VSCODE_STABLE'] = productService.quality === 'stable' ? '1' : '0'; - return { newArgs, envMixin }; + return { type, newArgs, envMixin }; } logService.warn(`Shell integration cannot be enabled for executable "${shellLaunchConfig.executable}" and args`, shellLaunchConfig.args); - return undefined; + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedShell }; } // Linux & macOS @@ -146,12 +159,12 @@ export function getShellIntegrationInjection( newArgs = shellIntegrationArgs.get(ShellIntegrationExecutable.Bash); } if (!newArgs) { - return undefined; + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedArgs }; } newArgs = [...newArgs]; // Shallow clone the array to avoid setting the default array newArgs[newArgs.length - 1] = format(newArgs[newArgs.length - 1], appRoot); envMixin['VSCODE_STABLE'] = productService.quality === 'stable' ? '1' : '0'; - return { newArgs, envMixin }; + return { type, newArgs, envMixin }; } case 'fish': { if (!originalArgs || originalArgs.length === 0) { @@ -162,7 +175,7 @@ export function getShellIntegrationInjection( newArgs = originalArgs; } if (!newArgs) { - return undefined; + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedArgs }; } // On fish, '$fish_user_paths' is always prepended to the PATH, for both login and non-login shells, so we need @@ -171,7 +184,7 @@ export function getShellIntegrationInjection( newArgs = [...newArgs]; // Shallow clone the array to avoid setting the default array newArgs[newArgs.length - 1] = format(newArgs[newArgs.length - 1], appRoot); - return { newArgs, envMixin }; + return { type, newArgs, envMixin }; } case 'pwsh': { if (!originalArgs || arePwshImpliedArgs(originalArgs)) { @@ -180,7 +193,7 @@ export function getShellIntegrationInjection( newArgs = shellIntegrationArgs.get(ShellIntegrationExecutable.PwshLogin); } if (!newArgs) { - return undefined; + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedArgs }; } if (options.shellIntegration.suggestEnabled) { envMixin['VSCODE_SUGGEST'] = '1'; @@ -188,7 +201,7 @@ export function getShellIntegrationInjection( newArgs = [...newArgs]; // Shallow clone the array to avoid setting the default array newArgs[newArgs.length - 1] = format(newArgs[newArgs.length - 1], appRoot, ''); envMixin['VSCODE_STABLE'] = productService.quality === 'stable' ? '1' : '0'; - return { newArgs, envMixin }; + return { type, newArgs, envMixin }; } case 'zsh': { if (!originalArgs || originalArgs.length === 0) { @@ -200,7 +213,7 @@ export function getShellIntegrationInjection( newArgs = originalArgs; } if (!newArgs) { - return undefined; + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedArgs }; } newArgs = [...newArgs]; // Shallow clone the array to avoid setting the default array newArgs[newArgs.length - 1] = format(newArgs[newArgs.length - 1], appRoot); @@ -212,7 +225,42 @@ export function getShellIntegrationInjection( } catch { username = 'unknown'; } - const zdotdir = path.join(os.tmpdir(), `${username}-${productService.applicationName}-zsh`); + + // Resolve the actual tmp directory so we can set the sticky bit + const realTmpDir = realpathSync(os.tmpdir()); + const zdotdir = path.join(realTmpDir, `${username}-${productService.applicationName}-zsh`); + + // Set directory permissions using octal notation: + // - 0o1700: + // - Sticky bit is set, preventing non-owners from deleting or renaming files within this directory (1) + // - Owner has full read (4), write (2), execute (1) permissions + // - Group has no permissions (0) + // - Others have no permissions (0) + if (!skipStickyBit) { + // skip for tests + try { + const chmodAsync = promisify(chmod); + await chmodAsync(zdotdir, 0o1700); + } catch (err) { + if (err.message.includes('ENOENT')) { + try { + mkdirSync(zdotdir); + } catch (err) { + logService.error(`Failed to create zdotdir at ${zdotdir}: ${err}`); + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.FailedToCreateTmpDir }; + } + try { + const chmodAsync = promisify(chmod); + await chmodAsync(zdotdir, 0o1700); + } catch { + logService.error(`Failed to set sticky bit on ${zdotdir}: ${err}`); + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.FailedToSetStickyBit }; + } + } + logService.error(`Failed to set sticky bit on ${zdotdir}: ${err}`); + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.FailedToSetStickyBit }; + } + } envMixin['ZDOTDIR'] = zdotdir; const userZdotdir = env?.ZDOTDIR ?? os.homedir() ?? `~`; envMixin['USER_ZDOTDIR'] = userZdotdir; @@ -233,11 +281,11 @@ export function getShellIntegrationInjection( source: path.join(appRoot, 'out/vs/workbench/contrib/terminal/common/scripts/shellIntegration-login.zsh'), dest: path.join(zdotdir, '.zlogin') }); - return { newArgs, envMixin, filesToCopy }; + return { type, newArgs, envMixin, filesToCopy }; } } logService.warn(`Shell integration cannot be enabled for executable "${shellLaunchConfig.executable}" and args`, shellLaunchConfig.args); - return undefined; + return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedShell }; } /** diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index e33d5beccb3..2999fddce0a 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -99,7 +99,8 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess resolvedShellLaunchConfig: {}, overrideDimensions: undefined, failedShellIntegrationActivation: false, - usedShellIntegrationInjection: undefined + usedShellIntegrationInjection: undefined, + shellIntegrationInjectionFailureReason: undefined, }; private static _lastKillOrStart = 0; private _exitCode: number | undefined; @@ -208,39 +209,38 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess return firstError; } - let injection: IShellIntegrationConfigInjection | undefined; - if (this._options.shellIntegration.enabled) { - injection = getShellIntegrationInjection(this.shellLaunchConfig, this._options, this._ptyOptions.env, this._logService, this._productService); - if (injection) { - this._onDidChangeProperty.fire({ type: ProcessPropertyType.UsedShellIntegrationInjection, value: true }); - if (injection.envMixin) { - for (const [key, value] of Object.entries(injection.envMixin)) { - this._ptyOptions.env ||= {}; - this._ptyOptions.env[key] = value; - } + const injection = await getShellIntegrationInjection(this.shellLaunchConfig, this._options, this._ptyOptions.env, this._logService, this._productService); + if (injection.type === 'injection') { + this._onDidChangeProperty.fire({ type: ProcessPropertyType.UsedShellIntegrationInjection, value: true }); + if (injection.envMixin) { + for (const [key, value] of Object.entries(injection.envMixin)) { + this._ptyOptions.env ||= {}; + this._ptyOptions.env[key] = value; } - if (injection.filesToCopy) { - for (const f of injection.filesToCopy) { - try { - await fs.promises.mkdir(path.dirname(f.dest), { recursive: true }); - await fs.promises.copyFile(f.source, f.dest); - } catch { - // Swallow error, this should only happen when multiple users are on the same - // machine. Since the shell integration scripts rarely change, plus the other user - // should be using the same version of the server in this case, assume the script is - // fine if copy fails and swallow the error. - } - } - } - } else { - this._onDidChangeProperty.fire({ type: ProcessPropertyType.FailedShellIntegrationActivation, value: true }); } + if (injection.filesToCopy) { + for (const f of injection.filesToCopy) { + try { + await fs.promises.mkdir(path.dirname(f.dest), { recursive: true }); + await fs.promises.copyFile(f.source, f.dest); + } catch { + // Swallow error, this should only happen when multiple users are on the same + // machine. Since the shell integration scripts rarely change, plus the other user + // should be using the same version of the server in this case, assume the script is + // fine if copy fails and swallow the error. + } + } + } + } else { + this._onDidChangeProperty.fire({ type: ProcessPropertyType.FailedShellIntegrationActivation, value: true }); + this._onDidChangeProperty.fire({ type: ProcessPropertyType.ShellIntegrationInjectionFailureReason, value: injection.reason }); } try { - await this.setupPtyProcess(this.shellLaunchConfig, this._ptyOptions, injection); - if (injection?.newArgs) { - return { injectedArgs: injection.newArgs }; + const injectionConfig: IShellIntegrationConfigInjection | undefined = injection.type === 'injection' ? injection : undefined; + await this.setupPtyProcess(this.shellLaunchConfig, this._ptyOptions, injectionConfig); + if (injectionConfig?.newArgs) { + return { injectedArgs: injectionConfig.newArgs }; } return undefined; } catch (err) { diff --git a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts index dc34f387e61..f1bd6de1c86 100644 --- a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts +++ b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts @@ -12,6 +12,7 @@ import type { ITerminalCommand } from '../../../../common/capabilities/capabilit import { ok, notDeepStrictEqual, strictEqual } from 'assert'; import { timeout } from '../../../../../../base/common/async.js'; import { importAMDNodeModule } from '../../../../../../amdX.js'; +import { GeneralShellType, PosixShellType } from '../../../../common/terminal.js'; suite('PromptInputModel', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -381,6 +382,52 @@ suite('PromptInputModel', () => { await assertPromptInput('STRIKE1 normal STRIKE2|'); // No ghost text expected }); + suite('With wrapping', () => { + test('Fish ghost text in long line with wrapped content', async () => { + promptInputModel.setShellType(PosixShellType.Fish); + await writePromise('$ '); + fireCommandStart(); + await assertPromptInput('|'); + + // Write a command with ghost text that will wrap + await writePromise('find . -name'); + await assertPromptInput(`find . -name|`); + + // Add ghost text with dim style + await writePromise('\x1b[2m test\x1b[0m\x1b[4D'); + await assertPromptInput(`find . -name |[test]`); + + // Move cursor within the ghost text + await writePromise('\x1b[C'); + await assertPromptInput(`find . -name t|[est]`); + + // Accept ghost text + await writePromise('\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); + await assertPromptInput(`find . -name test|`); + }); + test('Pwsh ghost text in long line with wrapped content', async () => { + promptInputModel.setShellType(GeneralShellType.PowerShell); + await writePromise('$ '); + fireCommandStart(); + await assertPromptInput('|'); + + // Write a command with ghost text that will wrap + await writePromise('find . -name'); + await assertPromptInput(`find . -name|`); + + // Add ghost text with dim style + await writePromise('\x1b[2m test\x1b[0m\x1b[4D'); + await assertPromptInput(`find . -name |[test]`); + + // Move cursor within the ghost text + await writePromise('\x1b[C'); + await assertPromptInput(`find . -name t|[est]`); + + // Accept ghost text + await writePromise('\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); + await assertPromptInput(`find . -name test|`); + }); + }); }); test('wide input (Korean)', async () => { @@ -681,7 +728,7 @@ suite('PromptInputModel', () => { }); }); - suite('wrapped line (non-continuation)', () => { + suite('multi-line wrapped (no continuation prompt)', () => { test('basic wrapped line', async () => { xterm.resize(5, 10); @@ -698,6 +745,66 @@ suite('PromptInputModel', () => { await writePromise('"a"'); // HACK: Trailing whitespace is due to flaky detection in wrapped lines (but it doesn't matter much) await assertPromptInput(`echo "a"| `); + await writePromise('\n\r\ b'); + await assertPromptInput(`echo "a"\n b|`); + await writePromise('\n\r\ c'); + await assertPromptInput(`echo "a"\n b\n c|`); + }); + }); + suite('multi-line wrapped (continuation prompt)', () => { + test('basic wrapped line', async () => { + xterm.resize(5, 10); + promptInputModel.setContinuationPrompt('∙ '); + await writePromise('$ '); + fireCommandStart(); + await assertPromptInput('|'); + + await writePromise('ech'); + await assertPromptInput(`ech|`); + + await writePromise('o '); + await assertPromptInput(`echo |`); + + await writePromise('"a"'); + // HACK: Trailing whitespace is due to flaky detection in wrapped lines (but it doesn't matter much) + await assertPromptInput(`echo "a"| `); + await writePromise('\n\r\∙ '); + await assertPromptInput(`echo "a"\n|`); + await writePromise('b'); + await assertPromptInput(`echo "a"\nb|`); + await writePromise('\n\r\∙ '); + await assertPromptInput(`echo "a"\nb\n|`); + await writePromise('c'); + await assertPromptInput(`echo "a"\nb\nc|`); + await writePromise('\n\r\∙ '); + await assertPromptInput(`echo "a"\nb\nc\n|`); + }); + }); + suite('multi-line wrapped fish', () => { + test('forward slash continuation', async () => { + promptInputModel.setShellType(PosixShellType.Fish); + await writePromise('$ '); + await assertPromptInput('|'); + await writePromise('[I] meganrogge@Megans-MacBook-Pro ~ (main|BISECTING)>'); + fireCommandStart(); + + await writePromise('ech\\'); + await assertPromptInput(`ech\\|`); + await writePromise('\no bye'); + await assertPromptInput(`echo bye|`); + }); + test('newline with no continuation', async () => { + promptInputModel.setShellType(PosixShellType.Fish); + await writePromise('$ '); + await assertPromptInput('|'); + await writePromise('[I] meganrogge@Megans-MacBook-Pro ~ (main|BISECTING)>'); + fireCommandStart(); + await assertPromptInput('|'); + + await writePromise('echo "hi'); + await assertPromptInput(`echo "hi|`); + await writePromise('\nand bye\nwhy"'); + await assertPromptInput(`echo "hi\nand bye\nwhy"|`); }); }); diff --git a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts index 6248f83003d..be014833344 100644 --- a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts +++ b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-test-async-suite */ import { deepStrictEqual, ok, strictEqual } from 'assert'; import { homedir, userInfo } from 'os'; import { isWindows } from '../../../../base/common/platform.js'; @@ -10,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITerminalProcessOptions } from '../../common/terminal.js'; -import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection } from '../../node/terminalEnvironment.js'; +import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, type IShellIntegrationInjectionFailure } from '../../node/terminalEnvironment.js'; const enabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: true, suggestEnabled: false, nonce: '' }, windowsEnableConpty: true, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined }; const disabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: false, suggestEnabled: false, nonce: '' }, windowsEnableConpty: true, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined }; @@ -21,36 +22,37 @@ const logService = new NullLogService(); const productService = { applicationName: 'vscode' } as IProductService; const defaultEnvironment = {}; -function deepStrictEqualIgnoreStableVar(actual: IShellIntegrationConfigInjection | undefined, expected: IShellIntegrationConfigInjection) { - if (actual?.envMixin) { +function deepStrictEqualIgnoreStableVar(actual: IShellIntegrationConfigInjection | IShellIntegrationInjectionFailure | undefined, expected: IShellIntegrationConfigInjection) { + if (actual && 'envMixin' in actual && actual.envMixin) { delete actual.envMixin['VSCODE_STABLE']; } deepStrictEqual(actual, expected); } -suite('platform - terminalEnvironment', () => { +suite('platform - terminalEnvironment', async () => { ensureNoDisposablesAreLeakedInTestSuite(); - suite('getShellIntegrationInjection', () => { - suite('should not enable', () => { + suite('getShellIntegrationInjection', async () => { + suite('should not enable', async () => { // This test is only expected to work on Windows 10 build 18309 and above - (getWindowsBuildNumber() < 18309 ? test.skip : test)('when isFeatureTerminal or when no executable is provided', () => { - ok(!getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo'], isFeatureTerminal: true }, enabledProcessOptions, defaultEnvironment, logService, productService)); - ok(getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo'], isFeatureTerminal: false }, enabledProcessOptions, defaultEnvironment, logService, productService)); + (getWindowsBuildNumber() < 18309 ? test.skip : test)('when isFeatureTerminal or when no executable is provided', async () => { + strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo'], isFeatureTerminal: true }, enabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); + strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo'], isFeatureTerminal: false }, enabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'injection'); }); if (isWindows) { - test('when on windows with conpty false', () => { - ok(!getShellIntegrationInjection({ executable: pwshExe, args: ['-l'], isFeatureTerminal: false }, winptyProcessOptions, defaultEnvironment, logService, productService)); + test('when on windows with conpty false', async () => { + strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: ['-l'], isFeatureTerminal: false }, winptyProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); }); } }); // These tests are only expected to work on Windows 10 build 18309 and above - (getWindowsBuildNumber() < 18309 ? suite.skip : suite)('pwsh', () => { + (getWindowsBuildNumber() < 18309 ? suite.skip : suite)('pwsh', async () => { const expectedPs1 = process.platform === 'win32' ? `try { . "${repoRoot}\\out\\vs\\workbench\\contrib\\terminal\\common\\scripts\\shellIntegration.ps1" } catch {}` : `. "${repoRoot}/out/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1"`; - suite('should override args', () => { + suite('should override args', async () => { const enabledExpectedResult = Object.freeze({ + type: 'injection', newArgs: [ '-noexit', '-command', @@ -60,27 +62,28 @@ suite('platform - terminalEnvironment', () => { VSCODE_INJECTION: '1' } }); - test('when undefined, []', () => { - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: [] }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: undefined }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); + test('when undefined, []', async () => { + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: [] }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: undefined }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); }); - suite('when no logo', () => { - test('array - case insensitive', () => { - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: ['-NoLogo'] }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: ['-NOLOGO'] }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: ['-nol'] }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: ['-NOL'] }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); + suite('when no logo', async () => { + test('array - case insensitive', async () => { + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: ['-NoLogo'] }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: ['-NOLOGO'] }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: ['-nol'] }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: ['-NOL'] }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); }); - test('string - case insensitive', () => { - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: '-NoLogo' }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: '-NOLOGO' }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: '-nol' }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: '-NOL' }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); + test('string - case insensitive', async () => { + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: '-NoLogo' }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: '-NOLOGO' }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: '-nol' }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: '-NOL' }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); }); }); }); - suite('should incorporate login arg', () => { + suite('should incorporate login arg', async () => { const enabledExpectedResult = Object.freeze({ + type: 'injection', newArgs: [ '-l', '-noexit', @@ -91,31 +94,31 @@ suite('platform - terminalEnvironment', () => { VSCODE_INJECTION: '1' } }); - test('when array contains no logo and login', () => { - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo'] }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); + test('when array contains no logo and login', async () => { + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo'] }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); }); - test('when string', () => { - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: pwshExe, args: '-l' }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); + test('when string', async () => { + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: pwshExe, args: '-l' }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); }); }); - suite('should not modify args', () => { - test('when shell integration is disabled', () => { - strictEqual(getShellIntegrationInjection({ executable: pwshExe, args: ['-l'] }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); - strictEqual(getShellIntegrationInjection({ executable: pwshExe, args: '-l' }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); - strictEqual(getShellIntegrationInjection({ executable: pwshExe, args: undefined }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); + suite('should not modify args', async () => { + test('when shell integration is disabled', async () => { + strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: ['-l'] }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); + strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: '-l' }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); + strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: undefined }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); }); - test('when using unrecognized arg', () => { - strictEqual(getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo', '-i'] }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); + test('when using unrecognized arg', async () => { + strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: ['-l', '-NoLogo', '-i'] }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); }); - test('when using unrecognized arg (string)', () => { - strictEqual(getShellIntegrationInjection({ executable: pwshExe, args: '-i' }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); + test('when using unrecognized arg (string)', async () => { + strictEqual((await getShellIntegrationInjection({ executable: pwshExe, args: '-i' }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); }); }); }); if (process.platform !== 'win32') { - suite('zsh', () => { - suite('should override args', () => { + suite('zsh', async () => { + suite('should override args', async () => { const username = userInfo().username; const expectedDir = new RegExp(`.+\/${username}-vscode-zsh`); const customZdotdir = '/custom/zsh/dotdir'; @@ -146,48 +149,49 @@ suite('platform - terminalEnvironment', () => { ok(result.filesToCopy[2].source.match(expectedSources[2])); ok(result.filesToCopy[3].source.match(expectedSources[3])); } - test('when undefined, []', () => { - const result1 = getShellIntegrationInjection({ executable: 'zsh', args: [] }, enabledProcessOptions, defaultEnvironment, logService, productService); + test('when undefined, []', async () => { + const result1 = await getShellIntegrationInjection({ executable: 'zsh', args: [] }, enabledProcessOptions, defaultEnvironment, logService, productService, true) as IShellIntegrationConfigInjection; deepStrictEqual(result1?.newArgs, ['-i']); assertIsEnabled(result1); - const result2 = getShellIntegrationInjection({ executable: 'zsh', args: undefined }, enabledProcessOptions, defaultEnvironment, logService, productService); + const result2 = await getShellIntegrationInjection({ executable: 'zsh', args: undefined }, enabledProcessOptions, defaultEnvironment, logService, productService, true) as IShellIntegrationConfigInjection; deepStrictEqual(result2?.newArgs, ['-i']); assertIsEnabled(result2); }); - suite('should incorporate login arg', () => { - test('when array', () => { - const result = getShellIntegrationInjection({ executable: 'zsh', args: ['-l'] }, enabledProcessOptions, defaultEnvironment, logService, productService); + suite('should incorporate login arg', async () => { + test('when array', async () => { + const result = await getShellIntegrationInjection({ executable: 'zsh', args: ['-l'] }, enabledProcessOptions, defaultEnvironment, logService, productService, true) as IShellIntegrationConfigInjection; deepStrictEqual(result?.newArgs, ['-il']); assertIsEnabled(result); }); }); - suite('should not modify args', () => { - test('when shell integration is disabled', () => { - strictEqual(getShellIntegrationInjection({ executable: 'zsh', args: ['-l'] }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); - strictEqual(getShellIntegrationInjection({ executable: 'zsh', args: undefined }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); + suite('should not modify args', async () => { + test('when shell integration is disabled', async () => { + strictEqual((await getShellIntegrationInjection({ executable: 'zsh', args: ['-l'] }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); + strictEqual((await getShellIntegrationInjection({ executable: 'zsh', args: undefined }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); }); - test('when using unrecognized arg', () => { - strictEqual(getShellIntegrationInjection({ executable: 'zsh', args: ['-l', '-fake'] }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); + test('when using unrecognized arg', async () => { + strictEqual((await getShellIntegrationInjection({ executable: 'zsh', args: ['-l', '-fake'] }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); }); }); - suite('should incorporate global ZDOTDIR env variable', () => { - test('when custom ZDOTDIR', () => { - const result1 = getShellIntegrationInjection({ executable: 'zsh', args: [] }, enabledProcessOptions, { ...defaultEnvironment, ZDOTDIR: customZdotdir }, logService, productService); + suite('should incorporate global ZDOTDIR env variable', async () => { + test('when custom ZDOTDIR', async () => { + const result1 = await getShellIntegrationInjection({ executable: 'zsh', args: [] }, enabledProcessOptions, { ...defaultEnvironment, ZDOTDIR: customZdotdir }, logService, productService, true) as IShellIntegrationConfigInjection; deepStrictEqual(result1?.newArgs, ['-i']); assertIsEnabled(result1, customZdotdir); }); - test('when undefined', () => { - const result1 = getShellIntegrationInjection({ executable: 'zsh', args: [] }, enabledProcessOptions, undefined, logService, productService); + test('when undefined', async () => { + const result1 = await getShellIntegrationInjection({ executable: 'zsh', args: [] }, enabledProcessOptions, undefined, logService, productService, true) as IShellIntegrationConfigInjection; deepStrictEqual(result1?.newArgs, ['-i']); assertIsEnabled(result1); }); }); }); }); - suite('bash', () => { - suite('should override args', () => { - test('when undefined, [], empty string', () => { + suite('bash', async () => { + suite('should override args', async () => { + test('when undefined, [], empty string', async () => { const enabledExpectedResult = Object.freeze({ + type: 'injection', newArgs: [ '--init-file', `${repoRoot}/out/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh` @@ -196,12 +200,13 @@ suite('platform - terminalEnvironment', () => { VSCODE_INJECTION: '1' } }); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: 'bash', args: [] }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: 'bash', args: '' }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: 'bash', args: undefined }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: 'bash', args: [] }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: 'bash', args: '' }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: 'bash', args: undefined }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); }); - suite('should set login env variable and not modify args', () => { + suite('should set login env variable and not modify args', async () => { const enabledExpectedResult = Object.freeze({ + type: 'injection', newArgs: [ '--init-file', `${repoRoot}/out/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh` @@ -211,17 +216,17 @@ suite('platform - terminalEnvironment', () => { VSCODE_SHELL_LOGIN: '1' } }); - test('when array', () => { - deepStrictEqualIgnoreStableVar(getShellIntegrationInjection({ executable: 'bash', args: ['-l'] }, enabledProcessOptions, defaultEnvironment, logService, productService), enabledExpectedResult); + test('when array', async () => { + deepStrictEqualIgnoreStableVar(await getShellIntegrationInjection({ executable: 'bash', args: ['-l'] }, enabledProcessOptions, defaultEnvironment, logService, productService, true), enabledExpectedResult); }); }); - suite('should not modify args', () => { - test('when shell integration is disabled', () => { - strictEqual(getShellIntegrationInjection({ executable: 'bash', args: ['-l'] }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); - strictEqual(getShellIntegrationInjection({ executable: 'bash', args: undefined }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); + suite('should not modify args', async () => { + test('when shell integration is disabled', async () => { + strictEqual((await getShellIntegrationInjection({ executable: 'bash', args: ['-l'] }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); + strictEqual((await getShellIntegrationInjection({ executable: 'bash', args: undefined }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); }); - test('when custom array entry', () => { - strictEqual(getShellIntegrationInjection({ executable: 'bash', args: ['-l', '-i'] }, disabledProcessOptions, defaultEnvironment, logService, productService), undefined); + test('when custom array entry', async () => { + strictEqual((await getShellIntegrationInjection({ executable: 'bash', args: ['-l', '-i'] }, disabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); }); }); }); diff --git a/src/vs/platform/theme/browser/defaultStyles.ts b/src/vs/platform/theme/browser/defaultStyles.ts index 33ee6b363cd..1d61d3eec44 100644 --- a/src/vs/platform/theme/browser/defaultStyles.ts +++ b/src/vs/platform/theme/browser/defaultStyles.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IButtonStyles } from '../../../base/browser/ui/button/button.js'; import { IKeybindingLabelStyles } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; -import { ColorIdentifier, keybindingLabelBackground, keybindingLabelBorder, keybindingLabelBottomBorder, keybindingLabelForeground, asCssVariable, widgetShadow, buttonForeground, buttonSeparator, buttonBackground, buttonHoverBackground, buttonSecondaryForeground, buttonSecondaryBackground, buttonSecondaryHoverBackground, buttonBorder, progressBarBackground, inputActiveOptionBorder, inputActiveOptionForeground, inputActiveOptionBackground, editorWidgetBackground, editorWidgetForeground, contrastBorder, checkboxBorder, checkboxBackground, checkboxForeground, problemsErrorIconForeground, problemsWarningIconForeground, problemsInfoIconForeground, inputBackground, inputForeground, inputBorder, textLinkForeground, inputValidationInfoBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBorder, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBorder, inputValidationErrorBackground, inputValidationErrorForeground, listFilterWidgetBackground, listFilterWidgetNoMatchesOutline, listFilterWidgetOutline, listFilterWidgetShadow, badgeBackground, badgeForeground, breadcrumbsBackground, breadcrumbsForeground, breadcrumbsFocusForeground, breadcrumbsActiveSelectionForeground, activeContrastBorder, listActiveSelectionBackground, listActiveSelectionForeground, listActiveSelectionIconForeground, listDropOverBackground, listFocusAndSelectionOutline, listFocusBackground, listFocusForeground, listFocusOutline, listHoverBackground, listHoverForeground, listInactiveFocusBackground, listInactiveFocusOutline, listInactiveSelectionBackground, listInactiveSelectionForeground, listInactiveSelectionIconForeground, tableColumnsBorder, tableOddRowsBackgroundColor, treeIndentGuidesStroke, asCssVariableWithDefault, editorWidgetBorder, focusBorder, pickerGroupForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, selectBackground, selectBorder, selectForeground, selectListBackground, treeInactiveIndentGuidesStroke, menuBorder, menuForeground, menuBackground, menuSelectionForeground, menuSelectionBackground, menuSelectionBorder, menuSeparatorBackground, scrollbarShadow, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground, listDropBetweenBackground, radioActiveBackground, radioActiveForeground, radioInactiveBackground, radioInactiveForeground, radioInactiveBorder, radioInactiveHoverBackground, radioActiveBorder } from '../common/colorRegistry.js'; +import { ColorIdentifier, keybindingLabelBackground, keybindingLabelBorder, keybindingLabelBottomBorder, keybindingLabelForeground, asCssVariable, widgetShadow, buttonForeground, buttonSeparator, buttonBackground, buttonHoverBackground, buttonSecondaryForeground, buttonSecondaryBackground, buttonSecondaryHoverBackground, buttonBorder, progressBarBackground, inputActiveOptionBorder, inputActiveOptionForeground, inputActiveOptionBackground, editorWidgetBackground, editorWidgetForeground, contrastBorder, checkboxBorder, checkboxBackground, checkboxForeground, problemsErrorIconForeground, problemsWarningIconForeground, problemsInfoIconForeground, inputBackground, inputForeground, inputBorder, textLinkForeground, inputValidationInfoBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBorder, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBorder, inputValidationErrorBackground, inputValidationErrorForeground, listFilterWidgetBackground, listFilterWidgetNoMatchesOutline, listFilterWidgetOutline, listFilterWidgetShadow, badgeBackground, badgeForeground, breadcrumbsBackground, breadcrumbsForeground, breadcrumbsFocusForeground, breadcrumbsActiveSelectionForeground, activeContrastBorder, listActiveSelectionBackground, listActiveSelectionForeground, listActiveSelectionIconForeground, listDropOverBackground, listFocusAndSelectionOutline, listFocusBackground, listFocusForeground, listFocusOutline, listHoverBackground, listHoverForeground, listInactiveFocusBackground, listInactiveFocusOutline, listInactiveSelectionBackground, listInactiveSelectionForeground, listInactiveSelectionIconForeground, tableColumnsBorder, tableOddRowsBackgroundColor, treeIndentGuidesStroke, asCssVariableWithDefault, editorWidgetBorder, focusBorder, pickerGroupForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, selectBackground, selectBorder, selectForeground, selectListBackground, treeInactiveIndentGuidesStroke, menuBorder, menuForeground, menuBackground, menuSelectionForeground, menuSelectionBackground, menuSelectionBorder, menuSeparatorBackground, scrollbarShadow, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground, listDropBetweenBackground, radioActiveBackground, radioActiveForeground, radioInactiveBackground, radioInactiveForeground, radioInactiveBorder, radioInactiveHoverBackground, radioActiveBorder, checkboxDisabledBackground, checkboxDisabledForeground } from '../common/colorRegistry.js'; import { IProgressBarStyles } from '../../../base/browser/ui/progressbar/progressbar.js'; import { ICheckboxStyles, IToggleStyles } from '../../../base/browser/ui/toggle/toggle.js'; import { IDialogStyles } from '../../../base/browser/ui/dialog/dialog.js'; @@ -89,13 +89,11 @@ export function getToggleStyles(override: IStyleOverride): IToggl export const defaultCheckboxStyles: ICheckboxStyles = { checkboxBackground: asCssVariable(checkboxBackground), checkboxBorder: asCssVariable(checkboxBorder), - checkboxForeground: asCssVariable(checkboxForeground) + checkboxForeground: asCssVariable(checkboxForeground), + checkboxDisabledBackground: asCssVariable(checkboxDisabledBackground), + checkboxDisabledForeground: asCssVariable(checkboxDisabledForeground), }; -export function getCheckboxStyles(override: IStyleOverride): ICheckboxStyles { - return overrideStyles(override, defaultCheckboxStyles); -} - export const defaultDialogStyles: IDialogStyles = { dialogBackground: asCssVariable(editorWidgetBackground), dialogForeground: asCssVariable(editorWidgetForeground), diff --git a/src/vs/platform/theme/common/colorUtils.ts b/src/vs/platform/theme/common/colorUtils.ts index 684be5f3d6b..f55c8aad640 100644 --- a/src/vs/platform/theme/common/colorUtils.ts +++ b/src/vs/platform/theme/common/colorUtils.ts @@ -12,6 +12,7 @@ import { IJSONContributionRegistry, Extensions as JSONExtensions } from '../../j import * as platform from '../../registry/common/platform.js'; import { IColorTheme } from './themeService.js'; import * as nls from '../../../nls.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; // ------ API types @@ -50,7 +51,8 @@ export const enum ColorTransformType { Opaque, OneOf, LessProminent, - IfDefinedThenElse + IfDefinedThenElse, + Mix, } export type ColorTransform = @@ -60,7 +62,8 @@ export type ColorTransform = | { op: ColorTransformType.Opaque; value: ColorValue; background: ColorValue } | { op: ColorTransformType.OneOf; values: readonly ColorValue[] } | { op: ColorTransformType.LessProminent; value: ColorValue; background: ColorValue; factor: number; transparency: number } - | { op: ColorTransformType.IfDefinedThenElse; if: ColorIdentifier; then: ColorValue; else: ColorValue }; + | { op: ColorTransformType.IfDefinedThenElse; if: ColorIdentifier; then: ColorValue; else: ColorValue } + | { op: ColorTransformType.Mix; color: ColorValue; with: ColorValue; ratio?: number }; export interface ColorDefaults { light: ColorValue | null; @@ -133,9 +136,9 @@ export interface IColorRegistry { type IJSONSchemaForColors = IJSONSchema & { properties: { [name: string]: { oneOf: [IJSONSchemaWithSnippets, IJSONSchema] } } }; type IJSONSchemaWithSnippets = IJSONSchema & { defaultSnippets: IJSONSchemaSnippet[] }; -class ColorRegistry implements IColorRegistry { +class ColorRegistry extends Disposable implements IColorRegistry { - private readonly _onDidChangeSchema = new Emitter(); + private readonly _onDidChangeSchema = this._register(new Emitter()); readonly onDidChangeSchema: Event = this._onDidChangeSchema.event; private colorsById: { [key: string]: ColorContribution }; @@ -143,6 +146,7 @@ class ColorRegistry implements IColorRegistry { private colorReferenceSchema: IJSONSchema & { enum: string[]; enumDescriptions: string[] } = { type: 'string', enum: [], enumDescriptions: [] }; constructor() { + super(); this.colorsById = {}; } @@ -150,7 +154,7 @@ class ColorRegistry implements IColorRegistry { for (const key of Object.keys(this.colorsById)) { const color = colorThemeData.getColor(key); if (color) { - this.colorSchema.properties[key].oneOf[0].defaultSnippets[0].body = `\${1:${color.toString()}}`; + this.colorSchema.properties[key].oneOf[0].defaultSnippets[0].body = `\${1:${Color.Format.CSS.formatHexA(color, true)}}`; } } this._onDidChangeSchema.fire(); @@ -214,7 +218,7 @@ class ColorRegistry implements IColorRegistry { return this.colorReferenceSchema; } - public toString() { + public override toString() { const sorter = (a: string, b: string) => { const cat1 = a.indexOf('.') === -1 ? 0 : 1; const cat2 = b.indexOf('.') === -1 ? 0 : 1; @@ -254,6 +258,12 @@ export function executeTransform(transform: ColorTransform, theme: IColorTheme): case ColorTransformType.Transparent: return resolveColorValue(transform.value, theme)?.transparent(transform.factor); + case ColorTransformType.Mix: { + const primaryColor = resolveColorValue(transform.color, theme) || Color.transparent; + const otherColor = resolveColorValue(transform.with, theme) || Color.transparent; + return primaryColor.mix(otherColor, transform.ratio); + } + case ColorTransformType.Opaque: { const backgroundColor = resolveColorValue(transform.background, theme); if (!backgroundColor) { diff --git a/src/vs/platform/theme/common/colors/inputColors.ts b/src/vs/platform/theme/common/colors/inputColors.ts index 1cf5a83e85a..f6a1348a6ae 100644 --- a/src/vs/platform/theme/common/colors/inputColors.ts +++ b/src/vs/platform/theme/common/colors/inputColors.ts @@ -7,7 +7,7 @@ import * as nls from '../../../../nls.js'; // Import the effects we need import { Color, RGBA } from '../../../../base/common/color.js'; -import { registerColor, transparent, lighten, darken } from '../colorUtils.js'; +import { registerColor, transparent, lighten, darken, ColorTransformType } from '../colorUtils.js'; // Import the colors we need import { foreground, contrastBorder, focusBorder, iconForeground } from './baseColors.js'; @@ -193,6 +193,14 @@ export const checkboxSelectBorder = registerColor('checkbox.selectBorder', iconForeground, nls.localize('checkbox.select.border', "Border color of checkbox widget when the element it's in is selected.")); +export const checkboxDisabledBackground = registerColor('checkbox.disabled.background', + { op: ColorTransformType.Mix, color: checkboxBackground, with: checkboxForeground, ratio: 0.33 }, + nls.localize('checkbox.disabled.background', "Background of a disabled checkbox.")); + +export const checkboxDisabledForeground = registerColor('checkbox.disabled.foreground', + { op: ColorTransformType.Mix, color: checkboxForeground, with: checkboxBackground, ratio: 0.33 }, + nls.localize('checkbox.disabled.foreground', "Foreground of a disabled checkbox.")); + // ------ keybinding label diff --git a/src/vs/platform/theme/common/iconRegistry.ts b/src/vs/platform/theme/common/iconRegistry.ts index 88cee9c3ddb..9feac0c4ca4 100644 --- a/src/vs/platform/theme/common/iconRegistry.ts +++ b/src/vs/platform/theme/common/iconRegistry.ts @@ -14,6 +14,7 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../jsonschemas/common/jsonContributionRegistry.js'; import * as platform from '../../registry/common/platform.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; // ------ API types @@ -156,9 +157,9 @@ export const fontColorRegex = /^#[0-9a-fA-F]{0,6}$/; export const fontIdErrorMessage = localize('schema.fontId.formatError', 'The font ID must only contain letters, numbers, underscores and dashes.'); -class IconRegistry implements IIconRegistry { +class IconRegistry extends Disposable implements IIconRegistry { - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; private iconsById: { [key: string]: IconContribution }; @@ -182,6 +183,7 @@ class IconRegistry implements IIconRegistry { private iconFontsById: { [key: string]: IconFontDefinition }; constructor() { + super(); this.iconsById = {}; this.iconFontsById = {}; } @@ -263,7 +265,7 @@ class IconRegistry implements IIconRegistry { return this.iconFontsById[id]; } - public toString() { + public override toString() { const sorter = (i1: IconContribution, i2: IconContribution) => { return i1.id.localeCompare(i2.id); }; diff --git a/src/vs/platform/theme/common/themeService.ts b/src/vs/platform/theme/common/themeService.ts index 1f54a38197a..fae8bf7f085 100644 --- a/src/vs/platform/theme/common/themeService.ts +++ b/src/vs/platform/theme/common/themeService.ts @@ -100,14 +100,12 @@ export interface IThemingParticipant { (theme: IColorTheme, collector: ICssStyleCollector, environment: IEnvironmentService): void; } -export type IThemeChangeEvent = { theme: IColorTheme }; - export interface IThemeService { readonly _serviceBrand: undefined; getColorTheme(): IColorTheme; - readonly onDidColorThemeChange: Event; + readonly onDidColorThemeChange: Event; getFileIconTheme(): IFileIconTheme; @@ -136,13 +134,14 @@ export interface IThemingRegistry { readonly onThemingParticipantAdded: Event; } -class ThemingRegistry implements IThemingRegistry { +class ThemingRegistry extends Disposable implements IThemingRegistry { private themingParticipants: IThemingParticipant[] = []; private readonly onThemingParticipantAddedEmitter: Emitter; constructor() { + super(); this.themingParticipants = []; - this.onThemingParticipantAddedEmitter = new Emitter(); + this.onThemingParticipantAddedEmitter = this._register(new Emitter()); } public onColorThemeChange(participant: IThemingParticipant): IDisposable { @@ -184,7 +183,7 @@ export class Themable extends Disposable { this.theme = themeService.getColorTheme(); // Hook up to theme changes - this._register(this.themeService.onDidColorThemeChange(e => this.onThemeChange(e.theme))); + this._register(this.themeService.onDidColorThemeChange(theme => this.onThemeChange(theme))); } protected onThemeChange(theme: IColorTheme): void { @@ -238,9 +237,3 @@ export interface IPartsSplash { windowBorderRadius: string | undefined; } | undefined; } - -export interface IPartsSplashWorkspaceOverride { - layoutInfo: { - auxiliarySideBarWidth: [number, string[] /* workspace identifier the override applies to */]; - }; -} diff --git a/src/vs/platform/theme/common/tokenClassificationRegistry.ts b/src/vs/platform/theme/common/tokenClassificationRegistry.ts index 6b7e07ce8f3..f3503dea201 100644 --- a/src/vs/platform/theme/common/tokenClassificationRegistry.ts +++ b/src/vs/platform/theme/common/tokenClassificationRegistry.ts @@ -7,6 +7,7 @@ import { RunOnceScheduler } from '../../../base/common/async.js'; import { Color } from '../../../base/common/color.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../base/common/jsonSchema.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; import * as nls from '../../../nls.js'; import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../jsonschemas/common/jsonContributionRegistry.js'; import * as platform from '../../registry/common/platform.js'; @@ -261,9 +262,9 @@ export interface ITokenClassificationRegistry { getTokenStylingSchema(): IJSONSchema; } -class TokenClassificationRegistry implements ITokenClassificationRegistry { +class TokenClassificationRegistry extends Disposable implements ITokenClassificationRegistry { - private readonly _onDidChangeSchema = new Emitter(); + private readonly _onDidChangeSchema = this._register(new Emitter()); readonly onDidChangeSchema: Event = this._onDidChangeSchema.event; private currentTypeNumber = 0; @@ -347,6 +348,7 @@ class TokenClassificationRegistry implements ITokenClassificationRegistry { }; constructor() { + super(); this.tokenTypeById = Object.create(null); this.tokenModifierById = Object.create(null); this.typeHierarchy = Object.create(null); @@ -471,7 +473,7 @@ class TokenClassificationRegistry implements ITokenClassificationRegistry { } - public toString() { + public override toString() { const sorter = (a: string, b: string) => { const cat1 = a.indexOf('.') === -1 ? 0 : 1; const cat2 = b.indexOf('.') === -1 ? 0 : 1; diff --git a/src/vs/platform/theme/electron-main/themeMainService.ts b/src/vs/platform/theme/electron-main/themeMainService.ts index 90d2be8dc27..33ba510b4d3 100644 --- a/src/vs/platform/theme/electron-main/themeMainService.ts +++ b/src/vs/platform/theme/electron-main/themeMainService.ts @@ -10,11 +10,12 @@ import { isLinux, isMacintosh, isWindows } from '../../../base/common/platform.j import { IConfigurationService } from '../../configuration/common/configuration.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IStateService } from '../../state/node/state.js'; -import { IPartsSplash, IPartsSplashWorkspaceOverride } from '../common/themeService.js'; +import { IPartsSplash } from '../common/themeService.js'; import { IColorScheme } from '../../window/common/window.js'; import { ThemeTypeSelector } from '../common/theme.js'; -import { IBaseWorkspaceIdentifier } from '../../workspace/common/workspace.js'; +import { ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { coalesce } from '../../../base/common/arrays.js'; +import { getAllWindowsExcludingOffscreen } from '../../windows/electron-main/windows.js'; // These default colors match our default themes // editor background color ("Dark Modern", etc...) @@ -27,7 +28,9 @@ const THEME_STORAGE_KEY = 'theme'; const THEME_BG_STORAGE_KEY = 'themeBackground'; const THEME_WINDOW_SPLASH_KEY = 'windowSplash'; -const THEME_WINDOW_SPLASH_WORKSPACE_OVERRIDE_KEY = 'windowSplashWorkspaceOverride'; +const THEME_WINDOW_SPLASH_OVERRIDE_KEY = 'windowSplashWorkspaceOverride'; + +const AUXILIARYBAR_DEFAULT_VISIBILITY = 'workbench.secondarySideBar.defaultVisibility'; namespace ThemeSettings { export const DETECT_COLOR_SCHEME = 'window.autoDetectColorScheme'; @@ -35,6 +38,22 @@ namespace ThemeSettings { export const SYSTEM_COLOR_THEME = 'window.systemColorTheme'; } +interface IPartSplashOverrideWorkspaces { + [workspaceId: string]: { + sideBarVisible: boolean; + auxiliaryBarVisible: boolean; + }; +} + +interface IPartsSplashOverride { + layoutInfo: { + sideBarWidth: number; + auxiliaryBarWidth: number; + + workspaces: IPartSplashOverrideWorkspaces; + }; +} + export const IThemeMainService = createDecorator('themeMainService'); export interface IThemeMainService { @@ -45,8 +64,8 @@ export interface IThemeMainService { getBackgroundColor(): string; - saveWindowSplash(windowId: number | undefined, workspace: IBaseWorkspaceIdentifier | undefined, splash: IPartsSplash): void; - getWindowSplash(workspace: IBaseWorkspaceIdentifier | undefined): IPartsSplash | undefined; + saveWindowSplash(windowId: number | undefined, workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | undefined, splash: IPartsSplash): void; + getWindowSplash(workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | undefined): IPartsSplash | undefined; getColorScheme(): IColorScheme; } @@ -55,10 +74,17 @@ export class ThemeMainService extends Disposable implements IThemeMainService { declare readonly _serviceBrand: undefined; + private static readonly DEFAULT_BAR_WIDTH = 300; + + private static readonly WORKSPACE_OVERRIDE_LIMIT = 50; + private readonly _onDidChangeColorScheme = this._register(new Emitter()); readonly onDidChangeColorScheme = this._onDidChangeColorScheme.event; - constructor(@IStateService private stateService: IStateService, @IConfigurationService private configurationService: IConfigurationService) { + constructor( + @IStateService private stateService: IStateService, + @IConfigurationService private configurationService: IConfigurationService + ) { super(); // System Theme @@ -77,8 +103,7 @@ export class ThemeMainService extends Disposable implements IThemeMainService { private updateSystemColorTheme(): void { if (isLinux || this.configurationService.getValue(ThemeSettings.DETECT_COLOR_SCHEME)) { - // only with `system` we can detect the system color scheme - electron.nativeTheme.themeSource = 'system'; + electron.nativeTheme.themeSource = 'system'; // only with `system` we can detect the system color scheme } else { switch (this.configurationService.getValue<'default' | 'auto' | 'light' | 'dark'>(ThemeSettings.SYSTEM_COLOR_THEME)) { case 'dark': @@ -98,28 +123,34 @@ export class ThemeMainService extends Disposable implements IThemeMainService { electron.nativeTheme.themeSource = 'system'; break; } - } } getColorScheme(): IColorScheme { + + // high contrast is reflected by the shouldUseInvertedColorScheme property if (isWindows) { - // high contrast is reflected by the shouldUseInvertedColorScheme property if (electron.nativeTheme.shouldUseHighContrastColors) { // shouldUseInvertedColorScheme is dark, !shouldUseInvertedColorScheme is light return { dark: electron.nativeTheme.shouldUseInvertedColorScheme, highContrast: true }; } - } else if (isMacintosh) { - // high contrast is set if one of shouldUseInvertedColorScheme or shouldUseHighContrastColors is set, reflecting the 'Invert colours' and `Increase contrast` settings in MacOS + } + + // high contrast is set if one of shouldUseInvertedColorScheme or shouldUseHighContrastColors is set, + // reflecting the 'Invert colours' and `Increase contrast` settings in MacOS + else if (isMacintosh) { if (electron.nativeTheme.shouldUseInvertedColorScheme || electron.nativeTheme.shouldUseHighContrastColors) { return { dark: electron.nativeTheme.shouldUseDarkColors, highContrast: true }; } - } else if (isLinux) { - // ubuntu gnome seems to have 3 states, light dark and high contrast + } + + // ubuntu gnome seems to have 3 states, light dark and high contrast + else if (isLinux) { if (electron.nativeTheme.shouldUseHighContrastColors) { return { dark: true, highContrast: true }; } } + return { dark: electron.nativeTheme.shouldUseDarkColors, highContrast: false @@ -131,9 +162,11 @@ export class ThemeMainService extends Disposable implements IThemeMainService { if (this.configurationService.getValue(ThemeSettings.DETECT_HC) && colorScheme.highContrast) { return colorScheme.dark ? ThemeTypeSelector.HC_BLACK : ThemeTypeSelector.HC_LIGHT; } + if (this.configurationService.getValue(ThemeSettings.DETECT_COLOR_SCHEME)) { return colorScheme.dark ? ThemeTypeSelector.VS_DARK : ThemeTypeSelector.VS; } + return undefined; } @@ -148,6 +181,7 @@ export class ThemeMainService extends Disposable implements IThemeMainService { return storedBackground; } } + // Otherwise we return the default background for the preferred base theme. If there's no preferred, use the stored one. switch (preferred ?? stored) { case ThemeTypeSelector.VS: return DEFAULT_BG_LIGHT; @@ -167,7 +201,7 @@ export class ThemeMainService extends Disposable implements IThemeMainService { } } - saveWindowSplash(windowId: number | undefined, workspace: IBaseWorkspaceIdentifier | undefined, splash: IPartsSplash): void { + saveWindowSplash(windowId: number | undefined, workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | undefined, splash: IPartsSplash): void { // Update override as needed const splashOverride = this.updateWindowSplashOverride(workspace, splash); @@ -177,7 +211,7 @@ export class ThemeMainService extends Disposable implements IThemeMainService { { key: THEME_STORAGE_KEY, data: splash.baseTheme }, { key: THEME_BG_STORAGE_KEY, data: splash.colorInfo.background }, { key: THEME_WINDOW_SPLASH_KEY, data: splash }, - splashOverride ? { key: THEME_WINDOW_SPLASH_WORKSPACE_OVERRIDE_KEY, data: splashOverride } : undefined + splashOverride ? { key: THEME_WINDOW_SPLASH_OVERRIDE_KEY, data: splashOverride } : undefined ])); // Update in opened windows @@ -189,37 +223,93 @@ export class ThemeMainService extends Disposable implements IThemeMainService { this.updateSystemColorTheme(); } - private updateWindowSplashOverride(workspace: IBaseWorkspaceIdentifier | undefined, splash: IPartsSplash): IPartsSplashWorkspaceOverride | undefined { - let splashOverride: IPartsSplashWorkspaceOverride | undefined = undefined; + private updateWindowSplashOverride(workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | undefined, splash: IPartsSplash): IPartsSplashOverride | undefined { + let splashOverride: IPartsSplashOverride | undefined = undefined; let changed = false; if (workspace) { splashOverride = { ...this.getWindowSplashOverride() }; // make a copy for modifications - const [auxiliarySideBarWidth, workspaceIds] = splashOverride.layoutInfo.auxiliarySideBarWidth; - if (splash.layoutInfo?.auxiliarySideBarWidth) { - if (auxiliarySideBarWidth !== splash.layoutInfo.auxiliarySideBarWidth) { - splashOverride.layoutInfo.auxiliarySideBarWidth[0] = splash.layoutInfo.auxiliarySideBarWidth; - changed = true; - } - - if (!workspaceIds.includes(workspace.id)) { - workspaceIds.push(workspace.id); - changed = true; - } - } else { - const index = workspaceIds.indexOf(workspace.id); - if (index > -1) { - workspaceIds.splice(index, 1); - changed = true; - } - } + changed = this.doUpdateWindowSplashOverride(workspace, splash, splashOverride, 'sideBar'); + changed = this.doUpdateWindowSplashOverride(workspace, splash, splashOverride, 'auxiliaryBar') || changed; } return changed ? splashOverride : undefined; } + private doUpdateWindowSplashOverride(workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier, splash: IPartsSplash, splashOverride: IPartsSplashOverride, part: 'sideBar' | 'auxiliaryBar'): boolean { + const currentWidth = part === 'sideBar' ? splash.layoutInfo?.sideBarWidth : splash.layoutInfo?.auxiliarySideBarWidth; + const overrideWidth = part === 'sideBar' ? splashOverride.layoutInfo.sideBarWidth : splashOverride.layoutInfo.auxiliaryBarWidth; + + // No layout info: remove override + let changed = false; + if (typeof currentWidth !== 'number') { + if (splashOverride.layoutInfo.workspaces[workspace.id]) { + delete splashOverride.layoutInfo.workspaces[workspace.id]; + changed = true; + } + + return changed; + } + + let workspaceOverride = splashOverride.layoutInfo.workspaces[workspace.id]; + if (!workspaceOverride) { + const workspaceEntries = Object.keys(splashOverride.layoutInfo.workspaces); + if (workspaceEntries.length >= ThemeMainService.WORKSPACE_OVERRIDE_LIMIT) { + delete splashOverride.layoutInfo.workspaces[workspaceEntries[0]]; + changed = true; + } + + workspaceOverride = { sideBarVisible: false, auxiliaryBarVisible: false }; + splashOverride.layoutInfo.workspaces[workspace.id] = workspaceOverride; + changed = true; + } + + // Part has width: update width & visibility override + if (currentWidth > 0) { + if (overrideWidth !== currentWidth) { + splashOverride.layoutInfo[part === 'sideBar' ? 'sideBarWidth' : 'auxiliaryBarWidth'] = currentWidth; + changed = true; + } + + switch (part) { + case 'sideBar': + if (!workspaceOverride.sideBarVisible) { + workspaceOverride.sideBarVisible = true; + changed = true; + } + break; + case 'auxiliaryBar': + if (!workspaceOverride.auxiliaryBarVisible) { + workspaceOverride.auxiliaryBarVisible = true; + changed = true; + } + break; + } + } + + // Part is hidden: update visibility override + else { + switch (part) { + case 'sideBar': + if (workspaceOverride.sideBarVisible) { + workspaceOverride.sideBarVisible = false; + changed = true; + } + break; + case 'auxiliaryBar': + if (workspaceOverride.auxiliaryBarVisible) { + workspaceOverride.auxiliaryBarVisible = false; + changed = true; + } + break; + } + } + + return changed; + } + private updateBackgroundColor(windowId: number, splash: IPartsSplash): void { - for (const window of electron.BrowserWindow.getAllWindows()) { + for (const window of getAllWindowsExcludingOffscreen()) { if (window.id === windowId) { window.setBackgroundColor(splash.colorInfo.background); break; @@ -227,34 +317,81 @@ export class ThemeMainService extends Disposable implements IThemeMainService { } } - getWindowSplash(workspace: IBaseWorkspaceIdentifier | undefined): IPartsSplash | undefined { + getWindowSplash(workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | undefined): IPartsSplash | undefined { const partSplash = this.stateService.getItem(THEME_WINDOW_SPLASH_KEY); if (!partSplash?.layoutInfo) { return partSplash; // return early: overrides currently only apply to layout info } - // Apply workspace specific overrides - let auxiliarySideBarWidthOverride: number | undefined; + const override = this.getWindowSplashOverride(); + + // Figure out side bar width based on workspace and overrides + let sideBarWidth: number; if (workspace) { - const [auxiliarySideBarWidth, workspaceIds] = this.getWindowSplashOverride().layoutInfo.auxiliarySideBarWidth; - if (workspaceIds.includes(workspace.id)) { - auxiliarySideBarWidthOverride = auxiliarySideBarWidth; + if (override.layoutInfo.workspaces[workspace.id]?.sideBarVisible === false) { + sideBarWidth = 0; + } else { + sideBarWidth = override.layoutInfo.sideBarWidth || partSplash.layoutInfo.sideBarWidth || ThemeMainService.DEFAULT_BAR_WIDTH; } + } else { + sideBarWidth = 0; + } + + // Figure out auxiliary bar width based on workspace, configuration and overrides + const auxiliarySideBarDefaultVisibility = this.configurationService.getValue(AUXILIARYBAR_DEFAULT_VISIBILITY); + let auxiliarySideBarWidth: number; + if (workspace) { + const auxiliaryBarVisible = override.layoutInfo.workspaces[workspace.id]?.auxiliaryBarVisible; + if (auxiliaryBarVisible === true) { + auxiliarySideBarWidth = override.layoutInfo.auxiliaryBarWidth || partSplash.layoutInfo.auxiliarySideBarWidth || ThemeMainService.DEFAULT_BAR_WIDTH; + } else if (auxiliaryBarVisible === false) { + auxiliarySideBarWidth = 0; + } else { + if (auxiliarySideBarDefaultVisibility === 'visible' || auxiliarySideBarDefaultVisibility === 'visibleInWorkspace') { + auxiliarySideBarWidth = override.layoutInfo.auxiliaryBarWidth || partSplash.layoutInfo.auxiliarySideBarWidth || ThemeMainService.DEFAULT_BAR_WIDTH; + } else { + auxiliarySideBarWidth = 0; + } + } + } else { + auxiliarySideBarWidth = 0; // technically not true if configured 'visible', but we never store splash per empty window, so we decide on a default here } return { ...partSplash, layoutInfo: { ...partSplash.layoutInfo, - // Only apply an auxiliary bar width when we have a workspace specific - // override. Auxiliary bar is not visible by default unless explicitly - // opened in a workspace. - auxiliarySideBarWidth: typeof auxiliarySideBarWidthOverride === 'number' ? auxiliarySideBarWidthOverride : 0 + sideBarWidth, + auxiliarySideBarWidth } }; } - private getWindowSplashOverride(): IPartsSplashWorkspaceOverride { - return this.stateService.getItem(THEME_WINDOW_SPLASH_WORKSPACE_OVERRIDE_KEY, { layoutInfo: { auxiliarySideBarWidth: [0, []] } }); + private getWindowSplashOverride(): IPartsSplashOverride { + let override = this.stateService.getItem(THEME_WINDOW_SPLASH_OVERRIDE_KEY); + + if (!override?.layoutInfo) { + override = { + layoutInfo: { + sideBarWidth: ThemeMainService.DEFAULT_BAR_WIDTH, + auxiliaryBarWidth: ThemeMainService.DEFAULT_BAR_WIDTH, + workspaces: {} + } + }; + } + + if (!override.layoutInfo.sideBarWidth) { + override.layoutInfo.sideBarWidth = ThemeMainService.DEFAULT_BAR_WIDTH; + } + + if (!override.layoutInfo.auxiliaryBarWidth) { + override.layoutInfo.auxiliaryBarWidth = ThemeMainService.DEFAULT_BAR_WIDTH; + } + + if (!override.layoutInfo.workspaces) { + override.layoutInfo.workspaces = {}; + } + + return override; } } diff --git a/src/vs/platform/theme/test/common/testThemeService.ts b/src/vs/platform/theme/test/common/testThemeService.ts index 0e6c72cadcc..8ee388d4dbd 100644 --- a/src/vs/platform/theme/test/common/testThemeService.ts +++ b/src/vs/platform/theme/test/common/testThemeService.ts @@ -7,7 +7,7 @@ import { Color } from '../../../../base/common/color.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IconContribution } from '../../common/iconRegistry.js'; import { ColorScheme } from '../../common/theme.js'; -import { IColorTheme, IFileIconTheme, IProductIconTheme, IThemeChangeEvent, IThemeService, ITokenStyle } from '../../common/themeService.js'; +import { IColorTheme, IFileIconTheme, IProductIconTheme, IThemeService, ITokenStyle } from '../../common/themeService.js'; export class TestColorTheme implements IColorTheme { @@ -58,7 +58,7 @@ export class TestThemeService implements IThemeService { _colorTheme: IColorTheme; _fileIconTheme: IFileIconTheme; _productIconTheme: IProductIconTheme; - _onThemeChange = new Emitter(); + _onThemeChange = new Emitter(); _onFileIconThemeChange = new Emitter(); _onProductIconThemeChange = new Emitter(); @@ -78,10 +78,10 @@ export class TestThemeService implements IThemeService { } fireThemeChange() { - this._onThemeChange.fire({ theme: this._colorTheme }); + this._onThemeChange.fire(this._colorTheme); } - public get onDidColorThemeChange(): Event { + public get onDidColorThemeChange(): Event { return this._onThemeChange.event; } diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index a1ec3fed95d..48d0d86a142 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -231,5 +231,5 @@ export abstract class AbstractUpdateService implements IUpdateService { } protected abstract buildUpdateFeedUrl(quality: string): string | undefined; - protected abstract doCheckForUpdates(context: any): void; + protected abstract doCheckForUpdates(explicit: boolean): void; } diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index 57398fba4c8..b78ebc526fc 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -91,8 +91,15 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return url; } - protected doCheckForUpdates(context: any): void { - this.setState(State.CheckingForUpdates(context)); + protected doCheckForUpdates(explicit: boolean): void { + if (!this.url) { + return; + } + + this.setState(State.CheckingForUpdates(explicit)); + + const url = explicit ? this.url : `${this.url}?bg=true`; + electron.autoUpdater.setFeedURL({ url }); electron.autoUpdater.checkForUpdates(); } diff --git a/src/vs/platform/update/electron-main/updateService.linux.ts b/src/vs/platform/update/electron-main/updateService.linux.ts index dd18900547d..8550ace2f43 100644 --- a/src/vs/platform/update/electron-main/updateService.linux.ts +++ b/src/vs/platform/update/electron-main/updateService.linux.ts @@ -32,13 +32,15 @@ export class LinuxUpdateService extends AbstractUpdateService { return createUpdateURL(`linux-${process.arch}`, quality, this.productService); } - protected doCheckForUpdates(context: any): void { + protected doCheckForUpdates(explicit: boolean): void { if (!this.url) { return; } - this.setState(State.CheckingForUpdates(context)); - this.requestService.request({ url: this.url }, CancellationToken.None) + const url = explicit ? this.url : `${this.url}?bg=true`; + this.setState(State.CheckingForUpdates(explicit)); + + this.requestService.request({ url }, CancellationToken.None) .then(asJson) .then(update => { if (!update || !update.url || !update.version || !update.productVersion) { @@ -50,7 +52,7 @@ export class LinuxUpdateService extends AbstractUpdateService { .then(undefined, err => { this.logService.error(err); // only show message when explicitly checking for updates - const message: string | undefined = !!context ? (err.message || err) : undefined; + const message: string | undefined = explicit ? (err.message || err) : undefined; this.setState(State.Idle(UpdateType.Archive, message)); }); } diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index db92de2f198..8f92a3e9f35 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -111,14 +111,15 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return createUpdateURL(platform, quality, this.productService); } - protected doCheckForUpdates(context: any): void { + protected doCheckForUpdates(explicit: boolean): void { if (!this.url) { return; } - this.setState(State.CheckingForUpdates(context)); + const url = explicit ? this.url : `${this.url}?bg=true`; + this.setState(State.CheckingForUpdates(explicit)); - this.requestService.request({ url: this.url }, CancellationToken.None) + this.requestService.request({ url }, CancellationToken.None) .then(asJson) .then(update => { const updateType = getUpdateType(); @@ -170,7 +171,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.logService.error(err); // only show message when explicitly checking for updates - const message: string | undefined = !!context ? (err.message || err) : undefined; + const message: string | undefined = explicit ? (err.message || err) : undefined; this.setState(State.Idle(getUpdateType(), message)); }); } diff --git a/src/vs/platform/userDataSync/common/abstractSynchronizer.ts b/src/vs/platform/userDataSync/common/abstractSynchronizer.ts index 54408a57aba..fcd19dbf3eb 100644 --- a/src/vs/platform/userDataSync/common/abstractSynchronizer.ts +++ b/src/vs/platform/userDataSync/common/abstractSynchronizer.ts @@ -145,13 +145,13 @@ export abstract class AbstractSynchroniser extends Disposable implements IUserDa readonly onDidChangeLocal: Event = this._onDidChangeLocal.event; protected readonly lastSyncResource: URI; - private readonly lastSyncUserDataStateKey = `${this.collection ? `${this.collection}.` : ''}${this.syncResource.syncResource}.lastSyncUserData`; + private readonly lastSyncUserDataStateKey: string; private hasSyncResourceStateVersionChanged: boolean = false; protected readonly syncResourceLogLabel: string; protected syncHeaders: IHeaders = {}; - readonly resource = this.syncResource.syncResource; + readonly resource: SyncResource; constructor( readonly syncResource: IUserDataSyncResource, @@ -168,6 +168,8 @@ export abstract class AbstractSynchroniser extends Disposable implements IUserDa @IUriIdentityService uriIdentityService: IUriIdentityService, ) { super(); + this.lastSyncUserDataStateKey = `${collection ? `${collection}.` : ''}${syncResource.syncResource}.lastSyncUserData`; + this.resource = syncResource.syncResource; this.syncResourceLogLabel = getSyncResourceLogLabel(syncResource.syncResource, syncResource.profile); this.extUri = uriIdentityService.extUri; this.syncFolder = this.extUri.joinPath(environmentService.userDataSyncHome, ...getPathSegments(syncResource.profile.isDefault ? undefined : syncResource.profile.id, syncResource.syncResource)); diff --git a/src/vs/platform/userDataSync/common/promptsSync/promptsSync.ts b/src/vs/platform/userDataSync/common/promptsSync/promptsSync.ts index 8c432401777..734c07abf07 100644 --- a/src/vs/platform/userDataSync/common/promptsSync/promptsSync.ts +++ b/src/vs/platform/userDataSync/common/promptsSync/promptsSync.ts @@ -7,13 +7,13 @@ import { URI } from '../../../../base/common/uri.js'; import { Event } from '../../../../base/common/event.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { deepClone } from '../../../../base/common/objects.js'; -import { isPromptFile } from '../../../prompts/common/constants.js'; import { IStorageService } from '../../../storage/common/storage.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { IStringDictionary } from '../../../../base/common/collections.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IUriIdentityService } from '../../../uriIdentity/common/uriIdentity.js'; import { IEnvironmentService } from '../../../environment/common/environment.js'; +import { isPromptOrInstructionsFile } from '../../../prompts/common/constants.js'; import { IUserDataProfile } from '../../../userDataProfile/common/userDataProfile.js'; import { IConfigurationService } from '../../../configuration/common/configuration.js'; import { areSame, IMergeResult as IPromptsMergeResult, merge } from './promptsMerge.js'; @@ -517,7 +517,7 @@ export class PromptsSynchronizer extends AbstractSynchroniser implements IUserDa for (const entry of stat.children || []) { const resource = entry.resource; - if (!isPromptFile(resource)) { + if (isPromptOrInstructionsFile(resource) === false) { continue; } diff --git a/src/vs/platform/userDataSync/common/userDataSync.ts b/src/vs/platform/userDataSync/common/userDataSync.ts index b8f7b5c506e..6f87ee815bc 100644 --- a/src/vs/platform/userDataSync/common/userDataSync.ts +++ b/src/vs/platform/userDataSync/common/userDataSync.ts @@ -536,6 +536,12 @@ export interface IUserDataSyncEnablementService { setResourceEnablement(resource: SyncResource, enabled: boolean): void; getResourceSyncStateVersion(resource: SyncResource): string | undefined; + + /** + * Checks if resource enabled was explicitly configured before, + * ignoring its default enablement value used in {@link isResourceEnabled}. + */ + isResourceEnablementConfigured(resource: SyncResource): boolean; } export interface IUserDataSyncTask { diff --git a/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts b/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts index 978ef5416f9..85968813907 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts @@ -58,6 +58,12 @@ export class UserDataSyncEnablementService extends Disposable implements IUserDa return storedValue ?? defaultValue; } + isResourceEnablementConfigured(resource: SyncResource): boolean { + const storedValue = this.storageService.getBoolean(getEnablementKey(resource), StorageScope.APPLICATION); + + return (storedValue !== undefined); + } + setResourceEnablement(resource: SyncResource, enabled: boolean): void { if (this.isResourceEnabled(resource) !== enabled) { const resourceEnablementKey = getEnablementKey(resource); diff --git a/src/vs/platform/webContentExtractor/common/webContentExtractor.ts b/src/vs/platform/webContentExtractor/common/webContentExtractor.ts index 5c9ab27ed41..322ecd9ecdb 100644 --- a/src/vs/platform/webContentExtractor/common/webContentExtractor.ts +++ b/src/vs/platform/webContentExtractor/common/webContentExtractor.ts @@ -3,16 +3,28 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { VSBuffer } from '../../../base/common/buffer.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IWebContentExtractorService = createDecorator('IWebContentExtractorService'); +export const ISharedWebContentExtractorService = createDecorator('ISharedWebContentExtractorService'); + export interface IWebContentExtractorService { _serviceBrand: undefined; extract(uri: URI[]): Promise; } +/* + * A service that extracts image content from a given arbitrary URI. This is done in the shared process to avoid running non trusted application code in the main process. + */ +export interface ISharedWebContentExtractorService { + _serviceBrand: undefined; + readImage(uri: URI, token: CancellationToken): Promise; +} + /** * A service that extracts web content from a given URI. * This is a placeholder implementation that does not perform any actual extraction. @@ -25,3 +37,10 @@ export class NullWebContentExtractorService implements IWebContentExtractorServi throw new Error('Not implemented'); } } + +export class NullSharedWebContentExtractorService implements ISharedWebContentExtractorService { + _serviceBrand: undefined; + readImage(_uri: URI, _token: CancellationToken): Promise { + throw new Error('Not implemented'); + } +} diff --git a/src/vs/platform/webContentExtractor/electron-main/cdpAccessibilityDomain.ts b/src/vs/platform/webContentExtractor/electron-main/cdpAccessibilityDomain.ts index 9e7a7c87b17..a34db9ebc44 100644 --- a/src/vs/platform/webContentExtractor/electron-main/cdpAccessibilityDomain.ts +++ b/src/vs/platform/webContentExtractor/electron-main/cdpAccessibilityDomain.ts @@ -3,23 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/** - * Contains types from https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/ - */ +//#region Types -export interface AXProperty { - name: string; - value: AXValue; -} +import { URI } from '../../../base/common/uri.js'; export interface AXValue { - type: string; - value: any; + type: AXValueType; + value?: any; + relatedNodes?: AXNode[]; + sources?: AXValueSource[]; +} + +export interface AXValueSource { + type: AXValueSourceType; + value?: AXValue; + attribute?: string; + attributeValue?: string; + superseded?: boolean; + nativeSource?: AXValueNativeSourceType; + nativeSourceValue?: string; + invalid?: boolean; + invalidReason?: string; } export interface AXNode { nodeId: string; - ignored?: boolean; + ignored: boolean; ignoredReasons?: AXProperty[]; role?: AXValue; chromeRole?: AXValue; @@ -27,173 +36,450 @@ export interface AXNode { description?: AXValue; value?: AXValue; properties?: AXProperty[]; - parentId?: string; childIds?: string[]; backendDOMNodeId?: number; - frameId?: string; } -/** - * Converts an array of AXNode objects to a readable format. - * It processes the nodes to extract their text content, ignoring navigation elements and - * formatting them in a structured way. - * - * @remarks We can do more here, but this is a good start. - * @param axNodes - The array of AXNode objects to be converted to a readable format. - * @returns string - */ -export function convertToReadibleFormat(axNodes: AXNode[]): string { - if (!axNodes.length) { - return ''; +export interface AXProperty { + name: AXPropertyName; + value: AXValue; +} + +export type AXValueType = 'boolean' | 'tristate' | 'booleanOrUndefined' | 'idref' | 'idrefList' | 'integer' | 'node' | 'nodeList' | 'number' | 'string' | 'computedString' | 'token' | 'tokenList' | 'domRelation' | 'role' | 'internalRole' | 'valueUndefined'; + +export type AXValueSourceType = 'attribute' | 'implicit' | 'style' | 'contents' | 'placeholder' | 'relatedElement'; + +export type AXValueNativeSourceType = 'description' | 'figcaption' | 'label' | 'labelfor' | 'labelwrapped' | 'legend' | 'rubyannotation' | 'tablecaption' | 'title' | 'other'; + +export type AXPropertyName = 'url' | 'busy' | 'disabled' | 'editable' | 'focusable' | 'focused' | 'hidden' | 'hiddenRoot' | 'invalid' | 'keyshortcuts' | 'settable' | 'roledescription' | 'live' | 'atomic' | 'relevant' | 'root' | 'autocomplete' | 'hasPopup' | 'level' | 'multiselectable' | 'orientation' | 'multiline' | 'readonly' | 'required' | 'valuemin' | 'valuemax' | 'valuetext' | 'checked' | 'expanded' | 'pressed' | 'selected' | 'activedescendant' | 'controls' | 'describedby' | 'details' | 'errormessage' | 'flowto' | 'labelledby' | 'owns'; + +//#endregion + +interface AXNodeTree { + readonly node: AXNode; + readonly children: AXNodeTree[]; + parent: AXNodeTree | null; +} + +function createNodeTree(nodes: AXNode[]): AXNodeTree | null { + if (nodes.length === 0) { + return null; } - const nodeMap = new Map(); - const processedNodes = new Set(); - const rootNodes: AXNode[] = []; - - // Build node map and identify root nodes - for (const node of axNodes) { - nodeMap.set(node.nodeId, node); - if (!node.parentId || !axNodes.some(n => n.nodeId === node.parentId)) { - rootNodes.push(node); - } + // Create a map of node IDs to their corresponding nodes for quick lookup + const nodeLookup = new Map(); + for (const node of nodes) { + nodeLookup.set(node.nodeId, node); } - function isNavigationElement(node: AXNode): boolean { - // Skip navigation and UI elements that don't contribute to content - const skipRoles = [ - 'navigation', - 'banner', - 'complementary', - 'toolbar', - 'menu', - 'menuitem', - 'tab', - 'tablist' - ]; - const skipTexts = [ - 'Skip to main content', - 'Toggle navigation', - 'Previous', - 'Next', - 'Copy', - 'Direct link to', - 'On this page', - 'Edit this page', - 'Search', - 'Command+K' - ]; - - const text = getNodeText(node); - const role = node.role?.value?.toString().toLowerCase() || ''; - // allow-any-unicode-next-line - return skipRoles.includes(role) || - skipTexts.some(skipText => text.includes(skipText)) || - text.startsWith('Direct link to') || - text.startsWith('\xAB ') || // Left-pointing double angle quotation mark - text.endsWith(' \xBB') || // Right-pointing double angle quotation mark - /^#\s*$/.test(text) || // Skip standalone # characters - text === '\u200B'; // Zero-width space character - } - - function getNodeText(node: AXNode): string { - const parts: string[] = []; - - // Add name if available - if (node.name?.value) { - parts.push(String(node.name.value)); - } - - // Add value if available and different from name - if (node.value?.value && node.value.value !== node.name?.value) { - parts.push(String(node.value.value)); - } - - // Add description if available and different from name and value - if (node.description?.value && - node.description.value !== node.name?.value && - node.description.value !== node.value?.value) { - parts.push(String(node.description.value)); - } - - return parts.join(' ').trim(); - } - - function isCodeBlock(node: AXNode): boolean { - return node.role?.value === 'code' || - (node.properties || []).some(p => p.name === 'code-block' || p.name === 'pre'); - } - - function processNode(node: AXNode, depth: number = 0, parentContext: { inCodeBlock: boolean; codeText: string[] } = { inCodeBlock: false, codeText: [] }): string[] { - if (!node || node.ignored || processedNodes.has(node.nodeId)) { + // Helper function to get all non-ignored descendants of a node + function getNonIgnoredDescendants(nodeId: string): string[] { + const node = nodeLookup.get(nodeId); + if (!node || !node.childIds) { return []; } - if (isNavigationElement(node)) { - return []; - } + const result: string[] = []; + for (const childId of node.childIds) { + const childNode = nodeLookup.get(childId); + if (!childNode) { + continue; + } - processedNodes.add(node.nodeId); - const lines: string[] = []; - const text = getNodeText(node); - const currentIsCode = isCodeBlock(node); - const context = currentIsCode ? { inCodeBlock: true, codeText: [] } : parentContext; - - if (text) { - const indent = ' '.repeat(depth); - if (currentIsCode || context.inCodeBlock) { - // For code blocks, collect text without adding newlines - context.codeText.push(text.trim()); + if (childNode.ignored) { + // If child is ignored, add its non-ignored descendants instead + result.push(...getNonIgnoredDescendants(childId)); } else { - lines.push(indent + text); + // Otherwise, add the child itself + result.push(childId); } } + return result; + } - // Process children + // Create tree nodes only for non-ignored nodes + const nodeMap = new Map(); + for (const node of nodes) { + if (!node.ignored) { + nodeMap.set(node.nodeId, { node, children: [], parent: null }); + } + } + + // Establish parent-child relationships, bypassing ignored nodes + for (const node of nodes) { + if (node.ignored) { + continue; + } + + const treeNode = nodeMap.get(node.nodeId)!; if (node.childIds) { for (const childId of node.childIds) { - const child = nodeMap.get(childId); - if (child) { - const childLines = processNode(child, depth + 1, context); - lines.push(...childLines); + const childNode = nodeLookup.get(childId); + if (!childNode) { + continue; + } + + if (childNode.ignored) { + // If child is ignored, connect its non-ignored descendants to this node + const nonIgnoredDescendants = getNonIgnoredDescendants(childId); + for (const descendantId of nonIgnoredDescendants) { + const descendantTreeNode = nodeMap.get(descendantId); + if (descendantTreeNode) { + descendantTreeNode.parent = treeNode; + treeNode.children.push(descendantTreeNode); + } + } + } else { + // Normal case: add non-ignored child directly + const childTreeNode = nodeMap.get(childId); + if (childTreeNode) { + childTreeNode.parent = treeNode; + treeNode.children.push(childTreeNode); + } } } } - - // If this is the root code block node, join all collected code text - if (currentIsCode && context.codeText.length > 0) { - const indent = ' '.repeat(depth); - lines.push(indent + context.codeText.join(' ')); - } - - return lines; } - // Process all nodes starting from roots - const allLines: string[] = []; - for (const node of rootNodes) { - const nodeLines = processNode(node); - if (nodeLines.length > 0) { - allLines.push(...nodeLines); + // Find the root node (a node without a parent) + for (const node of nodeMap.values()) { + if (!node.parent) { + return node; } } - // Process any remaining unprocessed nodes - for (const node of axNodes) { - if (!processedNodes.has(node.nodeId)) { - const nodeLines = processNode(node); - if (nodeLines.length > 0) { - allLines.push(...nodeLines); - } - } - } - - // Clean up empty lines and trim - return allLines - .filter((line, index, array) => { - // Keep the line if it's not empty or if it's not adjacent to another empty line - return line.trim() || (index > 0 && array[index - 1].trim()); - }) - .join('\n') - .trim(); + return null; +} + +/** + * When possible, we will make sure lines are no longer than 80. This is to help + * certain pieces of software that can't handle long lines. + */ +const LINE_MAX_LENGTH = 80; + +/** + * Converts an accessibility tree represented by AXNode objects into a markdown string. + * + * @param uri The URI of the document + * @param axNodes The array of AXNode objects representing the accessibility tree + * @returns A markdown representation of the accessibility tree + */ +export function convertAXTreeToMarkdown(uri: URI, axNodes: AXNode[]): string { + const tree = createNodeTree(axNodes); + if (!tree) { + return ''; // Return empty string for empty tree + } + + // Process tree to extract main content and navigation links + const mainContent = extractMainContent(uri, tree); + const navLinks = collectNavigationLinks(tree); + + // Combine main content and navigation links + return mainContent + (navLinks.length > 0 ? '\n\n## Additional Links\n' + navLinks.join('\n') : ''); +} + +function extractMainContent(uri: URI, tree: AXNodeTree): string { + const contentBuffer: string[] = []; + processNode(uri, tree, contentBuffer, 0, true); + return contentBuffer.join(''); +} + +function processNode(uri: URI, node: AXNodeTree, buffer: string[], depth: number, allowWrap: boolean): void { + const role = getNodeRole(node.node); + + switch (role) { + case 'navigation': + return; // Skip navigation nodes + + case 'heading': + processHeadingNode(uri, node, buffer, depth); + return; + + case 'paragraph': + processParagraphNode(uri, node, buffer, depth, allowWrap); + return; + + case 'list': + buffer.push('\n'); + for (const descChild of node.children) { + processNode(uri, descChild, buffer, depth + 1, true); + } + buffer.push('\n'); + return; + + case 'ListMarker': + // TODO: Should we normalize these ListMarkers to `-` and normal lists? + buffer.push(getNodeText(node.node, allowWrap)); + return; + + case 'listitem': { + const tempBuffer: string[] = []; + // Process the children of the list item + for (const descChild of node.children) { + processNode(uri, descChild, tempBuffer, depth + 1, true); + } + const indent = getLevel(node.node) > 1 ? ' '.repeat(getLevel(node.node)) : ''; + buffer.push(`${indent}${tempBuffer.join('').trim()}\n`); + return; + } + + case 'link': + if (!isNavigationLink(node)) { + const linkText = getNodeText(node.node, allowWrap); + const url = getLinkUrl(node.node); + if (!isSameUriIgnoringQueryAndFragment(uri, node.node)) { + buffer.push(`[${linkText}](${url})`); + } else { + buffer.push(linkText); + } + } + return; + case 'StaticText': { + const staticText = getNodeText(node.node, allowWrap); + if (staticText) { + buffer.push(staticText); + } + break; + } + case 'image': { + const altText = getNodeText(node.node, allowWrap) || 'Image'; + const imageUrl = getImageUrl(node.node); + if (imageUrl) { + buffer.push(`![${altText}](${imageUrl})\n\n`); + } else { + buffer.push(`[Image: ${altText}]\n\n`); + } + break; + } + + case 'DescriptionList': + processDescriptionListNode(uri, node, buffer, depth); + return; + + case 'blockquote': + buffer.push('> ' + getNodeText(node.node, allowWrap).replace(/\n/g, '\n> ') + '\n\n'); + break; + + // TODO: Is this the correct way to handle the generic role? + case 'generic': + buffer.push(' '); + break; + + case 'code': { + processCodeNode(uri, node, buffer, depth); + return; + } + + case 'pre': + buffer.push('```\n' + getNodeText(node.node, false) + '\n```\n\n'); + break; + + case 'table': + processTableNode(node, buffer); + return; + } + + // Process children if not already handled in specific cases + for (const child of node.children) { + processNode(uri, child, buffer, depth + 1, allowWrap); + } +} + +function getNodeRole(node: AXNode): string { + return node.role?.value as string || ''; +} + +function getNodeText(node: AXNode, allowWrap: boolean): string { + const text = node.name?.value as string || node.value?.value as string || ''; + if (!allowWrap) { + return text; + } + + if (text.length <= LINE_MAX_LENGTH) { + return text; + } + + const chars = text.split(''); + let lastSpaceIndex = -1; + for (let i = 1; i < chars.length; i++) { + if (chars[i] === ' ') { + lastSpaceIndex = i; + } + // Check if we reached the line max length, try to break at the last space + // before the line max length + if (i % LINE_MAX_LENGTH === 0 && lastSpaceIndex !== -1) { + // replace the space with a new line + chars[lastSpaceIndex] = '\n'; + lastSpaceIndex = i; + } + } + return chars.join(''); +} + +function getLevel(node: AXNode): number { + const levelProp = node.properties?.find(p => p.name === 'level'); + return levelProp ? Math.min(Number(levelProp.value.value) || 1, 6) : 1; +} + +function getLinkUrl(node: AXNode): string { + // Find URL in properties + const urlProp = node.properties?.find(p => p.name === 'url'); + return urlProp?.value.value as string || '#'; +} + +function getImageUrl(node: AXNode): string | null { + // Find URL in properties + const urlProp = node.properties?.find(p => p.name === 'url'); + return urlProp?.value.value as string || null; +} + +function isNavigationLink(node: AXNodeTree): boolean { + // Check if this link is part of navigation + let current: AXNodeTree | null = node; + while (current) { + const role = getNodeRole(current.node); + if (['navigation', 'menu', 'menubar'].includes(role)) { + return true; + } + current = current.parent; + } + return false; +} + +function isSameUriIgnoringQueryAndFragment(uri: URI, node: AXNode): boolean { + // Check if this link is an anchor link + const link = getLinkUrl(node); + try { + const parsed = URI.parse(link); + return parsed.scheme === uri.scheme && parsed.authority === uri.authority && parsed.path === uri.path; + } catch (e) { + return false; + } +} + +function processParagraphNode(uri: URI, node: AXNodeTree, buffer: string[], depth: number, allowWrap: boolean): void { + buffer.push('\n'); + // Process the children of the paragraph + for (const child of node.children) { + processNode(uri, child, buffer, depth + 1, allowWrap); + } + buffer.push('\n\n'); +} + +function processHeadingNode(uri: URI, node: AXNodeTree, buffer: string[], depth: number): void { + buffer.push('\n'); + const level = getLevel(node.node); + buffer.push(`${'#'.repeat(level)} `); + // Process children nodes of the heading + for (const child of node.children) { + if (getNodeRole(child.node) === 'StaticText') { + buffer.push(getNodeText(child.node, false)); + } else { + processNode(uri, child, buffer, depth + 1, false); + } + } + buffer.push('\n\n'); +} + +function processDescriptionListNode(uri: URI, node: AXNodeTree, buffer: string[], depth: number): void { + buffer.push('\n'); + + // Process each child of the description list + for (const child of node.children) { + if (getNodeRole(child.node) === 'term') { + buffer.push('- **'); + // Process term nodes + for (const termChild of child.children) { + processNode(uri, termChild, buffer, depth + 1, true); + } + buffer.push('** '); + } else if (getNodeRole(child.node) === 'definition') { + // Process description nodes + for (const descChild of child.children) { + processNode(uri, descChild, buffer, depth + 1, true); + } + buffer.push('\n'); + } + } + + buffer.push('\n'); +} + +function processTableNode(node: AXNodeTree, buffer: string[]): void { + buffer.push('\n'); + + // Find rows + const rows = node.children.filter(child => getNodeRole(child.node).includes('row')); + + if (rows.length > 0) { + // First row as header + const headerCells = rows[0].children.filter(cell => getNodeRole(cell.node).includes('cell')); + + // Generate header row + const headerContent = headerCells.map(cell => getNodeText(cell.node, false) || ' '); + buffer.push('| ' + headerContent.join(' | ') + ' |\n'); + + // Generate separator row + buffer.push('| ' + headerCells.map(() => '---').join(' | ') + ' |\n'); + + // Generate data rows + for (let i = 1; i < rows.length; i++) { + const dataCells = rows[i].children.filter(cell => getNodeRole(cell.node).includes('cell')); + const rowContent = dataCells.map(cell => getNodeText(cell.node, false) || ' '); + buffer.push('| ' + rowContent.join(' | ') + ' |\n'); + } + } + + buffer.push('\n'); +} + +function processCodeNode(uri: URI, node: AXNodeTree, buffer: string[], depth: number): void { + const tempBuffer: string[] = []; + // Process the children of the code node + for (const child of node.children) { + processNode(uri, child, tempBuffer, depth + 1, false); + } + const isCodeblock = tempBuffer.some(text => text.includes('\n')); + if (isCodeblock) { + buffer.push('\n```\n'); + // Append the processed text to the buffer + buffer.push(tempBuffer.join('')); + buffer.push('\n```\n'); + } else { + buffer.push('`'); + let characterCount = 0; + // Append the processed text to the buffer + for (const tempItem of tempBuffer) { + characterCount += tempItem.length; + if (characterCount > LINE_MAX_LENGTH) { + buffer.push('\n'); + characterCount = 0; + } + buffer.push(tempItem); + buffer.push('`'); + } + } +} + +function collectNavigationLinks(tree: AXNodeTree): string[] { + const links: string[] = []; + collectLinks(tree, links); + return links; +} + +function collectLinks(node: AXNodeTree, links: string[]): void { + const role = getNodeRole(node.node); + + if (role === 'link' && isNavigationLink(node)) { + const linkText = getNodeText(node.node, true); + const url = getLinkUrl(node.node); + const description = node.node.description?.value as string || ''; + + links.push(`- [${linkText}](${url})${description ? ' - ' + description : ''}`); + } + + // Process children + for (const child of node.children) { + collectLinks(child, links); + } } diff --git a/src/vs/platform/webContentExtractor/electron-main/webContentExtractorService.ts b/src/vs/platform/webContentExtractor/electron-main/webContentExtractorService.ts index 0cbfd73e6d5..bd5adb42daf 100644 --- a/src/vs/platform/webContentExtractor/electron-main/webContentExtractorService.ts +++ b/src/vs/platform/webContentExtractor/electron-main/webContentExtractorService.ts @@ -6,7 +6,7 @@ import { BrowserWindow } from 'electron'; import { IWebContentExtractorService } from '../common/webContentExtractor.js'; import { URI } from '../../../base/common/uri.js'; -import { AXNode, convertToReadibleFormat } from './cdpAccessibilityDomain.js'; +import { AXNode, convertAXTreeToMarkdown } from './cdpAccessibilityDomain.js'; import { Limiter } from '../../../base/common/async.js'; import { ResourceMap } from '../../../base/common/map.js'; @@ -60,7 +60,7 @@ export class NativeWebContentExtractorService implements IWebContentExtractorSer await win.loadURL(uri.toString(true)); win.webContents.debugger.attach('1.1'); const result: { nodes: AXNode[] } = await win.webContents.debugger.sendCommand('Accessibility.getFullAXTree'); - const str = convertToReadibleFormat(result.nodes); + const str = convertAXTreeToMarkdown(uri, result.nodes); this._webContentsCache.set(uri, { content: str, timestamp: Date.now() }); return str; } catch (err) { diff --git a/src/vs/platform/webContentExtractor/electron-sandbox/webContentExtractorService.ts b/src/vs/platform/webContentExtractor/electron-sandbox/webContentExtractorService.ts index 2f6d9964576..2d6ebf12b5f 100644 --- a/src/vs/platform/webContentExtractor/electron-sandbox/webContentExtractorService.ts +++ b/src/vs/platform/webContentExtractor/electron-sandbox/webContentExtractorService.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { registerMainProcessRemoteService } from '../../ipc/electron-sandbox/services.js'; -import { IWebContentExtractorService } from '../common/webContentExtractor.js'; +import { registerMainProcessRemoteService, registerSharedProcessRemoteService } from '../../ipc/electron-sandbox/services.js'; +import { ISharedWebContentExtractorService, IWebContentExtractorService } from '../common/webContentExtractor.js'; registerMainProcessRemoteService(IWebContentExtractorService, 'webContentExtractor'); +registerSharedProcessRemoteService(ISharedWebContentExtractorService, 'sharedWebContentExtractor'); diff --git a/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts new file mode 100644 index 00000000000..61ae28d1e9b --- /dev/null +++ b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../base/common/buffer.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { URI } from '../../../base/common/uri.js'; +import { ISharedWebContentExtractorService } from '../common/webContentExtractor.js'; + +export class SharedWebContentExtractorService implements ISharedWebContentExtractorService { + _serviceBrand: undefined; + + async readImage(uri: URI, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + + try { + const response = await fetch(uri.toString(true), { + headers: { + 'Accept': 'image/*', + 'User-Agent': 'Mozilla/5.0' + } + }); + const contentType = response.headers.get('content-type'); + if (!response.ok || !contentType?.startsWith('image/') || !/(webp|jpg|jpeg|gif|png|bmp)$/i.test(contentType)) { + return undefined; + } + + const content = VSBuffer.wrap(await response.bytes()); + return content; + } catch (err) { + console.log(err); + return undefined; + } + } +} diff --git a/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts new file mode 100644 index 00000000000..f82ef6c0279 --- /dev/null +++ b/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts @@ -0,0 +1,543 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { AXNode, AXProperty, AXValueType, convertAXTreeToMarkdown } from '../../electron-main/cdpAccessibilityDomain.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +suite('CDP Accessibility Domain', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const testUri = URI.parse('https://example.com/test'); + + function createAXValue(type: AXValueType, value: any) { + return { type, value }; + } + + function createAXProperty(name: string, value: any, type: AXValueType = 'string'): AXProperty { + return { + name: name as any, + value: createAXValue(type, value) + }; + } + + test('empty tree returns empty string', () => { + const result = convertAXTreeToMarkdown(testUri, []); + assert.strictEqual(result, ''); + }); + + //#region Heading Tests + + test('simple heading conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'node1', + childIds: ['node2'], + ignored: false, + role: createAXValue('role', 'heading'), + name: createAXValue('string', 'Test Heading'), + properties: [ + createAXProperty('level', 2, 'integer') + ] + }, + { + nodeId: 'node2', + childIds: [], + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Test Heading') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + assert.strictEqual(result.trim(), '## Test Heading'); + }); + + //#endregion + + //#region Paragraph Tests + + test('paragraph with text conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'node1', + ignored: false, + role: createAXValue('role', 'paragraph'), + childIds: ['node2'] + }, + { + nodeId: 'node2', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'This is a paragraph of text.') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + assert.strictEqual(result.trim(), 'This is a paragraph of text.'); + }); + + test('really long paragraph should insert newlines at the space before 80 characters', () => { + const longStr = [ + 'This is a paragraph of text. It is really long. Like really really really really', + 'really really really really really really really long. That long.' + ]; + + const nodes: AXNode[] = [ + { + nodeId: 'node2', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', longStr.join(' ')) + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + assert.strictEqual(result.trim(), longStr.join('\n')); + }); + + //#endregion + + //#region List Tests + + test('list conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'node1', + ignored: false, + role: createAXValue('role', 'list'), + childIds: ['node2', 'node3'] + }, + { + nodeId: 'node2', + ignored: false, + role: createAXValue('role', 'listitem'), + childIds: ['node4', 'node6'] + }, + { + nodeId: 'node3', + ignored: false, + role: createAXValue('role', 'listitem'), + childIds: ['node5', 'node7'] + }, + { + nodeId: 'node4', + ignored: false, + role: createAXValue('role', 'ListMarker'), + name: createAXValue('string', '1. ') + }, + { + nodeId: 'node5', + ignored: false, + role: createAXValue('role', 'ListMarker'), + name: createAXValue('string', '2. ') + }, + { + nodeId: 'node6', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Item 1') + }, + { + nodeId: 'node7', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Item 2') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + const expected = + ` +1. Item 1 +2. Item 2 + +`; + assert.strictEqual(result, expected); + }); + + test('nested list conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'list1', + ignored: false, + role: createAXValue('role', 'list'), + childIds: ['item1', 'item2'] + }, + { + nodeId: 'item1', + ignored: false, + role: createAXValue('role', 'listitem'), + childIds: ['marker1', 'text1', 'nestedList'], + properties: [ + createAXProperty('level', 1, 'integer') + ] + }, + { + nodeId: 'marker1', + ignored: false, + role: createAXValue('role', 'ListMarker'), + name: createAXValue('string', '- ') + }, + { + nodeId: 'text1', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Item 1') + }, + { + nodeId: 'nestedList', + ignored: false, + role: createAXValue('role', 'list'), + childIds: ['nestedItem'] + }, + { + nodeId: 'nestedItem', + ignored: false, + role: createAXValue('role', 'listitem'), + childIds: ['nestedMarker', 'nestedText'], + properties: [ + createAXProperty('level', 2, 'integer') + ] + }, + { + nodeId: 'nestedMarker', + ignored: false, + role: createAXValue('role', 'ListMarker'), + name: createAXValue('string', '- ') + }, + { + nodeId: 'nestedText', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Item 1a') + }, + { + nodeId: 'item2', + ignored: false, + role: createAXValue('role', 'listitem'), + childIds: ['marker2', 'text2'], + properties: [ + createAXProperty('level', 1, 'integer') + ] + }, + { + nodeId: 'marker2', + ignored: false, + role: createAXValue('role', 'ListMarker'), + name: createAXValue('string', '- ') + }, + { + nodeId: 'text2', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Item 2') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + const indent = ' '; + const expected = + ` +- Item 1 +${indent}- Item 1a +- Item 2 + +`; + assert.strictEqual(result, expected); + }); + + //#endregion + + //#region Links Tests + + test('links conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'node1', + ignored: false, + role: createAXValue('role', 'paragraph'), + childIds: ['node2'] + }, + { + nodeId: 'node2', + ignored: false, + role: createAXValue('role', 'link'), + name: createAXValue('string', 'Test Link'), + properties: [ + createAXProperty('url', 'https://test.com') + ] + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + assert.strictEqual(result.trim(), '[Test Link](https://test.com)'); + }); + + test('links to same page are not converted to markdown links', () => { + const pageUri = URI.parse('https://example.com/page'); + const nodes: AXNode[] = [ + { + nodeId: 'link', + ignored: false, + role: createAXValue('role', 'link'), + name: createAXValue('string', 'Current page link'), + properties: [createAXProperty('url', 'https://example.com/page?section=1#header')] + } + ]; + + const result = convertAXTreeToMarkdown(pageUri, nodes); + assert.strictEqual(result.includes('Current page link'), true); + assert.strictEqual(result.includes('[Current page link]'), false); + }); + + //#endregion + + //#region Image Tests + + test('image conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'node1', + ignored: false, + role: createAXValue('role', 'image'), + name: createAXValue('string', 'Alt text'), + properties: [ + createAXProperty('url', 'https://test.com/image.png') + ] + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + assert.strictEqual(result.trim(), '![Alt text](https://test.com/image.png)'); + }); + + test('image without URL shows alt text', () => { + const nodes: AXNode[] = [ + { + nodeId: 'node1', + ignored: false, + role: createAXValue('role', 'image'), + name: createAXValue('string', 'Alt text') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + assert.strictEqual(result.trim(), '[Image: Alt text]'); + }); + + //#endregion + + //#region Description List Tests + + test('description list conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'dl', + ignored: false, + role: createAXValue('role', 'DescriptionList'), + childIds: ['term1', 'def1', 'term2', 'def2'] + }, + { + nodeId: 'term1', + ignored: false, + role: createAXValue('role', 'term'), + childIds: ['termText1'] + }, + { + nodeId: 'termText1', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Term 1') + }, + { + nodeId: 'def1', + ignored: false, + role: createAXValue('role', 'definition'), + childIds: ['defText1'] + }, + { + nodeId: 'defText1', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Definition 1') + }, + { + nodeId: 'term2', + ignored: false, + role: createAXValue('role', 'term'), + childIds: ['termText2'] + }, + { + nodeId: 'termText2', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Term 2') + }, + { + nodeId: 'def2', + ignored: false, + role: createAXValue('role', 'definition'), + childIds: ['defText2'] + }, + { + nodeId: 'defText2', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'Definition 2') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + assert.strictEqual(result.includes('- **Term 1** Definition 1'), true); + assert.strictEqual(result.includes('- **Term 2** Definition 2'), true); + }); + + //#endregion + + //#region Blockquote Tests + + test('blockquote conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'node1', + ignored: false, + role: createAXValue('role', 'blockquote'), + name: createAXValue('string', 'This is a blockquote\nWith multiple lines') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + const expected = + `> This is a blockquote +> With multiple lines`; + assert.strictEqual(result.trim(), expected); + }); + + //#endregion + + //#region Code Tests + + test('preformatted text conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'node1', + ignored: false, + role: createAXValue('role', 'pre'), + name: createAXValue('string', 'function test() {\n return true;\n}') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + const expected = + '```\nfunction test() {\n return true;\n}\n```'; + assert.strictEqual(result.trim(), expected); + }); + + test('code block conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'code', + ignored: false, + role: createAXValue('role', 'code'), + childIds: ['codeText'] + }, + { + nodeId: 'codeText', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'const x = 42;\nconsole.log(x);') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + assert.strictEqual(result.includes('```'), true); + assert.strictEqual(result.includes('const x = 42;'), true); + assert.strictEqual(result.includes('console.log(x);'), true); + }); + + test('inline code conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'code', + ignored: false, + role: createAXValue('role', 'code'), + childIds: ['codeText'] + }, + { + nodeId: 'codeText', + ignored: false, + role: createAXValue('role', 'StaticText'), + name: createAXValue('string', 'const x = 42;') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + assert.strictEqual(result.includes('`const x = 42;`'), true); + }); + + //#endregion + + //#region Table Tests + + test('table conversion', () => { + const nodes: AXNode[] = [ + { + nodeId: 'table1', + ignored: false, + role: createAXValue('role', 'table'), + childIds: ['row1', 'row2'] + }, + { + nodeId: 'row1', + ignored: false, + role: createAXValue('role', 'row'), + childIds: ['cell1', 'cell2'] + }, + { + nodeId: 'row2', + ignored: false, + role: createAXValue('role', 'row'), + childIds: ['cell3', 'cell4'] + }, + { + nodeId: 'cell1', + ignored: false, + role: createAXValue('role', 'cell'), + name: createAXValue('string', 'Header 1') + }, + { + nodeId: 'cell2', + ignored: false, + role: createAXValue('role', 'cell'), + name: createAXValue('string', 'Header 2') + }, + { + nodeId: 'cell3', + ignored: false, + role: createAXValue('role', 'cell'), + name: createAXValue('string', 'Data 1') + }, + { + nodeId: 'cell4', + ignored: false, + role: createAXValue('role', 'cell'), + name: createAXValue('string', 'Data 2') + } + ]; + + const result = convertAXTreeToMarkdown(testUri, nodes); + const expected = + ` +| Header 1 | Header 2 | +| --- | --- | +| Data 1 | Data 2 | +`; + assert.strictEqual(result.trim(), expected.trim()); + }); + + //#endregion +}); diff --git a/src/vs/platform/window/common/window.ts b/src/vs/platform/window/common/window.ts index ddf6d46f7db..3d394c573d0 100644 --- a/src/vs/platform/window/common/window.ts +++ b/src/vs/platform/window/common/window.ts @@ -429,3 +429,4 @@ export function zoomLevelToZoomFactor(zoomLevel = 0): number { export const DEFAULT_WINDOW_SIZE = { width: 1200, height: 800 } as const; export const DEFAULT_AUX_WINDOW_SIZE = { width: 1024, height: 768 } as const; +export const DEFAULT_COMPACT_AUX_WINDOW_SIZE = { width: 640, height: 640 } as const; diff --git a/src/vs/platform/window/electron-main/window.ts b/src/vs/platform/window/electron-main/window.ts index af926cf5b51..11c60408036 100644 --- a/src/vs/platform/window/electron-main/window.ts +++ b/src/vs/platform/window/electron-main/window.ts @@ -196,5 +196,10 @@ export const enum WindowError { /** * Maps to the `did-fail-load` event on a `WebContents`. */ - LOAD = 3 + LOAD = 3, + + /** + * Maps to the `responsive` event on a `BrowserWindow`. + */ + RESPONSIVE = 4, } diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index d7c6a41d986..49cc9fb8f5c 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import electron, { BrowserWindowConstructorOptions } from 'electron'; -import { DeferredPromise, RunOnceScheduler, timeout } from '../../../base/common/async.js'; +import { DeferredPromise, RunOnceScheduler, timeout, Delayer } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter, Event } from '../../../base/common/event.js'; @@ -33,7 +33,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { IThemeMainService } from '../../theme/electron-main/themeMainService.js'; import { getMenuBarVisibility, IFolderToOpen, INativeWindowConfiguration, IWindowSettings, IWorkspaceToOpen, MenuBarVisibility, hasNativeTitlebar, useNativeFullScreen, useWindowControlsOverlay, DEFAULT_CUSTOM_TITLEBAR_HEIGHT, TitlebarStyle } from '../../window/common/window.js'; -import { defaultBrowserWindowOptions, IWindowsMainService, OpenContext, WindowStateValidator } from './windows.js'; +import { defaultBrowserWindowOptions, getAllWindowsExcludingOffscreen, IWindowsMainService, OpenContext, WindowStateValidator } from './windows.js'; import { ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, toWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { IWorkspacesManagementMainService } from '../../workspaces/electron-main/workspacesManagementMainService.js'; import { IWindowState, ICodeWindow, ILoadEvent, WindowMode, WindowError, LoadReason, defaultWindowState, IBaseWindow } from '../../window/electron-main/window.js'; @@ -44,6 +44,7 @@ import { IUserDataProfilesMainService } from '../../userDataProfile/electron-mai import { ILoggerMainService } from '../../log/electron-main/loggerService.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { VSBuffer } from '../../../base/common/buffer.js'; +import { errorHandler } from '../../../base/common/errors.js'; export interface IWindowCreationOptions { readonly state: IWindowState; @@ -104,6 +105,9 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { private readonly _onDidLeaveFullScreen = this._register(new Emitter()); readonly onDidLeaveFullScreen = this._onDidLeaveFullScreen.event; + private readonly _onDidChangeAlwaysOnTop = this._register(new Emitter()); + readonly onDidChangeAlwaysOnTop = this._onDidChangeAlwaysOnTop.event; + //#endregion abstract readonly id: number; @@ -129,6 +133,7 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { })); this._register(Event.fromNodeEventEmitter(this._win, 'enter-full-screen')(() => this._onDidEnterFullScreen.fire())); this._register(Event.fromNodeEventEmitter(this._win, 'leave-full-screen')(() => this._onDidLeaveFullScreen.fire())); + this._register(Event.fromNodeEventEmitter(this._win, 'always-on-top-changed', (_, alwaysOnTop) => alwaysOnTop)(alwaysOnTop => this._onDidChangeAlwaysOnTop.fire(alwaysOnTop))); // Sheet Offsets const useCustomTitleStyle = !hasNativeTitlebar(this.configurationService, options?.titleBarStyle === 'hidden' ? TitlebarStyle.CUSTOM : undefined /* unknown */); @@ -146,23 +151,13 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { } } - // Windows Custom System Context Menu - // See https://github.com/electron/electron/issues/24893 - // - // The purpose of this is to allow for the context menu in the Windows Title Bar - // - // Currently, all mouse events in the title bar are captured by the OS - // thus we need to capture them here with a window hook specific to Windows - // and then forward them to the correct window. + // Setup windows system context menu so it only is allowed in certain cases if (isWindows && useCustomTitleStyle) { - const WM_INITMENU = 0x0116; // https://docs.microsoft.com/en-us/windows/win32/menurc/wm-initmenu - - // This sets up a listener for the window hook. This is a Windows-only API provided by electron. - win.hookWindowMessage(WM_INITMENU, () => { + this._register(Event.fromNodeEventEmitter(win, 'system-context-menu', (event: Electron.Event, point: Electron.Point) => ({ event, point }))((e) => { const [x, y] = win.getPosition(); - const cursorPos = electron.screen.getCursorScreenPoint(); - const cx = cursorPos.x - x; - const cy = cursorPos.y - y; + const cursorPos = electron.screen.screenToDipPoint(e.point); + const cx = Math.floor(cursorPos.x) - x; + const cy = Math.floor(cursorPos.y) - y; // In some cases, show the default system context menu // 1) The mouse position is not within the title bar @@ -180,16 +175,11 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { }; if (!shouldTriggerDefaultSystemContextMenu()) { - - // This is necessary to make sure the native system context menu does not show up. - win.setEnabled(false); - win.setEnabled(true); + e.event.preventDefault(); this._onDidTriggerSystemContextMenu.fire({ x: cx, y: cy }); } - - return 0; - }); + })); } // Open devtools if instructed from command line args @@ -232,7 +222,7 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { const windowSettings = this.configurationService.getValue('window'); const useNativeTabs = isMacintosh && windowSettings?.nativeTabs === true; - if ((isMacintosh || isWindows) && hasMultipleDisplays && (!useNativeTabs || electron.BrowserWindow.getAllWindows().length === 1)) { + if ((isMacintosh || isWindows) && hasMultipleDisplays && (!useNativeTabs || getAllWindowsExcludingOffscreen().length === 1)) { if ([state.width, state.height, state.x, state.y].every(value => typeof value === 'number')) { this._win?.setBounds({ width: state.width, @@ -540,6 +530,11 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { private pendingLoadConfig: INativeWindowConfiguration | undefined; private wasLoaded = false; + private readonly jsCallStackMap: Map; + private readonly jsCallStackEffectiveSampleCount: number; + private readonly jsCallStackCollector: Delayer; + private readonly jsCallStackCollectorStopScheduler: RunOnceScheduler; + constructor( config: IWindowCreationOptions, @ILogService logService: ILogService, @@ -595,6 +590,25 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { } //#endregion + //#region JS Callstack Collector + + let sampleInterval = parseInt(this.environmentMainService.args['unresponsive-sample-interval'] || '1000'); + let samplePeriod = parseInt(this.environmentMainService.args['unresponsive-sample-period'] || '15000'); + if (sampleInterval <= 0 || samplePeriod <= 0 || sampleInterval > samplePeriod) { + this.logService.warn(`Invalid unresponsive sample interval (${sampleInterval}ms) or period (${samplePeriod}ms), using defaults.`); + sampleInterval = 1000; + samplePeriod = 15000; + } + + this.jsCallStackMap = new Map(); + this.jsCallStackEffectiveSampleCount = Math.round(sampleInterval / samplePeriod); + this.jsCallStackCollector = this._register(new Delayer(sampleInterval)); + this.jsCallStackCollectorStopScheduler = this._register(new RunOnceScheduler(() => { + this.stopCollectingJScallStacks(); // Stop collecting after 15s max + }, samplePeriod)); + + //#endregion + // respect configured menu bar visibility this.onConfigurationUpdated(); @@ -655,6 +669,7 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { // Window error conditions to handle this._register(Event.fromNodeEventEmitter(this._win, 'unresponsive')(() => this.onWindowError(WindowError.UNRESPONSIVE))); + this._register(Event.fromNodeEventEmitter(this._win, 'responsive')(() => this.onWindowError(WindowError.RESPONSIVE))); this._register(Event.fromNodeEventEmitter(this._win.webContents, 'render-process-gone', (event, details) => details)(details => this.onWindowError(WindowError.PROCESS_GONE, { ...details }))); this._register(Event.fromNodeEventEmitter(this._win.webContents, 'did-fail-load', (event, exitCode, reason) => ({ exitCode, reason }))(({ exitCode, reason }) => this.onWindowError(WindowError.LOAD, { reason, exitCode }))); @@ -705,7 +720,11 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { this._register(this.workspacesManagementMainService.onDidDeleteUntitledWorkspace(e => this.onDidDeleteUntitledWorkspace(e))); // Inject headers when requests are incoming - const urls = ['https://marketplace.visualstudio.com/*', 'https://*.vsassets.io/*']; + const urls = ['https://*.vsassets.io/*']; + if (this.productService.extensionsGallery?.serviceUrl) { + const serviceUrl = URI.parse(this.productService.extensionsGallery.serviceUrl); + urls.push(`${serviceUrl.scheme}://${serviceUrl.authority}/*`); + } this._win.webContents.session.webRequest.onBeforeSendHeaders({ urls }, async (details, cb) => { const headers = await this.getMarketplaceHeaders(); @@ -730,6 +749,7 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { } private async onWindowError(error: WindowError.UNRESPONSIVE): Promise; + private async onWindowError(error: WindowError.RESPONSIVE): Promise; private async onWindowError(error: WindowError.PROCESS_GONE, details: { reason: string; exitCode: number }): Promise; private async onWindowError(error: WindowError.LOAD, details: { reason: string; exitCode: number }): Promise; private async onWindowError(type: WindowError, details?: { reason?: string; exitCode?: number }): Promise { @@ -741,6 +761,9 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { case WindowError.UNRESPONSIVE: this.logService.error('CodeWindow: detected unresponsive'); break; + case WindowError.RESPONSIVE: + this.logService.error('CodeWindow: recovered from unresponsive'); + break; case WindowError.LOAD: this.logService.error(`CodeWindow: failed to load (reason: ${details?.reason || ''}, code: ${details?.exitCode || ''})`); break; @@ -799,6 +822,14 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { return; } + // Interrupt V8 and collect JavaScript stack + this.jsCallStackCollector.trigger(() => this.startCollectingJScallStacks()); + // Stack collection will stop under any of the following conditions: + // - The window becomes responsive again + // - The window is destroyed i-e reopen or closed + // - sampling period is complete, default is 15s + this.jsCallStackCollectorStopScheduler.schedule(); + // Show Dialog const { response, checkboxChecked } = await this.dialogMainService.showMessageBox({ type: 'warning', @@ -815,6 +846,7 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { // Handle choice if (response !== 2 /* keep waiting */) { const reopen = response === 0; + this.stopCollectingJScallStacks(); await this.destroyWindow(reopen, checkboxChecked); } } @@ -847,6 +879,9 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { await this.destroyWindow(reopen, checkboxChecked); } break; + case WindowError.RESPONSIVE: + this.stopCollectingJScallStacks(); + break; } } @@ -1449,6 +1484,50 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { return segments; } + private async startCollectingJScallStacks(): Promise { + if (!this.jsCallStackCollector.isTriggered()) { + const stack = await this._win.webContents.mainFrame.collectJavaScriptCallStack(); + + // Increment the count for this stack trace + if (stack) { + const count = this.jsCallStackMap.get(stack) || 0; + this.jsCallStackMap.set(stack, count + 1); + } + + this.jsCallStackCollector.trigger(() => this.startCollectingJScallStacks()); + } + } + + private stopCollectingJScallStacks(): void { + this.jsCallStackCollectorStopScheduler.cancel(); + this.jsCallStackCollector.cancel(); + + if (this.jsCallStackMap.size) { + let logMessage = `CodeWindow unresponsive samples:\n`; + let samples = 0; + + const sortedEntries = Array.from(this.jsCallStackMap.entries()) + .sort((a, b) => b[1] - a[1]); + + for (const [stack, count] of sortedEntries) { + samples += count; + // If the stack appears more than 20 percent of the time, log it + // to the error telemetry as UnresponsiveSampleError. + if (Math.round((count * 100) / this.jsCallStackEffectiveSampleCount) > 20) { + const fakeError = new UnresponsiveError(stack, this.id, this.win?.webContents.getOSProcessId()); + errorHandler.onUnexpectedError(fakeError); + } + logMessage += `<${count}> ${stack}\n`; + } + + logMessage += `Total Samples: ${samples}\n`; + logMessage += 'For full overview of the unresponsive period, capture cpu profile via https://aka.ms/vscode-tracing-cpu-profile'; + this.logService.error(logMessage); + } + + this.jsCallStackMap.clear(); + } + matches(webContents: electron.WebContents): boolean { return this._win?.webContents.id === webContents.id; } @@ -1460,3 +1539,17 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { this.loggerMainService.deregisterLoggers(this.id); } } + +class UnresponsiveError extends Error { + + constructor(sample: string, windowId: number, pid: number = 0) { + // Since the stacks are available via the sample + // we can avoid collecting them when constructing the error. + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 0; + super(`UnresponsiveSampleError: from window with ID ${windowId} belonging to process with pid ${pid}`); + Error.stackTraceLimit = stackTraceLimit; + this.name = 'UnresponsiveSampleError'; + this.stack = sample; + } +} diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 99f145c9236..42be1e253a5 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -122,6 +122,7 @@ export interface IOpenEmptyConfiguration extends IBaseOpenConfiguration { } export interface IDefaultBrowserWindowOptionsOverrides { forceNativeTitlebar?: boolean; disableFullscreen?: boolean; + alwaysOnTop?: boolean; } export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowState: IWindowState, overrides?: IDefaultBrowserWindowOptionsOverrides, webPreferences?: electron.WebPreferences): electron.BrowserWindowConstructorOptions & { experimentalDarkMode: boolean } { @@ -212,6 +213,10 @@ export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowSt } } + if (overrides?.alwaysOnTop) { + options.alwaysOnTop = true; + } + return options; } @@ -379,3 +384,14 @@ export namespace WindowStateValidator { return undefined; } } + +/** + * We have some components like `NativeWebContentExtractorService` that create offscreen windows + * to extract content from web pages. These windows are not visible to the user and are not + * considered part of the main application window. This function filters out those offscreen + * windows from the list of all windows. + * @returns An array of all BrowserWindow instances that are not offscreen. + */ +export function getAllWindowsExcludingOffscreen() { + return electron.BrowserWindow.getAllWindows().filter(win => !win.webContents.isOffscreen()); +} diff --git a/src/vs/server/node/serverEnvironmentService.ts b/src/vs/server/node/serverEnvironmentService.ts index b3e97edc704..c06a700390b 100644 --- a/src/vs/server/node/serverEnvironmentService.ts +++ b/src/vs/server/node/serverEnvironmentService.ts @@ -40,6 +40,7 @@ export const serverOptions: OptionDescriptions> = { 'log': OPTIONS['log'], 'logsPath': OPTIONS['logsPath'], 'force-disable-user-env': OPTIONS['force-disable-user-env'], + 'enable-proposed-api': OPTIONS['enable-proposed-api'], /* ----- vs code web options ----- */ @@ -163,6 +164,7 @@ export interface ServerParsedArgs { 'logsPath'?: string; 'force-disable-user-env'?: boolean; + 'enable-proposed-api'?: string[]; /* ----- vs code web options ----- */ diff --git a/src/vs/server/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index e36113b4bfe..5aa05b71bcb 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -25,7 +25,7 @@ import { CancellationToken } from '../../base/common/cancellation.js'; import { URI } from '../../base/common/uri.js'; import { streamToBuffer } from '../../base/common/buffer.js'; import { IProductConfiguration } from '../../base/common/product.js'; -import { isString } from '../../base/common/types.js'; +import { isString, Mutable } from '../../base/common/types.js'; import { CharCode } from '../../base/common/charCode.js'; import { IExtensionManifest } from '../../platform/extensions/common/extensions.js'; import { ICSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; @@ -333,7 +333,7 @@ export class WebClientServer { scopes: [['user:email'], ['repo']] } : undefined; - const productConfiguration = { + const productConfiguration: Partial> = { embedderIdentifier: 'server-distro', extensionsGallery: this._webExtensionResourceUrlTemplate && this._productService.extensionsGallery ? { ...this._productService.extensionsGallery, @@ -343,7 +343,13 @@ export class WebClientServer { path: `${webExtensionRoute}/${this._webExtensionResourceUrlTemplate.authority}${this._webExtensionResourceUrlTemplate.path}` }).toString(true) } : undefined - } satisfies Partial; + }; + + const proposedApi = this._environmentService.args['enable-proposed-api']; + if (proposedApi?.length) { + productConfiguration.extensionsEnabledWithApiProposalVersion ??= []; + productConfiguration.extensionsEnabledWithApiProposalVersion.push(...proposedApi); + } if (!this._environmentService.isBuilt) { try { diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index f0bf62c67ff..d5430634469 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -88,7 +88,9 @@ import './mainThreadShare.js'; import './mainThreadProfileContentHandlers.js'; import './mainThreadAiRelatedInformation.js'; import './mainThreadAiEmbeddingVector.js'; +import './mainThreadAiSettingsSearch.js'; import './mainThreadMcp.js'; +import './mainThreadChatStatus.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadAiSettingsSearch.ts b/src/vs/workbench/api/browser/mainThreadAiSettingsSearch.ts new file mode 100644 index 00000000000..8f7dde42d91 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadAiSettingsSearch.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { AiSettingsSearchResult, IAiSettingsSearchProvider, IAiSettingsSearchService } from '../../services/aiSettingsSearch/common/aiSettingsSearch.js'; +import { ExtHostContext, ExtHostAiSettingsSearchShape, MainContext, MainThreadAiSettingsSearchShape, } from '../common/extHost.protocol.js'; + +@extHostNamedCustomer(MainContext.MainThreadAiSettingsSearch) +export class MainThreadAiSettingsSearch extends Disposable implements MainThreadAiSettingsSearchShape { + private readonly _proxy: ExtHostAiSettingsSearchShape; + private readonly _registrations = this._register(new DisposableMap()); + + constructor( + context: IExtHostContext, + @IAiSettingsSearchService private readonly _settingsSearchService: IAiSettingsSearchService, + ) { + super(); + this._proxy = context.getProxy(ExtHostContext.ExtHostAiSettingsSearch); + } + + $registerAiSettingsSearchProvider(handle: number): void { + const provider: IAiSettingsSearchProvider = { + searchSettings: (query, option, token) => { + return this._proxy.$startSearch(handle, query, option, token); + } + }; + this._registrations.set(handle, this._settingsSearchService.registerSettingsSearchProvider(provider)); + } + + $unregisterAiSettingsSearchProvider(handle: number): void { + this._registrations.deleteAndDispose(handle); + } + + $handleSearchResult(handle: number, result: AiSettingsSearchResult): void { + if (!this._registrations.has(handle)) { + throw new Error(`No AI settings search provider found`); + } + + this._settingsSearchService.handleSearchResult(result); + } +} diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 0da8d2a09f1..4560cc6026f 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -21,6 +21,7 @@ import { ILanguageFeaturesService } from '../../../editor/common/services/langua import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../platform/log/common/log.js'; +import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js'; import { IChatWidgetService } from '../../contrib/chat/browser/chat.js'; import { ChatInputPart } from '../../contrib/chat/browser/chatInputPart.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/contrib/chatDynamicVariables.js'; @@ -29,7 +30,7 @@ import { IChatEditingService, IChatRelatedFileProviderMetadata } from '../../con import { ChatRequestAgentPart } from '../../contrib/chat/common/chatParserTypes.js'; import { ChatRequestParser } from '../../contrib/chat/common/chatRequestParser.js'; import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatNotebookEdit, IChatProgress, IChatService, IChatTask, IChatWarningMessage } from '../../contrib/chat/common/chatService.js'; -import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../../contrib/chat/common/constants.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; @@ -102,6 +103,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA @IInstantiationService private readonly _instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @IExtensionService private readonly _extensionService: IExtensionService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatAgents2); @@ -200,6 +202,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA slashCommands: [], disambiguation: [], locations: [ChatAgentLocation.Panel], // TODO all dynamic participants are panel only? + modes: [ChatMode.Ask] }, impl); } else { @@ -226,7 +229,19 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA } async $handleProgressChunk(requestId: string, progress: IChatProgressDto, responsePartHandle?: number): Promise { - const revivedProgress = progress.kind === 'notebookEdit' ? ChatNotebookEdit.fromChatEdit(revive(progress)) : revive(progress) as IChatProgress; + + const revivedProgress = progress.kind === 'notebookEdit' + ? ChatNotebookEdit.fromChatEdit(revive(progress)) + : revive(progress) as IChatProgress; + + if (revivedProgress.kind === 'notebookEdit' + || revivedProgress.kind === 'textEdit' + || revivedProgress.kind === 'codeblockUri' + ) { + // make sure to use the canonical uri + revivedProgress.uri = this._uriIdentityService.asCanonicalUri(revivedProgress.uri); + } + if (revivedProgress.kind === 'progressTask') { const handle = ++this._responsePartHandlePool; const responsePartId = `${requestId}_${handle}`; diff --git a/src/vs/workbench/api/browser/mainThreadChatCodeMapper.ts b/src/vs/workbench/api/browser/mainThreadChatCodeMapper.ts index 547e3346694..4f15773352b 100644 --- a/src/vs/workbench/api/browser/mainThreadChatCodeMapper.ts +++ b/src/vs/workbench/api/browser/mainThreadChatCodeMapper.ts @@ -37,6 +37,8 @@ export class MainThreadChatCodemapper extends Disposable implements MainThreadCo requestId, codeBlocks: uiRequest.codeBlocks, chatRequestId: uiRequest.chatRequestId, + chatRequestModel: uiRequest.chatRequestModel, + chatSessionId: uiRequest.chatSessionId, location: uiRequest.location }; try { diff --git a/src/vs/workbench/api/browser/mainThreadChatStatus.ts b/src/vs/workbench/api/browser/mainThreadChatStatus.ts new file mode 100644 index 00000000000..e2b8cd24fc6 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadChatStatus.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { IChatStatusItemService } from '../../contrib/chat/browser/chatStatusItemService.js'; +import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { ChatStatusItemDto, MainContext, MainThreadChatStatusShape } from '../common/extHost.protocol.js'; + +@extHostNamedCustomer(MainContext.MainThreadChatStatus) +export class MainThreadChatStatus extends Disposable implements MainThreadChatStatusShape { + + constructor( + _extHostContext: IExtHostContext, + @IChatStatusItemService private readonly _chatStatusItemService: IChatStatusItemService, + ) { + super(); + } + + $setEntry(id: string, entry: ChatStatusItemDto): void { + this._chatStatusItemService.setOrUpdateEntry({ + id, + label: entry.title, + description: entry.description, + detail: entry.detail, + }); + } + + $disposeEntry(id: string): void { + this._chatStatusItemService.deleteEntry(id); + } +} diff --git a/src/vs/workbench/api/browser/mainThreadComments.ts b/src/vs/workbench/api/browser/mainThreadComments.ts index 35799eeb566..74890c18ebc 100644 --- a/src/vs/workbench/api/browser/mainThreadComments.ts +++ b/src/vs/workbench/api/browser/mainThreadComments.ts @@ -5,7 +5,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { IRange, Range } from '../../../editor/common/core/range.js'; import * as languages from '../../../editor/common/languages.js'; @@ -93,9 +93,9 @@ export class MainThreadCommentThread implements languages.CommentThread { private readonly _onDidChangeCanReply = new Emitter(); get onDidChangeCanReply(): Event { return this._onDidChangeCanReply.event; } - set canReply(state: boolean) { + set canReply(state: boolean | languages.CommentAuthorInformation) { this._canReply = state; - this._onDidChangeCanReply.fire(this._canReply); + this._onDidChangeCanReply.fire(!!this._canReply); } get canReply() { @@ -182,7 +182,7 @@ export class MainThreadCommentThread implements languages.CommentThread { public resource: string, private _range: T | undefined, comments: languages.Comment[] | undefined, - private _canReply: boolean, + private _canReply: boolean | languages.CommentAuthorInformation, private _isTemplate: boolean, public editorId?: string ) { @@ -540,8 +540,9 @@ export class MainThreadComments extends Disposable implements MainThreadComments private _activeEditingCommentThread?: MainThreadCommentThread; private readonly _activeEditingCommentThreadDisposables = this._register(new DisposableStore()); - private _openViewListener: IDisposable | null = null; - + private readonly _openViewListener: MutableDisposable = this._register(new MutableDisposable()); + private readonly _onChangeContainerListener: MutableDisposable = this._register(new MutableDisposable()); + private readonly _onChangeContainerLocationListener: MutableDisposable = this._register(new MutableDisposable()); constructor( extHostContext: IExtHostContext, @@ -736,8 +737,8 @@ export class MainThreadComments extends Disposable implements MainThreadComments } private registerViewOpenedListener() { - if (!this._openViewListener) { - this._openViewListener = this._viewsService.onDidChangeViewVisibility(e => { + if (!this._openViewListener.value) { + this._openViewListener.value = this._viewsService.onDidChangeViewVisibility(e => { if (e.id === COMMENTS_VIEW_ID && e.visible) { this.setComments(); if (this._openViewListener) { @@ -758,19 +759,24 @@ export class MainThreadComments extends Disposable implements MainThreadComments this.registerViewOpenedListener(); } - this._register(this._viewDescriptorService.onDidChangeContainer(e => { - if (e.views.find(view => view.id === COMMENTS_VIEW_ID)) { - this.setComments(); - this.registerViewOpenedListener(); - } - })); - this._register(this._viewDescriptorService.onDidChangeContainerLocation(e => { - const commentsContainer = this._viewDescriptorService.getViewContainerByViewId(COMMENTS_VIEW_ID); - if (e.viewContainer.id === commentsContainer?.id) { - this.setComments(); - this.registerViewOpenedListener(); - } - })); + if (!this._onChangeContainerListener.value) { + this._onChangeContainerListener.value = this._viewDescriptorService.onDidChangeContainer(e => { + if (e.views.find(view => view.id === COMMENTS_VIEW_ID)) { + this.setComments(); + this.registerViewOpenedListener(); + } + }); + } + + if (!this._onChangeContainerLocationListener.value) { + this._onChangeContainerLocationListener.value = this._viewDescriptorService.onDidChangeContainerLocation(e => { + const commentsContainer = this._viewDescriptorService.getViewContainerByViewId(COMMENTS_VIEW_ID); + if (e.viewContainer.id === commentsContainer?.id) { + this.setComments(); + this.registerViewOpenedListener(); + } + }); + } } private getHandler(handle: number) { diff --git a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts index 965e3089a96..ee393a8c735 100644 --- a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts @@ -174,7 +174,9 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc return; } - webviewInput.webview.onDidDispose(() => { + const disposeSub = webviewInput.webview.onDidDispose(() => { + disposeSub.dispose(); + // If the model is still dirty, make sure we have time to save it if (modelRef.object.isDirty()) { const sub = modelRef.object.onDidChangeDirty(() => { diff --git a/src/vs/workbench/api/browser/mainThreadEditSessionIdentityParticipant.ts b/src/vs/workbench/api/browser/mainThreadEditSessionIdentityParticipant.ts index db70e241abc..ae1198c5455 100644 --- a/src/vs/workbench/api/browser/mainThreadEditSessionIdentityParticipant.ts +++ b/src/vs/workbench/api/browser/mainThreadEditSessionIdentityParticipant.ts @@ -16,7 +16,7 @@ import { WorkspaceFolder } from '../../../platform/workspace/common/workspace.js class ExtHostEditSessionIdentityCreateParticipant implements IEditSessionIdentityCreateParticipant { private readonly _proxy: ExtHostWorkspaceShape; - private readonly timeout = 10000; + private readonly timeout = 20000; constructor(extHostContext: IExtHostContext) { this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostWorkspace); diff --git a/src/vs/workbench/api/browser/mainThreadEditors.ts b/src/vs/workbench/api/browser/mainThreadEditors.ts index a61ba3c7801..5bf9b358673 100644 --- a/src/vs/workbench/api/browser/mainThreadEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadEditors.ts @@ -397,10 +397,10 @@ export class MainThreadTextEditors implements MainThreadTextEditorsShape { } try { - const scmQuickDiff = quickDiffModelRef.object.quickDiffs.find(quickDiff => quickDiff.isSCM); - const scmQuickDiffChanges = quickDiffModelRef.object.changes.filter(change => change.label === scmQuickDiff?.label); + const primaryQuickDiff = quickDiffModelRef.object.quickDiffs.find(quickDiff => quickDiff.kind === 'primary'); + const primaryQuickDiffChanges = quickDiffModelRef.object.changes.filter(change => change.providerId === primaryQuickDiff?.id); - return Promise.resolve(scmQuickDiffChanges.map(change => change.change) ?? []); + return Promise.resolve(primaryQuickDiffChanges.map(change => change.change) ?? []); } finally { quickDiffModelRef.dispose(); } diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 9aadd0d679a..9b5878d831d 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -614,7 +614,7 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread this._registrations.set(handle, this._languageFeaturesService.completionProvider.register(selector, provider)); } - $registerInlineCompletionsSupport(handle: number, selector: IDocumentFilterDto[], supportsHandleEvents: boolean, extensionId: string, yieldsToExtensionIds: string[], displayName: string | undefined, debounceDelayMs: number | undefined): void { + $registerInlineCompletionsSupport(handle: number, selector: IDocumentFilterDto[], supportsHandleEvents: boolean, extensionId: string, yieldsToExtensionIds: string[], displayName: string | undefined, debounceDelayMs: number | undefined, eventHandle: number | undefined): void { const provider: languages.InlineCompletionsProvider = { provideInlineCompletions: async (model: ITextModel, position: EditorPosition, context: languages.InlineCompletionContext, token: CancellationToken): Promise => { return this._proxy.$provideInlineCompletions(handle, model.uri, position, context, token); @@ -632,6 +632,22 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread await this._proxy.$handleInlineCompletionPartialAccept(handle, completions.pid, item.idx, acceptedCharacters, info); } }, + handleEndOfLifetime: async (completions, item, reason) => { + + function mapReason(reason: languages.InlineCompletionEndOfLifeReason, f: (reason: T1) => T2): languages.InlineCompletionEndOfLifeReason { + if (reason.kind === languages.InlineCompletionEndOfLifeReasonKind.Ignored) { + return { + ...reason, + supersededBy: reason.supersededBy ? f(reason.supersededBy) : undefined, + }; + } + return reason; + } + + if (supportsHandleEvents) { + await this._proxy.$handleInlineCompletionEndOfLifetime(handle, completions.pid, item.idx, mapReason(reason, i => ({ pid: completions.pid, idx: i.idx }))); + } + }, freeInlineCompletions: (completions: IdentifiableInlineCompletions): void => { this._proxy.$freeInlineCompletionsList(handle, completions.pid); }, @@ -649,6 +665,19 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread }, }; this._registrations.set(handle, this._languageFeaturesService.inlineCompletionsProvider.register(selector, provider)); + + if (typeof eventHandle === 'number') { + const emitter = new Emitter(); + this._registrations.set(eventHandle, emitter); + provider.onDidChangeInlineCompletions = emitter.event; + } + } + + $emitInlineCompletionsChange(handle: number): void { + const obj = this._registrations.get(handle); + if (obj instanceof Emitter) { + obj.fire(undefined); + } } $registerInlineEditProvider(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier, displayName: string): void { diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts index 67502d9db2c..8944b82e633 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts @@ -6,9 +6,9 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { revive } from '../../../base/common/marshalling.js'; -import { CountTokensCallback, ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; +import { CountTokensCallback, ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolProgress, toolResultHasBuffers, IToolProgressStep } from '../../contrib/chat/common/languageModelToolsService.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; -import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; +import { Dto, SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostContext, ExtHostLanguageModelToolsShape, MainContext, MainThreadLanguageModelToolsShape } from '../common/extHost.protocol.js'; @extHostNamedCustomer(MainContext.MainThreadLanguageModelTools) @@ -16,7 +16,10 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre private readonly _proxy: ExtHostLanguageModelToolsShape; private readonly _tools = this._register(new DisposableMap()); - private readonly _countTokenCallbacks = new Map(); + private readonly _runningToolCalls = new Map(); constructor( extHostContext: IExtHostContext, @@ -32,7 +35,7 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre return Array.from(this._languageModelToolsService.getTools()); } - async $invokeTool(dto: IToolInvocation, token?: CancellationToken): Promise> { + async $invokeTool(dto: IToolInvocation, token?: CancellationToken): Promise | SerializableObjectWithBuffers>> { const result = await this._languageModelToolsService.invokeTool( dto, (input, token) => this._proxy.$countTokensForInvocation(dto.callId, input, token), @@ -40,31 +43,35 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre ); // Don't return extra metadata to EH - return { - content: result.content, - }; + const out: Dto = { content: result.content }; + return toolResultHasBuffers(result) ? new SerializableObjectWithBuffers(out) : out; + } + + $acceptToolProgress(callId: string, progress: IToolProgressStep): void { + this._runningToolCalls.get(callId)?.progress.report(progress); } $countTokensForInvocation(callId: string, input: string, token: CancellationToken): Promise { - const fn = this._countTokenCallbacks.get(callId); + const fn = this._runningToolCalls.get(callId); if (!fn) { throw new Error(`Tool invocation call ${callId} not found`); } - return fn(input, token); + return fn.countTokens(input, token); } $registerTool(id: string): void { const disposable = this._languageModelToolsService.registerToolImplementation( id, { - invoke: async (dto, countTokens, token) => { + invoke: async (dto, countTokens, progress, token) => { try { - this._countTokenCallbacks.set(dto.callId, countTokens); - const resultDto = await this._proxy.$invokeTool(dto, token); - return revive(resultDto) as IToolResult; + this._runningToolCalls.set(dto.callId, { countTokens, progress }); + const resultSerialized = await this._proxy.$invokeTool(dto, token); + const resultDto: Dto = resultSerialized instanceof SerializableObjectWithBuffers ? resultSerialized.value : resultSerialized; + return revive(resultDto); } finally { - this._countTokenCallbacks.delete(dto.callId); + this._runningToolCalls.delete(dto.callId); } }, prepareToolInvocation: (parameters, token) => this._proxy.$prepareToolInvocation(id, parameters, token), diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts index 693eab881f2..bff5ae2f2a0 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { AsyncIterableSource, DeferredPromise } from '../../../base/common/async.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; +import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { SerializedError, transformErrorForSerialization, transformErrorFromSerialization } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; @@ -12,6 +14,7 @@ import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; +import { resizeImage } from '../../contrib/chat/browser/imageUtils.js'; import { ILanguageModelIgnoredFilesService } from '../../contrib/chat/common/ignoredFiles.js'; import { ILanguageModelStatsService } from '../../contrib/chat/common/languageModelStats.js'; import { IChatMessage, IChatResponseFragment, ILanguageModelChatMetadata, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelsService } from '../../contrib/chat/common/languageModels.js'; @@ -19,6 +22,7 @@ import { IAuthenticationAccessService } from '../../services/authentication/brow import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; +import { SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostContext, ExtHostLanguageModelsShape, MainContext, MainThreadLanguageModelsShape } from '../common/extHost.protocol.js'; import { LanguageModelError } from '../common/extHostTypes.js'; @@ -63,7 +67,14 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { try { this._pendingProgress.set(requestId, { defer, stream }); - await this._proxy.$startChatRequest(handle, requestId, from, messages, options, token); + await Promise.all( + messages.flatMap(msg => msg.content) + .filter(part => part.type === 'image_url') + .map(async part => { + part.value.data = VSBuffer.wrap(await resizeImage(part.value.data.buffer)); + }) + ); + await this._proxy.$startChatRequest(handle, requestId, from, new SerializableObjectWithBuffers(messages), options, token); } catch (err) { this._pendingProgress.delete(requestId); throw err; @@ -120,12 +131,12 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { this._languageModelStatsService.update(identifier, extensionId, participant, tokenCount); } - async $tryStartChatRequest(extension: ExtensionIdentifier, providerId: string, requestId: number, messages: IChatMessage[], options: {}, token: CancellationToken): Promise { + async $tryStartChatRequest(extension: ExtensionIdentifier, providerId: string, requestId: number, messages: SerializableObjectWithBuffers, options: {}, token: CancellationToken): Promise { this._logService.trace('[CHAT] request STARTED', extension.value, requestId); let response: ILanguageModelChatResponse; try { - response = await this._chatProviderService.sendChatRequest(providerId, extension, messages, options, token); + response = await this._chatProviderService.sendChatRequest(providerId, extension, messages.value, options, token); } catch (err) { this._logService.error('[CHAT] request FAILED', extension.value, requestId, err); throw err; @@ -143,7 +154,7 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { } this._logService.trace('[CHAT] request DONE', extension.value, requestId); } catch (err) { - this._logService.error('[CHAT] extension request ERRORED in STREAM', err, extension.value, requestId); + this._logService.error('[CHAT] extension request ERRORED in STREAM', toErrorMessage(err, true), extension.value, requestId); this._proxy.$acceptResponseDone(requestId, transformErrorForSerialization(err)); } })(); @@ -153,7 +164,7 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { this._logService.debug('[CHAT] extension request DONE', extension.value, requestId); this._proxy.$acceptResponseDone(requestId, undefined); }, err => { - this._logService.error('[CHAT] extension request ERRORED', err, extension.value, requestId); + this._logService.error('[CHAT] extension request ERRORED', toErrorMessage(err, true), extension.value, requestId); this._proxy.$acceptResponseDone(requestId, transformErrorForSerialization(err)); }); } diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index b18bebf211f..c93065c1b53 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -3,22 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { disposableTimeout } from '../../../base/common/async.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { ISettableObservable, observableValue } from '../../../base/common/observable.js'; +import { LogLevel } from '../../../platform/log/common/log.js'; import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; -import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; +import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; -import { ExtensionHostKind } from '../../services/extensions/common/extensionHostKind.js'; +import { ExtensionHostKind, extensionHostKindToString } from '../../services/extensions/common/extensionHostKind.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js'; +import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; +import { ExtHostContext, ExtHostMcpShape, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js'; @extHostNamedCustomer(MainContext.MainThreadMcp) export class MainThreadMcp extends Disposable implements MainThreadMcpShape { private _serverIdCounter = 0; - private readonly _servers: Map = new Map(); + private readonly _servers = new Map(); + private readonly _proxy: Proxied; private readonly _collectionDefinitions = this._register(new DisposableMap; @@ -30,13 +34,14 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, ) { super(); - const proxy = _extHostContext.getProxy(ExtHostContext.ExtHostMcp); + const proxy = this._proxy = _extHostContext.getProxy(ExtHostContext.ExtHostMcp); this._register(this._mcpRegistry.registerDelegate({ + // Prefer Node.js extension hosts when they're available. No CORS issues etc. + priority: _extHostContext.extensionHostKind === ExtensionHostKind.LocalWebWorker ? 0 : 1, waitForInitialProviderPromises() { return proxy.$waitForInitialCollectionProviders(); }, canStart(collection, serverDefinition) { - // todo: SSE MPC servers without a remote authority could be served from the renderer if (collection.remoteAuthority !== _extHostContext.remoteAuthority) { return false; } @@ -48,6 +53,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { start: (collection, _serverDefiniton, resolveLaunch) => { const id = ++this._serverIdCounter; const launch = new ExtHostMcpServerLaunch( + _extHostContext.extensionHostKind, () => proxy.$stopMcp(id), msg => proxy.$sendMessage(id, JSON.stringify(msg)), ); @@ -69,6 +75,10 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { const serverDefinitions = observableValue('mcpServers', servers); const handle = this._mcpRegistry.registerCollection({ ...collection, + resolveServerLanch: collection.canResolveLaunch ? (async def => { + const r = await this._proxy.$resolveMcpLaunch(collection.id, def.label); + return r ? McpServerLaunch.fromSerialized(r) : undefined; + }) : undefined, remoteAuthority: this._extHostContext.remoteAuthority, serverDefinitions, }); @@ -86,34 +96,52 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { } $onDidChangeState(id: number, update: McpConnectionState): void { - this._servers.get(id)?.state.set(update, undefined); + const server = this._servers.get(id); + if (!server) { + return; + } - if (update.state === McpConnectionState.Kind.Stopped || update.state === McpConnectionState.Kind.Error) { + server.state.set(update, undefined); + if (!McpConnectionState.isRunning(update)) { + server.dispose(); this._servers.delete(id); } } - $onDidPublishLog(id: number, log: string): void { - this._servers.get(id)?.pushLog(log); + $onDidPublishLog(id: number, level: LogLevel, log: string): void { + if (typeof level === 'string') { + level = LogLevel.Info; + log = level as unknown as string; + } + + this._servers.get(id)?.pushLog(level, log); } $onDidReceiveMessage(id: number, message: string): void { this._servers.get(id)?.pushMessage(message); } + + override dispose(): void { + for (const server of this._servers.values()) { + server.extHostDispose(); + } + this._servers.clear(); + super.dispose(); + } } class ExtHostMcpServerLaunch extends Disposable implements IMcpMessageTransport { public readonly state = observableValue('mcpServerState', { state: McpConnectionState.Kind.Starting }); - private readonly _onDidLog = this._register(new Emitter()); + private readonly _onDidLog = this._register(new Emitter<{ level: LogLevel; message: string }>()); public readonly onDidLog = this._onDidLog.event; private readonly _onDidReceiveMessage = this._register(new Emitter()); public readonly onDidReceiveMessage = this._onDidReceiveMessage.event; - pushLog(log: string): void { - this._onDidLog.fire(log); + pushLog(level: LogLevel, message: string): void { + this._onDidLog.fire({ message, level }); } pushMessage(message: string): void { @@ -121,19 +149,43 @@ class ExtHostMcpServerLaunch extends Disposable implements IMcpMessageTransport try { parsed = JSON.parse(message); } catch (e) { - this.pushLog(`Failed to parse message: ${JSON.stringify(message)}`); + this.pushLog(LogLevel.Warning, `Failed to parse message: ${JSON.stringify(message)}`); } if (parsed) { - this._onDidReceiveMessage.fire(parsed); + if (Array.isArray(parsed)) { // streamable HTTP supports batching + parsed.forEach(p => this._onDidReceiveMessage.fire(p)); + } else { + this._onDidReceiveMessage.fire(parsed); + } } } constructor( + extHostKind: ExtensionHostKind, public readonly stop: () => void, public readonly send: (message: MCP.JSONRPCMessage) => void, ) { super(); + + this._register(disposableTimeout(() => { + this.pushLog(LogLevel.Info, `Starting server from ${extensionHostKindToString(extHostKind)} extension host`); + })); } + public extHostDispose() { + if (McpConnectionState.isRunning(this.state.get())) { + this.pushLog(LogLevel.Warning, 'Extension host shut down, server will stop.'); + this.state.set({ state: McpConnectionState.Kind.Stopped }, undefined); + } + this.dispose(); + } + + public override dispose(): void { + if (McpConnectionState.isRunning(this.state.get())) { + this.stop(); + } + + super.dispose(); + } } diff --git a/src/vs/workbench/api/browser/mainThreadQuickDiff.ts b/src/vs/workbench/api/browser/mainThreadQuickDiff.ts index d86233aabf9..2d15ed9a843 100644 --- a/src/vs/workbench/api/browser/mainThreadQuickDiff.ts +++ b/src/vs/workbench/api/browser/mainThreadQuickDiff.ts @@ -23,13 +23,13 @@ export class MainThreadQuickDiff implements MainThreadQuickDiffShape { this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostQuickDiff); } - async $registerQuickDiffProvider(handle: number, selector: IDocumentFilterDto[], label: string, rootUri: UriComponents | undefined, visible: boolean): Promise { + async $registerQuickDiffProvider(handle: number, selector: IDocumentFilterDto[], id: string, label: string, rootUri: UriComponents | undefined): Promise { const provider: QuickDiffProvider = { + id, label, rootUri: URI.revive(rootUri), selector, - isSCM: false, - visible, + kind: 'contributed', getOriginalResource: async (uri: URI) => { return URI.revive(await this.proxy.$provideOriginalResource(handle, uri, CancellationToken.None)); } diff --git a/src/vs/workbench/api/browser/mainThreadSCM.ts b/src/vs/workbench/api/browser/mainThreadSCM.ts index 2e33d6f89a2..13be9f16149 100644 --- a/src/vs/workbench/api/browser/mainThreadSCM.ts +++ b/src/vs/workbench/api/browser/mainThreadSCM.ts @@ -16,7 +16,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; -import { IQuickDiffService, QuickDiffProvider } from '../../contrib/scm/common/quickDiff.js'; +import { IQuickDiffService } from '../../contrib/scm/common/quickDiff.js'; import { ISCMHistoryItem, ISCMHistoryItemChange, ISCMHistoryItemRef, ISCMHistoryItemRefsChangeEvent, ISCMHistoryOptions, ISCMHistoryProvider } from '../../contrib/scm/common/history.js'; import { ResourceTree } from '../../../base/common/resourceTree.js'; import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js'; @@ -235,7 +235,7 @@ class MainThreadSCMHistoryProvider implements ISCMHistoryProvider { } } -class MainThreadSCMProvider implements ISCMProvider, QuickDiffProvider { +class MainThreadSCMProvider implements ISCMProvider { private static ID_HANDLE = 0; private _id = `scm${MainThreadSCMProvider.ID_HANDLE++}`; @@ -287,8 +287,7 @@ class MainThreadSCMProvider implements ISCMProvider, QuickDiffProvider { get actionButton(): IObservable { return this._actionButton; } private _quickDiff: IDisposable | undefined; - public readonly isSCM: boolean = true; - public readonly visible: boolean = true; + private _stagedQuickDiff: IDisposable | undefined; private readonly _historyProvider = observableValue(this, undefined); get historyProvider() { return this._historyProvider; } @@ -335,17 +334,44 @@ class MainThreadSCMProvider implements ISCMProvider, QuickDiffProvider { if (features.hasQuickDiffProvider && !this._quickDiff) { this._quickDiff = this._quickDiffService.addQuickDiffProvider({ + id: `${this._providerId}.quickDiffProvider`, label: features.quickDiffLabel ?? this.label, rootUri: this.rootUri, - isSCM: this.isSCM, - visible: this.visible, - getOriginalResource: (uri: URI) => this.getOriginalResource(uri) + kind: 'primary', + getOriginalResource: async (uri: URI) => { + if (!this.features.hasQuickDiffProvider) { + return null; + } + + const result = await this.proxy.$provideOriginalResource(this.handle, uri, CancellationToken.None); + return result && URI.revive(result); + } }); } else if (features.hasQuickDiffProvider === false && this._quickDiff) { this._quickDiff.dispose(); this._quickDiff = undefined; } + if (features.hasSecondaryQuickDiffProvider && !this._stagedQuickDiff) { + this._stagedQuickDiff = this._quickDiffService.addQuickDiffProvider({ + id: `${this._providerId}.secondaryQuickDiffProvider`, + label: features.secondaryQuickDiffLabel ?? this.label, + rootUri: this.rootUri, + kind: 'secondary', + getOriginalResource: async (uri: URI) => { + if (!this.features.hasSecondaryQuickDiffProvider) { + return null; + } + + const result = await this.proxy.$provideSecondaryOriginalResource(this.handle, uri, CancellationToken.None); + return result && URI.revive(result); + } + }); + } else if (features.hasSecondaryQuickDiffProvider === false && this._stagedQuickDiff) { + this._stagedQuickDiff.dispose(); + this._stagedQuickDiff = undefined; + } + if (features.hasHistoryProvider && !this.historyProvider.get()) { const historyProvider = new MainThreadSCMHistoryProvider(this.proxy, this.handle); this._historyProvider.set(historyProvider, undefined); diff --git a/src/vs/workbench/api/browser/mainThreadSearch.ts b/src/vs/workbench/api/browser/mainThreadSearch.ts index 9f7772a2ffd..e8a72e6c59a 100644 --- a/src/vs/workbench/api/browser/mainThreadSearch.ts +++ b/src/vs/workbench/api/browser/mainThreadSearch.ts @@ -14,6 +14,7 @@ import { ExtHostContext, ExtHostSearchShape, MainContext, MainThreadSearchShape import { revive } from '../../../base/common/marshalling.js'; import * as Constants from '../../contrib/search/common/constants.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { AISearchKeyword } from '../../services/search/common/searchExtTypes.js'; @extHostNamedCustomer(MainContext.MainThreadSearch) export class MainThreadSearch implements MainThreadSearchShape { @@ -72,6 +73,16 @@ export class MainThreadSearch implements MainThreadSearchShape { provider.handleFindMatch(session, data); } + + $handleKeywordResult(handle: number, session: number, data: AISearchKeyword): void { + const provider = this._searchProvider.get(handle); + if (!provider) { + throw new Error('Got result for unknown provider'); + } + + provider.handleKeywordResult(session, data); + } + $handleTelemetry(eventName: string, data: any): void { this._telemetryService.publicLog(eventName, data); } @@ -84,7 +95,8 @@ class SearchOperation { constructor( readonly progress?: (match: IFileMatch) => any, readonly id: number = ++SearchOperation._idPool, - readonly matches = new Map() + readonly matches = new Map(), + readonly keywords: AISearchKeyword[] = [] ) { // } @@ -104,6 +116,10 @@ class SearchOperation { this.progress?.(match); } + + addKeyword(result: AISearchKeyword): void { + this.keywords.push(result); + } } class RemoteSearchProvider implements ISearchResultProvider, IDisposable { @@ -153,7 +169,7 @@ class RemoteSearchProvider implements ISearchResultProvider, IDisposable { return Promise.resolve(searchP).then((result: ISearchCompleteStats) => { this._searches.delete(search.id); - return { results: Array.from(search.matches.values()), stats: result.stats, limitHit: result.limitHit, messages: result.messages }; + return { results: Array.from(search.matches.values()), aiKeywords: Array.from(search.keywords), stats: result.stats, limitHit: result.limitHit, messages: result.messages }; }, err => { this._searches.delete(search.id); return Promise.reject(err); @@ -183,7 +199,17 @@ class RemoteSearchProvider implements ISearchResultProvider, IDisposable { }); } - private _provideSearchResults(query: ISearchQuery, session: number, token: CancellationToken): Promise { + handleKeywordResult(session: number, data: AISearchKeyword): void { + const searchOp = this._searches.get(session); + + if (!searchOp) { + // ignore... + return; + } + searchOp.addKeyword(data); + } + + private _provideSearchResults(query: ISearchQuery, session: number, token: CancellationToken, onKeywordResult?: (keyword: AISearchKeyword) => void): Promise { switch (query.type) { case QueryType.File: return this._proxy.$provideFileSearchResults(this._handle, session, query, token); diff --git a/src/vs/workbench/api/browser/mainThreadSpeech.ts b/src/vs/workbench/api/browser/mainThreadSpeech.ts index e2e0ad577a5..c8c7f103dec 100644 --- a/src/vs/workbench/api/browser/mainThreadSpeech.ts +++ b/src/vs/workbench/api/browser/mainThreadSpeech.ts @@ -98,7 +98,12 @@ export class MainThreadSpeech implements MainThreadSpeechShape { onDidChange: onDidChange.event, synthesize: async text => { await this.proxy.$synthesizeSpeech(session, text); - await raceCancellation(Event.toPromise(Event.filter(onDidChange.event, e => e.status === TextToSpeechStatus.Stopped)), token); + const disposable = new DisposableStore(); + try { + await raceCancellation(Event.toPromise(Event.filter(onDidChange.event, e => e.status === TextToSpeechStatus.Stopped, disposable), disposable), token); + } finally { + disposable.dispose(); + } } }; }, diff --git a/src/vs/workbench/api/browser/mainThreadTask.ts b/src/vs/workbench/api/browser/mainThreadTask.ts index 9d3a2c0eb4e..5a11aa6e92f 100644 --- a/src/vs/workbench/api/browser/mainThreadTask.ts +++ b/src/vs/workbench/api/browser/mainThreadTask.ts @@ -38,6 +38,7 @@ import { IConfigurationResolverService } from '../../services/configurationResol import { ConfigurationTarget } from '../../../platform/configuration/common/configuration.js'; import { ErrorNoTelemetry } from '../../../base/common/errors.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { ConfigurationResolverExpression } from '../../services/configurationResolver/common/configurationResolverExpression.js'; namespace TaskExecutionDTO { export function from(value: ITaskExecution): ITaskExecutionDTO { @@ -477,12 +478,15 @@ export class MainThreadTask extends Disposable implements MainThreadTaskShape { const execution = TaskExecutionDTO.from(task.getTaskExecution()); let resolvedDefinition: ITaskDefinitionDTO = execution.task!.definition; if (execution.task?.execution && CustomExecutionDTO.is(execution.task.execution) && event.resolvedVariables) { - const dictionary: IStringDictionary = {}; - for (const [key, value] of event.resolvedVariables.entries()) { - dictionary[key] = value; + const expr = ConfigurationResolverExpression.parse(execution.task.definition); + for (const replacement of expr.unresolved()) { + const value = event.resolvedVariables.get(replacement.inner); + if (value !== undefined) { + expr.resolve(replacement, value); + } } - resolvedDefinition = await this._configurationResolverService.resolveAnyAsync(task.getWorkspaceFolder(), - execution.task.definition, dictionary); + + resolvedDefinition = await this._configurationResolverService.resolveAsync(task.getWorkspaceFolder(), expr); } this._proxy.$onDidStartTask(execution, event.terminalId, resolvedDefinition); } else if (event.kind === TaskEventKind.ProcessStarted) { diff --git a/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts b/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts index aa0fd7d8b17..34a6d5247df 100644 --- a/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts +++ b/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts @@ -8,7 +8,7 @@ import { Disposable, toDisposable, type IDisposable } from '../../../base/common import { URI } from '../../../base/common/uri.js'; import { TerminalCapability, type ITerminalCommand } from '../../../platform/terminal/common/capabilities/capabilities.js'; import { ExtHostContext, MainContext, type ExtHostTerminalShellIntegrationShape, type MainThreadTerminalShellIntegrationShape } from '../common/extHost.protocol.js'; -import { ITerminalService } from '../../contrib/terminal/browser/terminal.js'; +import { ITerminalService, type ITerminalInstance } from '../../contrib/terminal/browser/terminal.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; import { extHostNamedCustomer, type IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { TerminalShellExecutionCommandLineConfidence } from '../common/extHostTypes.js'; @@ -36,20 +36,19 @@ export class MainThreadTerminalShellIntegration extends Disposable implements Ma // onDidChangeTerminalShellIntegration initial state for (const terminal of this._terminalService.instances) { const cmdDetection = terminal.capabilities.get(TerminalCapability.CommandDetection); - if (cmdDetection?.hasRichCommandDetection) { - this._proxy.$shellIntegrationChange(terminal.instanceId); - } - const cwdDetection = terminal.capabilities.get(TerminalCapability.CwdDetection); - if (cwdDetection) { - this._proxy.$cwdChange(terminal.instanceId, this._convertCwdToUri(cwdDetection.getCwd())); + if (cmdDetection) { + this._enableShellIntegration(terminal); } } - // onDidChangeTerminalShellIntegration via rich command detection - const onDidSetRichCommandDetection = this._store.add(this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CommandDetection, e => e.onSetRichCommandDetection)); - this._store.add(onDidSetRichCommandDetection.event(e => { - this._proxy.$shellIntegrationChange(e.instance.instanceId); - })); + // onDidChangeTerminalShellIntegration via command detection + const onDidAddCommandDetection = this._store.add(this._terminalService.createOnInstanceEvent(instance => { + return Event.map( + Event.filter(instance.capabilities.onDidAddCapabilityType, e => e === TerminalCapability.CommandDetection), + () => instance + ); + })).event; + this._store.add(onDidAddCommandDetection(e => this._enableShellIntegration(e))); // onDidChangeTerminalShellIntegration via cwd const cwdChangeEvent = this._store.add(this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CwdDetection, e => e.onDidChangeCwd)); @@ -115,6 +114,14 @@ export class MainThreadTerminalShellIntegration extends Disposable implements Ma private _convertCwdToUri(cwd: string | undefined): URI | undefined { return cwd ? URI.file(cwd) : undefined; } + + private _enableShellIntegration(instance: ITerminalInstance): void { + this._proxy.$shellIntegrationChange(instance.instanceId); + const cwdDetection = instance.capabilities.get(TerminalCapability.CwdDetection); + if (cwdDetection) { + this._proxy.$cwdChange(instance.instanceId, this._convertCwdToUri(cwdDetection.getCwd())); + } + } } function convertToExtHostCommandLineConfidence(command: ITerminalCommand): TerminalShellExecutionCommandLineConfidence { diff --git a/src/vs/workbench/api/browser/mainThreadUrls.ts b/src/vs/workbench/api/browser/mainThreadUrls.ts index d0bf2f4e746..b0d6b1b34b2 100644 --- a/src/vs/workbench/api/browser/mainThreadUrls.ts +++ b/src/vs/workbench/api/browser/mainThreadUrls.ts @@ -7,13 +7,10 @@ import { ExtHostContext, MainContext, MainThreadUrlsShape, ExtHostUrlsShape } fr import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { IURLService, IOpenURLOptions } from '../../../platform/url/common/url.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; -import { IDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; import { IExtensionContributedURLHandler, IExtensionUrlHandler } from '../../services/extensions/browser/extensionUrlHandler.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { ITrustedDomainService } from '../../contrib/url/browser/trustedDomainService.js'; -import { readStaticTrustedDomains } from '../../contrib/url/browser/trustedDomains.js'; -import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; -import { IWebContentExtractorService } from '../../../platform/webContentExtractor/common/webContentExtractor.js'; class ExtensionUrlHandler implements IExtensionContributedURLHandler { @@ -24,49 +21,48 @@ class ExtensionUrlHandler implements IExtensionContributedURLHandler { readonly extensionDisplayName: string ) { } - handleURL(uri: URI, options?: IOpenURLOptions): Promise { + async handleURL(uri: URI, options?: IOpenURLOptions): Promise { if (!ExtensionIdentifier.equals(this.extensionId, uri.authority)) { - return Promise.resolve(false); + return false; } - return Promise.resolve(this.proxy.$handleExternalUri(this.handle, uri)).then(() => true); + await this.proxy.$handleExternalUri(this.handle, uri); + return true; } } @extHostNamedCustomer(MainContext.MainThreadUrls) -export class MainThreadUrls implements MainThreadUrlsShape { +export class MainThreadUrls extends Disposable implements MainThreadUrlsShape { private readonly proxy: ExtHostUrlsShape; - private handlers = new Map(); + private readonly handlers = new Map(); constructor( context: IExtHostContext, @ITrustedDomainService trustedDomainService: ITrustedDomainService, @IURLService private readonly urlService: IURLService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IWebContentExtractorService private readonly webContentExtractorService: IWebContentExtractorService, @IExtensionUrlHandler private readonly extensionUrlHandler: IExtensionUrlHandler ) { + super(); + this.proxy = context.getProxy(ExtHostContext.ExtHostUrls); - trustedDomainService.onDidChangeTrustedDomains(() => this.handleTrustedDomainsChange()); - void this.handleTrustedDomainsChange(); } - $registerUriHandler(handle: number, extensionId: ExtensionIdentifier, extensionDisplayName: string): Promise { + async $registerUriHandler(handle: number, extensionId: ExtensionIdentifier, extensionDisplayName: string): Promise { const handler = new ExtensionUrlHandler(this.proxy, handle, extensionId, extensionDisplayName); const disposable = this.urlService.registerHandler(handler); this.handlers.set(handle, { extensionId, disposable }); this.extensionUrlHandler.registerExtensionHandler(extensionId, handler); - return Promise.resolve(undefined); + return undefined; } - $unregisterUriHandler(handle: number): Promise { + async $unregisterUriHandler(handle: number): Promise { const tuple = this.handlers.get(handle); if (!tuple) { - return Promise.resolve(undefined); + return undefined; } const { extensionId, disposable } = tuple; @@ -75,24 +71,16 @@ export class MainThreadUrls implements MainThreadUrlsShape { this.handlers.delete(handle); disposable.dispose(); - return Promise.resolve(undefined); + return undefined; } async $createAppUri(uri: UriComponents): Promise { return this.urlService.create(uri); } - async handleTrustedDomainsChange() { - const { defaultTrustedDomains, trustedDomains, } = this.instantiationService.invokeFunction(readStaticTrustedDomains); - await this.proxy.$updateTrustedDomains([...defaultTrustedDomains, ...trustedDomains]); - } + override dispose(): void { + super.dispose(); - async $extractExternalUris(uris: UriComponents[]): Promise { - const extractedUris = await this.webContentExtractorService.extract(uris.map(uri => URI.revive(uri))); - return extractedUris; - } - - dispose(): void { this.handlers.forEach(({ disposable }) => disposable.dispose()); this.handlers.clear(); } diff --git a/src/vs/workbench/api/browser/mainThreadWebviewPanels.ts b/src/vs/workbench/api/browser/mainThreadWebviewPanels.ts index e96588a20c9..7c0c51e08c8 100644 --- a/src/vs/workbench/api/browser/mainThreadWebviewPanels.ts +++ b/src/vs/workbench/api/browser/mainThreadWebviewPanels.ts @@ -139,7 +139,9 @@ export class MainThreadWebviewPanels extends Disposable implements extHostProtoc this._webviewInputs.add(handle, input); this._mainThreadWebviews.addWebview(handle, input.webview, options); - input.webview.onDidDispose(() => { + const disposeSub = input.webview.onDidDispose(() => { + disposeSub.dispose(); + this._proxy.$onDidDisposeWebviewPanel(handle).finally(() => { this._webviewInputs.delete(handle); }); diff --git a/src/vs/workbench/api/browser/mainThreadWebviewViews.ts b/src/vs/workbench/api/browser/mainThreadWebviewViews.ts index 2ce367773d7..b21653ed13c 100644 --- a/src/vs/workbench/api/browser/mainThreadWebviewViews.ts +++ b/src/vs/workbench/api/browser/mainThreadWebviewViews.ts @@ -5,7 +5,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; -import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { MainThreadWebviews, reviveWebviewExtension } from './mainThreadWebviews.js'; import * as extHostProtocol from '../common/extHost.protocol.js'; @@ -86,14 +86,16 @@ export class MainThreadWebviewsViews extends Disposable implements extHostProtoc webviewView.webview.options = options; } - webviewView.onDidChangeVisibility(visible => { + const subscriptions = new DisposableStore(); + subscriptions.add(webviewView.onDidChangeVisibility(visible => { this._proxy.$onDidChangeWebviewViewVisibility(handle, visible); - }); + })); - webviewView.onDispose(() => { + subscriptions.add(webviewView.onDispose(() => { this._proxy.$disposeWebviewView(handle); this._webviewViews.deleteAndDispose(handle); - }); + subscriptions.dispose(); + })); type CreateWebviewViewTelemetry = { extensionId: string; diff --git a/src/vs/workbench/api/browser/mainThreadWorkspace.ts b/src/vs/workbench/api/browser/mainThreadWorkspace.ts index 89b4f98c69e..7496427f1aa 100644 --- a/src/vs/workbench/api/browser/mainThreadWorkspace.ts +++ b/src/vs/workbench/api/browser/mainThreadWorkspace.ts @@ -29,9 +29,7 @@ import { EditorResourceAccessor, SaveReason, SideBySideEditor } from '../../comm import { coalesce } from '../../../base/common/arrays.js'; import { ICanonicalUriService } from '../../../platform/workspace/common/canonicalUri.js'; import { revive } from '../../../base/common/marshalling.js'; -import { bufferToStream, readableToBuffer, VSBuffer } from '../../../base/common/buffer.js'; import { ITextFileService } from '../../services/textfile/common/textfiles.js'; -import { consumeStream } from '../../../base/common/stream.js'; @extHostNamedCustomer(MainContext.MainThreadWorkspace) export class MainThreadWorkspace implements MainThreadWorkspaceShape { @@ -306,13 +304,15 @@ export class MainThreadWorkspace implements MainThreadWorkspaceShape { // --- encodings - async $decode(content: VSBuffer, resource: UriComponents | undefined, options?: { encoding: string }): Promise { - const stream = await this._textFileService.getDecodedStream(URI.revive(resource) ?? undefined, bufferToStream(content), { acceptTextOnly: true, encoding: options?.encoding }); - return consumeStream(stream, chunks => chunks.join()); + $resolveDecoding(resource: UriComponents | undefined, options?: { encoding: string }): Promise<{ preferredEncoding: string; guessEncoding: boolean; candidateGuessEncodings: string[] }> { + return this._textFileService.resolveDecoding(URI.revive(resource), options); } - async $encode(content: string, resource: UriComponents | undefined, options?: { encoding: string }): Promise { - const res = await this._textFileService.getEncodedReadable(URI.revive(resource) ?? undefined, content, { encoding: options?.encoding }); - return res instanceof VSBuffer ? res : readableToBuffer(res); + $validateDetectedEncoding(resource: UriComponents | undefined, detectedEncoding: string, options?: { encoding?: string }): Promise { + return this._textFileService.validateDetectedEncoding(URI.revive(resource), detectedEncoding, options); + } + + $resolveEncoding(resource: UriComponents | undefined, options?: { encoding: string }): Promise<{ encoding: string; addBOM: boolean }> { + return this._textFileService.resolveEncoding(URI.revive(resource), options); } } diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index d96bc0160c2..64386dde6b0 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -18,6 +18,7 @@ import { Extensions as ExtensionFeaturesExtensions, IExtensionFeatureTableRender import { Disposable } from '../../../base/common/lifecycle.js'; import { SyncDescriptor } from '../../../platform/instantiation/common/descriptors.js'; import { MarkdownString } from '../../../base/common/htmlContent.js'; +import product from '../../../platform/product/common/product.js'; const jsonRegistry = Registry.as(JSONExtensions.JSONContribution); const configurationRegistry = Registry.as(Extensions.Configuration); @@ -243,6 +244,7 @@ configurationExtPoint.setHandler((extensions, { added, removed }) => { function validateProperties(configuration: IConfigurationNode, extension: IExtensionPointUser): void { const properties = configuration.properties; + const extensionConfigurationPolicy = product.extensionConfigurationPolicy; if (properties) { if (typeof properties !== 'object') { extension.collector.error(nls.localize('invalid.properties', "'configuration.properties' must be an object")); @@ -266,6 +268,9 @@ configurationExtPoint.setHandler((extensions, { added, removed }) => { extension.collector.error(nls.localize('invalid.property', "configuration.properties property '{0}' must be an object", key)); continue; } + if (extensionConfigurationPolicy?.[key]) { + propertyConfiguration.policy = extensionConfigurationPolicy?.[key]; + } seenProperties.add(key); propertyConfiguration.scope = propertyConfiguration.scope ? parseScope(propertyConfiguration.scope.toString()) : ConfigurationScope.WINDOW; } @@ -373,8 +378,8 @@ jsonRegistry.registerSchema('vscode://schemas/workspaceConfig', { inputs: [], servers: { 'mcp-server-time': { - command: 'python', - args: ['-m', 'mcp_server_time', '--local-timezone=America/Los_Angeles'] + command: 'uvx', + args: ['mcp_server_time', '--local-timezone=America/Los_Angeles'] } } }, diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 49a830b0f45..71f70cb8489 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type * as vscode from 'vscode'; import { CancellationTokenSource } from '../../../base/common/cancellation.js'; import * as errors from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; @@ -21,6 +22,12 @@ import { ILogService, ILoggerService, LogLevel } from '../../../platform/log/com import { getRemoteName } from '../../../platform/remote/common/remoteHosts.js'; import { TelemetryTrustedValue } from '../../../platform/telemetry/common/telemetryUtils.js'; import { EditSessionIdentityMatch } from '../../../platform/workspace/common/editSessions.js'; +import { DebugConfigurationProviderTriggerKind } from '../../contrib/debug/common/debug.js'; +import { ExtensionDescriptionRegistry } from '../../services/extensions/common/extensionDescriptionRegistry.js'; +import { UIKind } from '../../services/extensions/common/extensionHostProtocol.js'; +import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; +import { ProxyIdentifier } from '../../services/extensions/common/proxyIdentifier.js'; +import { ExcludeSettingOptions, TextSearchCompleteMessageType, TextSearchContext2, TextSearchMatch2, AISearchKeyword } from '../../services/search/common/searchExtTypes.js'; import { CandidatePortSource, ExtHostContext, ExtHostLogLevelServiceShape, MainContext } from './extHost.protocol.js'; import { ExtHostRelatedInformation } from './extHostAiRelatedInformation.js'; import { ExtHostApiCommands } from './extHostApiCommands.js'; @@ -28,8 +35,10 @@ import { IExtHostApiDeprecationService } from './extHostApiDeprecationService.js import { IExtHostAuthentication } from './extHostAuthentication.js'; import { ExtHostBulkEdits } from './extHostBulkEdits.js'; import { ExtHostChatAgents2 } from './extHostChatAgents2.js'; +import { ExtHostChatStatus } from './extHostChatStatus.js'; import { ExtHostClipboard } from './extHostClipboard.js'; import { ExtHostEditorInsets } from './extHostCodeInsets.js'; +import { ExtHostCodeMapper } from './extHostCodeMapper.js'; import { IExtHostCommands } from './extHostCommands.js'; import { createExtHostComments } from './extHostComments.js'; import { ExtHostConfigProvider, IExtHostConfiguration } from './extHostConfiguration.js'; @@ -59,6 +68,7 @@ import { IExtHostLanguageModels } from './extHostLanguageModels.js'; import { ExtHostLanguages } from './extHostLanguages.js'; import { IExtHostLocalizationService } from './extHostLocalizationService.js'; import { IExtHostManagedSockets } from './extHostManagedSockets.js'; +import { IExtHostMpcService } from './extHostMcp.js'; import { ExtHostMessageService } from './extHostMessageService.js'; import { ExtHostNotebookController } from './extHostNotebook.js'; import { ExtHostNotebookDocumentSaveParticipant } from './extHostNotebookDocumentSaveParticipant.js'; @@ -100,15 +110,7 @@ import { ExtHostWebviewPanels } from './extHostWebviewPanels.js'; import { ExtHostWebviewViews } from './extHostWebviewView.js'; import { IExtHostWindow } from './extHostWindow.js'; import { IExtHostWorkspace } from './extHostWorkspace.js'; -import { DebugConfigurationProviderTriggerKind } from '../../contrib/debug/common/debug.js'; -import { ExtensionDescriptionRegistry } from '../../services/extensions/common/extensionDescriptionRegistry.js'; -import { UIKind } from '../../services/extensions/common/extensionHostProtocol.js'; -import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; -import { ProxyIdentifier } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExcludeSettingOptions, TextSearchCompleteMessageType, TextSearchContext2, TextSearchMatch2 } from '../../services/search/common/searchExtTypes.js'; -import type * as vscode from 'vscode'; -import { ExtHostCodeMapper } from './extHostCodeMapper.js'; -import { IExtHostMpcService } from './extHostMcp.js'; +import { ExtHostAiSettingsSearch } from './extHostAiSettingsSearch.js'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -213,10 +215,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostUriOpeners = rpcProtocol.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpcProtocol)); const extHostProfileContentHandlers = rpcProtocol.set(ExtHostContext.ExtHostProfileContentHandlers, new ExtHostProfileContentHandlers(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostInteractive, new ExtHostInteractive(rpcProtocol, extHostNotebook, extHostDocumentsAndEditors, extHostCommands, extHostLogService)); - const extHostLanguageModelTools = rpcProtocol.set(ExtHostContext.ExtHostLanguageModelTools, new ExtHostLanguageModelTools(rpcProtocol)); + const extHostLanguageModelTools = rpcProtocol.set(ExtHostContext.ExtHostLanguageModelTools, new ExtHostLanguageModelTools(rpcProtocol, extHostLanguageModels)); const extHostChatAgents2 = rpcProtocol.set(ExtHostContext.ExtHostChatAgents2, new ExtHostChatAgents2(rpcProtocol, extHostLogService, extHostCommands, extHostDocuments, extHostLanguageModels, extHostDiagnostics, extHostLanguageModelTools)); const extHostAiRelatedInformation = rpcProtocol.set(ExtHostContext.ExtHostAiRelatedInformation, new ExtHostRelatedInformation(rpcProtocol)); const extHostAiEmbeddingVector = rpcProtocol.set(ExtHostContext.ExtHostAiEmbeddingVector, new ExtHostAiEmbeddingVector(rpcProtocol)); + const extHostAiSettingsSearch = rpcProtocol.set(ExtHostContext.ExtHostAiSettingsSearch, new ExtHostAiSettingsSearch(rpcProtocol)); const extHostStatusBar = rpcProtocol.set(ExtHostContext.ExtHostStatusBar, new ExtHostStatusBar(rpcProtocol, extHostCommands.converter)); const extHostSpeech = rpcProtocol.set(ExtHostContext.ExtHostSpeech, new ExtHostSpeech(rpcProtocol)); const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); @@ -231,6 +234,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostClipboard = new ExtHostClipboard(rpcProtocol); const extHostMessageService = new ExtHostMessageService(rpcProtocol, extHostLogService); const extHostDialogs = new ExtHostDialogs(rpcProtocol); + const extHostChatStatus = new ExtHostChatStatus(rpcProtocol); // Register API-ish commands ExtHostApiCommands.register(extHostCommands); @@ -416,14 +420,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I throw err; } }, - isTrustedExternalUris(uris: URI[]): boolean[] { - checkProposedApiEnabled(extension, 'envExtractUri'); - return extHostUrls.isTrustedExternalUris(uris); - }, - extractExternalUris(uris: URI[]): Promise { - checkProposedApiEnabled(extension, 'envExtractUri'); - return extHostUrls.extractExternalUris(uris); - }, get remoteName() { return getRemoteName(initData.remote.authority); }, @@ -923,9 +919,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'profileContentHandlers'); return extHostProfileContentHandlers.registerProfileContentHandler(extension, id, handler); }, - registerQuickDiffProvider(selector: vscode.DocumentSelector, quickDiffProvider: vscode.QuickDiffProvider, label: string, rootUri?: vscode.Uri): vscode.Disposable { + registerQuickDiffProvider(selector: vscode.DocumentSelector, quickDiffProvider: vscode.QuickDiffProvider, id: string, label: string, rootUri?: vscode.Uri): vscode.Disposable { checkProposedApiEnabled(extension, 'quickDiffProvider'); - return extHostQuickDiff.registerQuickDiffProvider(checkSelector(selector), quickDiffProvider, label, rootUri); + return extHostQuickDiff.registerQuickDiffProvider(extension, checkSelector(selector), quickDiffProvider, id, label, rootUri); }, get tabGroups(): vscode.TabGroups { return extHostEditorTabs.tabGroups; @@ -937,7 +933,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I get nativeHandle(): Uint8Array | undefined { checkProposedApiEnabled(extension, 'nativeWindowHandle'); return extHostWindow.nativeHandle; - } + }, + createChatStatusItem: (id: string) => { + checkProposedApiEnabled(extension, 'chatStatusItem'); + return extHostChatStatus.createChatStatusItem(extension, id); + }, }; // namespace: workspace @@ -1039,9 +1039,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I let uriPromise: Thenable; options = (options ?? uriOrFileNameOrOptions) as ({ language?: string; content?: string; encoding?: string } | undefined); - if (typeof options?.encoding === 'string') { - checkProposedApiEnabled(extension, 'textDocumentEncoding'); - } if (typeof uriOrFileNameOrOptions === 'string') { uriPromise = Promise.resolve(URI.file(uriOrFileNameOrOptions)); @@ -1243,13 +1240,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'canonicalUriProvider'); return extHostWorkspace.provideCanonicalUri(uri, options, token); }, - decode(content: Uint8Array, uri: vscode.Uri | undefined, options?: { encoding: string }) { - checkProposedApiEnabled(extension, 'textDocumentEncoding'); - return extHostWorkspace.decode(content, uri, options); + decode(content: Uint8Array, options?: { uri?: vscode.Uri; encoding?: string }) { + return extHostWorkspace.decode(content, options); }, - encode(content: string, uri: vscode.Uri | undefined, options?: { encoding: string }) { - checkProposedApiEnabled(extension, 'textDocumentEncoding'); - return extHostWorkspace.encode(content, uri, options); + encode(content: string, options?: { uri?: vscode.Uri; encoding?: string }) { + return extHostWorkspace.encode(content, options); } }; @@ -1445,15 +1440,15 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerEmbeddingVectorProvider(model: string, provider: vscode.EmbeddingVectorProvider) { checkProposedApiEnabled(extension, 'aiRelatedInformation'); return extHostAiEmbeddingVector.registerEmbeddingVectorProvider(extension, model, provider); + }, + registerSettingsSearchProvider(provider: vscode.SettingsSearchProvider) { + checkProposedApiEnabled(extension, 'aiSettingsSearch'); + return extHostAiSettingsSearch.registerSettingsSearchProvider(extension, provider); } }; - // namespace: chat + // namespace: chatregisterMcpServerDefinitionProvider const chat: typeof vscode.chat = { - registerChatResponseProvider(id: string, provider: vscode.ChatResponseProvider, metadata: vscode.ChatResponseProviderMetadata) { - checkProposedApiEnabled(extension, 'chatProvider'); - return extHostLanguageModels.registerLanguageModel(extension, id, provider, metadata); - }, registerMappedEditsProvider(_selector: vscode.DocumentSelector, _provider: vscode.MappedEditsProvider) { checkProposedApiEnabled(extension, 'mappedEditsProvider'); // no longer supported @@ -1477,6 +1472,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerRelatedFilesProvider(provider: vscode.ChatRelatedFilesProvider, metadata: vscode.ChatRelatedFilesProviderMetadata) { checkProposedApiEnabled(extension, 'chatEditing'); return extHostChatAgents2.registerRelatedFilesProvider(extension, provider, metadata); + }, + onDidDisposeChatSession: (listeners, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatParticipantPrivate'); + return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); } }; @@ -1522,18 +1521,21 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I get tools() { return extHostLanguageModelTools.getTools(extension); }, - fileIsIgnored(uri: vscode.Uri, token: vscode.CancellationToken) { + fileIsIgnored(uri: vscode.Uri, token?: vscode.CancellationToken) { return extHostLanguageModels.fileIsIgnored(extension, uri, token); }, registerIgnoredFileProvider(provider: vscode.LanguageModelIgnoredFileProvider) { return extHostLanguageModels.registerIgnoredFileProvider(extension, provider); }, - registerMcpConfigurationProvider(id, provider) { + registerMcpServerDefinitionProvider(id, provider) { checkProposedApiEnabled(extension, 'mcpConfigurationProvider'); return extHostMcp.registerMcpConfigurationProvider(extension, id, provider); } }; + // todo@connor4312: proposed API back-compat + (lm as any).registerMcpConfigurationProvider = lm.registerMcpServerDefinitionProvider; + // namespace: speech const speech: typeof vscode.speech = { registerSpeechProvider(id: string, provider: vscode.SpeechProvider) { @@ -1786,7 +1788,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I SpeechToTextStatus: extHostTypes.SpeechToTextStatus, TextToSpeechStatus: extHostTypes.TextToSpeechStatus, PartialAcceptTriggerKind: extHostTypes.PartialAcceptTriggerKind, + InlineCompletionEndOfLifeReasonKind: extHostTypes.InlineCompletionEndOfLifeReasonKind, KeywordRecognitionStatus: extHostTypes.KeywordRecognitionStatus, + ChatImageMimeType: extHostTypes.ChatImageMimeType, ChatResponseMarkdownPart: extHostTypes.ChatResponseMarkdownPart, ChatResponseFileTreePart: extHostTypes.ChatResponseFileTreePart, ChatResponseAnchorPart: extHostTypes.ChatResponseAnchorPart, @@ -1803,20 +1807,28 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatResponseCommandButtonPart: extHostTypes.ChatResponseCommandButtonPart, ChatResponseConfirmationPart: extHostTypes.ChatResponseConfirmationPart, ChatResponseMovePart: extHostTypes.ChatResponseMovePart, + ChatResponseExtensionsPart: extHostTypes.ChatResponseExtensionsPart, ChatResponseReferencePartStatusKind: extHostTypes.ChatResponseReferencePartStatusKind, ChatRequestTurn: extHostTypes.ChatRequestTurn, + ChatRequestTurn2: extHostTypes.ChatRequestTurn, ChatResponseTurn: extHostTypes.ChatResponseTurn, ChatLocation: extHostTypes.ChatLocation, ChatRequestEditorData: extHostTypes.ChatRequestEditorData, ChatRequestNotebookData: extHostTypes.ChatRequestNotebookData, ChatReferenceBinaryData: extHostTypes.ChatReferenceBinaryData, + ChatRequestEditedFileEventKind: extHostTypes.ChatRequestEditedFileEventKind, LanguageModelChatMessageRole: extHostTypes.LanguageModelChatMessageRole, LanguageModelChatMessage: extHostTypes.LanguageModelChatMessage, + LanguageModelChatMessage2: extHostTypes.LanguageModelChatMessage2, LanguageModelToolResultPart: extHostTypes.LanguageModelToolResultPart, + LanguageModelToolResultPart2: extHostTypes.LanguageModelToolResultPart2, LanguageModelTextPart: extHostTypes.LanguageModelTextPart, LanguageModelToolCallPart: extHostTypes.LanguageModelToolCallPart, LanguageModelError: extHostTypes.LanguageModelError, LanguageModelToolResult: extHostTypes.LanguageModelToolResult, + LanguageModelToolResult2: extHostTypes.LanguageModelToolResult2, + LanguageModelDataPart: extHostTypes.LanguageModelDataPart, + LanguageModelExtraDataPart: extHostTypes.LanguageModelExtraDataPart, ExtendedLanguageModelToolResult: extHostTypes.ExtendedLanguageModelToolResult, PreparedTerminalToolInvocation: extHostTypes.PreparedTerminalToolInvocation, LanguageModelChatToolMode: extHostTypes.LanguageModelChatToolMode, @@ -1829,10 +1841,12 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ExcludeSettingOptions: ExcludeSettingOptions, TextSearchContext2: TextSearchContext2, TextSearchMatch2: TextSearchMatch2, + AISearchKeyword: AISearchKeyword, TextSearchCompleteMessageTypeNew: TextSearchCompleteMessageType, ChatErrorLevel: extHostTypes.ChatErrorLevel, - McpSSEServerDefinition: extHostTypes.McpSSEServerDefinition, + McpHttpServerDefinition: extHostTypes.McpHttpServerDefinition, McpStdioServerDefinition: extHostTypes.McpStdioServerDefinition, + SettingsSearchResultKind: extHostTypes.SettingsSearchResultKind }; }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 22bc07bfae9..cd387657765 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -59,7 +59,7 @@ import { IChatContentInlineReference, IChatFollowup, IChatNotebookEdit, IChatPro import { IChatRequestVariableValue } from '../../contrib/chat/common/chatVariables.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatMessage, IChatResponseFragment, ILanguageModelChatMetadata, ILanguageModelChatSelector, ILanguageModelsChangeEvent } from '../../contrib/chat/common/languageModels.js'; -import { IPreparedToolInvocation, IToolData, IToolInvocation, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; +import { IPreparedToolInvocation, IToolData, IToolInvocation, IToolProgressStep, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugTestRunReference, IDebugVisualization, IDebugVisualizationContext, IDebugVisualizationTreeItem, MainThreadDebugVisualization } from '../../contrib/debug/common/debug.js'; import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch } from '../../contrib/mcp/common/mcpTypes.js'; import * as notebookCommon from '../../contrib/notebook/common/notebookCommon.js'; @@ -85,7 +85,8 @@ import { OutputChannelUpdateMode } from '../../services/output/common/output.js' import { CandidatePort } from '../../services/remote/common/tunnelModel.js'; import { IFileQueryBuilderOptions, ITextQueryBuilderOptions } from '../../services/search/common/queryBuilder.js'; import * as search from '../../services/search/common/search.js'; -import { TextSearchCompleteMessage } from '../../services/search/common/searchExtTypes.js'; +import { AISearchKeyword, TextSearchCompleteMessage } from '../../services/search/common/searchExtTypes.js'; +import { AiSettingsSearchProviderOptions, AiSettingsSearchResult } from '../../services/aiSettingsSearch/common/aiSettingsSearch.js'; import { ISaveProfileResult } from '../../services/userDataProfile/common/userDataProfile.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; @@ -141,7 +142,7 @@ export type CommentThreadChanges = Partial<{ contextValue: string | null; comments: CommentChanges[]; collapseState: languages.CommentThreadCollapsibleState; - canReply: boolean; + canReply: boolean | languages.CommentAuthorInformation; state: languages.CommentThreadState; applicability: languages.CommentThreadApplicability; isTemplate: boolean; @@ -474,8 +475,9 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable { $emitDocumentSemanticTokensEvent(eventHandle: number): void; $registerDocumentRangeSemanticTokensProvider(handle: number, selector: IDocumentFilterDto[], legend: languages.SemanticTokensLegend): void; $registerCompletionsProvider(handle: number, selector: IDocumentFilterDto[], triggerCharacters: string[], supportsResolveDetails: boolean, extensionId: ExtensionIdentifier): void; - $registerInlineCompletionsSupport(handle: number, selector: IDocumentFilterDto[], supportsHandleDidShowCompletionItem: boolean, extensionId: string, yieldsToExtensionIds: string[], displayName: string | undefined, debounceDelayMs: number | undefined): void; + $registerInlineCompletionsSupport(handle: number, selector: IDocumentFilterDto[], supportsHandleDidShowCompletionItem: boolean, extensionId: string, yieldsToExtensionIds: string[], displayName: string | undefined, debounceDelayMs: number | undefined, eventHandle: number | undefined): void; $registerInlineEditProvider(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier, displayName: string): void; + $emitInlineCompletionsChange(handle: number): void; $registerSignatureHelpProvider(handle: number, selector: IDocumentFilterDto[], metadata: ISignatureHelpProviderMetadataDto): void; $registerInlayHintsProvider(handle: number, selector: IDocumentFilterDto[], supportsResolve: boolean, eventHandle: number | undefined, displayName: string | undefined): void; $emitInlayHintsEvent(eventHandle: number): void; @@ -1251,7 +1253,7 @@ export interface ExtHostSpeechShape { export interface MainThreadLanguageModelsShape extends IDisposable { $registerLanguageModelProvider(handle: number, identifier: string, metadata: ILanguageModelChatMetadata): void; $unregisterProvider(handle: number): void; - $tryStartChatRequest(extension: ExtensionIdentifier, provider: string, requestId: number, messages: IChatMessage[], options: {}, token: CancellationToken): Promise; + $tryStartChatRequest(extension: ExtensionIdentifier, provider: string, requestId: number, messages: SerializableObjectWithBuffers, options: {}, token: CancellationToken): Promise; $reportResponsePart(requestId: number, chunk: IChatResponseFragment): Promise; $reportResponseDone(requestId: number, error: SerializedError | undefined): Promise; $selectChatModels(selector: ILanguageModelChatSelector): Promise; @@ -1265,7 +1267,7 @@ export interface MainThreadLanguageModelsShape extends IDisposable { export interface ExtHostLanguageModelsShape { $acceptChatModelMetadata(data: ILanguageModelsChangeEvent): void; $updateModelAccesslist(data: { from: ExtensionIdentifier; to: ExtensionIdentifier; enabled: boolean }[]): void; - $startChatRequest(handle: number, requestId: number, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise; + $startChatRequest(handle: number, requestId: number, from: ExtensionIdentifier, messages: SerializableObjectWithBuffers, options: { [name: string]: any }, token: CancellationToken): Promise; $acceptResponsePart(requestId: number, chunk: IChatResponseFragment): Promise; $acceptResponseDone(requestId: number, error: SerializedError | undefined): Promise; $provideTokenLength(handle: number, value: string | IChatMessage, token: CancellationToken): Promise; @@ -1379,7 +1381,8 @@ export type IToolDataDto = Omit; export interface MainThreadLanguageModelToolsShape extends IDisposable { $getTools(): Promise[]>; - $invokeTool(dto: IToolInvocation, token?: CancellationToken): Promise>; + $acceptToolProgress(callId: string, progress: IToolProgressStep): void; + $invokeTool(dto: IToolInvocation, token?: CancellationToken): Promise | SerializableObjectWithBuffers>>; $countTokensForInvocation(callId: string, input: string, token: CancellationToken): Promise; $registerTool(id: string): void; $unregisterTool(name: string): void; @@ -1389,7 +1392,7 @@ export type IChatRequestVariableValueDto = Dto; export interface ExtHostLanguageModelToolsShape { $onDidChangeTools(tools: IToolDataDto[]): void; - $invokeTool(dto: IToolInvocation, token: CancellationToken): Promise>; + $invokeTool(dto: IToolInvocation, token: CancellationToken): Promise | SerializableObjectWithBuffers>>; $countTokensForInvocation(callId: string, input: string, token: CancellationToken): Promise; $prepareToolInvocation(toolId: string, parameters: any, token: CancellationToken): Promise; @@ -1399,7 +1402,6 @@ export interface MainThreadUrlsShape extends IDisposable { $registerUriHandler(handle: number, extensionId: ExtensionIdentifier, extensionDisplayName: string): Promise; $unregisterUriHandler(handle: number): Promise; $createAppUri(uri: UriComponents): Promise; - $extractExternalUris(uris: UriComponents[]): Promise; } export interface IChatDto { @@ -1437,7 +1439,6 @@ export type IChatProgressDto = export interface ExtHostUrlsShape { $handleExternalUri(handle: number, uri: UriComponents): Promise; - $updateTrustedDomains(trustedDomains: string[]): Promise; } export interface MainThreadUriOpenersShape extends IDisposable { @@ -1481,8 +1482,9 @@ export interface MainThreadWorkspaceShape extends IDisposable { $unregisterEditSessionIdentityProvider(handle: number): void; $registerCanonicalUriProvider(handle: number, scheme: string): void; $unregisterCanonicalUriProvider(handle: number): void; - $decode(content: VSBuffer, resource: UriComponents | undefined, options?: { encoding?: string }): Promise; - $encode(content: string, resource: UriComponents | undefined, options?: { encoding?: string }): Promise; + $resolveDecoding(resource: UriComponents | undefined, options?: { encoding?: string }): Promise<{ preferredEncoding: string; guessEncoding: boolean; candidateGuessEncodings: string[] }>; + $validateDetectedEncoding(resource: UriComponents | undefined, detectedEncoding: string, options?: { encoding?: string }): Promise; + $resolveEncoding(resource: UriComponents | undefined, options?: { encoding?: string }): Promise<{ encoding: string; addBOM: boolean }>; } export interface IFileChangeDto { @@ -1524,6 +1526,7 @@ export interface MainThreadSearchShape extends IDisposable { $unregisterProvider(handle: number): void; $handleFileMatch(handle: number, session: number, data: UriComponents[]): void; $handleTextMatch(handle: number, session: number, data: search.IRawFileMatch2[]): void; + $handleKeywordResult(handle: number, session: number, data: AISearchKeyword): void; $handleTelemetry(eventName: string, data: any): void; } @@ -1560,6 +1563,8 @@ export interface SCMProviderFeatures { hasHistoryProvider?: boolean; hasQuickDiffProvider?: boolean; quickDiffLabel?: string; + hasSecondaryQuickDiffProvider?: boolean; + secondaryQuickDiffLabel?: string; count?: number; commitTemplate?: string; acceptInputCommand?: languages.Command; @@ -1666,7 +1671,7 @@ export interface MainThreadSCMShape extends IDisposable { } export interface MainThreadQuickDiffShape extends IDisposable { - $registerQuickDiffProvider(handle: number, selector: IDocumentFilterDto[], label: string, rootUri: UriComponents | undefined, visible: boolean): Promise; + $registerQuickDiffProvider(handle: number, selector: IDocumentFilterDto[], id: string, label: string, rootUri: UriComponents | undefined): Promise; $unregisterQuickDiffProvider(handle: number): Promise; } @@ -1982,6 +1987,16 @@ export interface MainThreadAiRelatedInformationShape { $unregisterAiRelatedInformationProvider(handle: number): void; } +export interface ExtHostAiSettingsSearchShape { + $startSearch(handle: number, query: string, option: AiSettingsSearchProviderOptions, token: CancellationToken): Promise; +} + +export interface MainThreadAiSettingsSearchShape { + $registerAiSettingsSearchProvider(handle: number): void; + $unregisterAiSettingsSearchProvider(handle: number): void; + $handleSearchResult(handle: number, result: AiSettingsSearchResult): void; +} + export interface ExtHostAiEmbeddingVectorShape { $provideAiEmbeddingVector(handle: number, strings: string[], token: CancellationToken): Promise; } @@ -2337,6 +2352,7 @@ export interface ExtHostLanguageFeaturesShape { $provideInlineEditsForRange(handle: number, resource: UriComponents, range: IRange, context: languages.InlineCompletionContext, token: CancellationToken): Promise; $handleInlineCompletionDidShow(handle: number, pid: number, idx: number, updatedInsertText: string): void; $handleInlineCompletionPartialAccept(handle: number, pid: number, idx: number, acceptedCharacters: number, info: languages.PartialAcceptInfo): void; + $handleInlineCompletionEndOfLifetime(handle: number, pid: number, idx: number, reason: languages.InlineCompletionEndOfLifeReason<{ pid: number; idx: number }>): void; $handleInlineCompletionRejection(handle: number, pid: number, idx: number): void; $freeInlineCompletionsList(handle: number, pid: number): void; $provideSignatureHelp(handle: number, resource: UriComponents, position: IPosition, context: languages.SignatureHelpContext, token: CancellationToken): Promise; @@ -2531,6 +2547,7 @@ export interface ExtHostTerminalShellIntegrationShape { export interface ExtHostSCMShape { $provideOriginalResource(sourceControlHandle: number, uri: UriComponents, token: CancellationToken): Promise; + $provideSecondaryOriginalResource(sourceControlHandle: number, uri: UriComponents, token: CancellationToken): Promise; $onInputBoxValueChange(sourceControlHandle: number, value: string): void; $executeResourceCommand(sourceControlHandle: number, groupHandle: number, handle: number, preserveFocus: boolean): Promise; $validateInput(sourceControlHandle: number, value: string, cursorPosition: number): Promise<[string | IMarkdownString, number] | undefined>; @@ -2972,6 +2989,7 @@ export interface ExtHostTestingShape { } export interface ExtHostMcpShape { + $resolveMcpLaunch(collectionId: string, label: string): Promise; $startMcp(id: number, launch: McpServerLaunch.Serialized): void; $stopMcp(id: number): void; $sendMessage(id: number, message: string): void; @@ -2980,9 +2998,9 @@ export interface ExtHostMcpShape { export interface MainThreadMcpShape { $onDidChangeState(id: number, state: McpConnectionState): void; - $onDidPublishLog(id: number, log: string): void; + $onDidPublishLog(id: number, level: LogLevel, log: string): void; $onDidReceiveMessage(id: number, message: string): void; - $upsertMcpCollection(collection: McpCollectionDefinition.FromExtHost, servers: Dto[]): void; + $upsertMcpCollection(collection: McpCollectionDefinition.FromExtHost, servers: McpServerDefinition.Serialized[]): void; $deleteMcpCollection(collectionId: string): void; } @@ -3061,6 +3079,18 @@ export interface MainThreadTestingShape { $markTestRetired(testIds: string[] | undefined): void; } +export type ChatStatusItemDto = { + id: string; + title: string | { label: string; link: string }; + description: string; + detail: string | undefined; +}; + +export interface MainThreadChatStatusShape { + $setEntry(id: string, entry: ChatStatusItemDto): void; + $disposeEntry(id: string): void; +} + // --- proxy identifiers export const MainContext = { @@ -3134,7 +3164,9 @@ export const MainContext = { MainThreadLocalization: createProxyIdentifier('MainThreadLocalizationShape'), MainThreadMcp: createProxyIdentifier('MainThreadMcpShape'), MainThreadAiRelatedInformation: createProxyIdentifier('MainThreadAiRelatedInformation'), - MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector') + MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector'), + MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), + MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), }; export const ExtHostContext = { @@ -3197,6 +3229,7 @@ export const ExtHostContext = { ExtHostEmbeddings: createProxyIdentifier('ExtHostEmbeddings'), ExtHostAiRelatedInformation: createProxyIdentifier('ExtHostAiRelatedInformation'), ExtHostAiEmbeddingVector: createProxyIdentifier('ExtHostAiEmbeddingVector'), + ExtHostAiSettingsSearch: createProxyIdentifier('ExtHostAiSettingsSearch'), ExtHostTheming: createProxyIdentifier('ExtHostTheming'), ExtHostTunnelService: createProxyIdentifier('ExtHostTunnelService'), ExtHostManagedSockets: createProxyIdentifier('ExtHostManagedSockets'), diff --git a/src/vs/workbench/api/common/extHostAiSettingsSearch.ts b/src/vs/workbench/api/common/extHostAiSettingsSearch.ts new file mode 100644 index 00000000000..c7c2d32f33d --- /dev/null +++ b/src/vs/workbench/api/common/extHostAiSettingsSearch.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SettingsSearchProvider, SettingsSearchResult } from 'vscode'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { AiSettingsSearchProviderOptions } from '../../services/aiSettingsSearch/common/aiSettingsSearch.js'; +import { ExtHostAiSettingsSearchShape, IMainContext, MainContext, MainThreadAiSettingsSearchShape } from './extHost.protocol.js'; +import { Disposable } from './extHostTypes.js'; +import { Progress } from '../../../platform/progress/common/progress.js'; +import { AiSettingsSearch } from './extHostTypeConverters.js'; + +export class ExtHostAiSettingsSearch implements ExtHostAiSettingsSearchShape { + private _settingsSearchProviders: Map = new Map(); + private _nextHandle = 0; + + private readonly _proxy: MainThreadAiSettingsSearchShape; + + constructor(mainContext: IMainContext) { + this._proxy = mainContext.getProxy(MainContext.MainThreadAiSettingsSearch); + } + + async $startSearch(handle: number, query: string, option: AiSettingsSearchProviderOptions, token: CancellationToken): Promise { + if (this._settingsSearchProviders.size === 0) { + throw new Error('No related information providers registered'); + } + + const provider = this._settingsSearchProviders.get(handle); + if (!provider) { + throw new Error('Settings search provider not found'); + } + + const progressReporter = new Progress((data) => { + this._proxy.$handleSearchResult(handle, AiSettingsSearch.fromSettingsSearchResult(data)); + }); + + return provider.provideSettingsSearchResults(query, option, progressReporter, token); + } + + registerSettingsSearchProvider(extension: IExtensionDescription, provider: SettingsSearchProvider): Disposable { + const handle = this._nextHandle; + this._nextHandle++; + this._settingsSearchProviders.set(handle, provider); + this._proxy.$registerAiSettingsSearchProvider(handle); + return new Disposable(() => { + this._proxy.$unregisterAiSettingsSearchProvider(handle); + this._settingsSearchProviders.delete(handle); + }); + } +} diff --git a/src/vs/workbench/api/common/extHostApiCommands.ts b/src/vs/workbench/api/common/extHostApiCommands.ts index 1d08ef21082..0dc498638b9 100644 --- a/src/vs/workbench/api/common/extHostApiCommands.ts +++ b/src/vs/workbench/api/common/extHostApiCommands.ts @@ -547,6 +547,7 @@ const newCommands: ApiCommand[] = [ initialRange: v.initialRange ? typeConverters.Range.from(v.initialRange) : undefined, initialSelection: types.Selection.isSelection(v.initialSelection) ? typeConverters.Selection.from(v.initialSelection) : undefined, message: v.message, + attachments: v.attachments, autoSend: v.autoSend, position: v.position ? typeConverters.Position.from(v.position) : undefined, }; @@ -559,6 +560,7 @@ type InlineChatEditorApiArg = { initialRange?: vscode.Range; initialSelection?: vscode.Selection; message?: string; + attachments?: vscode.Uri[]; autoSend?: boolean; position?: vscode.Position; }; @@ -567,6 +569,7 @@ type InlineChatRunOptions = { initialRange?: IRange; initialSelection?: ISelection; message?: string; + attachments?: URI[]; autoSend?: boolean; position?: IPosition; }; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index d72fa520c15..1c83abfbf26 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -127,10 +127,10 @@ class ChatAgentResponseStream { _report(dto); return this; }, - codeblockUri(value) { + codeblockUri(value, isEdit) { throwIfDone(this.codeblockUri); checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); - const part = new extHostTypes.ChatResponseCodeblockUriPart(value); + const part = new extHostTypes.ChatResponseCodeblockUriPart(value, isEdit); const dto = typeConvert.ChatResponseCodeblockUriPart.from(part); _report(dto); return this; @@ -255,6 +255,7 @@ class ChatAgentResponseStream { part instanceof extHostTypes.ChatResponseConfirmationPart || part instanceof extHostTypes.ChatResponseCodeCitationPart || part instanceof extHostTypes.ChatResponseMovePart || + part instanceof extHostTypes.ChatResponseExtensionsPart || part instanceof extHostTypes.ChatResponseProgressPart2 ) { checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); @@ -321,6 +322,9 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private readonly _inFlightRequests = new Set(); + private readonly _onDidDisposeChatSession = this._register(new Emitter()); + readonly onDidDisposeChatSession = this._onDidDisposeChatSession.event; + constructor( mainContext: IMainContext, private readonly _logService: ILogService, @@ -406,8 +410,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const { request, location, history } = await this._createRequest(requestDto, context, detector.extension); const model = await this.getModelForRequest(request, detector.extension); - const includeInteractionId = isProposedApiEnabled(detector.extension, 'chatParticipantPrivate'); - const extRequest = typeConvert.ChatAgentRequest.to(includeInteractionId ? request : { ...request, requestId: '' }, location, model, this.getDiagnosticsWhenEnabled(detector.extension), this.getToolsForRequest(detector.extension, request)); + const extRequest = typeConvert.ChatAgentRequest.to(request, location, model, this.getDiagnosticsWhenEnabled(detector.extension), this.getToolsForRequest(detector.extension, request), this.getTools2ForRequest(detector.extension, request), detector.extension); return detector.provider.provideParticipantDetection( extRequest, @@ -491,13 +494,14 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS stream = new ChatAgentResponseStream(agent.extension, request, this._proxy, this._commands.converter, sessionDisposables); const model = await this.getModelForRequest(request, agent.extension); - const includeInteractionId = isProposedApiEnabled(agent.extension, 'chatParticipantPrivate'); const extRequest = typeConvert.ChatAgentRequest.to( - includeInteractionId ? request : { ...request, requestId: '' }, + request, location, model, this.getDiagnosticsWhenEnabled(agent.extension), - this.getToolsForRequest(agent.extension, request) + this.getToolsForRequest(agent.extension, request), + this.getTools2ForRequest(agent.extension, request), + agent.extension ); inFlightRequest = { requestId: requestDto.requestId, extRequest }; this._inFlightRequests.add(inFlightRequest); @@ -557,12 +561,29 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return this._diagnostics.getDiagnostics(); } - private getToolsForRequest(extension: IExtensionDescription, request: Dto) { + private getTools2ForRequest(extension: IExtensionDescription, request: Dto): Map { + if (!request.userSelectedTools2) { + return new Map(); + } + const result = new Map(); + for (const tool of this._tools.getTools(extension)) { + if (typeof request.userSelectedTools2[tool.name] === 'boolean') { + result.set(tool.name, request.userSelectedTools2[tool.name]); + } + } + return result; + } + + private getToolsForRequest(extension: IExtensionDescription, request: Dto): vscode.ChatRequestToolSelection | undefined { if (!isNonEmptyArray(request.userSelectedTools)) { return undefined; } const selector = new Set(request.userSelectedTools); - return this._tools.getTools(extension).filter(candidate => selector.has(candidate.name)); + const tools = this._tools.getTools(extension).filter(candidate => selector.has(candidate.name)); + return { + tools, + isExclusive: request.toolSelectionIsExclusive, + }; } private async prepareHistoryTurns(extension: Readonly, agentId: string, context: { history: IChatAgentHistoryEntryDto[] }): Promise<(vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]> { @@ -576,12 +597,13 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS // REQUEST turn const varsWithoutTools = h.request.variables.variables - .filter(v => !v.isTool) + .filter(v => v.kind !== 'tool') .map(v => typeConvert.ChatPromptReference.to(v, this.getDiagnosticsWhenEnabled(extension))); const toolReferences = h.request.variables.variables - .filter(v => v.isTool) + .filter(v => v.kind === 'tool') .map(typeConvert.ChatLanguageModelToolReference.to); - const turn = new extHostTypes.ChatRequestTurn(h.request.message, h.request.command, varsWithoutTools, h.request.agentId, toolReferences); + const editedFileEvents = isProposedApiEnabled(extension, 'chatParticipantPrivate') ? h.request.editedFileEvents : undefined; + const turn = new extHostTypes.ChatRequestTurn(h.request.message, h.request.command, varsWithoutTools, h.request.agentId, toolReferences, editedFileEvents); res.push(turn); // RESPONSE turn @@ -594,6 +616,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS $releaseSession(sessionId: string): void { this._sessionDisposables.deleteAndDispose(sessionId); + this._onDidDisposeChatSession.fire(sessionId); } async $provideFollowups(requestDto: Dto, handle: number, result: IChatAgentResult, context: { history: IChatAgentHistoryEntryDto[] }, token: CancellationToken): Promise { @@ -723,13 +746,12 @@ class ExtHostChatAgent { private _helpTextPrefix: string | vscode.MarkdownString | undefined; private _helpTextVariablesPrefix: string | vscode.MarkdownString | undefined; private _helpTextPostfix: string | vscode.MarkdownString | undefined; - private _isSecondary: boolean | undefined; private _onDidReceiveFeedback = new Emitter(); private _onDidPerformAction = new Emitter(); private _supportIssueReporting: boolean | undefined; private _agentVariableProvider?: { provider: vscode.ChatParticipantCompletionItemProvider; triggerCharacters: string[] }; private _welcomeMessageProvider?: vscode.ChatWelcomeMessageProvider | undefined; - private _welcomeMessageContent?: vscode.ChatWelcomeMessageContent | undefined; + private _additionalWelcomeMessage?: string | vscode.MarkdownString | undefined; private _titleProvider?: vscode.ChatTitleProvider | undefined; private _requester: vscode.ChatRequesterInformation | undefined; private _pauseStateEmitter = new Emitter(); @@ -820,16 +842,12 @@ class ExtHostChatAgent { undefined, themeIcon: this._iconPath instanceof extHostTypes.ThemeIcon ? this._iconPath : undefined, hasFollowups: this._followupProvider !== undefined, - isSecondary: this._isSecondary, helpTextPrefix: (!this._helpTextPrefix || typeof this._helpTextPrefix === 'string') ? this._helpTextPrefix : typeConvert.MarkdownString.from(this._helpTextPrefix), helpTextVariablesPrefix: (!this._helpTextVariablesPrefix || typeof this._helpTextVariablesPrefix === 'string') ? this._helpTextVariablesPrefix : typeConvert.MarkdownString.from(this._helpTextVariablesPrefix), helpTextPostfix: (!this._helpTextPostfix || typeof this._helpTextPostfix === 'string') ? this._helpTextPostfix : typeConvert.MarkdownString.from(this._helpTextPostfix), supportIssueReporting: this._supportIssueReporting, requester: this._requester, - welcomeMessageContent: this._welcomeMessageContent && { - ...this._welcomeMessageContent, - message: typeConvert.MarkdownString.from(this._welcomeMessageContent.message), - } + additionalWelcomeMessage: (!this._additionalWelcomeMessage || typeof this._additionalWelcomeMessage === 'string') ? this._additionalWelcomeMessage : typeConvert.MarkdownString.from(this._additionalWelcomeMessage), }); updateScheduled = false; }); @@ -888,15 +906,6 @@ class ExtHostChatAgent { that._helpTextPostfix = v; updateMetadataSoon(); }, - get isSecondary() { - checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); - return that._isSecondary; - }, - set isSecondary(v) { - checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); - that._isSecondary = v; - updateMetadataSoon(); - }, get supportIssueReporting() { checkProposedApiEnabled(that.extension, 'chatParticipantPrivate'); return that._supportIssueReporting; @@ -935,14 +944,14 @@ class ExtHostChatAgent { checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); return that._welcomeMessageProvider; }, - set welcomeMessageContent(v) { + set additionalWelcomeMessage(v) { checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); - that._welcomeMessageContent = v; + that._additionalWelcomeMessage = v; updateMetadataSoon(); }, - get welcomeMessageContent() { + get additionalWelcomeMessage() { checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); - return that._welcomeMessageContent; + return that._additionalWelcomeMessage; }, set titleProvider(v) { checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); diff --git a/src/vs/workbench/api/common/extHostChatStatus.ts b/src/vs/workbench/api/common/extHostChatStatus.ts new file mode 100644 index 00000000000..47a85537313 --- /dev/null +++ b/src/vs/workbench/api/common/extHostChatStatus.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import * as extHostProtocol from './extHost.protocol.js'; +import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; + +export class ExtHostChatStatus { + + private readonly _proxy: extHostProtocol.MainThreadChatStatusShape; + + private readonly _items = new Map(); + + constructor( + mainContext: extHostProtocol.IMainContext + ) { + this._proxy = mainContext.getProxy(extHostProtocol.MainContext.MainThreadChatStatus); + } + + createChatStatusItem(extension: IExtensionDescription, id: string): vscode.ChatStatusItem { + const internalId = asChatItemIdentifier(extension.identifier, id); + if (this._items.has(internalId)) { + throw new Error(`Chat status item '${id}' already exists`); + } + + const state: extHostProtocol.ChatStatusItemDto = { + id: internalId, + title: '', + description: '', + detail: '', + }; + + let disposed = false; + let visible = false; + const syncState = () => { + if (disposed) { + throw new Error('Chat status item is disposed'); + } + + if (!visible) { + return; + } + + this._proxy.$setEntry(id, state); + }; + + const item = Object.freeze({ + id: id, + + get title(): string | { label: string; link: string } { + return state.title; + }, + set title(value: string | { label: string; link: string }) { + state.title = value; + syncState(); + }, + + get description(): string { + return state.description; + }, + set description(value: string) { + state.description = value; + syncState(); + }, + + get detail(): string | undefined { + return state.detail; + }, + set detail(value: string | undefined) { + state.detail = value; + syncState(); + }, + + show: () => { + visible = true; + syncState(); + }, + hide: () => { + visible = false; + this._proxy.$disposeEntry(id); + }, + dispose: () => { + disposed = true; + this._proxy.$disposeEntry(id); + this._items.delete(internalId); + }, + }); + + this._items.set(internalId, item); + return item; + } +} + +function asChatItemIdentifier(extension: ExtensionIdentifier, id: string): string { + return `${ExtensionIdentifier.toKey(extension)}.${id}`; +} + diff --git a/src/vs/workbench/api/common/extHostCodeMapper.ts b/src/vs/workbench/api/common/extHostCodeMapper.ts index 5f573f995d9..5e22a066b8e 100644 --- a/src/vs/workbench/api/common/extHostCodeMapper.ts +++ b/src/vs/workbench/api/common/extHostCodeMapper.ts @@ -52,6 +52,8 @@ export class ExtHostCodeMapper implements extHostProtocol.ExtHostCodeMapperShape const request: vscode.MappedEditsRequest = { location: internalRequest.location, chatRequestId: internalRequest.chatRequestId, + chatRequestModel: internalRequest.chatRequestModel, + chatSessionId: internalRequest.chatSessionId, codeBlocks: internalRequest.codeBlocks.map(block => { return { code: block.code, diff --git a/src/vs/workbench/api/common/extHostCommands.ts b/src/vs/workbench/api/common/extHostCommands.ts index 0c170ec48a9..0bda2e82e46 100644 --- a/src/vs/workbench/api/common/extHostCommands.ts +++ b/src/vs/workbench/api/common/extHostCommands.ts @@ -32,6 +32,7 @@ import { IExtensionDescription } from '../../../platform/extensions/common/exten import { TelemetryTrustedValue } from '../../../platform/telemetry/common/telemetryUtils.js'; import { IExtHostTelemetry } from './extHostTelemetry.js'; import { generateUuid } from '../../../base/common/uuid.js'; +import { isCancellationError } from '../../../base/common/errors.js'; interface CommandHandler { callback: Function; @@ -256,7 +257,9 @@ export class ExtHostCommands implements ExtHostCommandsShape { id = actual.command; } } - this._logService.error(err, id, command.extension?.identifier); + if (!isCancellationError(err)) { + this._logService.error(err, id, command.extension?.identifier); + } if (!annotateError) { throw err; @@ -445,7 +448,6 @@ export class ApiCommandArgument { static readonly Selection = new ApiCommandArgument('selection', 'A selection in a text document', v => extHostTypes.Selection.isSelection(v), extHostTypeConverter.Selection.from); static readonly Number = new ApiCommandArgument('number', '', v => typeof v === 'number', v => v); static readonly String = new ApiCommandArgument('string', '', v => typeof v === 'string', v => v); - static readonly StringArray = ApiCommandArgument.Arr(ApiCommandArgument.String); static Arr(element: ApiCommandArgument) { return new ApiCommandArgument( diff --git a/src/vs/workbench/api/common/extHostComments.ts b/src/vs/workbench/api/common/extHostComments.ts index 908d094ec00..3f1ac7829cb 100644 --- a/src/vs/workbench/api/common/extHostComments.ts +++ b/src/vs/workbench/api/common/extHostComments.ts @@ -268,7 +268,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo contextValue: string | undefined; comments: vscode.Comment[]; collapsibleState: vscode.CommentThreadCollapsibleState; - canReply: boolean; + canReply: boolean | vscode.CommentAuthorInformation; state: vscode.CommentThreadState; isTemplate: boolean; applicability: vscode.CommentThreadApplicability; @@ -316,9 +316,9 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo return this._range; } - private _canReply: boolean = true; + private _canReply: boolean | vscode.CommentAuthorInformation = true; - set canReply(state: boolean) { + set canReply(state: boolean | vscode.CommentAuthorInformation) { if (this._canReply !== state) { this._canReply = state; this.modifications.canReply = state; @@ -465,7 +465,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo get collapsibleState() { return that.collapsibleState; }, set collapsibleState(value: vscode.CommentThreadCollapsibleState) { that.collapsibleState = value; }, get canReply() { return that.canReply; }, - set canReply(state: boolean) { that.canReply = state; }, + set canReply(state: boolean | vscode.CommentAuthorInformation) { that.canReply = state; }, get contextValue() { return that.contextValue; }, set contextValue(value: string | undefined) { that.contextValue = value; }, get label() { return that.label; }, diff --git a/src/vs/workbench/api/common/extHostConfiguration.ts b/src/vs/workbench/api/common/extHostConfiguration.ts index 0b03122f8ee..f0d9124a0da 100644 --- a/src/vs/workbench/api/common/extHostConfiguration.ts +++ b/src/vs/workbench/api/common/extHostConfiguration.ts @@ -160,9 +160,7 @@ export class ExtHostConfigProvider { getConfiguration(section?: string, scope?: vscode.ConfigurationScope | null, extensionDescription?: IExtensionDescription): vscode.WorkspaceConfiguration { const overrides = scopeToOverrides(scope) || {}; - const config = this._toReadonlyValue(section - ? lookUp(this._configuration.getValue(undefined, overrides, this._extHostWorkspace.workspace), section) - : this._configuration.getValue(undefined, overrides, this._extHostWorkspace.workspace)); + const config = this._toReadonlyValue(this._configuration.getValue(section, overrides, this._extHostWorkspace.workspace)); if (section) { this._validateConfigurationAccess(section, overrides, extensionDescription?.identifier); diff --git a/src/vs/workbench/api/common/extHostDebugService.ts b/src/vs/workbench/api/common/extHostDebugService.ts index b0666520e47..46648099c38 100644 --- a/src/vs/workbench/api/common/extHostDebugService.ts +++ b/src/vs/workbench/api/common/extHostDebugService.ts @@ -579,7 +579,7 @@ export abstract class ExtHostDebugServiceBase extends DisposableCls implements I }; } const variableResolver = await this._variableResolver.getResolver(); - return variableResolver.resolveAnyAsync(ws, config); + return variableResolver.resolveAsync(ws, config); } protected createDebugAdapter(adapter: vscode.DebugAdapterDescriptor, session: ExtHostDebugSession): AbstractDebugAdapter | undefined { diff --git a/src/vs/workbench/api/common/extHostExtensionService.ts b/src/vs/workbench/api/common/extHostExtensionService.ts index e6af1f781be..5b14b6d5a37 100644 --- a/src/vs/workbench/api/common/extHostExtensionService.ts +++ b/src/vs/workbench/api/common/extHostExtensionService.ts @@ -482,10 +482,14 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme this._logService.info(`ExtensionService#_doActivateExtension ${extensionDescription.identifier.value}, startup: ${reason.startup}, activationEvent: '${reason.activationEvent}'${extensionDescription.identifier.value !== reason.extensionId.value ? `, root cause: ${reason.extensionId.value}` : ``}`); this._logService.flush(); + const isESM = this._isESM(extensionDescription); + const extensionInternalStore = new DisposableStore(); // disposables that follow the extension lifecycle const activationTimesBuilder = new ExtensionActivationTimesBuilder(reason.startup); return Promise.all([ - this._loadCommonJSModule(extensionDescription, joinPath(extensionDescription.extensionLocation, entryPoint), activationTimesBuilder), + isESM + ? this._loadESMModule(extensionDescription, joinPath(extensionDescription.extensionLocation, entryPoint), activationTimesBuilder) + : this._loadCommonJSModule(extensionDescription, joinPath(extensionDescription.extensionLocation, entryPoint), activationTimesBuilder), this._loadExtensionContext(extensionDescription, extensionInternalStore) ]).then(values => { performance.mark(`code/extHost/willActivateExtension/${extensionDescription.identifier.value}`); @@ -743,8 +747,13 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme throw new Error(nls.localize('extensionTestError1', "Cannot load test runner.")); } + const extensionDescription = (await this.getExtensionPathIndex()).findSubstr(extensionTestsLocationURI); + const isESM = this._isESM(extensionDescription, extensionTestsLocationURI.path); + // Require the test runner via node require from the provided path - const testRunner = await this._loadCommonJSModule(null, extensionTestsLocationURI, new ExtensionActivationTimesBuilder(false)); + const testRunner = await (isESM + ? this._loadESMModule(null, extensionTestsLocationURI, new ExtensionActivationTimesBuilder(false)) + : this._loadCommonJSModule(null, extensionTestsLocationURI, new ExtensionActivationTimesBuilder(false))); if (!testRunner || typeof testRunner.run !== 'function') { throw new Error(nls.localize('extensionTestError', "Path {0} does not point to a valid extension test runner.", extensionTestsLocationURI.toString())); @@ -1077,9 +1086,15 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme this._onDidChangeRemoteConnectionData.fire(); } + protected _isESM(extensionDescription: IExtensionDescription | undefined, modulePath?: string): boolean { + modulePath ??= extensionDescription?.main; + return modulePath?.endsWith('.mjs') || (extensionDescription?.type === 'module' && !modulePath?.endsWith('.cjs')); + } + protected abstract _beforeAlmostReadyToRunExtensions(): Promise; protected abstract _getEntryPoint(extensionDescription: IExtensionDescription): string | undefined; protected abstract _loadCommonJSModule(extensionId: IExtensionDescription | null, module: URI, activationTimesBuilder: ExtensionActivationTimesBuilder): Promise; + protected abstract _loadESMModule(extension: IExtensionDescription | null, module: URI, activationTimesBuilder: ExtensionActivationTimesBuilder): Promise; public abstract $setRemoteEnvironment(env: { [key: string]: string | null }): Promise; } diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index b8122b07faa..9993c5c2112 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -1350,6 +1350,7 @@ class InlineCompletionAdapter { && (typeof this._provider.handleDidShowCompletionItem === 'function' || typeof this._provider.handleDidPartiallyAcceptCompletionItem === 'function' || typeof this._provider.handleDidRejectCompletionItem === 'function' + || typeof this._provider.handleEndOfLifetime === 'function' ); } @@ -1428,6 +1429,10 @@ class InlineCompletionAdapter { completeBracketPairs: this._isAdditionsProposedApiEnabled ? item.completeBracketPairs : false, isInlineEdit: this._isAdditionsProposedApiEnabled ? item.isInlineEdit : false, showInlineEditMenu: this._isAdditionsProposedApiEnabled ? item.showInlineEditMenu : false, + displayLocation: (item.displayLocation && this._isAdditionsProposedApiEnabled) ? { + range: typeConvert.Range.from(item.displayLocation.range), + label: item.displayLocation.label, + } : undefined, warning: (item.warning && this._isAdditionsProposedApiEnabled) ? { message: typeConvert.MarkdownString.from(item.warning.message), icon: item.warning.icon ? typeConvert.IconPath.fromThemeIcon(item.warning.icon) : undefined, @@ -1555,6 +1560,16 @@ class InlineCompletionAdapter { } } + handleEndOfLifetime(pid: number, idx: number, reason: languages.InlineCompletionEndOfLifeReason<{ pid: number; idx: number }>): void { + const completionItem = this._references.get(pid)?.items[idx]; + if (completionItem) { + if (this._provider.handleEndOfLifetime && this._isAdditionsProposedApiEnabled) { + const r = typeConvert.InlineCompletionEndOfLifeReason.to(reason, ref => this._references.get(ref.pid)?.items[ref.idx]); + this._provider.handleEndOfLifetime(completionItem, r); + } + } + } + handleRejection(pid: number, idx: number): void { const completionItem = this._references.get(pid)?.items[idx]; if (completionItem) { @@ -2704,8 +2719,16 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF // --- ghost text registerInlineCompletionsProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.InlineCompletionItemProvider, metadata: vscode.InlineCompletionItemProviderMetadata | undefined): vscode.Disposable { + const eventHandle = typeof provider.onDidChange === 'function' && isProposedApiEnabled(extension, 'inlineCompletionsAdditions') ? this._nextHandle() : undefined; const adapter = new InlineCompletionAdapter(extension, this._documents, provider, this._commands.converter); const handle = this._addNewAdapter(adapter, extension); + let result = this._createDisposable(handle); + + if (eventHandle !== undefined) { + const subscription = provider.onDidChange!(_ => this._proxy.$emitInlineCompletionsChange(eventHandle)); + result = Disposable.from(result, subscription); + } + this._proxy.$registerInlineCompletionsSupport( handle, this._transformDocumentSelector(selector, extension), @@ -2714,8 +2737,9 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF metadata?.yieldTo?.map(extId => ExtensionIdentifier.toKey(extId)) || [], metadata?.displayName, metadata?.debounceDelayMs, + eventHandle, ); - return this._createDisposable(handle); + return result; } $provideInlineCompletions(handle: number, resource: UriComponents, position: IPosition, context: languages.InlineCompletionContext, token: CancellationToken): Promise { @@ -2738,6 +2762,12 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF }, undefined, undefined); } + $handleInlineCompletionEndOfLifetime(handle: number, pid: number, idx: number, reason: languages.InlineCompletionEndOfLifeReason<{ pid: number; idx: number }>): void { + this._withAdapter(handle, InlineCompletionAdapter, async adapter => { + adapter.handleEndOfLifetime(pid, idx, reason); + }, undefined, undefined); + } + $handleInlineCompletionRejection(handle: number, pid: number, idx: number): void { this._withAdapter(handle, InlineCompletionAdapter, async adapter => { adapter.handleRejection(pid, idx); diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 7c6ad141c6e..67eeb33856f 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -12,12 +12,14 @@ import { revive } from '../../../base/common/marshalling.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { IPreparedToolInvocation, isToolInvocationContext, IToolInvocation, IToolInvocationContext, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; +import { ExtensionEditToolId, InternalEditToolId } from '../../contrib/chat/common/tools/editFileTool.js'; +import { InternalFetchWebPageToolId } from '../../contrib/chat/common/tools/tools.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; +import { Dto, SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostLanguageModelToolsShape, IMainContext, IToolDataDto, MainContext, MainThreadLanguageModelToolsShape } from './extHost.protocol.js'; +import { ExtHostLanguageModels } from './extHostLanguageModels.js'; import * as typeConvert from './extHostTypeConverters.js'; -import { IToolInputProcessor } from '../../contrib/chat/common/tools/tools.js'; -import { EditToolData, InternalEditToolId, EditToolInputProcessor, ExtensionEditToolId } from '../../contrib/chat/common/tools/editFileTool.js'; -import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; +import { SearchExtensionsToolId } from '../../contrib/extensions/common/searchExtensionsTool.js'; export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape { /** A map of tools that were registered in this EH */ @@ -28,9 +30,10 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape /** A map of all known tools, from other EHs or registered in vscode core */ private readonly _allTools = new Map(); - private readonly _toolInputProcessors = new Map(); - - constructor(mainContext: IMainContext) { + constructor( + mainContext: IMainContext, + private readonly _languageModels: ExtHostLanguageModels, + ) { this._proxy = mainContext.getProxy(MainContext.MainThreadLanguageModelTools); this._proxy.$getTools().then(tools => { @@ -38,8 +41,6 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape this._allTools.set(tool.id, revive(tool)); } }); - - this._toolInputProcessors.set(EditToolData.id, new EditToolInputProcessor()); } async $countTokensForInvocation(callId: string, input: string, token: CancellationToken): Promise { @@ -67,17 +68,18 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape } // Making the round trip here because not all tools were necessarily registered in this EH - const processedInput = this._toolInputProcessors.get(toolId)?.processInput(options.input) ?? options.input; const result = await this._proxy.$invokeTool({ toolId, callId, - parameters: processedInput, + parameters: options.input, tokenBudget: options.tokenizationOptions?.tokenBudget, context: options.toolInvocationToken as IToolInvocationContext | undefined, chatRequestId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatRequestId : undefined, chatInteractionId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatInteractionId : undefined, }, token); - return typeConvert.LanguageModelToolResult.to(revive(result)); + + const dto: Dto = result instanceof SerializableObjectWithBuffers ? result.value : result; + return typeConvert.LanguageModelToolResult2.to(revive(dto)); } finally { this._tokenCountFuncs.delete(callId); } @@ -94,15 +96,19 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape return Array.from(this._allTools.values()) .map(tool => typeConvert.LanguageModelToolDescription.to(tool)) .filter(tool => { - if (tool.name === InternalEditToolId || tool.name === ExtensionEditToolId) { - return isProposedApiEnabled(extension, 'chatParticipantPrivate'); + switch (tool.name) { + case InternalEditToolId: + case ExtensionEditToolId: + case InternalFetchWebPageToolId: + case SearchExtensionsToolId: + return isProposedApiEnabled(extension, 'chatParticipantPrivate'); + default: + return true; } - - return true; }); } - async $invokeTool(dto: IToolInvocation, token: CancellationToken): Promise> { + async $invokeTool(dto: IToolInvocation, token: CancellationToken): Promise | SerializableObjectWithBuffers>> { const item = this._registeredTools.get(dto.toolId); if (!item) { throw new Error(`Unknown tool ${dto.toolId}`); @@ -111,11 +117,19 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape const options: vscode.LanguageModelToolInvocationOptions = { input: dto.parameters, toolInvocationToken: dto.context as vscode.ChatParticipantToolToken | undefined, - chatRequestId: dto.chatRequestId, - chatInteractionId: dto.chatInteractionId, }; - if (isProposedApiEnabled(item.extension, 'chatParticipantPrivate') && dto.toolSpecificData?.kind === 'terminal') { - options.terminalCommand = dto.toolSpecificData.command; + if (isProposedApiEnabled(item.extension, 'chatParticipantPrivate')) { + options.chatRequestId = dto.chatRequestId; + options.chatInteractionId = dto.chatInteractionId; + options.chatSessionId = dto.context?.sessionId; + + if (dto.toolSpecificData?.kind === 'terminal') { + options.terminalCommand = dto.toolSpecificData.command; + } + } + + if (isProposedApiEnabled(item.extension, 'chatParticipantAdditions') && dto.modelId) { + options.model = await this.getModel(dto.modelId, item.extension); } if (dto.tokenBudget !== undefined) { @@ -126,12 +140,41 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape }; } - const extensionResult = await raceCancellation(Promise.resolve(item.tool.invoke(options, token)), token); + let progress: vscode.Progress<{ message?: string | vscode.MarkdownString; increment?: number }> | undefined; + if (isProposedApiEnabled(item.extension, 'toolProgress')) { + progress = { + report: value => { + this._proxy.$acceptToolProgress(dto.callId, { + message: typeConvert.MarkdownString.fromStrict(value.message), + increment: value.increment, + total: 100, + }); + } + }; + } + + // todo: 'any' cast because TS can't handle the overloads + const extensionResult = await raceCancellation(Promise.resolve((item.tool.invoke as any)(options, token, progress!)), token); if (!extensionResult) { throw new CancellationError(); } - return typeConvert.LanguageModelToolResult.from(extensionResult, item.extension); + return typeConvert.LanguageModelToolResult2.from(extensionResult, item.extension); + } + + private async getModel(modelId: string, extension: IExtensionDescription): Promise { + let model: vscode.LanguageModelChat | undefined; + if (modelId) { + model = await this._languageModels.getLanguageModelByIdentifier(extension, modelId); + } + if (!model) { + model = await this._languageModels.getDefaultLanguageModel(extension); + if (!model) { + throw new Error('Language model unavailable'); + } + } + + return model; } async $prepareToolInvocation(toolId: string, input: any, token: CancellationToken): Promise { diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index 66876aa5a13..63a2e81cd41 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -16,7 +16,7 @@ import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionIdentifierSet, IE import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { Progress } from '../../../platform/progress/common/progress.js'; -import { IChatMessage, IChatResponseFragment, IChatResponsePart, ILanguageModelChatMetadata } from '../../contrib/chat/common/languageModels.js'; +import { ChatImageMimeType, IChatMessage, IChatResponseFragment, IChatResponsePart, ILanguageModelChatMetadata } from '../../contrib/chat/common/languageModels.js'; import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js'; import { checkProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import { ExtHostLanguageModelsShape, MainContext, MainThreadLanguageModelsShape } from './extHost.protocol.js'; @@ -24,6 +24,9 @@ import { IExtHostAuthentication } from './extHostAuthentication.js'; import { IExtHostRpcService } from './extHostRpcService.js'; import * as typeConvert from './extHostTypeConverters.js'; import * as extHostTypes from './extHostTypes.js'; +import { SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../contrib/chat/common/modelPicker/modelPickerWidget.js'; export interface IExtHostLanguageModels extends ExtHostLanguageModels { } @@ -100,9 +103,11 @@ class LanguageModelResponse { this._responseStreams.set(fragment.index, res); } - let out: vscode.LanguageModelTextPart | vscode.LanguageModelToolCallPart; + let out: vscode.LanguageModelTextPart | vscode.LanguageModelDataPart | vscode.LanguageModelToolCallPart; if (fragment.part.type === 'text') { out = new extHostTypes.LanguageModelTextPart(fragment.part.value); + } else if (fragment.part.type === 'data') { + out = new extHostTypes.LanguageModelTextPart(''); } else { out = new extHostTypes.LanguageModelToolCallPart(fragment.part.toolCallId, fragment.part.name, fragment.part.parameters); } @@ -172,6 +177,8 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { vendor: metadata.vendor ?? ExtensionIdentifier.toKey(extension.identifier), name: metadata.name ?? '', family: metadata.family ?? '', + cost: metadata.cost, + description: metadata.description, version: metadata.version, maxInputTokens: metadata.maxInputTokens, maxOutputTokens: metadata.maxOutputTokens, @@ -179,6 +186,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { targetExtensions: metadata.extensions, isDefault: metadata.isDefault, isUserSelectable: metadata.isUserSelectable, + modelPickerCategory: metadata.category ?? DEFAULT_MODEL_PICKER_CATEGORY, capabilities: metadata.capabilities, }); @@ -193,7 +201,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { }); } - async $startChatRequest(handle: number, requestId: number, from: ExtensionIdentifier, messages: IChatMessage[], options: vscode.LanguageModelChatRequestOptions, token: CancellationToken): Promise { + async $startChatRequest(handle: number, requestId: number, from: ExtensionIdentifier, messages: SerializableObjectWithBuffers, options: vscode.LanguageModelChatRequestOptions, token: CancellationToken): Promise { const data = this._languageModels.get(handle); if (!data) { throw new Error('Provider not found'); @@ -209,6 +217,8 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { part = { type: 'tool_use', name: fragment.part.name, parameters: fragment.part.input, toolCallId: fragment.part.callId }; } else if (fragment.part instanceof extHostTypes.LanguageModelTextPart) { part = { type: 'text', value: fragment.part.value }; + } else if (fragment.part instanceof extHostTypes.LanguageModelDataPart) { + part = { type: 'data', value: { mimeType: fragment.part.mimeType as ChatImageMimeType, data: VSBuffer.wrap(fragment.part.data) } }; } if (!part) { @@ -222,24 +232,13 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { let value: any; try { - if (data.provider.provideLanguageModelResponse2) { - value = data.provider.provideLanguageModelResponse2( - messages.map(typeConvert.LanguageModelChatMessage.to), - options, - ExtensionIdentifier.toKey(from), - progress, - token - ); - - } else { - value = data.provider.provideLanguageModelResponse( - messages.map(typeConvert.LanguageModelChatMessage.to), - options, - ExtensionIdentifier.toKey(from), - progress, - token - ); - } + value = data.provider.provideLanguageModelResponse( + messages.value.map(typeConvert.LanguageModelChatMessage2.to), + options, + ExtensionIdentifier.toKey(from), + progress, + token + ); } catch (err) { // synchronously failed @@ -367,7 +366,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { return result; } - private async _sendChatRequest(extension: IExtensionDescription, languageModelId: string, messages: vscode.LanguageModelChatMessage[], options: vscode.LanguageModelChatRequestOptions, token: CancellationToken) { + private async _sendChatRequest(extension: IExtensionDescription, languageModelId: string, messages: vscode.LanguageModelChatMessage2[], options: vscode.LanguageModelChatRequestOptions, token: CancellationToken) { const internalMessages: IChatMessage[] = this._convertMessages(extension, messages); @@ -391,7 +390,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { this._pendingRequest.set(requestId, { languageModelId, res }); try { - await this._proxy.$tryStartChatRequest(from, languageModelId, requestId, internalMessages, options, token); + await this._proxy.$tryStartChatRequest(from, languageModelId, requestId, new SerializableObjectWithBuffers(internalMessages), options, token); } catch (error) { // error'ing here means that the request could NOT be started/made, e.g. wrong model, no access, etc, but @@ -403,13 +402,13 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { return res.apiObject; } - private _convertMessages(extension: IExtensionDescription, messages: vscode.LanguageModelChatMessage[]) { + private _convertMessages(extension: IExtensionDescription, messages: vscode.LanguageModelChatMessage2[]) { const internalMessages: IChatMessage[] = []; for (const message of messages) { if (message.role as number === extHostTypes.LanguageModelChatMessageRole.System) { checkProposedApiEnabled(extension, 'languageModelSystem'); } - internalMessages.push(typeConvert.LanguageModelChatMessage.from(message)); + internalMessages.push(typeConvert.LanguageModelChatMessage2.from(message)); } return internalMessages; } @@ -488,7 +487,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { } } - private async _computeTokenLength(languageModelId: string, value: string | vscode.LanguageModelChatMessage, token: vscode.CancellationToken): Promise { + private async _computeTokenLength(languageModelId: string, value: string | vscode.LanguageModelChatMessage2, token: vscode.CancellationToken): Promise { const data = this._allLanguageModelData.get(languageModelId); if (!data) { @@ -501,7 +500,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { return local.provider.provideTokenCount(value, token); } - return this._proxy.$countTokens(languageModelId, (typeof value === 'string' ? value : typeConvert.LanguageModelChatMessage.from(value)), token); + return this._proxy.$countTokens(languageModelId, (typeof value === 'string' ? value : typeConvert.LanguageModelChatMessage2.from(value)), token); } $updateModelAccesslist(data: { from: ExtensionIdentifier; to: ExtensionIdentifier; enabled: boolean }[]): void { @@ -565,7 +564,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { }; } - fileIsIgnored(extension: IExtensionDescription, uri: vscode.Uri, token: vscode.CancellationToken): Promise { + fileIsIgnored(extension: IExtensionDescription, uri: vscode.Uri, token: vscode.CancellationToken = CancellationToken.None): Promise { checkProposedApiEnabled(extension, 'chatParticipantAdditions'); return this._proxy.$fileIsIgnored(uri, token); diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index eead33f21fe..4c54d7dfe1f 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -3,34 +3,34 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type * as ES from '@c4312/eventsource-umd'; import * as vscode from 'vscode'; -import { importAMDNodeModule } from '../../../amdX.js'; -import { DeferredPromise, Sequencer } from '../../../base/common/async.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { Lazy } from '../../../base/common/lazy.js'; +import { DeferredPromise, raceCancellationError, Sequencer, timeout } from '../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { SSEParser } from '../../../base/common/sseParser.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { LogLevel } from '../../../platform/log/common/log.js'; import { StorageScope } from '../../../platform/storage/common/storage.js'; -import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportSSE, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; +import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportHTTP, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; import { ExtHostMcpShape, MainContext, MainThreadMcpShape } from './extHost.protocol.js'; import { IExtHostRpcService } from './extHostRpcService.js'; +import * as Convert from './extHostTypeConverters.js'; export const IExtHostMpcService = createDecorator('IExtHostMpcService'); export interface IExtHostMpcService extends ExtHostMcpShape { - registerMcpConfigurationProvider(extension: IExtensionDescription, id: string, provider: vscode.McpConfigurationProvider): IDisposable; + registerMcpConfigurationProvider(extension: IExtensionDescription, id: string, provider: vscode.McpServerDefinitionProvider): IDisposable; } export class ExtHostMcpService extends Disposable implements IExtHostMpcService { protected _proxy: MainThreadMcpShape; private readonly _initialProviderPromises = new Set>(); - private readonly _sseEventSources = this._register(new DisposableMap()); - private readonly _eventSource = new Lazy(async () => { - const es = await importAMDNodeModule('@c4312/eventsource-umd', 'dist/index.umd.js'); - return es.EventSource; - }); + private readonly _sseEventSources = this._register(new DisposableMap()); + private readonly _unresolvedMcpServers = new Map(); constructor( @IExtHostRpcService extHostRpc: IExtHostRpcService, @@ -44,8 +44,8 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService } protected _startMcp(id: number, launch: McpServerLaunch): void { - if (launch.type === McpServerTransportType.SSE) { - this._sseEventSources.set(id, new McpSSEHandle(this._eventSource.value, id, launch, this._proxy)); + if (launch.type === McpServerTransportType.HTTP) { + this._sseEventSources.set(id, new McpHTTPHandle(id, launch, this._proxy)); return; } @@ -67,8 +67,26 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService await Promise.all(this._initialProviderPromises); } - /** {@link vscode.lm.registerMcpConfigurationProvider} */ - public registerMcpConfigurationProvider(extension: IExtensionDescription, id: string, provider: vscode.McpConfigurationProvider): IDisposable { + async $resolveMcpLaunch(collectionId: string, label: string): Promise { + const rec = this._unresolvedMcpServers.get(collectionId); + if (!rec) { + return; + } + + const server = rec.servers.find(s => s.label === label); + if (!server) { + return; + } + if (!rec.provider.resolveMcpServerDefinition) { + return Convert.McpServerDefinition.from(server); + } + + const resolved = await rec.provider.resolveMcpServerDefinition(server, CancellationToken.None); + return resolved ? Convert.McpServerDefinition.from(resolved) : undefined; + } + + /** {@link vscode.lm.registerMcpServerDefinitionProvider} */ + public registerMcpConfigurationProvider(extension: IExtensionDescription, id: string, provider: vscode.McpServerDefinitionProvider): IDisposable { const store = new DisposableStore(); const metadata = extension.contributes?.modelContextServerCollections?.find(m => m.id === id); @@ -80,36 +98,22 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService id: extensionPrefixedIdentifier(extension.identifier, id), isTrustedByDefault: true, label: metadata?.label ?? extension.displayName ?? extension.name, - scope: StorageScope.WORKSPACE + scope: StorageScope.WORKSPACE, + canResolveLaunch: typeof provider.resolveMcpServerDefinition === 'function', + extensionId: extension.identifier.value, }; const update = async () => { - const list = await provider.provideMcpServerDefinitions(CancellationToken.None); + this._unresolvedMcpServers.set(mcp.id, { servers: list ?? [], provider }); - function isSSEConfig(candidate: vscode.McpServerDefinition): candidate is vscode.McpSSEServerDefinition { - return !!(candidate as vscode.McpSSEServerDefinition).uri; - } - - const servers: McpServerDefinition[] = []; - + const servers: McpServerDefinition.Serialized[] = []; for (const item of list ?? []) { servers.push({ id: ExtensionIdentifier.toKey(extension.identifier), label: item.label, - launch: isSSEConfig(item) - ? { - type: McpServerTransportType.SSE, - uri: item.uri, - headers: item.headers, - } - : { - type: McpServerTransportType.Stdio, - cwd: item.cwd, - args: item.args, - command: item.command, - env: item.env - } + cacheNonce: item.version, + launch: Convert.McpServerDefinition.from(item) }); } @@ -117,11 +121,16 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService }; store.add(toDisposable(() => { + this._unresolvedMcpServers.delete(mcp.id); this._proxy.$deleteMcpCollection(mcp.id); })); - if (provider.onDidChange) { - store.add(provider.onDidChange(update)); + if (provider.onDidChangeServerDefinitions) { + store.add(provider.onDidChangeServerDefinitions(update)); + } + // todo@connor4312: proposed API back-compat + if ((provider as any).onDidChange) { + store.add((provider as any).onDidChange(update)); } const promise = new Promise(resolve => { @@ -137,96 +146,313 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService } } -class McpSSEHandle extends Disposable { +const enum HttpMode { + Unknown, + Http, + SSE, +} + +type HttpModeT = + | { value: HttpMode.Unknown } + | { value: HttpMode.Http; sessionId: string | undefined } + | { value: HttpMode.SSE; endpoint: string }; + +/** + * Implementation of both MCP HTTP Streaming as well as legacy SSE. + * + * The first request will POST to the endpoint, assuming HTTP streaming. If the + * server is legacy SSE, it should return some 4xx status in that case, + * and we'll automatically fall back to SSE and res + */ +class McpHTTPHandle extends Disposable { private readonly _requestSequencer = new Sequencer(); - private readonly _postEndpoint = new DeferredPromise(); + private readonly _postEndpoint = new DeferredPromise<{ url: string; transport: McpServerTransportHTTP }>(); + private _mode: HttpModeT = { value: HttpMode.Unknown }; + private readonly _cts = new CancellationTokenSource(); + private readonly _abortCtrl = new AbortController(); + constructor( - eventSourceCtor: Promise, private readonly _id: number, - launch: McpServerTransportSSE, + private readonly _launch: McpServerTransportHTTP, private readonly _proxy: MainThreadMcpShape ) { super(); - eventSourceCtor.then(EventSourceCtor => this._attach(EventSourceCtor, launch)); - } - private _attach(EventSourceCtor: typeof ES.EventSource, launch: McpServerTransportSSE) { - if (this._store.isDisposed) { - return; - } - - const eventSource = new EventSourceCtor(launch.uri.toString(), { - // recommended way to do things https://github.com/EventSource/eventsource?tab=readme-ov-file#setting-http-request-headers - fetch: (input, init) => - fetch(input, { - ...init, - headers: { - ...Object.fromEntries(launch.headers), - ...init?.headers, - }, - }).then(async res => { - // we get more details on failure at this point, so handle it explicitly: - if (res.status >= 300) { - this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `${res.status} status connecting to ${launch.uri}: ${await this._getErrText(res)}` }); - eventSource.close(); - } - return res; - }, err => { - this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `Error connecting to ${launch.uri}: ${String(err)}` }); - - eventSource.close(); - return Promise.reject(err); - }) - }); - - this._register(toDisposable(() => eventSource.close())); - - // https://github.com/modelcontextprotocol/typescript-sdk/blob/0fa2397174eba309b54575294d56754c52b13a65/src/server/sse.ts#L52 - eventSource.addEventListener('endpoint', e => { - this._postEndpoint.complete(new URL(e.data, launch.uri.toString()).toString()); - }); - - // https://github.com/modelcontextprotocol/typescript-sdk/blob/0fa2397174eba309b54575294d56754c52b13a65/src/server/sse.ts#L133 - eventSource.addEventListener('message', e => { - this._proxy.$onDidReceiveMessage(this._id, e.data); - }); - - eventSource.addEventListener('open', () => { - this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Running }); - }); - - eventSource.addEventListener('error', (err) => { - this._postEndpoint.cancel(); - this._proxy.$onDidChangeState(this._id, { - state: McpConnectionState.Kind.Error, - message: `Error connecting to ${launch.uri}: ${err.code || 0} ${err.message || JSON.stringify(err)}`, - }); - eventSource.close(); - }); + this._register(toDisposable(() => { + this._abortCtrl.abort(); + this._cts.dispose(true); + })); + this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Running }); } async send(message: string) { - // only the sending of the request needs to be sequenced try { - const res = await this._requestSequencer.queue(async () => { - const endpoint = await this._postEndpoint.p; - const asBytes = new TextEncoder().encode(message); + await this._requestSequencer.queue(() => { + if (this._mode.value === HttpMode.SSE) { + return this._sendLegacySSE(this._mode.endpoint, message); + } else { + return this._sendStreamableHttp(message, this._mode.value === HttpMode.Http ? this._mode.sessionId : undefined); + } + }); + } catch (err) { + const msg = `Error sending message to ${this._launch.uri}: ${String(err)}`; + this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: msg }); + } + } - return fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': String(asBytes.length), - }, - body: asBytes, + /** + * Sends a streamable-HTTP request. + * 1. Posts to the endpoint + * 2. Updates internal state as needed. Falls back to SSE if appropriate. + * 3. If the response body is empty, JSON, or a JSON stream, handle it appropriately. + */ + private async _sendStreamableHttp(message: string, sessionId: string | undefined) { + const asBytes = new TextEncoder().encode(message); + const headers: Record = { + ...Object.fromEntries(this._launch.headers), + 'Content-Type': 'application/json', + 'Content-Length': String(asBytes.length), + Accept: 'text/event-stream, application/json', + }; + if (sessionId) { + headers['Mcp-Session-Id'] = sessionId; + } + + const res = await fetch(this._launch.uri.toString(true), { + method: 'POST', + signal: this._abortCtrl.signal, + headers, + body: asBytes, + }); + + const wasUnknown = this._mode.value === HttpMode.Unknown; + + // Mcp-Session-Id is the strongest signal that we're in streamable HTTP mode + const nextSessionId = res.headers.get('Mcp-Session-Id'); + if (nextSessionId) { + this._mode = { value: HttpMode.Http, sessionId: nextSessionId }; + } + + if (this._mode.value === HttpMode.Unknown && res.status >= 400 && res.status < 500) { + this._log(LogLevel.Info, `${res.status} status sending message to ${this._launch.uri}, will attempt to fall back to legacy SSE`); + const endpoint = await this._attachSSE(); + if (endpoint) { + this._mode = { value: HttpMode.SSE, endpoint }; + await this._sendLegacySSE(endpoint, message); + } + return; + } + + if (res.status >= 300) { + // "When a client receives HTTP 404 in response to a request containing an Mcp-Session-Id, it MUST start a new session by sending a new InitializeRequest without a session ID attached" + // Though this says only 404, some servers send 400s as well, including their example + // https://github.com/modelcontextprotocol/typescript-sdk/issues/389 + const retryWithSessionId = this._mode.value === HttpMode.Http && !!this._mode.sessionId; + + this._proxy.$onDidChangeState(this._id, { + state: McpConnectionState.Kind.Error, + message: `${res.status} status sending message to ${this._launch.uri}: ${await this._getErrText(res)}` + retryWithSessionId ? `; will retry with new session ID` : '', + shouldRetry: retryWithSessionId, + }); + return; + } + + if (this._mode.value === HttpMode.Unknown) { + this._mode = { value: HttpMode.Http, sessionId: undefined }; + } + if (wasUnknown) { + this._attachStreamableBackchannel(); + } + + // Not awaited, we don't need to block the sequencer while we read the response + this._handleSuccessfulStreamableHttp(res); + } + + private async _handleSuccessfulStreamableHttp(res: Response) { + if (res.status === 202) { + return; // no body + } + + switch (res.headers.get('Content-Type')?.toLowerCase()) { + case 'text/event-stream': { + const parser = new SSEParser(event => { + if (event.type === 'message') { + this._proxy.$onDidReceiveMessage(this._id, event.data); + } }); + + try { + await this._doSSE(parser, res); + } catch (err) { + this._log(LogLevel.Warning, `Error reading SSE stream: ${String(err)}`); + } + break; + } + case 'application/json': + this._proxy.$onDidReceiveMessage(this._id, await res.text()); + break; + default: { + const responseBody = await res.text(); + if (isJSON(responseBody)) { // try to read as JSON even if the server didn't set the content type + this._proxy.$onDidReceiveMessage(this._id, responseBody); + } else { + this._log(LogLevel.Warning, `Unexpected ${res.status} response for request: ${responseBody}`); + } + } + } + } + + /** + * Attaches the SSE backchannel that streamable HTTP servers can use + * for async notifications. This is a "MAY" support, so if the server gives + * us a 4xx code, we'll stop trying to connect.. + */ + private async _attachStreamableBackchannel() { + let lastEventId: string | undefined; + for (let retry = 0; !this._store.isDisposed; retry++) { + await timeout(Math.min(retry * 1000, 30_000), this._cts.token); + + let res: Response; + try { + const headers: Record = { + ...Object.fromEntries(this._launch.headers), + 'Accept': 'text/event-stream', + }; + + if (this._mode.value === HttpMode.Http && this._mode.sessionId !== undefined) { + headers['Mcp-Session-Id'] = this._mode.sessionId; + } + if (lastEventId) { + headers['Last-Event-ID'] = lastEventId; + } + + res = await fetch(this._launch.uri.toString(true), { + method: 'GET', + signal: this._abortCtrl.signal, + headers, + }); + } catch (e) { + this._log(LogLevel.Info, `Error connecting to ${this._launch.uri} for async notifications, will retry`); + continue; + } + + if (res.status >= 400) { + this._log(LogLevel.Debug, `${res.status} status connecting to ${this._launch.uri} for async notifications; they will be disabled: ${await this._getErrText(res)}`); + return; + } + + retry = 0; + + const parser = new SSEParser(event => { + if (event.type === 'message') { + this._proxy.$onDidReceiveMessage(this._id, event.data); + } + if (event.id) { + lastEventId = event.id; + } }); - if (res.status >= 300) { - this._proxy.$onDidPublishLog(this._id, `${res.status} status sending message to ${this._postEndpoint}: ${await this._getErrText(res)}`); + try { + await this._doSSE(parser, res); + } catch (e) { + this._log(LogLevel.Info, `Error reading from async stream, we will reconnect: ${e}`); } - } catch (err) { - // ignored + } + } + + /** + * Starts a legacy SSE attachment, where the SSE response is the session lifetime. + * Unlike `_attachStreamableBackchannel`, this fails the server if it disconnects. + */ + private async _attachSSE(): Promise { + const postEndpoint = new DeferredPromise(); + + let res: Response; + try { + res = await fetch(this._launch.uri.toString(true), { + method: 'GET', + signal: this._abortCtrl.signal, + headers: { + ...Object.fromEntries(this._launch.headers), + 'Accept': 'text/event-stream', + }, + }); + if (res.status >= 300) { + this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `${res.status} status connecting to ${this._launch.uri} as SSE: ${await this._getErrText(res)}` }); + return; + } + } catch (e) { + this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `Error connecting to ${this._launch.uri} as SSE: ${e}` }); + return; + } + + const parser = new SSEParser(event => { + if (event.type === 'message') { + this._proxy.$onDidReceiveMessage(this._id, event.data); + } else if (event.type === 'endpoint') { + postEndpoint.complete(new URL(event.data, this._launch.uri.toString(true)).toString()); + } + }); + + this._register(toDisposable(() => postEndpoint.cancel())); + this._doSSE(parser, res).catch(err => { + this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `Error reading SSE stream: ${String(err)}` }); + }); + + return postEndpoint.p; + } + + /** + * Sends a legacy SSE message to the server. The response is always empty and + * is otherwise received in {@link _attachSSE}'s loop. + */ + private async _sendLegacySSE(url: string, message: string) { + const asBytes = new TextEncoder().encode(message); + const res = await fetch(url, { + method: 'POST', + signal: this._abortCtrl.signal, + headers: { + ...Object.fromEntries(this._launch.headers), + 'Content-Type': 'application/json', + 'Content-Length': String(asBytes.length), + }, + body: asBytes, + }); + + if (res.status >= 300) { + this._log(LogLevel.Warning, `${res.status} status sending message to ${this._postEndpoint}: ${await this._getErrText(res)}`); + } + } + + /** Generic handle to pipe a response into an SSE parser. */ + private async _doSSE(parser: SSEParser, res: Response) { + if (!res.body) { + return; + } + + const reader = res.body.getReader(); + let chunk: ReadableStreamReadResult; + do { + try { + chunk = await raceCancellationError(reader.read(), this._cts.token); + } catch (err) { + reader.cancel(); + if (this._store.isDisposed) { + return; + } else { + throw err; + } + } + + if (chunk.value) { + parser.feed(chunk.value); + } + } while (!chunk.done); + } + + private _log(level: LogLevel, message: string) { + if (!this._store.isDisposed) { + this._proxy.$onDidPublishLog(this._id, level, message); } } @@ -238,3 +464,12 @@ class McpSSEHandle extends Disposable { } } } + +function isJSON(str: string): boolean { + try { + JSON.parse(str); + return true; + } catch (e) { + return false; + } +} diff --git a/src/vs/workbench/api/common/extHostNotebookKernels.ts b/src/vs/workbench/api/common/extHostNotebookKernels.ts index 9c923a42745..1994bc803b5 100644 --- a/src/vs/workbench/api/common/extHostNotebookKernels.ts +++ b/src/vs/workbench/api/common/extHostNotebookKernels.ts @@ -84,6 +84,9 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { if (notebookEditorId === undefined) { throw new Error(`Cannot invoke 'notebook.selectKernel' for unrecognized notebook editor ${v.notebookEditor.notebook.uri.toString()}`); } + if ('skipIfAlreadySelected' in v) { + return { notebookEditorId, skipIfAlreadySelected: v.skipIfAlreadySelected }; + } return { notebookEditorId }; } return v; diff --git a/src/vs/workbench/api/common/extHostQuickDiff.ts b/src/vs/workbench/api/common/extHostQuickDiff.ts index 2b04fcd6222..b17b67c6a3d 100644 --- a/src/vs/workbench/api/common/extHostQuickDiff.ts +++ b/src/vs/workbench/api/common/extHostQuickDiff.ts @@ -10,6 +10,7 @@ import { ExtHostQuickDiffShape, IMainContext, MainContext, MainThreadQuickDiffSh import { asPromise } from '../../../base/common/async.js'; import { DocumentSelector } from './extHostTypeConverters.js'; import { IURITransformer } from '../../../base/common/uriIpc.js'; +import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; export class ExtHostQuickDiff implements ExtHostQuickDiffShape { private static handlePool: number = 0; @@ -36,10 +37,12 @@ export class ExtHostQuickDiff implements ExtHostQuickDiffShape { .then(r => r || null); } - registerQuickDiffProvider(selector: vscode.DocumentSelector, quickDiffProvider: vscode.QuickDiffProvider, label: string, rootUri?: vscode.Uri): vscode.Disposable { + registerQuickDiffProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, quickDiffProvider: vscode.QuickDiffProvider, id: string, label: string, rootUri?: vscode.Uri): vscode.Disposable { const handle = ExtHostQuickDiff.handlePool++; this.providers.set(handle, quickDiffProvider); - this.proxy.$registerQuickDiffProvider(handle, DocumentSelector.from(selector, this.uriTransformer), label, rootUri, quickDiffProvider.visible ?? true); + + const extensionId = ExtensionIdentifier.toKey(extension.identifier); + this.proxy.$registerQuickDiffProvider(handle, DocumentSelector.from(selector, this.uriTransformer), `${extensionId}.${id}`, label, rootUri); return { dispose: () => { this.proxy.$unregisterQuickDiffProvider(handle); diff --git a/src/vs/workbench/api/common/extHostRequireInterceptor.ts b/src/vs/workbench/api/common/extHostRequireInterceptor.ts index f01e80dd53e..e106a29e575 100644 --- a/src/vs/workbench/api/common/extHostRequireInterceptor.ts +++ b/src/vs/workbench/api/common/extHostRequireInterceptor.ts @@ -27,7 +27,7 @@ interface IAlternativeModuleProvider { alternativeModuleName(name: string): string | undefined; } -interface INodeModuleFactory extends Partial { +export interface INodeModuleFactory extends Partial { readonly nodeModuleName: string | string[]; load(request: string, parent: URI, original: LoadFunction): any; } diff --git a/src/vs/workbench/api/common/extHostSCM.ts b/src/vs/workbench/api/common/extHostSCM.ts index 8ec74753cd6..487f91504eb 100644 --- a/src/vs/workbench/api/common/extHostSCM.ts +++ b/src/vs/workbench/api/common/extHostSCM.ts @@ -591,6 +591,21 @@ class ExtHostSourceControl implements vscode.SourceControl { this.#proxy.$updateSourceControl(this.handle, { hasQuickDiffProvider: !!quickDiffProvider, quickDiffLabel }); } + private _secondaryQuickDiffProvider: vscode.QuickDiffProvider | undefined = undefined; + + get secondaryQuickDiffProvider(): vscode.QuickDiffProvider | undefined { + checkProposedApiEnabled(this._extension, 'quickDiffProvider'); + return this._secondaryQuickDiffProvider; + } + + set secondaryQuickDiffProvider(secondaryQuickDiffProvider: vscode.QuickDiffProvider | undefined) { + checkProposedApiEnabled(this._extension, 'quickDiffProvider'); + + this._secondaryQuickDiffProvider = secondaryQuickDiffProvider; + const secondaryQuickDiffLabel = secondaryQuickDiffProvider?.label; + this.#proxy.$updateSourceControl(this.handle, { hasSecondaryQuickDiffProvider: !!secondaryQuickDiffProvider, secondaryQuickDiffLabel }); + } + private _historyProvider: vscode.SourceControlHistoryProvider | undefined; private readonly _historyProviderDisposable = new MutableDisposable(); @@ -944,6 +959,20 @@ export class ExtHostSCM implements ExtHostSCMShape { .then(r => r || null); } + $provideSecondaryOriginalResource(sourceControlHandle: number, uriComponents: UriComponents, token: CancellationToken): Promise { + const uri = URI.revive(uriComponents); + this.logService.trace('ExtHostSCM#$provideSecondaryOriginalResource', sourceControlHandle, uri.toString()); + + const sourceControl = this._sourceControls.get(sourceControlHandle); + + if (!sourceControl || !sourceControl.secondaryQuickDiffProvider || !sourceControl.secondaryQuickDiffProvider.provideOriginalResource) { + return Promise.resolve(null); + } + + return asPromise(() => sourceControl.secondaryQuickDiffProvider!.provideOriginalResource!(uri, token)) + .then(r => r || null); + } + $onInputBoxValueChange(sourceControlHandle: number, value: string): Promise { this.logService.trace('ExtHostSCM#$onInputBoxValueChange', sourceControlHandle); diff --git a/src/vs/workbench/api/common/extHostSearch.ts b/src/vs/workbench/api/common/extHostSearch.ts index fb92adb1fc9..92ba9241bf8 100644 --- a/src/vs/workbench/api/common/extHostSearch.ts +++ b/src/vs/workbench/api/common/extHostSearch.ts @@ -176,7 +176,7 @@ export class ExtHostSearch implements IExtHostSearch { const query = reviveQuery(rawQuery); const engine = this.createAITextSearchManager(query, provider); - return engine.search(progress => this._proxy.$handleTextMatch(handle, session, progress), token); + return engine.search(progress => this._proxy.$handleTextMatch(handle, session, progress), token, result => this._proxy.$handleKeywordResult(handle, session, result)); } $enableExtensionHostSearch(): void { } diff --git a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts index 5ab8edeb955..42a736c29a1 100644 --- a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts +++ b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts @@ -444,7 +444,6 @@ class ShellExecutionDataStream extends Disposable { endExecution(): void { this._barrier?.open(); - this._barrier = undefined; } async flush(): Promise { diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 55f3e4d1d5b..5edf7e1c49b 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -34,18 +34,18 @@ import * as languageSelector from '../../../editor/common/languageSelector.js'; import * as languages from '../../../editor/common/languages.js'; import { EndOfLineSequence, TrackedRangeStickiness } from '../../../editor/common/model.js'; import { ITextEditorOptions } from '../../../platform/editor/common/editor.js'; -import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { IExtensionDescription, IRelaxedExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { IMarkerData, IRelatedInformation, MarkerSeverity, MarkerTag } from '../../../platform/markers/common/markers.js'; import { ProgressLocation as MainProgressLocation } from '../../../platform/progress/common/progress.js'; import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from '../../common/editor.js'; import { IViewBadge } from '../../common/views.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/chatAgents.js'; import { IChatRequestDraft } from '../../contrib/chat/common/chatEditingService.js'; -import { IChatRequestVariableEntry } from '../../contrib/chat/common/chatModel.js'; -import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatMarkdownContent, IChatMoveMessage, IChatProgressMessage, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTextEdit, IChatTreeData, IChatUserActionEvent, IChatWarningMessage } from '../../contrib/chat/common/chatService.js'; +import { IChatRequestVariableEntry, isImageVariableEntry } from '../../contrib/chat/common/chatModel.js'; +import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMoveMessage, IChatProgressMessage, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTextEdit, IChatTreeData, IChatUserActionEvent, IChatWarningMessage } from '../../contrib/chat/common/chatService.js'; import { IToolData, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; -import { IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; +import { IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; import { DebugTreeItemCollapsibleState, IDebugVisualizationTreeItem } from '../../contrib/debug/common/debug.js'; import * as notebooks from '../../contrib/notebook/common/notebookCommon.js'; import { CellEditType } from '../../contrib/notebook/common/notebookCommon.js'; @@ -55,14 +55,16 @@ import { TestId } from '../../contrib/testing/common/testId.js'; import { CoverageDetails, DetailType, ICoverageCount, IFileCoverage, ISerializedTestResults, ITestErrorMessage, ITestItem, ITestRunProfileReference, ITestTag, TestMessageType, TestResultItem, TestRunProfileBitset, denamespaceTestTag, namespaceTestTag } from '../../contrib/testing/common/testTypes.js'; import { EditorGroupColumn } from '../../services/editor/common/editorGroupColumn.js'; import { ACTIVE_GROUP, SIDE_GROUP } from '../../services/editor/common/editorService.js'; -import { checkProposedApiEnabled } from '../../services/extensions/common/extensions.js'; -import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; +import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; +import { Dto, SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; import * as extHostProtocol from './extHost.protocol.js'; import { CommandsConverter } from './extHostCommands.js'; import { getPrivateApiFor } from './extHostTestingPrivateApi.js'; import * as types from './extHostTypes.js'; -import { LanguageModelPromptTsxPart, LanguageModelTextPart } from './extHostTypes.js'; +import { LanguageModelDataPart, LanguageModelPromptTsxPart, LanguageModelTextPart } from './extHostTypes.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; +import { AiSettingsSearchResult, AiSettingsSearchResultKind } from '../../services/aiSettingsSearch/common/aiSettingsSearch.js'; +import { McpServerLaunch, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; export namespace Command { @@ -2327,10 +2329,14 @@ export namespace LanguageModelChatMessage { } }); return new types.LanguageModelToolResultPart(c.toolCallId, content, c.isError); + } else if (c.type === 'image_url' || c.type === 'extra_data') { + // Non-stable types + return undefined; } else { return new types.LanguageModelToolCallPart(c.toolCallId, c.name, c.parameters); } - }); + }).filter(c => c !== undefined); + const role = LanguageModelChatMessageRole.to(message.role); const result = new types.LanguageModelChatMessage(role, content, message.name); return result; @@ -2401,6 +2407,125 @@ export namespace LanguageModelChatMessage { } } +export namespace LanguageModelChatMessage2 { + + export function to(message: chatProvider.IChatMessage): vscode.LanguageModelChatMessage2 { + const content = message.content.map(c => { + if (c.type === 'text') { + return new LanguageModelTextPart(c.value); + } else if (c.type === 'tool_result') { + const content: (LanguageModelTextPart | LanguageModelPromptTsxPart | LanguageModelDataPart)[] = c.value.map(part => { + if (part.type === 'text') { + return new types.LanguageModelTextPart(part.value); + } else if (part.type === 'data') { + return new types.LanguageModelDataPart(part.value.data.buffer, part.value.mimeType); + } else { + return new types.LanguageModelPromptTsxPart(part.value); + } + }); + return new types.LanguageModelToolResultPart2(c.toolCallId, content, c.isError); + } else if (c.type === 'image_url') { + return new types.LanguageModelDataPart(c.value.data.buffer, c.value.mimeType); + } else if (c.type === 'extra_data') { + return new types.LanguageModelExtraDataPart(c.kind, c.data); + } else { + return new types.LanguageModelToolCallPart(c.toolCallId, c.name, c.parameters); + } + }); + const role = LanguageModelChatMessageRole.to(message.role); + const result = new types.LanguageModelChatMessage2(role, content, message.name); + return result; + } + + export function from(message: vscode.LanguageModelChatMessage2): chatProvider.IChatMessage { + + const role = LanguageModelChatMessageRole.from(message.role); + const name = message.name; + + let messageContent = message.content; + if (typeof messageContent === 'string') { + messageContent = [new types.LanguageModelTextPart(messageContent)]; + } + + const content = messageContent.map((c): chatProvider.IChatMessagePart => { + if (c instanceof types.LanguageModelToolResultPart2) { + return { + type: 'tool_result', + toolCallId: c.callId, + value: coalesce(c.content.map(part => { + if (part instanceof types.LanguageModelTextPart) { + return { + type: 'text', + value: part.value + } satisfies IChatResponseTextPart; + } else if (part instanceof types.LanguageModelPromptTsxPart) { + return { + type: 'prompt_tsx', + value: part.value, + } satisfies IChatResponsePromptTsxPart; + } else if (part instanceof types.LanguageModelDataPart) { + return { + type: 'data', + value: { + mimeType: part.mimeType as chatProvider.ChatImageMimeType, + data: VSBuffer.wrap(part.data) + } + } satisfies IChatResponseDataPart; + } else { + // Strip unknown parts + return undefined; + } + })), + isError: c.isError + }; + } else if (c instanceof types.LanguageModelDataPart) { + const value: chatProvider.IChatImageURLPart = { + mimeType: c.mimeType as chatProvider.ChatImageMimeType, + data: VSBuffer.wrap(c.data), + }; + + return { + type: 'image_url', + value: value + }; + } else if (c instanceof types.LanguageModelToolCallPart) { + return { + type: 'tool_use', + toolCallId: c.callId, + name: c.name, + parameters: c.input + }; + } else if (c instanceof types.LanguageModelTextPart) { + return { + type: 'text', + value: c.value + }; + } else if (c instanceof types.LanguageModelExtraDataPart) { + return { + type: 'extra_data', + kind: c.kind, + data: c.data + } satisfies chatProvider.IChatMessagePart; + } else { + if (typeof c !== 'string') { + throw new Error('Unexpected chat message content type llm 2'); + } + + return { + type: 'text', + value: c + }; + } + }); + + return { + role, + name, + content + }; + } +} + export namespace ChatResponseMarkdownPart { export function from(part: vscode.ChatResponseMarkdownPart): Dto { return { @@ -2418,10 +2543,11 @@ export namespace ChatResponseCodeblockUriPart { return { kind: 'codeblockUri', uri: part.value, + isEdit: part.isEdit, }; } export function to(part: Dto): vscode.ChatResponseCodeblockUriPart { - return new types.ChatResponseCodeblockUriPart(URI.revive(part.uri)); + return new types.ChatResponseCodeblockUriPart(URI.revive(part.uri), part.isEdit); } } @@ -2543,6 +2669,15 @@ export namespace ChatResponseWarningPart { } } +export namespace ChatResponseExtensionsPart { + export function from(part: vscode.ChatResponseExtensionsPart): Dto { + return { + kind: 'extensions', + extensions: part.extensions + }; + } +} + export namespace ChatResponseMovePart { export function from(part: vscode.ChatResponseMovePart): Dto { return { @@ -2702,7 +2837,7 @@ export namespace ChatResponseCodeCitationPart { export namespace ChatResponsePart { - export function from(part: vscode.ChatResponsePart | vscode.ChatResponseTextEditPart | vscode.ChatResponseMarkdownWithVulnerabilitiesPart | vscode.ChatResponseWarningPart | vscode.ChatResponseConfirmationPart | vscode.ChatResponseReferencePart2 | vscode.ChatResponseMovePart, commandsConverter: CommandsConverter, commandDisposables: DisposableStore): extHostProtocol.IChatProgressDto { + export function from(part: vscode.ChatResponsePart | vscode.ChatResponseTextEditPart | vscode.ChatResponseMarkdownWithVulnerabilitiesPart | vscode.ChatResponseWarningPart | vscode.ChatResponseConfirmationPart | vscode.ChatResponseReferencePart2 | vscode.ChatResponseMovePart | vscode.ChatResponseNotebookEditPart | vscode.ChatResponseExtensionsPart, commandsConverter: CommandsConverter, commandDisposables: DisposableStore): extHostProtocol.IChatProgressDto { if (part instanceof types.ChatResponseMarkdownPart) { return ChatResponseMarkdownPart.from(part); } else if (part instanceof types.ChatResponseAnchorPart) { @@ -2731,6 +2866,8 @@ export namespace ChatResponsePart { return ChatResponseCodeCitationPart.from(part); } else if (part instanceof types.ChatResponseMovePart) { return ChatResponseMovePart.from(part); + } else if (part instanceof types.ChatResponseExtensionsPart) { + return ChatResponseExtensionsPart.from(part); } return { @@ -2766,10 +2903,11 @@ export namespace ChatResponsePart { } export namespace ChatAgentRequest { - export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat, diagnostics: readonly [vscode.Uri, readonly vscode.Diagnostic[]][], tools: vscode.LanguageModelToolInformation[] | undefined): vscode.ChatRequest { - const toolReferences = request.variables.variables.filter(v => v.isTool); - const variableReferences = request.variables.variables.filter(v => !v.isTool); - const requestWithoutId = { + export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat, diagnostics: readonly [vscode.Uri, readonly vscode.Diagnostic[]][], toolSelection: vscode.ChatRequestToolSelection | undefined, tools: Map, extension: IRelaxedExtensionDescription): vscode.ChatRequest { + const toolReferences = request.variables.variables.filter(v => v.kind === 'tool'); + const variableReferences = request.variables.variables.filter(v => v.kind !== 'tool'); + const requestWithAllProps: vscode.ChatRequest = { + id: request.requestId, prompt: request.message, command: request.command, attempt: request.attempt ?? 0, @@ -2782,17 +2920,30 @@ export namespace ChatAgentRequest { rejectedConfirmationData: request.rejectedConfirmationData, location2, toolInvocationToken: Object.freeze({ sessionId: request.sessionId }) as never, + toolSelection, tools, - model + model, + editedFileEvents: request.editedFileEvents, }; - if (request.requestId) { - return { - ...requestWithoutId, - id: request.requestId - }; + + if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { + delete (requestWithAllProps as any).id; + delete (requestWithAllProps as any).attempt; + delete (requestWithAllProps as any).enableCommandDetection; + delete (requestWithAllProps as any).isParticipantDetected; + delete (requestWithAllProps as any).location; + delete (requestWithAllProps as any).location2; + delete (requestWithAllProps as any).editedFileEvents; } - // This cast is done to allow sending the stabl version of ChatRequest which does not have an id property - return requestWithoutId as unknown as vscode.ChatRequest; + + if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) { + delete requestWithAllProps.acceptedConfirmationData; + delete requestWithAllProps.rejectedConfirmationData; + delete (requestWithAllProps as any).tools; + } + + + return requestWithAllProps; } } @@ -2812,7 +2963,6 @@ export namespace ChatLocation { case ChatAgentLocation.Terminal: return types.ChatLocation.Terminal; case ChatAgentLocation.Panel: return types.ChatLocation.Panel; case ChatAgentLocation.Editor: return types.ChatLocation.Editor; - case ChatAgentLocation.EditingSession: return types.ChatLocation.EditingSession; } } @@ -2822,7 +2972,6 @@ export namespace ChatLocation { case types.ChatLocation.Terminal: return ChatAgentLocation.Terminal; case types.ChatLocation.Panel: return ChatAgentLocation.Panel; case types.ChatLocation.Editor: return ChatAgentLocation.Editor; - case types.ChatLocation.EditingSession: return ChatAgentLocation.EditingSession; } } } @@ -2838,11 +2987,12 @@ export namespace ChatPromptReference { value = URI.revive(value); } else if (value && typeof value === 'object' && 'uri' in value && 'range' in value && isUriComponents(value.uri)) { value = Location.to(revive(value)); - } else if (variable.isImage) { + } else if (isImageVariableEntry(variable)) { + const ref = variable.references?.[0]?.reference; value = new types.ChatReferenceBinaryData( variable.mimeType ?? 'image/png', () => Promise.resolve(new Uint8Array(Object.values(variable.value as number[]))), - variable.references && URI.isUri(variable.references[0].reference) ? variable.references[0].reference : undefined + ref && URI.isUri(ref) ? ref : undefined ); } else if (variable.kind === 'diagnostic') { const filterSeverity = variable.filterSeverity && DiagnosticSeverity.to(variable.filterSeverity); @@ -3046,6 +3196,26 @@ export namespace PartialAcceptTriggerKind { } } +export namespace InlineCompletionEndOfLifeReason { + export function to(reason: languages.InlineCompletionEndOfLifeReason, convertFn: (item: T) => vscode.InlineCompletionItem | undefined): vscode.InlineCompletionEndOfLifeReason { + if (reason.kind === languages.InlineCompletionEndOfLifeReasonKind.Ignored) { + const supersededBy = reason.supersededBy ? convertFn(reason.supersededBy) : undefined; + return { + kind: types.InlineCompletionEndOfLifeReasonKind.Ignored, + supersededBy: supersededBy, + userTypingDisagreed: reason.userTypingDisagreed, + }; + } else if (reason.kind === languages.InlineCompletionEndOfLifeReasonKind.Accepted) { + return { + kind: types.InlineCompletionEndOfLifeReasonKind.Accepted, + }; + } + return { + kind: types.InlineCompletionEndOfLifeReasonKind.Rejected, + }; + } +} + export namespace DebugTreeItem { export function from(item: vscode.DebugTreeItem, id: number): IDebugVisualizationTreeItem { return { @@ -3109,8 +3279,112 @@ export namespace LanguageModelToolResult { } } +export namespace LanguageModelToolResult2 { + export function to(result: IToolResult): vscode.LanguageModelToolResult2 { + return new types.LanguageModelToolResult2(result.content.map(item => { + if (item.kind === 'text') { + return new types.LanguageModelTextPart(item.value); + } else if (item.kind === 'data') { + const mimeType = Object.values(types.ChatImageMimeType).includes(item.value.mimeType as types.ChatImageMimeType) ? item.value.mimeType as types.ChatImageMimeType : undefined; + if (!mimeType) { + throw new Error('Invalid MIME type'); + } + return new types.LanguageModelDataPart(item.value.data.buffer, mimeType); + } else { + return new types.LanguageModelPromptTsxPart(item.value); + } + })); + } + + export function from(result: vscode.ExtendedLanguageModelToolResult, extension: IExtensionDescription): Dto | SerializableObjectWithBuffers> { + if (result.toolResultMessage) { + checkProposedApiEnabled(extension, 'chatParticipantPrivate'); + } + + let hasBuffers = false; + const dto: Dto = { + content: result.content.map(item => { + if (item instanceof types.LanguageModelTextPart) { + return { + kind: 'text', + value: item.value + }; + } else if (item instanceof types.LanguageModelPromptTsxPart) { + return { + kind: 'promptTsx', + value: item.value, + }; + } else if (item instanceof types.LanguageModelDataPart) { + hasBuffers = true; + return { + kind: 'data', + value: { + mimeType: item.mimeType, + data: VSBuffer.wrap(item.data) + } + }; + } else { + throw new Error('Unknown LanguageModelToolResult part type'); + } + }), + toolResultMessage: MarkdownString.fromStrict(result.toolResultMessage), + toolResultDetails: result.toolResultDetails?.map(detail => URI.isUri(detail) ? detail : Location.from(detail as vscode.Location)), + }; + + return hasBuffers ? new SerializableObjectWithBuffers(dto) : dto; + } +} + export namespace IconPath { export function fromThemeIcon(iconPath: vscode.ThemeIcon): languages.IconPath { return iconPath; } } + +export namespace AiSettingsSearch { + export function fromSettingsSearchResult(result: vscode.SettingsSearchResult): AiSettingsSearchResult { + return { + query: result.query, + kind: fromSettingsSearchResultKind(result.kind), + settings: result.settings + }; + } + + function fromSettingsSearchResultKind(kind: number): AiSettingsSearchResultKind { + switch (kind) { + case AiSettingsSearchResultKind.EMBEDDED: + return AiSettingsSearchResultKind.EMBEDDED; + case AiSettingsSearchResultKind.LLM_RANKED: + return AiSettingsSearchResultKind.LLM_RANKED; + case AiSettingsSearchResultKind.CANCELED: + return AiSettingsSearchResultKind.CANCELED; + default: + throw new Error('Unknown AiSettingsSearchResultKind'); + } + } +} + +export namespace McpServerDefinition { + function isHttpConfig(candidate: vscode.McpServerDefinition): candidate is vscode.McpHttpServerDefinition { + return !!(candidate as vscode.McpHttpServerDefinition).uri; + } + + export function from(item: vscode.McpServerDefinition): McpServerLaunch.Serialized { + return McpServerLaunch.toSerialized( + isHttpConfig(item) + ? { + type: McpServerTransportType.HTTP, + uri: item.uri, + headers: Object.entries(item.headers), + } + : { + type: McpServerTransportType.Stdio, + cwd: item.cwd, + args: item.args, + command: item.command, + env: item.env, + envFile: undefined, + } + ); + } +} diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 3e7c8a4caf0..4590e039171 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -7,6 +7,7 @@ import type * as vscode from 'vscode'; import { asArray, coalesceInPlace, equals } from '../../../base/common/arrays.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { illegalArgument, SerializedError } from '../../../base/common/errors.js'; import { IRelativePattern } from '../../../base/common/glob.js'; import { MarkdownString as BaseMarkdownString, MarkdownStringTrustedOptions } from '../../../base/common/htmlContent.js'; @@ -17,12 +18,12 @@ import { nextCharLength } from '../../../base/common/strings.js'; import { isNumber, isObject, isString, isStringArray } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; +import { TextEditorSelectionSource } from '../../../platform/editor/common/editor.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { FileSystemProviderErrorCode, markAsFileSystemProviderError } from '../../../platform/files/common/files.js'; import { RemoteAuthorityResolverErrorCode } from '../../../platform/remote/common/remoteAuthorityResolver.js'; import { CellEditType, ICellMetadataEdit, IDocumentMetadataEdit, isTextStreamMime } from '../../contrib/notebook/common/notebookCommon.js'; import { IRelativePatternDto } from './extHost.protocol.js'; -import { TextEditorSelectionSource } from '../../../platform/editor/common/editor.js'; /** * @deprecated @@ -1865,6 +1866,12 @@ export enum PartialAcceptTriggerKind { Suggest = 3, } +export enum InlineCompletionEndOfLifeReasonKind { + Accepted = 0, + Rejected = 1, + Ignored = 2, +} + export enum ViewColumn { Active = -1, Beside = -2, @@ -4518,6 +4525,12 @@ export enum ChatEditingSessionActionOutcome { Saved = 3 } +export enum ChatRequestEditedFileEventKind { + Keep = 1, + Undo = 2, + UserModification = 3, +} + //#endregion //#region Interactive Editor @@ -4646,9 +4659,11 @@ export class ChatResponseReferencePart { } export class ChatResponseCodeblockUriPart { + isEdit?: boolean; value: vscode.Uri; - constructor(value: vscode.Uri) { + constructor(value: vscode.Uri, isEdit?: boolean) { this.value = value; + this.isEdit = isEdit; } } @@ -4671,6 +4686,13 @@ export class ChatResponseMovePart { } } +export class ChatResponseExtensionsPart { + constructor( + public readonly extensions: string[], + ) { + } +} + export class ChatResponseTextEditPart implements vscode.ChatResponseTextEditPart { uri: vscode.Uri; edits: vscode.TextEdit[]; @@ -4702,13 +4724,14 @@ export class ChatResponseNotebookEditPart implements vscode.ChatResponseNotebook } } -export class ChatRequestTurn implements vscode.ChatRequestTurn { +export class ChatRequestTurn implements vscode.ChatRequestTurn2 { constructor( readonly prompt: string, readonly command: string | undefined, readonly references: vscode.ChatPromptReference[], readonly participant: string, - readonly toolReferences: vscode.ChatLanguageModelToolReference[] + readonly toolReferences: vscode.ChatLanguageModelToolReference[], + readonly editedFileEvents?: vscode.ChatRequestEditedFileEvent[] ) { } } @@ -4727,7 +4750,6 @@ export enum ChatLocation { Terminal = 2, Notebook = 3, Editor = 4, - EditingSession = 5, } export enum ChatResponseReferencePartStatusKind { @@ -4784,6 +4806,19 @@ export class LanguageModelToolResultPart implements vscode.LanguageModelToolResu } } +export class LanguageModelToolResultPart2 implements vscode.LanguageModelToolResultPart2 { + + callId: string; + content: (LanguageModelTextPart | LanguageModelPromptTsxPart | LanguageModelDataPart | unknown)[]; + isError: boolean; + + constructor(callId: string, content: (LanguageModelTextPart | LanguageModelPromptTsxPart | LanguageModelDataPart | unknown)[], isError?: boolean) { + this.callId = callId; + this.content = content; + this.isError = isError ?? false; + } +} + export class PreparedTerminalToolInvocation { constructor( public readonly command: string, @@ -4826,8 +4861,45 @@ export class LanguageModelChatMessage implements vscode.LanguageModelChatMessage return this._content; } + name: string | undefined; + + constructor(role: vscode.LanguageModelChatMessageRole, content: string | (LanguageModelTextPart | LanguageModelToolResultPart | LanguageModelToolCallPart)[], name?: string) { + this.role = role; + this.content = content; + this.name = name; + } +} + +export class LanguageModelChatMessage2 implements vscode.LanguageModelChatMessage2 { + + static User(content: string | (LanguageModelTextPart | LanguageModelToolResultPart2 | LanguageModelToolCallPart | LanguageModelDataPart | LanguageModelExtraDataPart)[], name?: string): LanguageModelChatMessage2 { + return new LanguageModelChatMessage2(LanguageModelChatMessageRole.User, content, name); + } + + static Assistant(content: string | (LanguageModelTextPart | LanguageModelToolResultPart2 | LanguageModelToolCallPart | LanguageModelDataPart | LanguageModelExtraDataPart)[], name?: string): LanguageModelChatMessage2 { + return new LanguageModelChatMessage2(LanguageModelChatMessageRole.Assistant, content, name); + } + + role: vscode.LanguageModelChatMessageRole; + + private _content: (LanguageModelTextPart | LanguageModelToolResultPart2 | LanguageModelToolCallPart | LanguageModelDataPart | LanguageModelExtraDataPart)[] = []; + + set content(value: string | (LanguageModelTextPart | LanguageModelToolResultPart2 | LanguageModelToolCallPart | LanguageModelDataPart | LanguageModelExtraDataPart)[]) { + if (typeof value === 'string') { + // we changed this and still support setting content with a string property. this keep the API runtime stable + // despite the breaking change in the type definition. + this._content = [new LanguageModelTextPart(value)]; + } else { + this._content = value; + } + } + + get content(): (LanguageModelTextPart | LanguageModelToolResultPart2 | LanguageModelToolCallPart | LanguageModelDataPart | LanguageModelExtraDataPart)[] { + return this._content; + } + // Temp to avoid breaking changes - set content2(value: (string | LanguageModelToolResultPart | LanguageModelToolCallPart)[] | undefined) { + set content2(value: (string | LanguageModelToolResultPart2 | LanguageModelToolCallPart | LanguageModelDataPart)[] | undefined) { if (value) { this.content = value.map(part => { if (typeof part === 'string') { @@ -4838,7 +4910,7 @@ export class LanguageModelChatMessage implements vscode.LanguageModelChatMessage } } - get content2(): (string | LanguageModelToolResultPart | LanguageModelToolCallPart)[] | undefined { + get content2(): (string | LanguageModelToolResultPart2 | LanguageModelToolCallPart | LanguageModelDataPart | LanguageModelExtraDataPart)[] | undefined { return this.content.map(part => { if (part instanceof LanguageModelTextPart) { return part.value; @@ -4849,13 +4921,14 @@ export class LanguageModelChatMessage implements vscode.LanguageModelChatMessage name: string | undefined; - constructor(role: vscode.LanguageModelChatMessageRole, content: string | (LanguageModelTextPart | LanguageModelToolResultPart | LanguageModelToolCallPart)[], name?: string) { + constructor(role: vscode.LanguageModelChatMessageRole, content: string | (LanguageModelTextPart | LanguageModelToolResultPart2 | LanguageModelToolCallPart | LanguageModelDataPart | LanguageModelExtraDataPart)[], name?: string) { this.role = role; this.content = content; this.name = name; } } + export class LanguageModelToolCallPart implements vscode.LanguageModelToolCallPart { callId: string; name: string; @@ -4884,6 +4957,64 @@ export class LanguageModelTextPart implements vscode.LanguageModelTextPart { } } +export class LanguageModelDataPart implements vscode.LanguageModelDataPart { + mimeType: string; + data: Uint8Array; + + constructor(data: Uint8Array, mimeType: string) { + this.mimeType = mimeType; + this.data = data; + } + + static image(data: Uint8Array, mimeType: ChatImageMimeType): vscode.LanguageModelDataPart { + return new LanguageModelDataPart(data, mimeType as string); + } + + static json(value: object): vscode.LanguageModelDataPart { + const rawStr = JSON.stringify(value, undefined, '\t'); + return new LanguageModelDataPart(VSBuffer.fromString(rawStr).buffer, 'json'); + } + + static text(value: string): vscode.LanguageModelDataPart { + return new LanguageModelDataPart(VSBuffer.fromString(value).buffer, 'text/plain'); + } + + toJSON() { + return { + $mid: MarshalledId.LanguageModelDataPart, + mimeType: this.mimeType, + data: this.data, + }; + } +} + +export enum ChatImageMimeType { + PNG = 'image/png', + JPEG = 'image/jpeg', + GIF = 'image/gif', + WEBP = 'image/webp', + BMP = 'image/bmp', +} + +export class LanguageModelExtraDataPart implements vscode.LanguageModelExtraDataPart { + kind: string; + data: any; + + constructor(kind: string, data: any) { + this.kind = kind; + this.data = data; + } + + toJSON() { + return { + $mid: MarshalledId.LanguageModelExtraDataPart, + kind: this.kind, + data: this.data, + }; + } +} + + export class LanguageModelPromptTsxPart { value: unknown; @@ -4980,6 +5111,17 @@ export class LanguageModelToolResult { } } +export class LanguageModelToolResult2 { + constructor(public content: (LanguageModelTextPart | LanguageModelPromptTsxPart | LanguageModelDataPart)[]) { } + + toJSON() { + return { + $mid: MarshalledId.LanguageModelToolResult, + content: this.content, + }; + } +} + export class ExtendedLanguageModelToolResult extends LanguageModelToolResult { } @@ -4999,6 +5141,12 @@ export enum RelatedInformationType { SettingInformation = 4 } +export enum SettingsSearchResultKind { + EMBEDDED = 1, + LLM_RANKED = 2, + CANCELED = 3, +} + //#endregion //#region Speech @@ -5048,15 +5196,17 @@ export class McpStdioServerDefinition implements vscode.McpStdioServerDefinition public label: string, public command: string, public args: string[], - public env: Record + public env: Record = {}, + public version?: string, ) { } } -export class McpSSEServerDefinition implements vscode.McpSSEServerDefinition { - headers: [string, string][] = []; +export class McpHttpServerDefinition implements vscode.McpHttpServerDefinition { constructor( public label: string, - public uri: URI + public uri: URI, + public headers: Record = {}, + public version?: string, ) { } } //#endregion diff --git a/src/vs/workbench/api/common/extHostUrls.ts b/src/vs/workbench/api/common/extHostUrls.ts index d2fce5fdfcf..c75ce2d4ca1 100644 --- a/src/vs/workbench/api/common/extHostUrls.ts +++ b/src/vs/workbench/api/common/extHostUrls.ts @@ -9,7 +9,6 @@ import { URI, UriComponents } from '../../../base/common/uri.js'; import { toDisposable } from '../../../base/common/lifecycle.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { ExtensionIdentifierSet, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; -import { isURLDomainTrusted } from '../../contrib/url/common/trustedDomains.js'; export class ExtHostUrls implements ExtHostUrlsShape { @@ -19,8 +18,6 @@ export class ExtHostUrls implements ExtHostUrlsShape { private handles = new ExtensionIdentifierSet(); private handlers = new Map(); - private _trustedDomains: string[] = []; - constructor( mainContext: IMainContext ) { @@ -63,16 +60,4 @@ export class ExtHostUrls implements ExtHostUrlsShape { async createAppUri(uri: URI): Promise { return URI.revive(await this._proxy.$createAppUri(uri)); } - - async $updateTrustedDomains(trustedDomains: string[]): Promise { - this._trustedDomains = trustedDomains; - } - - isTrustedExternalUris(uris: URI[]): boolean[] { - return uris.map(uri => isURLDomainTrusted(uri, this._trustedDomains)); - } - - extractExternalUris(uris: URI[]): Promise { - return this._proxy.$extractExternalUris(uris); - } } diff --git a/src/vs/workbench/api/common/extHostWebviewMessaging.ts b/src/vs/workbench/api/common/extHostWebviewMessaging.ts index faf3d228656..49f0fa06c99 100644 --- a/src/vs/workbench/api/common/extHostWebviewMessaging.ts +++ b/src/vs/workbench/api/common/extHostWebviewMessaging.ts @@ -7,9 +7,9 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import * as extHostProtocol from './extHost.protocol.js'; class ArrayBufferSet { - public readonly buffers: ArrayBuffer[] = []; + public readonly buffers: ArrayBufferLike[] = []; - public add(buffer: ArrayBuffer): number { + public add(buffer: ArrayBufferLike): number { let index = this.buffers.indexOf(buffer); if (index < 0) { index = this.buffers.length; diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index 4b6e88df490..b93ce34773d 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -13,7 +13,7 @@ import { Schemas } from '../../../base/common/network.js'; import { Counter } from '../../../base/common/numbers.js'; import { basename, basenameOrAuthority, dirname, ExtUri, relativePath } from '../../../base/common/resources.js'; import { compare } from '../../../base/common/strings.js'; -import { URI, UriComponents } from '../../../base/common/uri.js'; +import { isUriComponents, URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { FileSystemProviderCapabilities } from '../../../platform/files/common/files.js'; @@ -35,7 +35,10 @@ import { ExtHostWorkspaceShape, IRelativePatternDto, IWorkspaceData, MainContext import { revive } from '../../../base/common/marshalling.js'; import { AuthInfo, Credentials } from '../../../platform/request/common/request.js'; import { ExcludeSettingOptions, TextSearchContext2, TextSearchMatch2 } from '../../services/search/common/searchExtTypes.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; +import { bufferToStream, readableToBuffer, VSBuffer } from '../../../base/common/buffer.js'; +import { toDecodeStream, toEncodeReadable, UTF8 } from '../../services/textfile/common/encoding.js'; +import { consumeStream } from '../../../base/common/stream.js'; +import { stringToSnapshot } from '../../services/textfile/common/textfiles.js'; export interface IExtHostWorkspaceProvider { getWorkspaceFolder2(uri: vscode.Uri, resolveParent?: boolean): Promise; @@ -942,13 +945,47 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac // --- encodings --- - decode(content: Uint8Array, uri: UriComponents | undefined, options?: { encoding: string }): Promise { - return this._proxy.$decode(VSBuffer.wrap(content), uri, options); + async decode(content: Uint8Array, args?: { uri?: vscode.Uri; encoding?: string }): Promise { + const [uri, opts] = this.toEncodeDecodeParameters(args); + const options = await this._proxy.$resolveDecoding(uri, opts); + + const stream = (await toDecodeStream(bufferToStream(VSBuffer.wrap(content)), { + ...options, + acceptTextOnly: true, + overwriteEncoding: detectedEncoding => { + if (detectedEncoding === null || detectedEncoding === options.preferredEncoding) { + // Prevent another roundtrip to the main thread + // if the detected encoding is null or the same + // as the preferred encoding + return Promise.resolve(options.preferredEncoding); + } + + return this._proxy.$validateDetectedEncoding(uri, detectedEncoding, opts); + }, + })).stream; + + return consumeStream(stream, chunks => chunks.join('')); } - async encode(content: string, uri: UriComponents | undefined, options?: { encoding: string }): Promise { - const buff = await this._proxy.$encode(content, uri, options); - return buff.buffer; + async encode(content: string, args?: { uri?: vscode.Uri; encoding?: string }): Promise { + const [uri, options] = this.toEncodeDecodeParameters(args); + const { encoding, addBOM } = await this._proxy.$resolveEncoding(uri, options); + + // when encoding is standard skip encoding step + if (encoding === UTF8 && !addBOM) { + return VSBuffer.fromString(content).buffer; + } + + // otherwise create encoded readable + const res = await toEncodeReadable(stringToSnapshot(content), encoding, { addBOM }); + return readableToBuffer(res).buffer; + } + + private toEncodeDecodeParameters(opts?: { uri?: vscode.Uri; encoding?: string }): [UriComponents | undefined, { encoding: string } | undefined] { + const uri = isUriComponents(opts?.uri) ? opts.uri : undefined; + const encoding = typeof opts?.encoding === 'string' ? opts.encoding : undefined; + + return [uri, encoding ? { encoding } : undefined]; } } diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index 331d9a7b180..db7afe10529 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -28,7 +28,7 @@ import { ISignService } from '../../../platform/sign/common/sign.js'; import { SignService } from '../../../platform/sign/node/signService.js'; import { ExtHostTelemetry, IExtHostTelemetry } from '../common/extHostTelemetry.js'; import { IExtHostMpcService } from '../common/extHostMcp.js'; -import { NodeExtHostMpcService } from './extHostMpcNode.js'; +import { NodeExtHostMpcService } from './extHostMcpNode.js'; // ######################################################################### // ### ### diff --git a/src/vs/workbench/api/node/extHostExtensionService.ts b/src/vs/workbench/api/node/extHostExtensionService.ts index 7fae836bf43..edc1452c76d 100644 --- a/src/vs/workbench/api/node/extHostExtensionService.ts +++ b/src/vs/workbench/api/node/extHostExtensionService.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as performance from '../../../base/common/performance.js'; +import type * as vscode from 'vscode'; import { createApiFactoryAndRegisterActors } from '../common/extHost.api.impl.js'; -import { RequireInterceptor } from '../common/extHostRequireInterceptor.js'; +import { INodeModuleFactory, RequireInterceptor } from '../common/extHostRequireInterceptor.js'; import { ExtensionActivationTimesBuilder } from '../common/extHostExtensionActivator.js'; import { connectProxyResolver } from './proxyResolver.js'; import { AbstractExtHostExtensionService } from '../common/extHostExtensionService.js'; @@ -18,8 +19,13 @@ import { CLIServer } from './extHostCLIServer.js'; import { realpathSync } from '../../../base/node/extpath.js'; import { ExtHostConsoleForwarder } from './extHostConsoleForwarder.js'; import { ExtHostDiskFileSystemProvider } from './extHostDiskFileSystemProvider.js'; -import { createRequire } from 'node:module'; -const require = createRequire(import.meta.url); +import nodeModule from 'node:module'; +import { assertType } from '../../../base/common/types.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { BidirectionalMap } from '../../../base/common/map.js'; +import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; + +const require = nodeModule.createRequire(import.meta.url); class NodeModuleRequireInterceptor extends RequireInterceptor { @@ -69,6 +75,124 @@ class NodeModuleRequireInterceptor extends RequireInterceptor { } } +class NodeModuleESMInterceptor extends RequireInterceptor { + + private static _createDataUri(scriptContent: string): string { + return `data:text/javascript;base64,${Buffer.from(scriptContent).toString('base64')}`; + } + + // This string is a script that runs in the loader thread of NodeJS. + private static _loaderScript = ` + let lookup; + export const initialize = async (context) => { + let requestIds = 0; + const { port } = context; + const pendingRequests = new Map(); + port.onmessage = (event) => { + const { id, url } = event.data; + pendingRequests.get(id)?.(url); + }; + lookup = url => { + // debugger; + const myId = requestIds++; + return new Promise((resolve) => { + pendingRequests.set(myId, resolve); + port.postMessage({ id: myId, url, }); + }); + }; + }; + export const resolve = async (specifier, context, nextResolve) => { + if (specifier !== 'vscode' || !context.parentURL) { + return nextResolve(specifier, context); + } + const otherUrl = await lookup(context.parentURL); + return { + url: otherUrl, + shortCircuit: true, + }; + };`; + + private static _vscodeImportFnName = `_VSCODE_IMPORT_VSCODE_API`; + + private readonly _store = new DisposableStore(); + + dispose(): void { + this._store.dispose(); + } + + protected override _installInterceptor(): void { + + type Message = { id: string; url: string }; + + const apiInstances = new BidirectionalMap(); + const apiImportDataUrl = new Map(); + + // define a global function that can be used to get API instances given a random key + Object.defineProperty(globalThis, NodeModuleESMInterceptor._vscodeImportFnName, { + enumerable: false, + configurable: false, + writable: false, + value: (key: string) => { + return apiInstances.getKey(key); + } + }); + + const { port1, port2 } = new MessageChannel(); + + let apiModuleFactory: INodeModuleFactory | undefined; + + // this is a workaround for the fact that the layer checker does not understand + // that onmessage is NodeJS API here + const port1LayerCheckerWorkaround: any = port1; + + port1LayerCheckerWorkaround.onmessage = (e: { data: Message }) => { + + // Get the vscode-module factory - which is the same logic that's also used by + // the CommonJS require interceptor + if (!apiModuleFactory) { + apiModuleFactory = this._factories.get('vscode'); + assertType(apiModuleFactory); + } + + const { id, url } = e.data; + const uri = URI.parse(url); + + // Get or create the API instance. The interface is per extension and extensions are + // looked up by the uri (e.data.url) and path containment. + const apiInstance = apiModuleFactory.load('_not_used', uri, () => { throw new Error('CANNOT LOAD MODULE from here.'); }); + let key = apiInstances.get(apiInstance); + if (!key) { + key = generateUuid(); + apiInstances.set(apiInstance, key); + } + + // Create and cache a data-url which is the import script for the API instance + let scriptDataUrlSrc = apiImportDataUrl.get(key); + if (!scriptDataUrlSrc) { + const jsCode = `const _vscodeInstance = globalThis.${NodeModuleESMInterceptor._vscodeImportFnName}('${key}');\n\n${Object.keys(apiInstance).map((name => `export const ${name} = _vscodeInstance['${name}'];`)).join('\n')}`; + scriptDataUrlSrc = NodeModuleESMInterceptor._createDataUri(jsCode); + apiImportDataUrl.set(key, scriptDataUrlSrc); + } + + port1.postMessage({ + id, + url: scriptDataUrlSrc + }); + }; + + nodeModule.register(NodeModuleESMInterceptor._createDataUri(NodeModuleESMInterceptor._loaderScript), { + parentURL: import.meta.url, + data: { port: port2 }, + transferList: [port2], + }); + + this._store.add(toDisposable(() => { + port1.close(); + port2.close(); + })); + } +} + export class ExtHostExtensionService extends AbstractExtHostExtensionService { readonly extensionRuntime = ExtensionRuntime.Node; @@ -93,8 +217,13 @@ export class ExtHostExtensionService extends AbstractExtHostExtensionService { this._instaService.createInstance(ExtHostDiskFileSystemProvider); // Module loading tricks - const interceptor = this._instaService.createInstance(NodeModuleRequireInterceptor, extensionApiFactory, { mine: this._myRegistry, all: this._globalRegistry }); - await interceptor.install(); + await this._instaService.createInstance(NodeModuleRequireInterceptor, extensionApiFactory, { mine: this._myRegistry, all: this._globalRegistry }) + .install(); + + // ESM loading tricks + await this._store.add(this._instaService.createInstance(NodeModuleESMInterceptor, extensionApiFactory, { mine: this._myRegistry, all: this._globalRegistry })) + .install(); + performance.mark('code/extHost/didInitAPI'); // Do this when extension service exists, but extensions are not being activated yet. @@ -107,13 +236,13 @@ export class ExtHostExtensionService extends AbstractExtHostExtensionService { return extensionDescription.main; } - protected async _loadCommonJSModule(extension: IExtensionDescription | null, module: URI, activationTimesBuilder: ExtensionActivationTimesBuilder): Promise { + private async _doLoadModule(extension: IExtensionDescription | null, module: URI, activationTimesBuilder: ExtensionActivationTimesBuilder, mode: 'esm' | 'cjs'): Promise { if (module.scheme !== Schemas.file) { throw new Error(`Cannot load URI: '${module}', must be of file-scheme`); } let r: T | null = null; activationTimesBuilder.codeLoadingStart(); - this._logService.trace(`ExtensionService#loadCommonJSModule ${module.toString(true)}`); + this._logService.trace(`ExtensionService#loadModule [${mode}] -> ${module.toString(true)}`); this._logService.flush(); const extensionId = extension?.identifier.value; if (extension) { @@ -123,7 +252,11 @@ export class ExtHostExtensionService extends AbstractExtHostExtensionService { if (extensionId) { performance.mark(`code/extHost/willLoadExtensionCode/${extensionId}`); } - r = (require)(module.fsPath); + if (mode === 'esm') { + r = await import(module.toString(true)); + } else { + r = require(module.fsPath); + } } finally { if (extensionId) { performance.mark(`code/extHost/didLoadExtensionCode/${extensionId}`); @@ -133,6 +266,14 @@ export class ExtHostExtensionService extends AbstractExtHostExtensionService { return r; } + protected async _loadCommonJSModule(extension: IExtensionDescription | null, module: URI, activationTimesBuilder: ExtensionActivationTimesBuilder): Promise { + return this._doLoadModule(extension, module, activationTimesBuilder, 'cjs'); + } + + protected async _loadESMModule(extension: IExtensionDescription | null, module: URI, activationTimesBuilder: ExtensionActivationTimesBuilder): Promise { + return this._doLoadModule(extension, module, activationTimesBuilder, 'esm'); + } + public async $setRemoteEnvironment(env: { [key: string]: string | null }): Promise { if (!this._initData.remote.isRemote) { return; diff --git a/src/vs/workbench/api/node/extHostMpcNode.ts b/src/vs/workbench/api/node/extHostMcpNode.ts similarity index 51% rename from src/vs/workbench/api/node/extHostMpcNode.ts rename to src/vs/workbench/api/node/extHostMcpNode.ts index 15fb24c0a84..16e848a8d57 100644 --- a/src/vs/workbench/api/node/extHostMpcNode.ts +++ b/src/vs/workbench/api/node/extHostMcpNode.ts @@ -4,14 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; -import { mapValues } from '../../../base/common/objects.js'; +import { readFile } from 'fs/promises'; +import { homedir } from 'os'; +import { parseEnvFile } from '../../../base/common/envfile.js'; import { URI } from '../../../base/common/uri.js'; import { StreamSplitter } from '../../../base/node/nodeStreams.js'; +import { LogLevel } from '../../../platform/log/common/log.js'; import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; import { ExtHostMcpService } from '../common/extHostMcp.js'; import { IExtHostRpcService } from '../common/extHostRpcService.js'; -import { homedir } from 'os'; -import { PassThrough } from 'stream'; +import { findExecutable } from '../../../base/node/processes.js'; +import { untildify } from '../../../base/common/labels.js'; export class NodeExtHostMpcService extends ExtHostMcpService { constructor( @@ -46,31 +49,55 @@ export class NodeExtHostMpcService extends ExtHostMcpService { override $sendMessage(id: number, message: string): void { const nodeServer = this.nodeServers.get(id); if (nodeServer) { - this._proxy.$onDidPublishLog(id, '[Client Says] ' + message.toString()); - nodeServer.child.stdin.write(message + '\n'); } else { super.$sendMessage(id, message); } } - private startNodeMpc(id: number, launch: McpServerTransportStdio): void { - const onError = (err: Error) => this._proxy.$onDidChangeState(id, { + private async startNodeMpc(id: number, launch: McpServerTransportStdio) { + const onError = (err: Error | string) => this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Error, - message: err.message, + code: err.hasOwnProperty('code') ? String((err as any).code) : undefined, + message: typeof err === 'string' ? err : err.message, }); + // MCP servers are run on the same authority where they are defined, so + // reading the envfile based on its path off the filesystem here is fine. + const env = { ...process.env }; + if (launch.envFile) { + try { + for (const [key, value] of parseEnvFile(await readFile(launch.envFile, 'utf-8'))) { + env[key] = value; + } + } catch (e) { + onError(`Failed to read envFile '${launch.envFile}': ${e.message}`); + return; + } + } + for (const [key, value] of Object.entries(launch.env)) { + env[key] = value === null ? undefined : String(value); + } + const abortCtrl = new AbortController(); let child: ChildProcessWithoutNullStreams; try { - child = spawn(launch.command, launch.args, { + const home = homedir(); + const cwd = launch.cwd ? URI.revive(launch.cwd).fsPath : home; + const { executable, args, shell } = await formatSubprocessArguments( + untildify(launch.command, home), + launch.args.map(a => untildify(a, home)), + cwd, + env + ); + + this._proxy.$onDidPublishLog(id, LogLevel.Debug, `Server command line: ${executable} ${args.join(' ')}`); + child = spawn(executable, args, { stdio: 'pipe', cwd: launch.cwd ? URI.revive(launch.cwd).fsPath : homedir(), signal: abortCtrl.signal, - env: { - ...process.env, - ...mapValues(launch.env, v => typeof v === 'number' ? String(v) : (v === null ? undefined : v)), - }, + env, + shell, }); } catch (e) { onError(e); @@ -80,25 +107,26 @@ export class NodeExtHostMpcService extends ExtHostMcpService { this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Starting }); - const debug = new PassThrough(); - debug.on('data', line => { - this._proxy.$onDidPublishLog(id, '[Server Says] ' + line.toString()); - }); - - child.stdout.pipe(new StreamSplitter('\n')).pipe(debug).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString())); + child.stdout.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidReceiveMessage(id, line.toString())); child.stdin.on('error', onError); child.stdout.on('error', onError); // Stderr handling is not currently specified https://github.com/modelcontextprotocol/specification/issues/177 // Just treat it as generic log data for now - child.stderr.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidPublishLog(id, line.toString())); + child.stderr.pipe(new StreamSplitter('\n')).on('data', line => this._proxy.$onDidPublishLog(id, LogLevel.Warning, `[server stderr] ${line.toString().trimEnd()}`)); child.on('spawn', () => this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Running })); - child.on('error', onError); + child.on('error', e => { + if (abortCtrl.signal.aborted) { + this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped }); + } else { + onError(e); + } + }); child.on('exit', code => - code === 0 + code === 0 || abortCtrl.signal.aborted ? this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Stopped }) : this._proxy.$onDidChangeState(id, { state: McpConnectionState.Kind.Error, @@ -109,3 +137,31 @@ export class NodeExtHostMpcService extends ExtHostMcpService { this.nodeServers.set(id, { abortCtrl, child }); } } + +const windowsShellScriptRe = /\.(bat|cmd)$/i; + +/** + * Formats arguments to avoid issues on Windows for CVE-2024-27980. + */ +export const formatSubprocessArguments = async ( + executable: string, + args: ReadonlyArray, + cwd: string | undefined, + env: Record, +) => { + if (process.platform !== 'win32') { + return { executable, args, shell: false }; + } + + const found = await findExecutable(executable, cwd, undefined, env); + if (found && windowsShellScriptRe.test(found)) { + const quote = (s: string) => s.includes(' ') ? `"${s}"` : s; + return { + executable: quote(found), + args: args.map(quote), + shell: true, + }; + } + + return { executable, args, shell: false }; +}; diff --git a/src/vs/workbench/api/node/extensionHostProcess.ts b/src/vs/workbench/api/node/extensionHostProcess.ts index feaece95534..704a0dbb5bd 100644 --- a/src/vs/workbench/api/node/extensionHostProcess.ts +++ b/src/vs/workbench/api/node/extensionHostProcess.ts @@ -103,9 +103,10 @@ function patchProcess(allowExit: boolean) { process.on = function (event: string, listener: (...args: any[]) => void) { if (event === 'uncaughtException') { - listener = function () { + const actualListener = listener; + listener = function (...args: any[]) { try { - return listener.call(undefined, arguments); + return actualListener.apply(undefined, args); } catch { // DO NOT HANDLE NOR PRINT the error here because this can and will lead to // more errors which will cause error handling to be reentrant and eventually diff --git a/src/vs/workbench/api/test/browser/extHostConfiguration.test.ts b/src/vs/workbench/api/test/browser/extHostConfiguration.test.ts index 4bc30da2455..f549ee37d3b 100644 --- a/src/vs/workbench/api/test/browser/extHostConfiguration.test.ts +++ b/src/vs/workbench/api/test/browser/extHostConfiguration.test.ts @@ -104,6 +104,29 @@ suite('ExtHostConfiguration', function () { assert.deepStrictEqual(config.get('nested'), { config1: 42, config2: 'Das Pferd frisst kein Reis.' }); }); + test('get nested config', () => { + + const all = createExtHostConfiguration({ + 'farboo': { + 'config0': true, + 'nested': { + 'config1': 42, + 'config2': 'Das Pferd frisst kein Reis.' + }, + 'config4': '' + } + }); + + assert.deepStrictEqual(all.getConfiguration('farboo.nested').get('config1'), 42); + assert.deepStrictEqual(all.getConfiguration('farboo.nested').get('config2'), 'Das Pferd frisst kein Reis.'); + assert.deepStrictEqual(all.getConfiguration('farboo.nested')['config1'], 42); + assert.deepStrictEqual(all.getConfiguration('farboo.nested')['config2'], 'Das Pferd frisst kein Reis.'); + assert.deepStrictEqual(all.getConfiguration('farboo.nested1').get('config1'), undefined); + assert.deepStrictEqual(all.getConfiguration('farboo.nested1').get('config2'), undefined); + assert.deepStrictEqual(all.getConfiguration('farboo.config0.config1').get('a'), undefined); + assert.deepStrictEqual(all.getConfiguration('farboo.config0.config1')['a'], undefined); + }); + test('can modify the returned configuration', function () { const all = createExtHostConfiguration({ diff --git a/src/vs/workbench/api/test/browser/extHostMessagerService.test.ts b/src/vs/workbench/api/test/browser/extHostMessagerService.test.ts index d5fc283aeb6..bb5ff5e46ff 100644 --- a/src/vs/workbench/api/test/browser/extHostMessagerService.test.ts +++ b/src/vs/workbench/api/test/browser/extHostMessagerService.test.ts @@ -6,10 +6,10 @@ import assert from 'assert'; import { MainThreadMessageService } from '../../browser/mainThreadMessageService.js'; import { IDialogService, IPrompt, IPromptButton } from '../../../../platform/dialogs/common/dialogs.js'; -import { INotificationService, INotification, NoOpNotification, INotificationHandle, Severity, IPromptChoice, IPromptOptions, IStatusMessageOptions, INotificationSource, INotificationSourceFilter, NotificationsFilter } from '../../../../platform/notification/common/notification.js'; +import { INotificationService, INotification, NoOpNotification, INotificationHandle, Severity, IPromptChoice, IPromptOptions, IStatusMessageOptions, INotificationSource, INotificationSourceFilter, NotificationsFilter, IStatusHandle } from '../../../../platform/notification/common/notification.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { mock } from '../../../../base/test/common/mock.js'; -import { IDisposable, Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; import { TestDialogService } from '../../../../platform/dialogs/test/common/testDialogService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -44,8 +44,8 @@ const emptyNotificationService = new class implements INotificationService { prompt(severity: Severity, message: string, choices: IPromptChoice[], options?: IPromptOptions): INotificationHandle { throw new Error('not implemented'); } - status(message: string | Error, options?: IStatusMessageOptions): IDisposable { - return Disposable.None; + status(message: string | Error, options?: IStatusMessageOptions): IStatusHandle { + return { close: () => { } }; } setFilter(): void { throw new Error('not implemented'); @@ -87,8 +87,8 @@ class EmptyNotificationService implements INotificationService { prompt(severity: Severity, message: string, choices: IPromptChoice[], options?: IPromptOptions): INotificationHandle { throw new Error('Method not implemented'); } - status(message: string, options?: IStatusMessageOptions): IDisposable { - return Disposable.None; + status(message: string, options?: IStatusMessageOptions): IStatusHandle { + return { close: () => { } }; } setFilter(): void { throw new Error('Method not implemented.'); diff --git a/src/vs/workbench/api/test/browser/extHostTypes.test.ts b/src/vs/workbench/api/test/browser/extHostTypes.test.ts index 96c55513d54..8cd81152eac 100644 --- a/src/vs/workbench/api/test/browser/extHostTypes.test.ts +++ b/src/vs/workbench/api/test/browser/extHostTypes.test.ts @@ -776,22 +776,6 @@ suite('ExtHostTypes', function () { assert.throws(() => types.FileDecoration.validate({ badge: 'ããã' })); }); - test('No longer possible to set content on LanguageModelChatMessage', function () { - const m = types.LanguageModelChatMessage.Assistant(''); - m.content = [new types.LanguageModelToolCallPart('toolCall.call.callId', 'toolCall.tool.name', 'toolCall.call.parameters')]; - - assert.equal(m.content.length, 1); - assert.equal(m.content2?.length, 1); - - - m.content2 = ['foo']; - assert.equal(m.content.length, 1); - assert.ok(m.content[0] instanceof types.LanguageModelTextPart); - - assert.equal(m.content2?.length, 1); - assert.ok(typeof m.content2[0] === 'string'); - }); - test('runtime stable, type-def changed', function () { // see https://github.com/microsoft/vscode/issues/231938 const m = new types.LanguageModelChatMessage(types.LanguageModelChatMessageRole.User, []); diff --git a/src/vs/workbench/api/test/common/extHostExtensionActivator.test.ts b/src/vs/workbench/api/test/common/extHostExtensionActivator.test.ts index c13ac061a76..d7a3c6f68e8 100644 --- a/src/vs/workbench/api/test/common/extHostExtensionActivator.test.ts +++ b/src/vs/workbench/api/test/common/extHostExtensionActivator.test.ts @@ -13,6 +13,7 @@ import { NullLogService } from '../../../../platform/log/common/log.js'; import { ActivatedExtension, EmptyExtension, ExtensionActivationTimes, ExtensionsActivator, IExtensionsActivatorHost } from '../../common/extHostExtensionActivator.js'; import { ExtensionDescriptionRegistry, IActivationEventsReader } from '../../../services/extensions/common/extensionDescriptionRegistry.js'; import { ExtensionActivationReason, MissingExtensionDependency } from '../../../services/extensions/common/extensions.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; suite('ExtensionsActivator', () => { @@ -23,26 +24,30 @@ suite('ExtensionsActivator', () => { const idC = new ExtensionIdentifier(`c`); test('calls activate only once with sequential activations', async () => { + const disposables = new DisposableStore(); const host = new SimpleExtensionsActivatorHost(); const activator = createActivator(host, [ desc(idA) - ]); + ], [], disposables); await activator.activateByEvent('*', false); assert.deepStrictEqual(host.activateCalls, [idA]); await activator.activateByEvent('*', false); assert.deepStrictEqual(host.activateCalls, [idA]); + + disposables.dispose(); }); test('calls activate only once with parallel activations', async () => { + const disposables = new DisposableStore(); const extActivation = new ExtensionActivationPromiseSource(); const host = new PromiseExtensionsActivatorHost([ [idA, extActivation] ]); const activator = createActivator(host, [ desc(idA, [], ['evt1', 'evt2']) - ]); + ], [], disposables); const activate1 = activator.activateByEvent('evt1', false); const activate2 = activator.activateByEvent('evt2', false); @@ -53,9 +58,12 @@ suite('ExtensionsActivator', () => { await activate2; assert.deepStrictEqual(host.activateCalls, [idA]); + + disposables.dispose(); }); test('activates dependencies first', async () => { + const disposables = new DisposableStore(); const extActivationA = new ExtensionActivationPromiseSource(); const extActivationB = new ExtensionActivationPromiseSource(); const host = new PromiseExtensionsActivatorHost([ @@ -65,7 +73,7 @@ suite('ExtensionsActivator', () => { const activator = createActivator(host, [ desc(idA, [idB], ['evt1']), desc(idB, [], ['evt1']), - ]); + ], [], disposables); const activate = activator.activateByEvent('evt1', false); @@ -81,22 +89,28 @@ suite('ExtensionsActivator', () => { await activate; assert.deepStrictEqual(host.activateCalls, [idB, idA]); + + disposables.dispose(); }); test('Supports having resolved extensions', async () => { + const disposables = new DisposableStore(); const host = new SimpleExtensionsActivatorHost(); const bExt = desc(idB); delete (>bExt).main; delete (>bExt).browser; const activator = createActivator(host, [ desc(idA, [idB]) - ], [bExt]); + ], [bExt], disposables); await activator.activateByEvent('*', false); assert.deepStrictEqual(host.activateCalls, [idA]); + + disposables.dispose(); }); test('Supports having external extensions', async () => { + const disposables = new DisposableStore(); const extActivationA = new ExtensionActivationPromiseSource(); const extActivationB = new ExtensionActivationPromiseSource(); const host = new PromiseExtensionsActivatorHost([ @@ -107,7 +121,7 @@ suite('ExtensionsActivator', () => { (>bExt).api = 'none'; const activator = createActivator(host, [ desc(idA, [idB]) - ], [bExt]); + ], [bExt], disposables); const activate = activator.activateByEvent('*', false); @@ -121,14 +135,17 @@ suite('ExtensionsActivator', () => { await activate; assert.deepStrictEqual(host.activateCalls, [idB, idA]); + + disposables.dispose(); }); test('Error: activateById with missing extension', async () => { + const disposables = new DisposableStore(); const host = new SimpleExtensionsActivatorHost(); const activator = createActivator(host, [ desc(idA), desc(idB), - ]); + ], [], disposables); let error: Error | undefined = undefined; try { @@ -138,21 +155,27 @@ suite('ExtensionsActivator', () => { } assert.strictEqual(typeof error === 'undefined', false); + + disposables.dispose(); }); test('Error: dependency missing', async () => { + const disposables = new DisposableStore(); const host = new SimpleExtensionsActivatorHost(); const activator = createActivator(host, [ desc(idA, [idB]), - ]); + ], [], disposables); await activator.activateByEvent('*', false); assert.deepStrictEqual(host.errors.length, 1); assert.deepStrictEqual(host.errors[0][0], idA); + + disposables.dispose(); }); test('Error: dependency activation failed', async () => { + const disposables = new DisposableStore(); const extActivationA = new ExtensionActivationPromiseSource(); const extActivationB = new ExtensionActivationPromiseSource(); const host = new PromiseExtensionsActivatorHost([ @@ -162,7 +185,7 @@ suite('ExtensionsActivator', () => { const activator = createActivator(host, [ desc(idA, [idB]), desc(idB) - ]); + ], [], disposables); const activate = activator.activateByEvent('*', false); extActivationB.reject(new Error(`b fails!`)); @@ -171,9 +194,12 @@ suite('ExtensionsActivator', () => { assert.deepStrictEqual(host.errors.length, 2); assert.deepStrictEqual(host.errors[0][0], idB); assert.deepStrictEqual(host.errors[1][0], idA); + + disposables.dispose(); }); test('issue #144518: Problem with git extension and vscode-icons', async () => { + const disposables = new DisposableStore(); const extActivationA = new ExtensionActivationPromiseSource(); const extActivationB = new ExtensionActivationPromiseSource(); const extActivationC = new ExtensionActivationPromiseSource(); @@ -186,7 +212,7 @@ suite('ExtensionsActivator', () => { desc(idA, [idB]), desc(idB), desc(idC), - ]); + ], [], disposables); activator.activateByEvent('*', false); assert.deepStrictEqual(host.activateCalls, [idB, idC]); @@ -196,6 +222,8 @@ suite('ExtensionsActivator', () => { assert.deepStrictEqual(host.activateCalls, [idB, idC, idA]); extActivationA.resolve(); + + disposables.dispose(); }); class SimpleExtensionsActivatorHost implements IExtensionsActivatorHost { @@ -255,10 +283,10 @@ suite('ExtensionsActivator', () => { } }; - function createActivator(host: IExtensionsActivatorHost, extensionDescriptions: IExtensionDescription[], otherHostExtensionDescriptions: IExtensionDescription[] = []): ExtensionsActivator { - const registry = new ExtensionDescriptionRegistry(basicActivationEventsReader, extensionDescriptions); - const globalRegistry = new ExtensionDescriptionRegistry(basicActivationEventsReader, extensionDescriptions.concat(otherHostExtensionDescriptions)); - return new ExtensionsActivator(registry, globalRegistry, host, new NullLogService()); + function createActivator(host: IExtensionsActivatorHost, extensionDescriptions: IExtensionDescription[], otherHostExtensionDescriptions: IExtensionDescription[] = [], disposables: DisposableStore): ExtensionsActivator { + const registry = disposables.add(new ExtensionDescriptionRegistry(basicActivationEventsReader, extensionDescriptions)); + const globalRegistry = disposables.add(new ExtensionDescriptionRegistry(basicActivationEventsReader, extensionDescriptions.concat(otherHostExtensionDescriptions))); + return disposables.add(new ExtensionsActivator(registry, globalRegistry, host, new NullLogService())); } function desc(id: ExtensionIdentifier, deps: ExtensionIdentifier[] = [], activationEvents: string[] = ['*']): IExtensionDescription { diff --git a/src/vs/workbench/api/test/node/extHostSearch.test.ts b/src/vs/workbench/api/test/node/extHostSearch.test.ts index 94380c10ec4..4abd86979a5 100644 --- a/src/vs/workbench/api/test/node/extHostSearch.test.ts +++ b/src/vs/workbench/api/test/node/extHostSearch.test.ts @@ -26,6 +26,7 @@ import { IFileMatch, IFileQuery, IPatternInfo, IRawFileMatch2, ISearchCompleteSt import { TextSearchManager } from '../../../services/search/common/textSearchManager.js'; import { NativeTextSearchManager } from '../../../services/search/node/textSearchManager.js'; import type * as vscode from 'vscode'; +import { AISearchKeyword } from '../../../services/search/common/searchExtTypes.js'; let rpcProtocol: TestRPCProtocol; let extHostSearch: NativeExtHostSearch; @@ -36,6 +37,8 @@ class MockMainThreadSearch implements MainThreadSearchShape { results: Array = []; + keywords: Array = []; + $registerFileSearchProvider(handle: number, scheme: string): void { this.lastHandle = handle; } @@ -59,6 +62,10 @@ class MockMainThreadSearch implements MainThreadSearchShape { this.results.push(...data); } + $handleKeywordResult(handle: number, session: number, data: AISearchKeyword): void { + this.keywords.push(data); + } + $handleTelemetry(eventName: string, data: any): void { } diff --git a/src/vs/workbench/api/worker/extHostExtensionService.ts b/src/vs/workbench/api/worker/extHostExtensionService.ts index 6f3384ab89c..46bb2a0970f 100644 --- a/src/vs/workbench/api/worker/extHostExtensionService.ts +++ b/src/vs/workbench/api/worker/extHostExtensionService.ts @@ -125,6 +125,10 @@ export class ExtHostExtensionService extends AbstractExtHostExtensionService { } } + protected override _loadESMModule(extension: IExtensionDescription | null, module: URI, activationTimesBuilder: ExtensionActivationTimesBuilder): Promise { + throw new Error('ESM modules are not supported in the web worker extension host'); + } + async $setRemoteEnvironment(_env: { [key: string]: string | null }): Promise { return; } diff --git a/src/vs/workbench/api/worker/extensionHostWorker.ts b/src/vs/workbench/api/worker/extensionHostWorker.ts index d9953269acb..ef9b56b6951 100644 --- a/src/vs/workbench/api/worker/extensionHostWorker.ts +++ b/src/vs/workbench/api/worker/extensionHostWorker.ts @@ -234,10 +234,6 @@ function isInitMessage(a: any): a is IInitMessage { return !!a && typeof a === 'object' && a.type === 'vscode.init' && a.data instanceof Map; } -/** - * Defines the worker entry point. Must be exported and named `create`. - * @skipMangle - */ export function create(): { onmessage: (message: any) => void } { performance.mark(`code/extHost/willConnectToRenderer`); const res = new ExtensionWorker(); diff --git a/src/vs/workbench/browser/actions/helpActions.ts b/src/vs/workbench/browser/actions/helpActions.ts index efda59bfc12..2487213aa2d 100644 --- a/src/vs/workbench/browser/actions/helpActions.ts +++ b/src/vs/workbench/browser/actions/helpActions.ts @@ -9,13 +9,14 @@ import { isMacintosh, isLinux, language, isWeb } from '../../../base/common/plat import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; import { URI } from '../../../base/common/uri.js'; -import { MenuId, Action2, registerAction2 } from '../../../platform/actions/common/actions.js'; +import { MenuId, Action2, registerAction2, MenuRegistry } from '../../../platform/actions/common/actions.js'; import { KeyChord, KeyMod, KeyCode } from '../../../base/common/keyCodes.js'; import { IProductService } from '../../../platform/product/common/productService.js'; import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../platform/keybinding/common/keybindingsRegistry.js'; import { Categories } from '../../../platform/action/common/actionCommonCategories.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../platform/contextkey/common/contextkey.js'; class KeybindingsReferenceAction extends Action2 { @@ -279,7 +280,7 @@ class OpenLicenseUrlAction extends Action2 { class OpenPrivacyStatementUrlAction extends Action2 { static readonly ID = 'workbench.action.openPrivacyStatementUrl'; - static readonly AVAILABE = !!product.privacyStatementUrl; + static readonly AVAILABLE = !!product.privacyStatementUrl; constructor() { super({ @@ -331,30 +332,35 @@ class GetStartedWithAccessibilityFeatures extends Action2 { } } -class GetStartedWithCopilot extends Action2 { - - static readonly ID = 'workbench.action.getStartedWithCopilot'; - static readonly AVAILABE = !!product.defaultChatAgent?.documentationUrl; +class AskVSCodeCopilot extends Action2 { + static readonly ID = 'workbench.action.askVScode'; constructor() { super({ - id: GetStartedWithCopilot.ID, - title: localize2('getStartedWithCopilot', 'Get Started with Copilot'), + id: AskVSCodeCopilot.ID, + title: localize2('askVScode', 'Ask @vscode'), category: Categories.Help, f1: true, - menu: { - id: MenuId.MenubarHelpMenu, - group: '1_welcome', - order: 7 - } + precondition: ContextKeyExpr.equals('chatSetupHidden', false) }); } - run(accessor: ServicesAccessor): void { - const openerService = accessor.get(IOpenerService); - openerService.open(URI.parse(product.defaultChatAgent!.documentationUrl)); + + async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); + commandService.executeCommand('workbench.action.chat.open', { mode: 'ask', query: '@vscode ', isPartialQuery: true }); } } +MenuRegistry.appendMenuItem(MenuId.MenubarHelpMenu, { + command: { + id: AskVSCodeCopilot.ID, + title: localize2('askVScode', 'Ask @vscode'), + }, + order: 7, + group: '1_welcome', + when: ContextKeyExpr.equals('chatSetupHidden', false) +}); + // --- Actions Registration if (KeybindingsReferenceAction.AVAILABLE) { @@ -389,12 +395,10 @@ if (OpenLicenseUrlAction.AVAILABLE) { registerAction2(OpenLicenseUrlAction); } -if (OpenPrivacyStatementUrlAction.AVAILABE) { +if (OpenPrivacyStatementUrlAction.AVAILABLE) { registerAction2(OpenPrivacyStatementUrlAction); } registerAction2(GetStartedWithAccessibilityFeatures); -if (GetStartedWithCopilot.AVAILABE) { - registerAction2(GetStartedWithCopilot); -} +registerAction2(AskVSCodeCopilot); diff --git a/src/vs/workbench/browser/actions/layoutActions.ts b/src/vs/workbench/browser/actions/layoutActions.ts index fd3a6842589..88d98a9f0ce 100644 --- a/src/vs/workbench/browser/actions/layoutActions.ts +++ b/src/vs/workbench/browser/actions/layoutActions.ts @@ -22,7 +22,7 @@ import { IPaneCompositePartService } from '../../services/panecomposite/browser/ import { ToggleAuxiliaryBarAction } from '../parts/auxiliarybar/auxiliaryBarActions.js'; import { TogglePanelAction } from '../parts/panel/panelActions.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; -import { AuxiliaryBarVisibleContext, PanelAlignmentContext, PanelVisibleContext, SideBarVisibleContext, FocusedViewContext, InEditorZenModeContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, IsMainWindowFullscreenContext, PanelPositionContext, IsAuxiliaryWindowFocusedContext, TitleBarStyleContext } from '../../common/contextkeys.js'; +import { AuxiliaryBarVisibleContext, PanelAlignmentContext, PanelVisibleContext, SideBarVisibleContext, FocusedViewContext, InEditorZenModeContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, IsMainWindowFullscreenContext, PanelPositionContext, IsAuxiliaryWindowFocusedContext, TitleBarStyleContext, IsAuxiliaryTitleBarContext } from '../../common/contextkeys.js'; import { Codicon } from '../../../base/common/codicons.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; @@ -173,7 +173,10 @@ MenuRegistry.appendMenuItem(MenuId.LayoutControlMenu, { title: localize('configureLayout', "Configure Layout"), icon: configureLayoutIcon, group: '1_workbench_layout', - when: ContextKeyExpr.equals('config.workbench.layoutControl.type', 'menu') + when: ContextKeyExpr.and( + IsAuxiliaryTitleBarContext.negate(), + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'menu') + ) }); @@ -345,7 +348,13 @@ MenuRegistry.appendMenuItems([ icon: panelLeftOffIcon, toggled: { condition: SideBarVisibleContext, icon: panelLeftIcon } }, - when: ContextKeyExpr.and(ContextKeyExpr.or(ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), ContextKeyExpr.equals('config.workbench.sideBar.location', 'left')), + when: ContextKeyExpr.and( + IsAuxiliaryTitleBarContext.negate(), + ContextKeyExpr.or( + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), + ContextKeyExpr.equals('config.workbench.sideBar.location', 'left') + ), order: 0 } }, { @@ -358,7 +367,13 @@ MenuRegistry.appendMenuItems([ icon: panelRightOffIcon, toggled: { condition: SideBarVisibleContext, icon: panelRightIcon } }, - when: ContextKeyExpr.and(ContextKeyExpr.or(ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), ContextKeyExpr.equals('config.workbench.sideBar.location', 'right')), + when: ContextKeyExpr.and( + IsAuxiliaryTitleBarContext.negate(), + ContextKeyExpr.or( + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), + ContextKeyExpr.equals('config.workbench.sideBar.location', 'right') + ), order: 2 } } @@ -1433,7 +1448,10 @@ registerAction2(class CustomizeLayoutAction extends Action2 { }, { id: MenuId.LayoutControlMenu, - when: ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both'), + when: ContextKeyExpr.and( + IsAuxiliaryTitleBarContext.toNegated(), + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both') + ), group: '1_layout' } ] diff --git a/src/vs/workbench/browser/contextkeys.ts b/src/vs/workbench/browser/contextkeys.ts index e5192e1dd69..840174189ee 100644 --- a/src/vs/workbench/browser/contextkeys.ts +++ b/src/vs/workbench/browser/contextkeys.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../base/common/event.js'; -import { Disposable } from '../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../base/common/lifecycle.js'; import { IContextKeyService, IContextKey, setConstant as setConstantContextKey } from '../../platform/contextkey/common/contextkey.js'; import { InputFocusedContext, IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext } from '../../platform/contextkey/common/contextkeys.js'; import { SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext } from '../common/contextkeys.js'; @@ -216,7 +216,7 @@ export class WorkbenchContextKeysHandler extends Disposable { this._register(this.editorGroupService.onDidChangeEditorPartOptions(() => this.updateEditorAreaContextKeys())); - this._register(Event.runAndSubscribe(onDidRegisterWindow, ({ window, disposables }) => disposables.add(addDisposableListener(window, EventType.FOCUS_IN, () => this.updateInputContextKeys(window.document), true)), { window: mainWindow, disposables: this._store })); + this._register(Event.runAndSubscribe(onDidRegisterWindow, ({ window, disposables }) => disposables.add(addDisposableListener(window, EventType.FOCUS_IN, () => this.updateInputContextKeys(window.document, disposables), true)), { window: mainWindow, disposables: this._store })); this._register(this.contextService.onDidChangeWorkbenchState(() => this.updateWorkbenchStateContextKey())); this._register(this.contextService.onDidChangeWorkspaceFolders(() => { @@ -297,7 +297,7 @@ export class WorkbenchContextKeysHandler extends Disposable { this.editorTabsVisibleContext.set(this.editorGroupService.partOptions.showTabs === 'multiple'); } - private updateInputContextKeys(ownerDocument: Document): void { + private updateInputContextKeys(ownerDocument: Document, disposables: DisposableStore): void { function activeElementIsInput(): boolean { return !!ownerDocument.activeElement && isEditableElement(ownerDocument.activeElement); @@ -307,7 +307,7 @@ export class WorkbenchContextKeysHandler extends Disposable { this.inputFocusedContext.set(isInputFocused); if (isInputFocused) { - const tracker = trackFocus(ownerDocument.activeElement as HTMLElement); + const tracker = disposables.add(trackFocus(ownerDocument.activeElement as HTMLElement)); Event.once(tracker.onDidBlur)(() => { // Ensure we are only updating the context key if we are @@ -323,7 +323,7 @@ export class WorkbenchContextKeysHandler extends Disposable { } tracker.dispose(); - }); + }, undefined, disposables); } } diff --git a/src/vs/workbench/browser/dnd.ts b/src/vs/workbench/browser/dnd.ts index 72def8c4b99..16bddbd0ea1 100644 --- a/src/vs/workbench/browser/dnd.ts +++ b/src/vs/workbench/browser/dnd.ts @@ -216,7 +216,11 @@ export function fillEditorsDragData(accessor: ServicesAccessor, resourcesOrEdito return undefined; // editor without resource } - return { ...resourceOrEditor, resource: resourceOrEditor.selection ? withSelection(resourceOrEditor.resource, resourceOrEditor.selection) : resourceOrEditor.resource }; + return { + resource: resourceOrEditor.selection ? withSelection(resourceOrEditor.resource, resourceOrEditor.selection) : resourceOrEditor.resource, + isDirectory: resourceOrEditor.isDirectory, + selection: resourceOrEditor.selection, + }; })); const fileSystemResources = resources.filter(({ resource }) => fileService.hasProvider(resource)); @@ -334,9 +338,12 @@ export function fillEditorsDragData(accessor: ServicesAccessor, resourcesOrEdito if (draggedEditors.length) { event.dataTransfer.setData(CodeDataTransfers.EDITORS, stringify(draggedEditors)); + } - // Add a URI list entry - const uriListEntries: URI[] = []; + // Add a URI list entry + const draggedDirectories: URI[] = fileSystemResources.filter(({ isDirectory }) => isDirectory).map(({ resource }) => resource); + if (draggedEditors.length || draggedDirectories.length) { + const uriListEntries: URI[] = [...draggedDirectories]; for (const editor of draggedEditors) { if (editor.resource) { uriListEntries.push(editor.options?.selection ? withSelection(editor.resource, editor.options.selection) : editor.resource); diff --git a/src/vs/workbench/browser/labels.ts b/src/vs/workbench/browser/labels.ts index f83bdc050b8..9f3885d69e7 100644 --- a/src/vs/workbench/browser/labels.ts +++ b/src/vs/workbench/browser/labels.ts @@ -25,7 +25,7 @@ import { IInstantiationService } from '../../platform/instantiation/common/insta import { normalizeDriveLetter } from '../../base/common/labels.js'; import { IRange } from '../../editor/common/core/range.js'; import { ThemeIcon } from '../../base/common/themables.js'; -import { INotebookDocumentService } from '../services/notebook/common/notebookDocumentService.js'; +import { INotebookDocumentService, extractCellOutputDetails } from '../services/notebook/common/notebookDocumentService.js'; export interface IResourceLabelProps { resource?: URI | { primary?: URI; secondary?: URI }; @@ -489,6 +489,39 @@ class ResourceLabelWidget extends IconLabel { } } + if (!options.forceLabel && !isSideBySideEditor && resource?.scheme === Schemas.vscodeNotebookCellOutput) { + const notebookDocument = this.notebookDocumentService.getNotebook(resource); + const outputUriData = extractCellOutputDetails(resource); + if (outputUriData?.cellFragment) { + if (!outputUriData.notebook) { + return; + } + const cellUri = outputUriData.notebook.with({ + scheme: Schemas.vscodeNotebookCell, + fragment: outputUriData.cellFragment + }); + const cellIndex = notebookDocument?.getCellIndex(cellUri); + const outputIndex = outputUriData.outputIndex; + + if (cellIndex !== undefined && outputIndex !== undefined && typeof label.name === 'string') { + label.name = localize( + 'notebookCellOutputLabel', + "{0} • Cell {1} • Output {2}", + label.name, + `${cellIndex + 1}`, + `${outputIndex + 1}` + ); + } else if (cellIndex !== undefined && typeof label.name === 'string') { + label.name = localize( + 'notebookCellOutputLabelSimple', + "{0} • Cell {1} • Output", + label.name, + `${cellIndex + 1}` + ); + } + } + } + const hasResourceChanged = this.hasResourceChanged(label); const hasPathLabelChanged = hasResourceChanged || this.hasPathLabelChanged(label); const hasFileKindChanged = this.hasFileKindChanged(options); diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 501d95a8713..0c3fbf7525b 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -12,7 +12,7 @@ import { isWindows, isLinux, isMacintosh, isWeb, isIOS } from '../../base/common import { EditorInputCapabilities, GroupIdentifier, isResourceEditorInput, IUntypedEditorInput, pathsToEditors } from '../common/editor.js'; import { SidebarPart } from './parts/sidebar/sidebarPart.js'; import { PanelPart } from './parts/panel/panelPart.js'; -import { Position, Parts, PanelOpensMaximizedOptions, IWorkbenchLayoutService, positionFromString, positionToString, panelOpensMaximizedFromString, PanelAlignment, ActivityBarPosition, LayoutSettings, MULTI_WINDOW_PARTS, SINGLE_WINDOW_PARTS, ZenModeSettings, EditorTabsMode, EditorActionsLocation, shouldShowCustomTitleBar, isHorizontal } from '../services/layout/browser/layoutService.js'; +import { Position, Parts, PanelOpensMaximizedOptions, IWorkbenchLayoutService, positionFromString, positionToString, panelOpensMaximizedFromString, PanelAlignment, ActivityBarPosition, LayoutSettings, MULTI_WINDOW_PARTS, SINGLE_WINDOW_PARTS, ZenModeSettings, EditorTabsMode, EditorActionsLocation, shouldShowCustomTitleBar, isHorizontal, isMultiWindowPart } from '../services/layout/browser/layoutService.js'; import { isTemporaryWorkspace, IWorkspaceContextService, WorkbenchState } from '../../platform/workspace/common/workspace.js'; import { IStorageService, StorageScope, StorageTarget } from '../../platform/storage/common/storage.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../platform/configuration/common/configuration.js'; @@ -363,6 +363,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi LegacyWorkbenchLayoutSettings.SIDEBAR_POSITION, LegacyWorkbenchLayoutSettings.STATUSBAR_VISIBLE, ].some(setting => e.affectsConfiguration(setting))) { + // Show Command Center if command center actions enabled const shareEnabled = e.affectsConfiguration('workbench.experimental.share.enabled') && this.configurationService.getValue('workbench.experimental.share.enabled'); const navigationControlEnabled = e.affectsConfiguration('workbench.navigationControl.enabled') && this.configurationService.getValue('workbench.navigationControl.enabled'); @@ -1047,10 +1048,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi mark('code/willRestoreViewlet'); - const viewlet = await this.paneCompositeService.openPaneComposite(this.state.initialization.views.containerToRestore.sideBar, ViewContainerLocation.Sidebar); - if (!viewlet) { - await this.paneCompositeService.openPaneComposite(this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Sidebar)?.id, ViewContainerLocation.Sidebar); // fallback to default viewlet as needed - } + await this.openViewContainer(ViewContainerLocation.Sidebar, this.state.initialization.views.containerToRestore.sideBar); mark('code/didRestoreViewlet'); })()); @@ -1067,10 +1065,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi mark('code/willRestorePanel'); - const panel = await this.paneCompositeService.openPaneComposite(this.state.initialization.views.containerToRestore.panel, ViewContainerLocation.Panel); - if (!panel) { - await this.paneCompositeService.openPaneComposite(this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Panel)?.id, ViewContainerLocation.Panel); // fallback to default panel as needed - } + await this.openViewContainer(ViewContainerLocation.Panel, this.state.initialization.views.containerToRestore.panel); mark('code/didRestorePanel'); })()); @@ -1087,10 +1082,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi mark('code/willRestoreAuxiliaryBar'); - const viewlet = await this.paneCompositeService.openPaneComposite(this.state.initialization.views.containerToRestore.auxiliaryBar, ViewContainerLocation.AuxiliaryBar); - if (!viewlet) { - await this.paneCompositeService.openPaneComposite(this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.AuxiliaryBar)?.id, ViewContainerLocation.AuxiliaryBar); // fallback to default viewlet as needed - } + await this.openViewContainer(ViewContainerLocation.AuxiliaryBar, this.state.initialization.views.containerToRestore.auxiliaryBar); mark('code/didRestoreAuxiliaryBar'); })()); @@ -1121,6 +1113,22 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi }); } + private async openViewContainer(location: ViewContainerLocation, id: string, focus?: boolean): Promise { + let viewContainer = await this.paneCompositeService.openPaneComposite(id, location, focus); + if (viewContainer) { + return; + } + + // fallback to default view container + viewContainer = await this.paneCompositeService.openPaneComposite(this.viewDescriptorService.getDefaultViewContainer(location)?.id, location, focus); + if (viewContainer) { + return; + } + + // finally try to just open the first visible view container + await this.paneCompositeService.openPaneComposite(this.paneCompositeService.getVisiblePaneCompositeIds(location).at(0), location, focus); + } + registerPart(part: Part): IDisposable { const id = part.getId(); this.parts.set(id, part); @@ -1155,6 +1163,16 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return isAncestorUsingFlowTo(activeElement, container); } + private _getFocusedPart(): Parts | undefined { + for (const part of this.parts.keys()) { + if (this.hasFocus(part as Parts)) { + return part as Parts; + } + } + + return undefined; + } + focusPart(part: MULTI_WINDOW_PARTS, targetWindow: Window): void; focusPart(part: SINGLE_WINDOW_PARTS): void; focusPart(part: Parts, targetWindow: Window = mainWindow): void { @@ -1224,32 +1242,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return true; // cannot hide editor part in auxiliary windows } - if (this.initialized) { - switch (part) { - case Parts.TITLEBAR_PART: - return this.workbenchGrid.isViewVisible(this.titleBarPartView); - case Parts.SIDEBAR_PART: - return !this.stateModel.getRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN); - case Parts.PANEL_PART: - return !this.stateModel.getRuntimeValue(LayoutStateKeys.PANEL_HIDDEN); - case Parts.AUXILIARYBAR_PART: - return !this.stateModel.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN); - case Parts.STATUSBAR_PART: - return !this.stateModel.getRuntimeValue(LayoutStateKeys.STATUSBAR_HIDDEN); - case Parts.ACTIVITYBAR_PART: - return !this.stateModel.getRuntimeValue(LayoutStateKeys.ACTIVITYBAR_HIDDEN); - case Parts.EDITOR_PART: - return !this.stateModel.getRuntimeValue(LayoutStateKeys.EDITOR_HIDDEN); - case Parts.BANNER_PART: - return this.workbenchGrid.isViewVisible(this.bannerPartView); - default: - return false; // any other part cannot be hidden - } - } - switch (part) { case Parts.TITLEBAR_PART: - return shouldShowCustomTitleBar(this.configurationService, mainWindow, this.state.runtime.menuBar.toggled); + return this.initialized ? + this.workbenchGrid.isViewVisible(this.titleBarPartView) : + shouldShowCustomTitleBar(this.configurationService, mainWindow, this.state.runtime.menuBar.toggled); case Parts.SIDEBAR_PART: return !this.stateModel.getRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN); case Parts.PANEL_PART: @@ -1262,6 +1259,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return !this.stateModel.getRuntimeValue(LayoutStateKeys.ACTIVITYBAR_HIDDEN); case Parts.EDITOR_PART: return !this.stateModel.getRuntimeValue(LayoutStateKeys.EDITOR_HIDDEN); + case Parts.BANNER_PART: + return this.initialized ? this.workbenchGrid.isViewVisible(this.bannerPartView) : false; default: return false; // any other part cannot be hidden } @@ -1327,6 +1326,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } toggleZenMode(skipLayout?: boolean, restoring = false): void { + const focusedPartPreTransition = this._getFocusedPart(); + this.setZenModeActive(!this.isZenModeActive()); this.state.runtime.zenMode.transitionDisposables.clearAndDisposeAll(); @@ -1369,14 +1370,14 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.setPanelHidden(true, true); this.setAuxiliaryBarHidden(true, true); - this.setSideBarHidden(true, true); + this.setSideBarHidden(true); if (config.hideActivityBar) { - this.setActivityBarHidden(true, true); + this.setActivityBarHidden(true); } if (config.hideStatusBar) { - this.setStatusBarHidden(true, true); + this.setStatusBarHidden(true); } if (config.hideLineNumbers) { @@ -1401,13 +1402,13 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi // Activity Bar if (e.affectsConfiguration(ZenModeSettings.HIDE_ACTIVITYBAR)) { const zenModeHideActivityBar = this.configurationService.getValue(ZenModeSettings.HIDE_ACTIVITYBAR); - this.setActivityBarHidden(zenModeHideActivityBar, true); + this.setActivityBarHidden(zenModeHideActivityBar); } // Status Bar if (e.affectsConfiguration(ZenModeSettings.HIDE_STATUSBAR)) { const zenModeHideStatusBar = this.configurationService.getValue(ZenModeSettings.HIDE_STATUSBAR); - this.setStatusBarHidden(zenModeHideStatusBar, true); + this.setStatusBarHidden(zenModeHideStatusBar); } // Center Layout @@ -1450,15 +1451,15 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } if (zenModeExitInfo.wasVisible.sideBar) { - this.setSideBarHidden(false, true); + this.setSideBarHidden(false); } if (!this.stateModel.getRuntimeValue(LayoutStateKeys.ACTIVITYBAR_HIDDEN, true)) { - this.setActivityBarHidden(false, true); + this.setActivityBarHidden(false); } if (!this.stateModel.getRuntimeValue(LayoutStateKeys.STATUSBAR_HIDDEN, true)) { - this.setStatusBarHidden(false, true); + this.setStatusBarHidden(false); } if (zenModeExitInfo.transitionedToCenteredEditorLayout) { @@ -1471,8 +1472,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi setLineNumbers(); - this.focus(); - toggleMainWindowFullScreen = zenModeExitInfo.transitionedToFullScreen && this.state.runtime.mainWindowFullscreen; } @@ -1484,11 +1483,22 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.hostService.toggleFullScreen(mainWindow); } + // restore focus if part is still visible, otherwise fallback to editor + if (focusedPartPreTransition && this.isVisible(focusedPartPreTransition, getWindow(this.activeContainer))) { + if (isMultiWindowPart(focusedPartPreTransition)) { + this.focusPart(focusedPartPreTransition, getWindow(this.activeContainer)); + } else { + this.focusPart(focusedPartPreTransition); + } + } else { + this.focus(); + } + // Event this._onDidChangeZenMode.fire(this.isZenModeActive()); } - private setStatusBarHidden(hidden: boolean, skipLayout?: boolean): void { + private setStatusBarHidden(hidden: boolean): void { this.stateModel.setRuntimeValue(LayoutStateKeys.STATUSBAR_HIDDEN, hidden); // Adjust CSS @@ -1548,13 +1558,13 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi for (const part of [titleBar, editorPart, activityBar, panelPart, sideBar, statusBar, auxiliaryBarPart, bannerPart]) { this._register(part.onDidVisibilityChange((visible) => { if (part === sideBar) { - this.setSideBarHidden(!visible, true); + this.setSideBarHidden(!visible); } else if (part === panelPart) { this.setPanelHidden(!visible, true); } else if (part === auxiliaryBarPart) { this.setAuxiliaryBarHidden(!visible, true); } else if (part === editorPart) { - this.setEditorHidden(!visible, true); + this.setEditorHidden(!visible); } this._onDidChangePartVisibility.fire(); this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); @@ -1732,7 +1742,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } } - private setActivityBarHidden(hidden: boolean, skipLayout?: boolean): void { + private setActivityBarHidden(hidden: boolean): void { this.stateModel.setRuntimeValue(LayoutStateKeys.ACTIVITYBAR_HIDDEN, hidden); this.workbenchGrid.setViewVisible(this.activityBarPartView, !hidden); } @@ -1741,7 +1751,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.workbenchGrid.setViewVisible(this.bannerPartView, !hidden); } - private setEditorHidden(hidden: boolean, skipLayout?: boolean): void { + private setEditorHidden(hidden: boolean): void { this.stateModel.setRuntimeValue(LayoutStateKeys.EDITOR_HIDDEN, hidden); // Adjust CSS @@ -1771,7 +1781,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi ]); } - private setSideBarHidden(hidden: boolean, skipLayout?: boolean): void { + private setSideBarHidden(hidden: boolean): void { this.stateModel.setRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN, hidden); // Adjust CSS @@ -1791,10 +1801,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi else if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { const viewletToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Sidebar); if (viewletToOpen) { - const viewlet = this.paneCompositeService.openPaneComposite(viewletToOpen, ViewContainerLocation.Sidebar, true); - if (!viewlet) { - this.paneCompositeService.openPaneComposite(this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Sidebar)?.id, ViewContainerLocation.Sidebar, true); - } + this.openViewContainer(ViewContainerLocation.Sidebar, viewletToOpen, true); } } @@ -1879,7 +1886,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } } - setPanelAlignment(alignment: PanelAlignment, skipLayout?: boolean): void { + setPanelAlignment(alignment: PanelAlignment): void { // Panel alignment only applies to a panel in the top/bottom position if (!isHorizontal(this.getPanelPosition())) { @@ -1939,11 +1946,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } if (panelToOpen) { - const focus = !skipLayout; - const panel = this.paneCompositeService.openPaneComposite(panelToOpen, ViewContainerLocation.Panel, focus); - if (!panel) { - this.paneCompositeService.openPaneComposite(this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Panel)?.id, ViewContainerLocation.Panel, focus); - } + this.openViewContainer(ViewContainerLocation.Panel, panelToOpen, !skipLayout); } } @@ -2042,11 +2045,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } if (viewletToOpen) { - const focus = !skipLayout; - const viewlet = this.paneCompositeService.openPaneComposite(viewletToOpen, ViewContainerLocation.AuxiliaryBar, focus); - if (!viewlet) { - this.paneCompositeService.openPaneComposite(this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.AuxiliaryBar)?.id, ViewContainerLocation.AuxiliaryBar, focus); - } + this.openViewContainer(ViewContainerLocation.AuxiliaryBar, viewletToOpen, !skipLayout); } } @@ -2054,9 +2053,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.workbenchGrid.setViewVisible(this.auxiliaryBarPartView, !hidden); } - setPartHidden(hidden: boolean, part: Exclude): void; - setPartHidden(hidden: boolean, part: Exclude, targetWindow: Window): void; - setPartHidden(hidden: boolean, part: Parts, targetWindow: Window = mainWindow): void { + setPartHidden(hidden: boolean, part: Parts): void { switch (part) { case Parts.ACTIVITYBAR_PART: return this.setActivityBarHidden(hidden); @@ -2615,6 +2612,8 @@ interface ILayoutStateChangeEvent { } enum WorkbenchLayoutSettings { + AUXILIARYBAR_DEFAULT_VISIBILITY = 'workbench.secondarySideBar.defaultVisibility', + ACTIVITY_BAR_VISIBLE = 'workbench.activityBar.visible', PANEL_POSITION = 'workbench.panel.defaultLocation', PANEL_OPENS_MAXIMIZED = 'workbench.panel.opensMaximized', ZEN_MODE_CONFIG = 'zenMode', @@ -2622,8 +2621,8 @@ enum WorkbenchLayoutSettings { } enum LegacyWorkbenchLayoutSettings { - STATUSBAR_VISIBLE = 'workbench.statusBar.visible', // Deprecated to UI State - SIDEBAR_POSITION = 'workbench.sideBar.location', // Deprecated to UI State + STATUSBAR_VISIBLE = 'workbench.statusBar.visible', // Deprecated to UI State + SIDEBAR_POSITION = 'workbench.sideBar.location', // Deprecated to UI State } class LayoutStateModel extends Disposable { @@ -2693,11 +2692,22 @@ class LayoutStateModel extends Disposable { this.stateCache.set(LayoutStateKeys.SIDEBAR_POSITON.name, positionFromString(this.configurationService.getValue(LegacyWorkbenchLayoutSettings.SIDEBAR_POSITION) ?? 'left')); // Set dynamic defaults: part sizing and side bar visibility - LayoutStateKeys.PANEL_POSITION.defaultValue = positionFromString(this.configurationService.getValue(WorkbenchLayoutSettings.PANEL_POSITION) ?? 'bottom'); + const workbenchState = this.contextService.getWorkbenchState(); LayoutStateKeys.SIDEBAR_SIZE.defaultValue = Math.min(300, mainContainerDimension.width / 4); + LayoutStateKeys.SIDEBAR_HIDDEN.defaultValue = workbenchState === WorkbenchState.EMPTY; LayoutStateKeys.AUXILIARYBAR_SIZE.defaultValue = Math.min(300, mainContainerDimension.width / 4); + LayoutStateKeys.AUXILIARYBAR_HIDDEN.defaultValue = (() => { + switch (this.configurationService.getValue(WorkbenchLayoutSettings.AUXILIARYBAR_DEFAULT_VISIBILITY)) { + case 'visible': + return false; + case 'visibleInWorkspace': + return workbenchState === WorkbenchState.EMPTY; + default: + return true; + } + })(); LayoutStateKeys.PANEL_SIZE.defaultValue = (this.stateCache.get(LayoutStateKeys.PANEL_POSITION.name) ?? isHorizontal(LayoutStateKeys.PANEL_POSITION.defaultValue)) ? mainContainerDimension.height / 3 : mainContainerDimension.width / 4; - LayoutStateKeys.SIDEBAR_HIDDEN.defaultValue = this.contextService.getWorkbenchState() === WorkbenchState.EMPTY; + LayoutStateKeys.PANEL_POSITION.defaultValue = positionFromString(this.configurationService.getValue(WorkbenchLayoutSettings.PANEL_POSITION) ?? 'bottom'); // Apply all defaults for (key in LayoutStateKeys) { @@ -2782,7 +2792,7 @@ class LayoutStateModel extends Disposable { } private isActivityBarHidden(): boolean { - const oldValue = this.configurationService.getValue('workbench.activityBar.visible'); + const oldValue = this.configurationService.getValue(WorkbenchLayoutSettings.ACTIVITY_BAR_VISIBLE); if (oldValue !== undefined) { return !oldValue; } diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts index e5cc8c36550..72f165f7f00 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts @@ -9,7 +9,7 @@ import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../plat import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; -import { AuxiliaryBarVisibleContext } from '../../../common/contextkeys.js'; +import { AuxiliaryBarVisibleContext, IsAuxiliaryTitleBarContext } from '../../../common/contextkeys.js'; import { ViewContainerLocation, ViewContainerLocationToString } from '../../../common/views.js'; import { ActivityBarPosition, IWorkbenchLayoutService, LayoutSettings, Parts } from '../../../services/layout/browser/layoutService.js'; import { IPaneCompositePartService } from '../../../services/panecomposite/browser/panecomposite.js'; @@ -132,7 +132,13 @@ MenuRegistry.appendMenuItems([ toggled: { condition: AuxiliaryBarVisibleContext, icon: auxiliaryBarLeftIcon }, icon: auxiliaryBarLeftOffIcon, }, - when: ContextKeyExpr.and(ContextKeyExpr.or(ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), ContextKeyExpr.equals('config.workbench.sideBar.location', 'right')), + when: ContextKeyExpr.and( + IsAuxiliaryTitleBarContext.negate(), + ContextKeyExpr.or( + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), + ContextKeyExpr.equals('config.workbench.sideBar.location', 'right') + ), order: 0 } }, { @@ -145,7 +151,13 @@ MenuRegistry.appendMenuItems([ toggled: { condition: AuxiliaryBarVisibleContext, icon: auxiliaryBarRightIcon }, icon: auxiliaryBarRightOffIcon, }, - when: ContextKeyExpr.and(ContextKeyExpr.or(ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), ContextKeyExpr.equals('config.workbench.sideBar.location', 'left')), + when: ContextKeyExpr.and( + IsAuxiliaryTitleBarContext.negate(), + ContextKeyExpr.or( + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), + ContextKeyExpr.equals('config.workbench.sideBar.location', 'left') + ), order: 2 } }, { diff --git a/src/vs/workbench/browser/parts/banner/bannerPart.ts b/src/vs/workbench/browser/parts/banner/bannerPart.ts index 8495bd425bc..aa0ed529deb 100644 --- a/src/vs/workbench/browser/parts/banner/bannerPart.ts +++ b/src/vs/workbench/browser/parts/banner/bannerPart.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/bannerpart.css'; -import { localize2 } from '../../../../nls.js'; +import { localize, localize2 } from '../../../../nls.js'; import { $, addDisposableListener, append, clearNode, EventType, isHTMLElement } from '../../../../base/browser/dom.js'; import { asCSSUrl } from '../../../../base/browser/cssValue.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; @@ -225,7 +225,7 @@ export class BannerPart extends Part implements IBannerService { // Action const actionBarContainer = append(this.element, $('div.action-container')); this.actionBar = this._register(new ActionBar(actionBarContainer)); - const label = item.closeLabel ?? 'Close Banner'; + const label = item.closeLabel ?? localize('closeBanner', "Close Banner"); const closeAction = this._register(new Action('banner.close', label, ThemeIcon.asClassName(widgetClose), true, () => this.close(item))); this.actionBar.push(closeAction, { icon: true, label: false }); this.actionBar.setFocusable(false); diff --git a/src/vs/workbench/browser/parts/compositeBarActions.ts b/src/vs/workbench/browser/parts/compositeBarActions.ts index 5a792fe9a3a..136b41c9800 100644 --- a/src/vs/workbench/browser/parts/compositeBarActions.ts +++ b/src/vs/workbench/browser/parts/compositeBarActions.ts @@ -9,7 +9,7 @@ import { $, addDisposableListener, append, clearNode, EventHelper, EventType, ge import { ICommandService } from '../../../platform/commands/common/commands.js'; import { toDisposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; -import { IThemeService, IColorTheme, IThemeChangeEvent } from '../../../platform/theme/common/themeService.js'; +import { IThemeService, IColorTheme } from '../../../platform/theme/common/themeService.js'; import { NumberBadge, IBadge, IActivity, ProgressBadge, IconBadge } from '../../services/activity/common/activity.js'; import { IInstantiationService, ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { DelayedDragHandler } from '../../../base/browser/dnd.js'; @@ -289,7 +289,7 @@ export class CompositeBarActionViewItem extends BaseActionViewItem { this.updateTitle(); } - private onThemeChange(theme: IThemeChangeEvent): void { + private onThemeChange(theme: IColorTheme): void { this.updateStyles(); } diff --git a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts index ac461ef9438..d4db3411faf 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts @@ -114,12 +114,9 @@ export class BrowserDialogHandler extends AbstractDialogHandler { customOptions.markdownDetails?.forEach(markdownDetail => { const result = this.markdownRenderer.render(markdownDetail.markdown, { actionHandler: { - callback: link => { - if (markdownDetail.dismissOnLinkClick) { - dialog.dispose(); - } + callback: markdownDetail.actionHandler || (link => { return openLinkFromMarkdown(this.openerService, link, markdownDetail.markdown.isTrusted, true /* skip URL validation to prevent another dialog from showing which is unsupported */); - }, + }), disposables: dialogDisposables } }); diff --git a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts index 4a001a815ec..59276b6858f 100644 --- a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { onDidChangeFullscreen } from '../../../../base/browser/browser.js'; -import { $, hide, show } from '../../../../base/browser/dom.js'; +import { $, getActiveWindow, hide, show } from '../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, markAsSingleton, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { isNative } from '../../../../base/common/platform.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; @@ -27,6 +27,11 @@ import { IWorkbenchLayoutService, shouldShowCustomTitleBar } from '../../../serv import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; import { IStatusbarService } from '../../../services/statusbar/browser/statusbar.js'; import { ITitleService } from '../../../services/title/browser/titleService.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IsAuxiliaryTitleBarContext, IsAuxiliaryWindowFocusedContext, IsCompactTitleBarContext } from '../../../common/contextkeys.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; export interface IAuxiliaryEditorPartOpenOptions extends IAuxiliaryWindowOpenOptions { readonly state?: IEditorPartUIState; @@ -38,6 +43,66 @@ export interface ICreateAuxiliaryEditorPartResult { readonly disposables: DisposableStore; } +const compactWindowEmitter = markAsSingleton(new Emitter<{ windowId: number; compact: boolean | 'toggle' }>()); + +registerAction2(class extends Action2 { + + constructor() { + super({ + id: 'workbench.action.toggleCompactAuxiliaryWindow', + title: localize2('toggleCompactAuxiliaryWindow', "Toggle Window Compact Mode"), + category: Categories.View, + f1: true, + precondition: IsAuxiliaryWindowFocusedContext + }); + } + + override async run(): Promise { + compactWindowEmitter.fire({ windowId: getActiveWindow().vscodeWindowId, compact: 'toggle' }); + } +}); + +registerAction2(class extends Action2 { + + constructor() { + super({ + id: 'workbench.action.enableCompactAuxiliaryWindow', + title: localize('enableCompactAuxiliaryWindow', "Set Compact Mode"), + icon: Codicon.screenFull, + menu: { + id: MenuId.LayoutControlMenu, + when: ContextKeyExpr.and(IsAuxiliaryTitleBarContext, IsCompactTitleBarContext.toNegated()), + order: 0 + } + }); + } + + override async run(): Promise { + compactWindowEmitter.fire({ windowId: getActiveWindow().vscodeWindowId, compact: true }); + } +}); + +registerAction2(class extends Action2 { + + constructor() { + super({ + id: 'workbench.action.disableCompactAuxiliaryWindow', + title: localize('disableCompactAuxiliaryWindow', "Unset Compact Mode"), + icon: Codicon.screenNormal, + toggled: ContextKeyExpr.and(IsAuxiliaryTitleBarContext, IsCompactTitleBarContext), + menu: { + id: MenuId.LayoutControlMenu, + when: ContextKeyExpr.and(IsAuxiliaryTitleBarContext, IsCompactTitleBarContext), + order: 0 + } + }); + } + + override async run(): Promise { + compactWindowEmitter.fire({ windowId: getActiveWindow().vscodeWindowId, compact: false }); + } +}); + export class AuxiliaryEditorPart { private static STATUS_BAR_VISIBILITY = 'workbench.statusBar.visible'; @@ -56,6 +121,10 @@ export class AuxiliaryEditorPart { } async create(label: string, options?: IAuxiliaryEditorPartOpenOptions): Promise { + const that = this; + const disposables = new DisposableStore(); + + let compact = Boolean(options?.compact); function computeEditorPartHeightOffset(): number { let editorPartHeightOffset = 0; @@ -99,7 +168,22 @@ export class AuxiliaryEditorPart { } } - const disposables = new DisposableStore(); + function updateCompact(newCompact: boolean): void { + if (newCompact === compact) { + return; + } + + compact = newCompact; + auxiliaryWindow.updateOptions({ compact }); + titlebarPart?.updateOptions({ compact }); + editorPart.updateOptions({ compact }); + + const oldStatusbarVisible = statusbarVisible; + statusbarVisible = !compact && that.configurationService.getValue(AuxiliaryEditorPart.STATUS_BAR_VISIBILITY) !== false; + if (oldStatusbarVisible !== statusbarVisible) { + updateStatusbarVisibility(true); + } + } // Auxiliary Window const auxiliaryWindow = disposables.add(await this.auxiliaryWindowService.open(options)); @@ -110,6 +194,7 @@ export class AuxiliaryEditorPart { auxiliaryWindow.container.appendChild(editorPartContainer); const editorPart = disposables.add(this.instantiationService.createInstance(AuxiliaryEditorPartImpl, auxiliaryWindow.window.vscodeWindowId, this.editorPartsView, options?.state, label)); + editorPart.updateOptions({ compact }); disposables.add(this.editorPartsView.registerPart(editorPart)); editorPart.create(editorPartContainer); @@ -119,6 +204,7 @@ export class AuxiliaryEditorPart { const useCustomTitle = isNative && hasCustomTitlebar(this.configurationService); // custom title in aux windows only enabled in native if (useCustomTitle) { titlebarPart = disposables.add(this.titleService.createAuxiliaryTitlebarPart(auxiliaryWindow.container, editorPart)); + titlebarPart.updateOptions({ compact }); titlebarVisible = shouldShowCustomTitleBar(this.configurationService, auxiliaryWindow.window, undefined); const handleTitleBarVisibilityEvent = () => { @@ -146,10 +232,10 @@ export class AuxiliaryEditorPart { // Statusbar const statusbarPart = disposables.add(this.statusbarService.createAuxiliaryStatusbarPart(auxiliaryWindow.container)); - let statusbarVisible = this.configurationService.getValue(AuxiliaryEditorPart.STATUS_BAR_VISIBILITY) !== false; + let statusbarVisible = !compact && this.configurationService.getValue(AuxiliaryEditorPart.STATUS_BAR_VISIBILITY) !== false; disposables.add(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AuxiliaryEditorPart.STATUS_BAR_VISIBILITY)) { - statusbarVisible = this.configurationService.getValue(AuxiliaryEditorPart.STATUS_BAR_VISIBILITY) !== false; + statusbarVisible = !compact && this.configurationService.getValue(AuxiliaryEditorPart.STATUS_BAR_VISIBILITY) !== false; updateStatusbarVisibility(true); } @@ -200,7 +286,20 @@ export class AuxiliaryEditorPart { })); auxiliaryWindow.layout(); - // Have a InstantiationService that is scoped to the auxiliary window + // Compact mode + disposables.add(compactWindowEmitter.event(e => { + if (e.windowId === auxiliaryWindow.window.vscodeWindowId) { + let newCompact: boolean; + if (typeof e.compact === 'boolean') { + newCompact = e.compact; + } else { + newCompact = !compact; + } + updateCompact(newCompact); + } + })); + + // Have a scoped instantiation service that is scoped to the auxiliary window const instantiationService = disposables.add(this.instantiationService.createChild(new ServiceCollection( [IStatusbarService, this.statusbarService.createScoped(statusbarPart, disposables)], [IEditorService, this.editorService.createScoped(editorPart, disposables)] @@ -221,6 +320,8 @@ class AuxiliaryEditorPartImpl extends EditorPart implements IAuxiliaryEditorPart private readonly _onWillClose = this._register(new Emitter()); readonly onWillClose = this._onWillClose.event; + private readonly optionsDisposable = this._register(new MutableDisposable()); + constructor( windowId: number, editorPartsView: IEditorPartsView, @@ -238,6 +339,16 @@ class AuxiliaryEditorPartImpl extends EditorPart implements IAuxiliaryEditorPart super(editorPartsView, `workbench.parts.auxiliaryEditor.${id}`, groupsLabel, windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); } + updateOptions(options: { compact: boolean }): void { + if (options.compact && !this.optionsDisposable.value) { + this.optionsDisposable.value = this.enforcePartOptions({ + showTabs: 'none' + }); + } else if (!options.compact) { + this.optionsDisposable.clear(); + } + } + override removeGroup(group: number | IEditorGroupView, preserveFocus?: boolean): void { // Close aux window when last group removed @@ -266,7 +377,7 @@ class AuxiliaryEditorPartImpl extends EditorPart implements IAuxiliaryEditorPart } } - this.doClose(false /* do not merge any groups to main part */); + this.doClose(false /* do not merge any confirming editors to main part */); } protected override loadState(): IEditorPartUIState | undefined { @@ -278,12 +389,19 @@ class AuxiliaryEditorPartImpl extends EditorPart implements IAuxiliaryEditorPart } close(): boolean { - return this.doClose(true /* merge all groups to main part */); + return this.doClose(true /* merge all confirming editors to main part */); } - private doClose(mergeGroupsToMainPart: boolean): boolean { + private doClose(mergeConfirmingEditorsToMainPart: boolean): boolean { let result = true; - if (mergeGroupsToMainPart) { + if (mergeConfirmingEditorsToMainPart) { + + // First close all editors that are non-confirming + for (const group of this.groups) { + group.closeAllEditors({ excludeConfirming: true }); + } + + // Then merge remaining to main part result = this.mergeGroupsToMainPart(); } diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts b/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts index 351252cecda..838d74fd56f 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts @@ -290,6 +290,7 @@ export class BreadcrumbsControl { dispose(): void { this._disposables.dispose(); this._breadcrumbsDisposables.dispose(); + this._model.dispose(); this._ckBreadcrumbsPossible.reset(); this._ckBreadcrumbsVisible.reset(); this._ckBreadcrumbsActive.reset(); diff --git a/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts b/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts index dc3f83afe6c..df972d58710 100644 --- a/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts @@ -16,6 +16,7 @@ import { TextDiffEditor } from './textDiffEditor.js'; import { ActiveCompareEditorCanSwapContext, TextCompareEditorActiveContext, TextCompareEditorVisibleContext } from '../../../common/contextkeys.js'; import { DiffEditorInput } from '../../../common/editor/diffEditorInput.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IUntypedEditorInput } from '../../../common/editor.js'; export const TOGGLE_DIFF_SIDE_BY_SIDE = 'toggle.diff.renderSideBySide'; export const GOTO_NEXT_CHANGE = 'workbench.action.compareEditor.nextChange'; @@ -150,14 +151,14 @@ export function registerDiffEditorCommands(): void { // yet opened. This ensures that the swapping is not // bringing up a confirmation dialog to save. if (diffInput.modified.isModified() && editorService.findEditors({ resource: diffInput.modified.resource, typeId: diffInput.modified.typeId, editorId: diffInput.modified.editorId }).length === 0) { - await editorService.openEditor({ - ...untypedDiffInput.modified, - options: { - ...untypedDiffInput.modified.options, - pinned: true, - inactive: true - } - }, activeGroup); + const editorToOpen: IUntypedEditorInput = { ...untypedDiffInput.modified }; + if (!editorToOpen.options) { + editorToOpen.options = {}; + } + editorToOpen.options.pinned = true; + editorToOpen.options.inactive = true; + + await editorService.openEditor(editorToOpen, activeGroup); } // Replace the input with the swapped variant diff --git a/src/vs/workbench/browser/parts/editor/editorActions.ts b/src/vs/workbench/browser/parts/editor/editorActions.ts index 52d1dc5859c..d7452326923 100644 --- a/src/vs/workbench/browser/parts/editor/editorActions.ts +++ b/src/vs/workbench/browser/parts/editor/editorActions.ts @@ -586,9 +586,17 @@ abstract class AbstractCloseAllAction extends Action2 { for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: this.excludeSticky })) { let confirmClose = false; + let handlerDidError = false; if (editor.closeHandler) { - confirmClose = editor.closeHandler.showConfirm(); // custom handling of confirmation on close - } else { + try { + confirmClose = editor.closeHandler.showConfirm(); // custom handling of confirmation on close + } catch (error) { + logService.error(error); + handlerDidError = true; + } + } + + if (!editor.closeHandler || handlerDidError) { confirmClose = editor.isDirty() && !editor.isSaving(); // default confirm only when dirty and not saving } diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 83a724a79b1..9463dfc1de3 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -61,6 +61,7 @@ export const LOCK_GROUP_COMMAND_ID = 'workbench.action.lockEditorGroup'; export const UNLOCK_GROUP_COMMAND_ID = 'workbench.action.unlockEditorGroup'; export const SHOW_EDITORS_IN_GROUP = 'workbench.action.showEditorsInGroup'; export const REOPEN_WITH_COMMAND_ID = 'workbench.action.reopenWithEditor'; +export const REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID = 'reopenActiveEditorWith'; export const PIN_EDITOR_COMMAND_ID = 'workbench.action.pinEditor'; export const UNPIN_EDITOR_COMMAND_ID = 'workbench.action.unpinEditor'; @@ -867,74 +868,88 @@ function registerCloseEditorCommands() { weight: KeybindingWeight.WorkbenchContrib, when: undefined, primary: undefined, - handler: async (accessor, ...args: unknown[]) => { - const editorService = accessor.get(IEditorService); - const editorResolverService = accessor.get(IEditorResolverService); - const telemetryService = accessor.get(ITelemetryService); - - const resolvedContext = resolveCommandsContext(args, editorService, accessor.get(IEditorGroupsService), accessor.get(IListService)); - const editorReplacements = new Map(); - - for (const { group, editors } of resolvedContext.groupedEditors) { - for (const editor of editors) { - const untypedEditor = editor.toUntyped(); - if (!untypedEditor) { - return; // Resolver can only resolve untyped editors - } - - untypedEditor.options = { ...editorService.activeEditorPane?.options, override: EditorResolution.PICK }; - const resolvedEditor = await editorResolverService.resolveEditor(untypedEditor, group); - if (!isEditorInputWithOptionsAndGroup(resolvedEditor)) { - return; - } - - let editorReplacementsInGroup = editorReplacements.get(group); - if (!editorReplacementsInGroup) { - editorReplacementsInGroup = []; - editorReplacements.set(group, editorReplacementsInGroup); - } - - editorReplacementsInGroup.push({ - editor: editor, - replacement: resolvedEditor.editor, - forceReplaceDirty: editor.resource?.scheme === Schemas.untitled, - options: resolvedEditor.options - }); - - // Telemetry - type WorkbenchEditorReopenClassification = { - owner: 'rebornix'; - comment: 'Identify how a document is reopened'; - scheme: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'File system provider scheme for the resource' }; - ext: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'File extension for the resource' }; - from: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The editor view type the resource is switched from' }; - to: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The editor view type the resource is switched to' }; - }; - - type WorkbenchEditorReopenEvent = { - scheme: string; - ext: string; - from: string; - to: string; - }; - - telemetryService.publicLog2('workbenchEditorReopen', { - scheme: editor.resource?.scheme ?? '', - ext: editor.resource ? extname(editor.resource) : '', - from: editor.editorId ?? '', - to: resolvedEditor.editor.editorId ?? '' - }); - } - } - - // Replace editor with resolved one and make active - for (const [group, replacements] of editorReplacements) { - await group.replaceEditors(replacements); - await group.openEditor(replacements[0].replacement); - } + handler: (accessor, ...args: unknown[]) => { + return reopenEditorWith(accessor, EditorResolution.PICK, ...args); } }); + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID, + weight: KeybindingWeight.WorkbenchContrib, + when: undefined, + primary: undefined, + handler: (accessor, override?: string, ...args: unknown[]) => { + return reopenEditorWith(accessor, override ?? EditorResolution.PICK, ...args); + } + }); + + async function reopenEditorWith(accessor: ServicesAccessor, editorOverride: string | EditorResolution, ...args: unknown[]) { + const editorService = accessor.get(IEditorService); + const editorResolverService = accessor.get(IEditorResolverService); + const telemetryService = accessor.get(ITelemetryService); + + const resolvedContext = resolveCommandsContext(args, editorService, accessor.get(IEditorGroupsService), accessor.get(IListService)); + const editorReplacements = new Map(); + + for (const { group, editors } of resolvedContext.groupedEditors) { + for (const editor of editors) { + const untypedEditor = editor.toUntyped(); + if (!untypedEditor) { + return; // Resolver can only resolve untyped editors + } + + untypedEditor.options = { ...editorService.activeEditorPane?.options, override: editorOverride }; + const resolvedEditor = await editorResolverService.resolveEditor(untypedEditor, group); + if (!isEditorInputWithOptionsAndGroup(resolvedEditor)) { + return; + } + + let editorReplacementsInGroup = editorReplacements.get(group); + if (!editorReplacementsInGroup) { + editorReplacementsInGroup = []; + editorReplacements.set(group, editorReplacementsInGroup); + } + + editorReplacementsInGroup.push({ + editor: editor, + replacement: resolvedEditor.editor, + forceReplaceDirty: editor.resource?.scheme === Schemas.untitled, + options: resolvedEditor.options + }); + + // Telemetry + type WorkbenchEditorReopenClassification = { + owner: 'rebornix'; + comment: 'Identify how a document is reopened'; + scheme: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'File system provider scheme for the resource' }; + ext: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'File extension for the resource' }; + from: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The editor view type the resource is switched from' }; + to: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The editor view type the resource is switched to' }; + }; + + type WorkbenchEditorReopenEvent = { + scheme: string; + ext: string; + from: string; + to: string; + }; + + telemetryService.publicLog2('workbenchEditorReopen', { + scheme: editor.resource?.scheme ?? '', + ext: editor.resource ? extname(editor.resource) : '', + from: editor.editorId ?? '', + to: resolvedEditor.editor.editorId ?? '' + }); + } + } + + // Replace editor with resolved one and make active + for (const [group, replacements] of editorReplacements) { + await group.replaceEditors(replacements); + await group.openEditor(replacements[0].replacement); + } + } + CommandsRegistry.registerCommand(CLOSE_EDITORS_AND_GROUP_COMMAND_ID, async (accessor: ServicesAccessor, ...args: unknown[]) => { const editorGroupsService = accessor.get(IEditorGroupsService); diff --git a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts index c235e44adcd..501b35cad23 100644 --- a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts +++ b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts @@ -24,7 +24,8 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc private static readonly AUTO_LOCK_DEFAULT_ENABLED = new Set([ 'terminalEditor', 'mainThreadWebview-simpleBrowser.view', - 'mainThreadWebview-browserPreview' + 'mainThreadWebview-browserPreview', + 'workbench.editor.chatSession' ]); private static readonly AUTO_LOCK_EXTRA_EDITORS: RegisteredEditorInfo[] = [ diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index 1a6788573ec..f78f3150637 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -1771,12 +1771,18 @@ export class EditorGroupView extends Themable implements IEditorGroupView { await this.hostService.focus(getWindow(this.element)); // Let editor handle confirmation if implemented + let handlerDidError = false; if (typeof editor.closeHandler?.confirm === 'function') { - confirmation = await editor.closeHandler.confirm([{ editor, groupId: this.id }]); + try { + confirmation = await editor.closeHandler.confirm([{ editor, groupId: this.id }]); + } catch (e) { + this.logService.error(e); + handlerDidError = true; + } } - // Show a file specific confirmation - else { + // Show a file specific confirmation if there is no handler or it errored + if (typeof editor.closeHandler?.confirm !== 'function' || handlerDidError) { let name: string; if (editor instanceof SideBySideEditorInput) { name = editor.primary.getName(); // prefer shorter names by using primary's name in this case @@ -1839,7 +1845,11 @@ export class EditorGroupView extends Themable implements IEditorGroupView { private shouldConfirmClose(editor: EditorInput): boolean { if (editor.closeHandler) { - return editor.closeHandler.showConfirm(); // custom handling of confirmation on close + try { + return editor.closeHandler.showConfirm(); // custom handling of confirmation on close + } catch (error) { + this.logService.error(error); + } } return editor.isDirty() && !editor.isSaving(); // editor must be dirty and not saving @@ -1925,7 +1935,9 @@ export class EditorGroupView extends Themable implements IEditorGroupView { //#region closeAllEditors() - async closeAllEditors(options?: ICloseAllEditorsOptions): Promise { + closeAllEditors(options: { excludeConfirming: true }): boolean; + closeAllEditors(options?: ICloseAllEditorsOptions): Promise; + closeAllEditors(options?: ICloseAllEditorsOptions): boolean | Promise { if (this.isEmpty) { // If the group is empty and the request is to close all editors, we still close @@ -1938,22 +1950,21 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return true; } - // Apply the `excludeConfirming` filter if present - let editors = this.model.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, options); + // We can go ahead and close "sync" when we exclude confirming editors if (options?.excludeConfirming) { - editors = editors.filter(editor => !this.shouldConfirmClose(editor)); + this.doCloseAllEditors(options); + return true; } - // Check for confirmation and veto - const veto = await this.handleCloseConfirmation(editors); - if (veto) { - return false; - } + // Otherwise go through potential confirmation "async" + return this.handleCloseConfirmation(this.model.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, options)).then(veto => { + if (veto) { + return false; + } - // Do close - this.doCloseAllEditors(options); - - return true; + this.doCloseAllEditors(options); + return true; + }); } private doCloseAllEditors(options?: ICloseAllEditorsOptions): void { diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index 31c863e9901..924d9b33607 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -104,6 +104,7 @@ height: var(--editor-group-tab-height); box-sizing: border-box; padding-left: 10px; + outline-offset: -2px; } /* Tab Background Color in/active group/tab */ @@ -300,10 +301,6 @@ background-color: var(--tab-border-bottom-color); } -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.tab-border-bottom:focus > .tab-border-bottom-container { - background-color: var(--vscode-focusBorder); -} - .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty-border-top:not(:focus) > .tab-border-top-container { z-index: 6; top: 0; diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 04f6aefec26..88f8e4f9842 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -515,16 +515,15 @@ export class MultiEditorTabsControl extends EditorTabsControl { // redraw of tabs. const activeEditorChanged = this.didActiveEditorChange(); - const oldActiveTabLabel = this.activeTabLabel; - const oldTabLabelsLength = this.tabLabels.length; + const oldTabLabels = this.tabLabels; this.computeTabLabels(); // Redraw and update in these cases let didChange = false; if ( - activeEditorChanged || // active editor changed - oldTabLabelsLength !== this.tabLabels.length || // number of tabs changed - !this.equalsEditorInputLabel(oldActiveTabLabel, this.activeTabLabel) // active editor label changed + activeEditorChanged || // active editor changed + oldTabLabels.length !== this.tabLabels.length || // number of tabs changed + oldTabLabels.some((label, index) => !this.equalsEditorInputLabel(label, this.tabLabels.at(index))) // editor labels changed ) { this.redraw({ forceRevealActiveTab: true }); didChange = true; diff --git a/src/vs/workbench/browser/parts/globalCompositeBar.ts b/src/vs/workbench/browser/parts/globalCompositeBar.ts index e927fbca8e3..fd90b8c7a06 100644 --- a/src/vs/workbench/browser/parts/globalCompositeBar.ts +++ b/src/vs/workbench/browser/parts/globalCompositeBar.ts @@ -408,11 +408,6 @@ export class AccountsActivityActionViewItem extends AbstractGlobalActivityAction } } - if (providers.length && !menus.length) { - const noAccountsAvailableAction = disposables.add(new Action('noAccountsAvailable', localize('noAccounts', "You are not signed in to any accounts"), undefined, false)); - menus.push(noAccountsAvailableAction); - } - if (menus.length && otherCommands.length) { menus.push(new Separator()); } diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css b/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css index 2025ec3ea7d..79a7fd74d33 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css @@ -6,8 +6,8 @@ .monaco-workbench > .notifications-center { position: absolute; z-index: 1000; - right: 11px; /* attempt to position at same location as a toast */ - bottom: 33px; /* 22px status bar height + 11px (attempt to position at same location as a toast) */ + right: 7px; /* attempt to position at same location as a toast */ + bottom: 29px; /* 22px status bar height + 7px (attempt to position at same location as a toast) */ display: none; overflow: hidden; border-radius: 4px; diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css b/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css index f12d6b925df..033a0026ebd 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css @@ -26,7 +26,7 @@ } .monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast { - margin: 8px; /* enables separation and drop shadows around toasts */ + margin: 4px; /* enables separation and drop shadows around toasts */ transform: translate3d(0px, 100%, 0px); /* move the notification 50px to the bottom (to prevent bleed through) */ opacity: 0; /* fade the toast in */ transition: transform 300ms ease-out, opacity 300ms ease-out; diff --git a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts index 3a02bb87193..cc5e523f5b8 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts @@ -7,7 +7,7 @@ import './media/notificationsToasts.css'; import { localize } from '../../../../nls.js'; import { INotificationsModel, NotificationChangeType, INotificationChangeEvent, INotificationViewItem, NotificationViewItemContentChangeKind } from '../../../common/notifications.js'; import { IDisposable, dispose, toDisposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { addDisposableListener, EventType, Dimension, scheduleAtNextAnimationFrame, isAncestorOfActiveElement, getWindow, $ } from '../../../../base/browser/dom.js'; +import { addDisposableListener, EventType, Dimension, scheduleAtNextAnimationFrame, isAncestorOfActiveElement, getWindow, $, isElementInBottomRightQuarter, isHTMLElement, isEditableElement, getActiveElement } from '../../../../base/browser/dom.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { NotificationsList } from './notificationsList.js'; import { Event, Emitter } from '../../../../base/common/event.js'; @@ -141,6 +141,13 @@ export class NotificationsToasts extends Themable implements INotificationsToast return; // do not show toasts for silenced notifications } + if (item.priority === NotificationPriority.OPTIONAL) { + const activeElement = getActiveElement(); + if (isHTMLElement(activeElement) && isEditableElement(activeElement) && isElementInBottomRightQuarter(activeElement, this.layoutService.mainContainer)) { + return; // skip showing optional toast that potentially covers input fields + } + } + // Optimization: it is possible that a lot of notifications are being // added in a very short time. To prevent this kind of spam, we protect // against showing too many notifications at once. Since they can always diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index d1d67e1ebef..f164e31785e 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -40,6 +40,7 @@ import { ViewsSubMenu } from './views/viewPaneContainer.js'; import { getActionBarActions } from '../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { HiddenItemStrategy, WorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; +import { DeferredPromise } from '../../../base/common/async.js'; export enum CompositeBarPosition { TOP, @@ -130,7 +131,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart | undefined = undefined; protected contentDimension: Dimension | undefined; constructor( @@ -511,21 +512,34 @@ export abstract class AbstractPaneCompositePart extends CompositePart { if (this.blockOpening) { - return undefined; // Workaround against a potential race condition + // Workaround against a potential race condition when calling + // `setPartHidden` we may end up in `openPaneComposite` again. + // But we still want to return the result of the original call, + // so we return the promise of the original call. + return this.blockOpening.p; } + let blockOpening: DeferredPromise | undefined; if (!this.layoutService.isVisible(this.partId)) { try { - this.blockOpening = true; + blockOpening = this.blockOpening = new DeferredPromise(); this.layoutService.setPartHidden(false, this.partId); } finally { - this.blockOpening = false; + this.blockOpening = undefined; } } - return this.openComposite(id, focus) as PaneComposite; + try { + const result = this.openComposite(id, focus) as PaneComposite | undefined; + blockOpening?.complete(result); + + return result; + } catch (error) { + blockOpening?.error(error); + throw error; + } } getPaneComposite(id: string): PaneCompositeDescriptor | undefined { diff --git a/src/vs/workbench/browser/parts/panel/panelActions.ts b/src/vs/workbench/browser/parts/panel/panelActions.ts index 6e43176a62e..bff8b9abf9e 100644 --- a/src/vs/workbench/browser/parts/panel/panelActions.ts +++ b/src/vs/workbench/browser/parts/panel/panelActions.ts @@ -9,7 +9,7 @@ import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { MenuId, MenuRegistry, registerAction2, Action2, IAction2Options } from '../../../../platform/actions/common/actions.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { isHorizontal, IWorkbenchLayoutService, PanelAlignment, Parts, Position, positionToString } from '../../../services/layout/browser/layoutService.js'; -import { PanelAlignmentContext, PanelMaximizedContext, PanelPositionContext, PanelVisibleContext } from '../../../common/contextkeys.js'; +import { IsAuxiliaryTitleBarContext, PanelAlignmentContext, PanelMaximizedContext, PanelPositionContext, PanelVisibleContext } from '../../../common/contextkeys.js'; import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; @@ -323,7 +323,14 @@ MenuRegistry.appendMenuItems([ icon: panelOffIcon, toggled: { condition: PanelVisibleContext, icon: panelIcon } }, - when: ContextKeyExpr.or(ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), + when: + ContextKeyExpr.and( + IsAuxiliaryTitleBarContext.negate(), + ContextKeyExpr.or( + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), + ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both') + ) + ), order: 1 } } diff --git a/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css b/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css index 9e1e2041ab8..a21f003405a 100644 --- a/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css +++ b/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css @@ -17,7 +17,19 @@ transition: background-color 0.15s ease-out; } -.monaco-workbench .part.statusbar.status-border-top::after { +.monaco-workbench.mac:not(.fullscreen) .part.statusbar:focus { + /* Rounded corners to make focus outline appear properly (unless fullscreen) */ + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; +} +.monaco-workbench.mac:not(.fullscreen).macos-bigsur-or-newer .part.statusbar:focus { + /* macOS Big Sur increased rounded corners size */ + border-bottom-right-radius: 10px; + border-bottom-left-radius: 10px; +} + +.monaco-workbench .part.statusbar:not(:focus).status-border-top::after { + /* Top border only visible unless focused to make room for focus outline */ content: ''; position: absolute; top: 0; @@ -73,32 +85,12 @@ border-right: 5px solid transparent; } -.monaco-workbench .part.statusbar > .items-container > .statusbar-item > .statusbar-item-label { - margin-right: 3px; - margin-left: 3px; -} - -.monaco-workbench .part.statusbar > .items-container > .statusbar-item.compact-left > .statusbar-item-label { - margin-right: 3px; - margin-left: 0; -} - -.monaco-workbench .part.statusbar > .items-container > .statusbar-item.compact-right > .statusbar-item-label { - margin-right: 0; - margin-left: 3px; -} - -.monaco-workbench .part.statusbar > .items-container > .statusbar-item.compact-left.compact-right > .statusbar-item-label { - margin-right:0; - margin-left: 0; -} - .monaco-workbench .part.statusbar > .items-container > .statusbar-item.left.first-visible-item { padding-left: 7px; /* Add padding to the most left status bar item */ } .monaco-workbench .part.statusbar > .items-container > .statusbar-item.right.last-visible-item { - padding-right: 7px; /* Add padding to the most right status bar item */ + margin-right: 7px; /* Add margin to the most right status bar item. Margin is used to position beak properly. */ } /* Tweak appearance for items with background to improve hover feedback */ @@ -108,9 +100,40 @@ padding-left: 0; } -.monaco-workbench .part.statusbar > .items-container > .statusbar-item.has-background-color > .statusbar-item-label { - margin-right: 0; +.monaco-workbench .part.statusbar > .items-container > .statusbar-item > .statusbar-item-label { + cursor: pointer; + display: flex; + height: 100%; + margin-right: 3px; + margin-left: 3px; + padding: 0 5px 0 5px; + white-space: pre; /* gives some degree of styling */ + align-items: center; + text-overflow: ellipsis; + overflow: hidden; + outline-width: 0px; /* do not render focus outline, we already have background */ +} + +.monaco-workbench .part.statusbar > .items-container > .statusbar-item.compact-left > .statusbar-item-label { margin-left: 0; + margin-right: 5px; /* +2px because padding is smaller and we want to preserve spacing between items */ + padding: 0 3px; +} + +.monaco-workbench .part.statusbar > .items-container > .statusbar-item.compact-right > .statusbar-item-label { + margin-left: 5px; /* +2px because padding is smaller and we want to preserve spacing between items */ + margin-right: 0; + padding: 0 3px; +} + +.monaco-workbench .part.statusbar > .items-container > .statusbar-item.compact-left.compact-right > .statusbar-item-label { + margin-left: 0; + margin-right:0; +} + +.monaco-workbench .part.statusbar > .items-container > .statusbar-item.has-background-color > .statusbar-item-label { + margin-left: 0; + margin-right: 0; padding-left: 10px; padding-right: 10px; } @@ -125,26 +148,6 @@ padding-right: 3px; } -.monaco-workbench .part.statusbar > .items-container > .statusbar-item > .statusbar-item-label { - cursor: pointer; - display: flex; - height: 100%; - padding: 0 5px 0 5px; - white-space: pre; /* gives some degree of styling */ - align-items: center; - text-overflow: ellipsis; - overflow: hidden; - outline-width: 0px; /* do not render focus outline, we already have background */ -} - -.monaco-workbench .part.statusbar > .items-container > .statusbar-item.compact-left > .statusbar-item-label { - padding: 0 3px; -} - -.monaco-workbench .part.statusbar > .items-container > .statusbar-item.compact-right > .statusbar-item-label { - padding: 0 3px; -} - .monaco-workbench .part.statusbar > .items-container > .statusbar-item > a:hover:not(.disabled) { text-decoration: none; color: var(--vscode-statusBarItem-hoverForeground); diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts index 26078d8b0b0..241526727af 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts @@ -252,8 +252,8 @@ export class StatusbarEntryItem extends Disposable { if (isThemeColor(color)) { colorResult = this.themeService.getColorTheme().getColor(color.id)?.toString(); - const listener = this.themeService.onDidColorThemeChange(e => { - const colorValue = e.theme.getColor(color.id)?.toString(); + const listener = this.themeService.onDidColorThemeChange(theme => { + const colorValue = theme.getColor(color.id)?.toString(); if (isBackground) { container.style.backgroundColor = colorValue ?? ''; diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts index 2e6ce3bc4a2..e601d324307 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts @@ -188,6 +188,9 @@ class StatusbarPart extends Part implements IStatusbarEntryContainer { persistence: { hideOnKeyDown: true, sticky: focus + }, + appearance: { + maxHeightRatio: 0.9 } } ))); @@ -675,7 +678,7 @@ class StatusbarPart extends Part implements IStatusbarEntryContainer { /* Notification Beak */ .monaco-workbench .part.statusbar > .items-container > .statusbar-item.has-beak > .status-bar-item-beak-container:before { - border-bottom-color: ${backgroundColor}; + border-bottom-color: ${borderColor ?? backgroundColor}; } `; } diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts index e09172e4a7f..f8e5e74bf6b 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts @@ -12,16 +12,14 @@ import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/c import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ACCOUNTS_ACTIVITY_ID, GLOBAL_ACTIVITY_ID } from '../../../common/activity.js'; import { IAction } from '../../../../base/common/actions.js'; -import { IsAuxiliaryWindowFocusedContext, IsMainWindowFullscreenContext, TitleBarStyleContext, TitleBarVisibleContext } from '../../../common/contextkeys.js'; +import { IsMainWindowFullscreenContext, IsCompactTitleBarContext, TitleBarStyleContext, TitleBarVisibleContext } from '../../../common/contextkeys.js'; import { CustomTitleBarVisibility, TitleBarSetting, TitlebarStyle } from '../../../../platform/window/common/window.js'; -import { isLinux, isNative } from '../../../../base/common/platform.js'; // --- Context Menu Actions --- // export class ToggleTitleBarConfigAction extends Action2 { - constructor(private readonly section: string, title: string, description: string | ILocalizedString | undefined, order: number, mainWindowOnly: boolean, when?: ContextKeyExpression) { - when = ContextKeyExpr.and(mainWindowOnly ? IsAuxiliaryWindowFocusedContext.toNegated() : ContextKeyExpr.true(), when); + constructor(private readonly section: string, title: string, description: string | ILocalizedString | undefined, order: number, when?: ContextKeyExpression) { super({ id: `toggle.${section}`, @@ -54,19 +52,19 @@ export class ToggleTitleBarConfigAction extends Action2 { registerAction2(class ToggleCommandCenter extends ToggleTitleBarConfigAction { constructor() { - super(LayoutSettings.COMMAND_CENTER, localize('toggle.commandCenter', 'Command Center'), localize('toggle.commandCenterDescription', "Toggle visibility of the Command Center in title bar"), 1, false); + super(LayoutSettings.COMMAND_CENTER, localize('toggle.commandCenter', 'Command Center'), localize('toggle.commandCenterDescription', "Toggle visibility of the Command Center in title bar"), 1, IsCompactTitleBarContext.toNegated()); } }); registerAction2(class ToggleNavigationControl extends ToggleTitleBarConfigAction { constructor() { - super('workbench.navigationControl.enabled', localize('toggle.navigation', 'Navigation Controls'), localize('toggle.navigationDescription', "Toggle visibility of the Navigation Controls in title bar"), 2, false, ContextKeyExpr.has('config.window.commandCenter')); + super('workbench.navigationControl.enabled', localize('toggle.navigation', 'Navigation Controls'), localize('toggle.navigationDescription', "Toggle visibility of the Navigation Controls in title bar"), 2, ContextKeyExpr.and(IsCompactTitleBarContext.toNegated(), ContextKeyExpr.has('config.window.commandCenter'))); } }); registerAction2(class ToggleLayoutControl extends ToggleTitleBarConfigAction { constructor() { - super(LayoutSettings.LAYOUT_ACTIONS, localize('toggle.layout', 'Layout Controls'), localize('toggle.layoutDescription', "Toggle visibility of the Layout Controls in title bar"), 4, true); + super(LayoutSettings.LAYOUT_ACTIONS, localize('toggle.layout', 'Layout Controls'), localize('toggle.layoutDescription', "Toggle visibility of the Layout Controls in title bar"), 4); } }); @@ -259,26 +257,6 @@ registerAction2(class ToggleEditorActions extends Action2 { } }); -if (isLinux && isNative) { - registerAction2(class ToggleCustomTitleBar extends Action2 { - constructor() { - super({ - id: `toggle.${TitleBarSetting.TITLE_BAR_STYLE}`, - title: localize('toggle.titleBarStyle', 'Restore Native Title Bar'), - menu: [ - { id: MenuId.TitleBarContext, order: 0, when: ContextKeyExpr.equals(TitleBarStyleContext.key, TitlebarStyle.CUSTOM), group: '4_restore_native_title' }, - { id: MenuId.TitleBarTitleContext, order: 0, when: ContextKeyExpr.equals(TitleBarStyleContext.key, TitlebarStyle.CUSTOM), group: '4_restore_native_title' }, - ] - }); - } - - run(accessor: ServicesAccessor): void { - const configService = accessor.get(IConfigurationService); - configService.updateValue(TitleBarSetting.TITLE_BAR_STYLE, TitlebarStyle.NATIVE); - } - }); -} - // --- Toolbar actions --- // export const ACCOUNTS_ACTIVITY_TILE_ACTION: IAction = { diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 87fa51d50d8..62dfcd39e87 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -26,7 +26,7 @@ import { IStorageService, StorageScope } from '../../../../platform/storage/comm import { Parts, IWorkbenchLayoutService, ActivityBarPosition, LayoutSettings, EditorActionsLocation, EditorTabsMode } from '../../../services/layout/browser/layoutService.js'; import { createActionViewItem, fillInActionBarActions as fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { Action2, IMenu, IMenuService, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { WindowTitle } from './windowTitle.js'; import { CommandCenterControl } from './commandCenterControl.js'; @@ -54,7 +54,7 @@ import { IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionba import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js'; import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { safeIntl } from '../../../../base/common/date.js'; -import { TitleBarVisibleContext } from '../../../common/contextkeys.js'; +import { IsAuxiliaryTitleBarContext, IsCompactTitleBarContext, TitleBarVisibleContext } from '../../../common/contextkeys.js'; export interface ITitleVariable { readonly name: string; @@ -248,6 +248,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { //#endregion + protected scopedContextKeyService: IContextKeyService; + protected rootContainer!: HTMLElement; protected windowControlsContainer: HTMLElement | undefined; @@ -269,8 +271,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private readonly editorActionsChangeDisposable = this._register(new DisposableStore()); private actionToolBarElement!: HTMLElement; - private globalToolbarMenu: IMenu; - private hasGlobalToolbarEntries = false; + private globalToolbarMenu: IMenu | undefined; private layoutToolbarMenu: IMenu | undefined; private readonly globalToolbarMenuDisposables = this._register(new DisposableStore()); @@ -284,7 +285,10 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private titleBarStyle: TitlebarStyle; private isInactive: boolean = false; + private readonly isAuxiliary: boolean; + private isCompact = false; + private readonly isCompactContextKey: IContextKey; private readonly windowTitle: WindowTitle; @@ -302,7 +306,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { @IThemeService themeService: IThemeService, @IStorageService private readonly storageService: IStorageService, @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextKeyService contextKeyService: IContextKeyService, @IHostService private readonly hostService: IHostService, @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @@ -311,10 +315,18 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { ) { super(id, { hasTitle: false }, themeService, storageService, layoutService); - this.titleBarStyle = getTitleBarStyle(this.configurationService); - this.globalToolbarMenu = this._register(this.menuService.createMenu(MenuId.TitleBar, this.contextKeyService)); - this.isAuxiliary = editorGroupsContainer !== 'main'; + + this.scopedContextKeyService = contextKeyService.createScoped(layoutService.getContainer(targetWindow)); + + const isAuxiliaryTitleBarContext = IsAuxiliaryTitleBarContext.bindTo(this.scopedContextKeyService); + isAuxiliaryTitleBarContext.set(this.isAuxiliary); + + this.isCompactContextKey = IsCompactTitleBarContext.bindTo(this.scopedContextKeyService); + this.isCompactContextKey.set(this.isCompact); + + this.titleBarStyle = getTitleBarStyle(this.configurationService); + this.editorService = editorService.createScoped(editorGroupsContainer, this._store); this.editorGroupsContainer = editorGroupsContainer === 'main' ? editorGroupService.mainPart : editorGroupsContainer; @@ -384,9 +396,25 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { // Command Center if (event.affectsConfiguration(LayoutSettings.COMMAND_CENTER)) { - this.createTitle(); + this.recreateTitle(); + } + } - this._onDidChange.fire(undefined); + private recreateTitle(): void { + this.createTitle(); + + this._onDidChange.fire(undefined); + } + + updateOptions(options: { compact: boolean }): void { + const oldIsCompact = this.isCompact; + this.isCompact = options.compact; + + this.isCompactContextKey.set(this.isCompact); + + if (oldIsCompact !== this.isCompact) { + this.recreateTitle(); + this.createActionToolBarMenus(true); } } @@ -548,9 +576,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { this.title.innerText = this.windowTitle.value; this.titleDisposables.add(this.windowTitle.onDidChange(() => { this.title.innerText = this.windowTitle.value; - // layout menubar and other renderings in the titlebar if (this.lastLayoutDimensions) { - this.updateLayout(this.lastLayoutDimensions); + this.updateLayout(this.lastLayoutDimensions); // layout menubar and other renderings in the titlebar } })); } @@ -590,12 +617,12 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } private getKeybinding(action: IAction): ResolvedKeybinding | undefined { - const editorPaneAwareContextKeyService = this.editorGroupsContainer.activeGroup?.activeEditorPane?.scopedContextKeyService ?? this.contextKeyService; + const editorPaneAwareContextKeyService = this.editorGroupsContainer.activeGroup?.activeEditorPane?.scopedContextKeyService ?? this.scopedContextKeyService; return this.keybindingService.lookupKeybinding(action.id, editorPaneAwareContextKeyService); } - private createActionToolBar() { + private createActionToolBar(): void { // Creates the action tool bar. Depends on the configuration of the title bar menus // Requires to be recreated whenever editor actions enablement changes @@ -610,7 +637,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { overflowBehavior: { maxItems: 9, exempted: [ACCOUNTS_ACTIVITY_ID, GLOBAL_ACTIVITY_ID, ...EDITOR_CORE_NAVIGATION_COMMANDS] }, anchorAlignmentProvider: () => AnchorAlignment.RIGHT, telemetrySource: 'titlePart', - highlightToggledItems: this.editorActionsEnabled, // Only show toggled state for editor actions (Layout actions are not shown as toggled) + highlightToggledItems: this.editorActionsEnabled || this.isAuxiliary, // Only show toggled state for editor actions or auxiliary title bars actionViewItemProvider: (action, options) => this.actionViewItemProvider(action, options), hoverDelegate: this.hoverDelegate })); @@ -620,9 +647,9 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } - private createActionToolBarMenus(update: true | { editorActions?: boolean; layoutActions?: boolean; activityActions?: boolean } = true) { + private createActionToolBarMenus(update: true | { editorActions?: boolean; layoutActions?: boolean; globalActions?: boolean; activityActions?: boolean } = true): void { if (update === true) { - update = { editorActions: true, layoutActions: true, activityActions: true }; + update = { editorActions: true, layoutActions: true, globalActions: true, activityActions: true }; } const updateToolBarActions = () => { @@ -644,12 +671,12 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } // --- Global Actions - const globalToolbarActions = this.globalToolbarMenu.getActions(); - this.hasGlobalToolbarEntries = globalToolbarActions.length > 0; - fillInActionBarActions( - globalToolbarActions, - actions - ); + if (this.globalToolbarMenu) { + fillInActionBarActions( + this.globalToolbarMenu.getActions(), + actions + ); + } // --- Layout Actions if (this.layoutToolbarMenu) { @@ -694,7 +721,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { this.layoutToolbarMenuDisposables.clear(); if (this.layoutControlEnabled) { - this.layoutToolbarMenu = this.menuService.createMenu(MenuId.LayoutControlMenu, this.contextKeyService); + this.layoutToolbarMenu = this.menuService.createMenu(MenuId.LayoutControlMenu, this.scopedContextKeyService); this.layoutToolbarMenuDisposables.add(this.layoutToolbarMenu); this.layoutToolbarMenuDisposables.add(this.layoutToolbarMenu.onDidChange(() => updateToolBarActions())); @@ -703,8 +730,18 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } - this.globalToolbarMenuDisposables.clear(); - this.globalToolbarMenuDisposables.add(this.globalToolbarMenu.onDidChange(() => updateToolBarActions())); + if (update.globalActions) { + this.globalToolbarMenuDisposables.clear(); + + if (this.globalActionsEnabled) { + this.globalToolbarMenu = this.menuService.createMenu(MenuId.TitleBar, this.scopedContextKeyService); + + this.globalToolbarMenuDisposables.add(this.globalToolbarMenu); + this.globalToolbarMenuDisposables.add(this.globalToolbarMenu.onDidChange(() => updateToolBarActions())); + } else { + this.globalToolbarMenu = undefined; + } + } if (update.activityActions) { this.activityToolbarDisposables.clear(); @@ -761,7 +798,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { this.contextMenuService.showContextMenu({ getAnchor: () => event, menuId, - contextKeyService: this.contextKeyService, + contextKeyService: this.scopedContextKeyService, domForShadowRoot: isMacintosh && isNative ? event.target : undefined }); } @@ -775,15 +812,15 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } private get layoutControlEnabled(): boolean { - return !this.isAuxiliary && this.configurationService.getValue(LayoutSettings.LAYOUT_ACTIONS) !== false; + return this.configurationService.getValue(LayoutSettings.LAYOUT_ACTIONS) !== false; } protected get isCommandCenterVisible() { - return this.configurationService.getValue(LayoutSettings.COMMAND_CENTER) !== false; + return !this.isCompact && this.configurationService.getValue(LayoutSettings.COMMAND_CENTER) !== false; } private get editorActionsEnabled(): boolean { - return this.editorGroupService.partOptions.editorActionsLocation === EditorActionsLocation.TITLEBAR || + return !this.isCompact && this.editorGroupService.partOptions.editorActionsLocation === EditorActionsLocation.TITLEBAR || ( this.editorGroupService.partOptions.editorActionsLocation === EditorActionsLocation.DEFAULT && this.editorGroupService.partOptions.showTabs === EditorTabsMode.NONE @@ -792,13 +829,17 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private get activityActionsEnabled(): boolean { const activityBarPosition = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION); - return !this.isAuxiliary && (activityBarPosition === ActivityBarPosition.TOP || activityBarPosition === ActivityBarPosition.BOTTOM); + return !this.isCompact && !this.isAuxiliary && (activityBarPosition === ActivityBarPosition.TOP || activityBarPosition === ActivityBarPosition.BOTTOM); + } + + private get globalActionsEnabled(): boolean { + return !this.isCompact; } get hasZoomableElements(): boolean { const hasMenubar = !(this.currentMenubarVisibility === 'hidden' || this.currentMenubarVisibility === 'compact' || (!isWeb && isMacintosh)); const hasCommandCenter = this.isCommandCenterVisible; - const hasToolBarActions = this.hasGlobalToolbarEntries || this.layoutControlEnabled || this.editorActionsEnabled || this.activityActionsEnabled; + const hasToolBarActions = this.globalActionsEnabled || this.layoutControlEnabled || this.editorActionsEnabled || this.activityActionsEnabled; return hasMenubar || hasCommandCenter || hasToolBarActions; } @@ -877,6 +918,8 @@ export class MainBrowserTitlebarPart extends BrowserTitlebarPart { export interface IAuxiliaryTitlebarPart extends ITitlebarPart, IView { readonly container: HTMLElement; readonly height: number; + + updateOptions(options: { compact: boolean }): void; } export class AuxiliaryBrowserTitlebarPart extends BrowserTitlebarPart implements IAuxiliaryTitlebarPart { diff --git a/src/vs/workbench/browser/parts/views/treeView.ts b/src/vs/workbench/browser/parts/views/treeView.ts index 9a152cef023..bc660c6f7a1 100644 --- a/src/vs/workbench/browser/parts/views/treeView.ts +++ b/src/vs/workbench/browser/parts/views/treeView.ts @@ -460,6 +460,9 @@ abstract class AbstractTreeView extends Disposable implements ITreeView { set title(name: string) { this._title = name; + if (this.tree) { + this.tree.ariaLabel = this._title; + } this._onDidChangeTitle.fire(this._title); } @@ -830,6 +833,7 @@ abstract class AbstractTreeView extends Disposable implements ITreeView { return command; } + private onContextMenu(treeMenus: TreeMenus, treeEvent: ITreeContextMenuEvent, actionRunner: MultipleSelectionActionRunner): void { this.hoverService.hideHover(); const node: ITreeItem | null = treeEvent.element; diff --git a/src/vs/workbench/browser/parts/views/viewPane.ts b/src/vs/workbench/browser/parts/views/viewPane.ts index 87acf448f1d..e8dedb875d8 100644 --- a/src/vs/workbench/browser/parts/views/viewPane.ts +++ b/src/vs/workbench/browser/parts/views/viewPane.ts @@ -550,14 +550,18 @@ export abstract class ViewPane extends Pane implements IView { } this.iconContainerHover = this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.iconContainer, calculatedTitle)); - this.iconContainer.setAttribute('aria-label', this._getAriaLabel(calculatedTitle)); + this.iconContainer.setAttribute('aria-label', this._getAriaLabel(calculatedTitle, this._titleDescription)); } - private _getAriaLabel(title: string): string { + private _getAriaLabel(title: string, description: string | undefined): string { const viewHasAccessibilityHelpContent = this.viewDescriptorService.getViewDescriptorById(this.id)?.accessibilityHelpContent; const accessibleViewHasShownForView = this.accessibleViewInformationService?.hasShownAccessibleView(this.id); if (!viewHasAccessibilityHelpContent || accessibleViewHasShownForView) { - return title; + if (description) { + return `${title} - ${description}`; + } else { + return title; + } } return nls.localize('viewAccessibilityHelp', 'Use Alt+F1 for accessibility help {0}', title); @@ -570,17 +574,21 @@ export abstract class ViewPane extends Pane implements IView { this.titleContainerHover?.update(calculatedTitle); } - const ariaLabel = this._getAriaLabel(calculatedTitle); - if (this.iconContainer) { - this.iconContainerHover?.update(calculatedTitle); - this.iconContainer.setAttribute('aria-label', ariaLabel); - } - this.ariaHeaderLabel = this.getAriaHeaderLabel(ariaLabel); + this.updateAriaHeaderLabel(calculatedTitle, this._titleDescription); this._title = title; this._onDidChangeTitleArea.fire(); } + private updateAriaHeaderLabel(title: string, description: string | undefined) { + const ariaLabel = this._getAriaLabel(title, description); + if (this.iconContainer) { + this.iconContainerHover?.update(title); + this.iconContainer.setAttribute('aria-label', ariaLabel); + } + this.ariaHeaderLabel = this.getAriaHeaderLabel(ariaLabel); + } + private setTitleDescription(description: string | undefined) { if (this.titleDescriptionContainer) { this.titleDescriptionContainer.textContent = description ?? ''; @@ -594,7 +602,7 @@ export abstract class ViewPane extends Pane implements IView { protected updateTitleDescription(description?: string | undefined): void { this.setTitleDescription(description); - + this.updateAriaHeaderLabel(this._title, description); this._titleDescription = description; this._onDidChangeTitleArea.fire(); } diff --git a/src/vs/workbench/browser/web.api.ts b/src/vs/workbench/browser/web.api.ts index 9ae723f51fd..24f54228122 100644 --- a/src/vs/workbench/browser/web.api.ts +++ b/src/vs/workbench/browser/web.api.ts @@ -73,10 +73,10 @@ export interface IWorkbench { retrievePerformanceMarks(): Promise<[string, readonly PerformanceMark[]][]>; /** - * Allows to open a `URI` with the standard opener service of the + * Allows to open a target Uri with the standard opener service of the * workbench. */ - openUri(target: URI): Promise; + openUri(target: URI | UriComponents): Promise; }; window: { @@ -357,11 +357,6 @@ export interface IWorkbenchConstructionOptions { */ readonly initialColorTheme?: IInitialColorTheme; - /** - * Welcome dialog. Can be dismissed by the user. - */ - readonly welcomeDialog?: IWelcomeDialog; - //#endregion @@ -657,40 +652,6 @@ export interface IInitialColorTheme { readonly colors?: { [colorId: string]: string }; } -export interface IWelcomeDialog { - - /** - * Unique identifier of the welcome dialog. The identifier will be used to determine - * if the dialog has been previously displayed. - */ - id: string; - - /** - * Title of the welcome dialog. - */ - title: string; - - /** - * Button text of the welcome dialog. - */ - buttonText: string; - - /** - * Button command to execute from the welcome dialog. - */ - buttonCommand: string; - - /** - * Message text for the welcome dialog. - */ - message: string; - - /** - * Media to include in the welcome dialog. - */ - media: { altText: string; path: string }; -} - export interface IDefaultView { /** @@ -794,6 +755,7 @@ export interface ISettingsSyncOptions { * Authentication provider */ readonly authenticationProvider?: { + /** * Unique identifier of the authentication provider. */ @@ -840,6 +802,7 @@ export interface IDevelopmentOptions { * when remote resolvers are used in the web. */ export interface IRemoteResourceProvider { + /** * Path the workbench should delegate requests to. The embedder should * install a service worker on this path and emit {@link onDidReceiveRequest} @@ -858,6 +821,7 @@ export interface IRemoteResourceProvider { * headers, but for now we only deal with GET requests. */ export interface IRemoteResourceRequest { + /** * Request URI. Generally will begin with the current * origin and {@link IRemoteResourceProvider.pathPrefix}. diff --git a/src/vs/workbench/browser/web.factory.ts b/src/vs/workbench/browser/web.factory.ts index 422fbba83e7..0ebe2713def 100644 --- a/src/vs/workbench/browser/web.factory.ts +++ b/src/vs/workbench/browser/web.factory.ts @@ -5,7 +5,7 @@ import { ITunnel, ITunnelOptions, IWorkbench, IWorkbenchConstructionOptions, Menu } from './web.api.js'; import { BrowserMain } from './web.main.js'; -import { URI } from '../../base/common/uri.js'; +import { URI, UriComponents } from '../../base/common/uri.js'; import { IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { CommandsRegistry } from '../../platform/commands/common/commands.js'; import { mark, PerformanceMark } from '../../base/common/performance.js'; @@ -125,10 +125,10 @@ export namespace env { /** * {@linkcode IWorkbench.env IWorkbench.env.openUri} */ - export async function openUri(target: URI): Promise { + export async function openUri(target: URI | UriComponents): Promise { const workbench = await workbenchPromise.p; - return workbench.env.openUri(target); + return workbench.env.openUri(URI.isUri(target) ? target : URI.from(target)); } } diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index 5dcdad8aaac..8aec734eb49 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -27,7 +27,7 @@ import { IAnyWorkspaceIdentifier, IWorkspaceContextService, UNKNOWN_EMPTY_WINDOW import { IWorkbenchConfigurationService } from '../services/configuration/common/configuration.js'; import { onUnexpectedError } from '../../base/common/errors.js'; import { setFullscreen } from '../../base/browser/browser.js'; -import { URI } from '../../base/common/uri.js'; +import { URI, UriComponents } from '../../base/common/uri.js'; import { WorkspaceService } from '../services/configuration/browser/configurationService.js'; import { ConfigurationCache } from '../services/configuration/common/configurationCache.js'; import { ISignService } from '../../platform/sign/common/sign.js'; @@ -182,8 +182,8 @@ export class BrowserMain extends Disposable { return timerService.getPerformanceMarks(); }, - async openUri(uri: URI): Promise { - return openerService.open(uri, {}); + async openUri(uri: URI | UriComponents): Promise { + return openerService.open(URI.isUri(uri) ? uri : URI.from(uri), {}); } }, logger: { diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 5e785e81b93..e82e936637e 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -533,10 +533,22 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('workbench.panel.opensMaximized.preserve', "Open the panel to the state that it was in, before it was closed.") ] }, + 'workbench.secondarySideBar.defaultVisibility': { + 'type': 'string', + 'enum': ['hidden', 'visibleInWorkspace', 'visible'], + 'default': 'hidden', + 'tags': ['onExp'], + 'description': localize('secondarySideBarDefaultVisibility', "Controls the default visibility of the secondary side bar in workspaces or empty windows opened for the first time."), + 'enumDescriptions': [ + localize('workbench.secondarySideBar.defaultVisibility.hidden', "The secondary side bar is hidden by default."), + localize('workbench.secondarySideBar.defaultVisibility.visibleInWorkspace', "The secondary side bar is visible by default if a workspace is opened."), + localize('workbench.secondarySideBar.defaultVisibility.visible', "The secondary side bar is visible by default.") + ] + }, 'workbench.secondarySideBar.showLabels': { 'type': 'boolean', 'default': true, - 'markdownDescription': localize('secondarySideBarShowLabels', "Controls whether activity items in the secondary sidebar title are shown as label or icon. This setting only has an effect when {0} is not set to {1}.", '`#workbench.activityBar.location#`', '`top`'), + 'markdownDescription': localize('secondarySideBarShowLabels', "Controls whether activity items in the secondary side bar title are shown as label or icon. This setting only has an effect when {0} is not set to {1}.", '`#workbench.activityBar.location#`', '`top`'), }, 'workbench.statusBar.visible': { 'type': 'boolean', @@ -603,10 +615,16 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('settings.editor.ui', "Use the settings UI editor."), localize('settings.editor.json', "Use the JSON file editor."), ], - 'description': localize('settings.editor.desc', "Determines which settings editor to use by default."), + 'description': localize('settings.editor.desc', "Determines which Settings editor to use by default."), 'default': 'ui', 'scope': ConfigurationScope.WINDOW }, + 'workbench.settings.showExperimentalSuggestions': { + 'type': 'boolean', + 'default': false, + 'description': localize('settings.showExperimentalSuggestions', "Controls whether experimental suggestions are shown in the Settings editor. This setting requires a reload to take effect."), + 'tags': ['experimental'] + }, 'workbench.hover.delay': { 'type': 'number', 'description': localize('workbench.hover.delay', "Controls the delay in milliseconds after which the hover is shown for workbench items (ex. some extension provided tree view items). Already visible items may require a refresh before reflecting this setting change."), diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index d11f9b168aa..548fe41ac89 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -34,6 +34,7 @@ export const TemporaryWorkspaceContext = new RawContextKey('temporaryWo export const IsMainWindowFullscreenContext = new RawContextKey('isFullscreen', false, localize('isFullscreen', "Whether the main window is in fullscreen mode")); export const IsAuxiliaryWindowFocusedContext = new RawContextKey('isAuxiliaryWindowFocusedContext', false, localize('isAuxiliaryWindowFocusedContext', "Whether an auxiliary window is focused")); +export const IsWindowAlwaysOnTopContext = new RawContextKey('isWindowAlwaysOnTop', false, localize('isWindowAlwaysOnTop', "Whether the window is always on top")); export const HasWebFileSystemAccess = new RawContextKey('hasWebFileSystemAccess', false, true); // Support for FileSystemAccess web APIs (https://wicg.github.io/file-system-access) @@ -111,6 +112,8 @@ export const StatusBarFocused = new RawContextKey('statusBarFocused', f export const TitleBarStyleContext = new RawContextKey('titleBarStyle', 'custom', localize('titleBarStyle', "Style of the window title bar")); export const TitleBarVisibleContext = new RawContextKey('titleBarVisible', false, localize('titleBarVisible', "Whether the title bar is visible")); +export const IsAuxiliaryTitleBarContext = new RawContextKey('isAuxiliaryTitleBar', false, localize('isAuxiliaryTitleBar', "Title bar is in an auxiliary window")); +export const IsCompactTitleBarContext = new RawContextKey('isCompactTitleBar', false, localize('isCompactTitleBar', "Title bar is in compact mode")); //#endregion diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index a3e8516c6e6..3db16dd0d94 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -410,7 +410,7 @@ export interface IFileEditorFactory { typeId: string; /** - * Creates new new editor capable of showing files. + * Creates new editor capable of showing files. */ createFileEditor(resource: URI, preferredResource: URI | undefined, preferredName: string | undefined, preferredDescription: string | undefined, preferredEncoding: string | undefined, preferredLanguageId: string | undefined, preferredContents: string | undefined, instantiationService: IInstantiationService): IFileEditorInput; @@ -504,12 +504,12 @@ export interface IResourceSideBySideEditorInput extends IBaseUntypedEditorInput /** * The right hand side editor to open inside a side-by-side editor. */ - readonly primary: IResourceEditorInput | ITextResourceEditorInput | IUntitledTextResourceEditorInput; + readonly primary: Omit | Omit | Omit; /** * The left hand side editor to open inside a side-by-side editor. */ - readonly secondary: IResourceEditorInput | ITextResourceEditorInput | IUntitledTextResourceEditorInput; + readonly secondary: Omit | Omit | Omit; } /** @@ -524,12 +524,25 @@ export interface IResourceDiffEditorInput extends IBaseUntypedEditorInput { /** * The left hand side editor to open inside a diff editor. */ - readonly original: IResourceEditorInput | ITextResourceEditorInput | IUntitledTextResourceEditorInput; + readonly original: Omit | Omit | Omit; /** * The right hand side editor to open inside a diff editor. */ - readonly modified: IResourceEditorInput | ITextResourceEditorInput | IUntitledTextResourceEditorInput; + readonly modified: Omit | Omit | Omit; +} + +export interface ITextResourceDiffEditorInput extends IBaseTextResourceEditorInput { + + /** + * The left hand side text editor to open inside a diff editor. + */ + readonly original: Omit | Omit; + + /** + * The right hand side text editor to open inside a diff editor. + */ + readonly modified: Omit | Omit; } /** @@ -558,7 +571,7 @@ export interface IResourceMultiDiffEditorInput extends IBaseUntypedEditorInput { export interface IMultiDiffEditorResource extends IResourceDiffEditorInput { readonly goToFileResource?: URI; } -export type IResourceMergeEditorInputSide = (IResourceEditorInput | ITextResourceEditorInput) & { detail?: string }; +export type IResourceMergeEditorInputSide = (Omit | Omit) & { detail?: string }; /** * A resource merge editor input compares multiple editors @@ -582,12 +595,12 @@ export interface IResourceMergeEditorInput extends IBaseUntypedEditorInput { /** * The base common ancestor of the file to merge. */ - readonly base: IResourceEditorInput | ITextResourceEditorInput; + readonly base: Omit | Omit; /** * The resulting output of the merge. */ - readonly result: IResourceEditorInput | ITextResourceEditorInput; + readonly result: Omit | Omit; } export function isResourceEditorInput(editor: unknown): editor is IResourceEditorInput { diff --git a/src/vs/workbench/common/notifications.ts b/src/vs/workbench/common/notifications.ts index 6a690593672..08861595cb0 100644 --- a/src/vs/workbench/common/notifications.ts +++ b/src/vs/workbench/common/notifications.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { INotification, INotificationHandle, INotificationActions, INotificationProgress, NoOpNotification, Severity, NotificationMessage, IPromptChoice, IStatusMessageOptions, NotificationsFilter, INotificationProgressProperties, IPromptChoiceWithMenu, NotificationPriority, INotificationSource, isNotificationSource } from '../../platform/notification/common/notification.js'; +import { INotification, INotificationHandle, INotificationActions, INotificationProgress, NoOpNotification, Severity, NotificationMessage, IPromptChoice, IStatusMessageOptions, NotificationsFilter, INotificationProgressProperties, IPromptChoiceWithMenu, NotificationPriority, INotificationSource, isNotificationSource, IStatusHandle } from '../../platform/notification/common/notification.js'; import { toErrorMessage, isErrorWithActions } from '../../base/common/errorMessage.js'; import { Event, Emitter } from '../../base/common/event.js'; -import { Disposable, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { Disposable } from '../../base/common/lifecycle.js'; import { isCancellationError } from '../../base/common/errors.js'; import { Action } from '../../base/common/actions.js'; import { equals } from '../../base/common/arrays.js'; @@ -35,7 +35,7 @@ export interface INotificationsModel { readonly onDidChangeStatusMessage: Event; - showStatusMessage(message: NotificationMessage, options?: IStatusMessageOptions): IDisposable; + showStatusMessage(message: NotificationMessage, options?: IStatusMessageOptions): IStatusHandle; //#endregion } @@ -275,24 +275,23 @@ export class NotificationsModel extends Disposable implements INotificationsMode return item; } - showStatusMessage(message: NotificationMessage, options?: IStatusMessageOptions): IDisposable { + showStatusMessage(message: NotificationMessage, options?: IStatusMessageOptions): IStatusHandle { const item = StatusMessageViewItem.create(message, options); if (!item) { - return Disposable.None; + return { close: () => { } }; } - // Remember as current status message and fire events this._statusMessage = item; this._onDidChangeStatusMessage.fire({ kind: StatusMessageChangeType.ADD, item }); - return toDisposable(() => { - - // Only reset status message if the item is still the one we had remembered - if (this._statusMessage === item) { - this._statusMessage = undefined; - this._onDidChangeStatusMessage.fire({ kind: StatusMessageChangeType.REMOVE, item }); + return { + close: () => { + if (this._statusMessage === item) { + this._statusMessage = undefined; + this._onDidChangeStatusMessage.fire({ kind: StatusMessageChangeType.REMOVE, item }); + } } - }); + }; } } @@ -492,7 +491,7 @@ export class NotificationViewItem extends Disposable implements INotificationVie } let priority = notification.priority ?? NotificationPriority.DEFAULT; - if (priority === NotificationPriority.DEFAULT && severity !== Severity.Error) { + if ((priority === NotificationPriority.DEFAULT || priority === NotificationPriority.OPTIONAL) && severity !== Severity.Error) { if (filter.global === NotificationsFilter.ERROR) { priority = NotificationPriority.SILENT; // filtered globally } else if (isNotificationSource(notification.source) && filter.sources.get(notification.source.id) === NotificationsFilter.ERROR) { diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 6e1dddaefb3..77bc44df9cf 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -290,6 +290,20 @@ const configuration: IConfigurationNode = { } } }, + 'accessibility.signals.nextEditSuggestion': { + ...signalFeatureBase, + 'description': localize('accessibility.signals.nextEditSuggestion', "Plays a signal - sound / audio cue and/or announcement (alert) when there is a next edit suggestion."), + 'properties': { + 'sound': { + 'description': localize('accessibility.signals.nextEditSuggestion.sound', "Plays a sound when there is a next edit suggestion."), + ...soundFeatureBase, + }, + 'announcement': { + 'description': localize('accessibility.signals.nextEditSuggestion.announcement', "Announces when there is a next edit suggestion."), + ...announcementFeatureBase, + }, + } + }, 'accessibility.signals.lineHasError': { ...signalFeatureBase, 'description': localize('accessibility.signals.lineHasError', "Plays a signal - sound (audio cue) and/or announcement (alert) - when the active line has an error."), @@ -641,6 +655,34 @@ const configuration: IConfigurationNode = { }, }, }, + 'accessibility.signals.editsUndone': { + ...signalFeatureBase, + 'description': localize('accessibility.signals.editsUndone', "Plays a signal - sound (audio cue) and/or announcement (alert) - when edits have been undone."), + 'properties': { + 'sound': { + 'description': localize('accessibility.signals.editsUndone.sound', "Plays a sound when edits have been undone."), + ...soundFeatureBase + }, + 'announcement': { + 'description': localize('accessibility.signals.editsUndone.announcement', "Announces when edits have been undone."), + ...announcementFeatureBase + }, + }, + }, + 'accessibility.signals.editsKept': { + ...signalFeatureBase, + 'description': localize('accessibility.signals.editsKept', "Plays a signal - sound (audio cue) and/or announcement (alert) - when edits are kept."), + 'properties': { + 'sound': { + 'description': localize('accessibility.signals.editsKept.sound', "Plays a sound when edits are kept."), + ...soundFeatureBase + }, + 'announcement': { + 'description': localize('accessibility.signals.editsKept.announcement', "Announces when edits are kept."), + ...announcementFeatureBase + }, + }, + }, 'accessibility.signals.save': { 'type': 'object', 'tags': ['accessibility'], diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts index 6e000778220..21be375a627 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts @@ -12,6 +12,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import * as marked from '../../../../base/common/marked/marked.js'; +import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh, isWindows } from '../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -21,11 +22,13 @@ import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/b import { IPosition, Position } from '../../../../editor/common/core/position.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { IModelService } from '../../../../editor/common/services/model.js'; +import { ITextModelContentProvider, ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { AccessibilityHelpNLS } from '../../../../editor/common/standaloneStrings.js'; import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; import { localize } from '../../../../nls.js'; -import { AccessibleViewProviderId, AccessibleViewType, AccessibleContentProvider, ExtensionContentProvider, IAccessibleViewService, IAccessibleViewSymbol, isIAccessibleViewContentProvider } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType, ExtensionContentProvider, IAccessibleViewService, IAccessibleViewSymbol, isIAccessibleViewContentProvider } from '../../../../platform/accessibility/browser/accessibleView.js'; import { ACCESSIBLE_VIEW_SHOWN_STORAGE_PREFIX, IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; @@ -40,14 +43,13 @@ import { ILayoutService } from '../../../../platform/layout/browser/layoutServic import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { AccessibilityVerbositySettingId, AccessibilityWorkbenchSettingId, accessibilityHelpIsShown, accessibleViewContainsCodeBlocks, accessibleViewCurrentProviderId, accessibleViewGoToSymbolSupported, accessibleViewHasAssignedKeybindings, accessibleViewHasUnassignedKeybindings, accessibleViewInCodeBlock, accessibleViewIsShown, accessibleViewOnLastLine, accessibleViewSupportsNavigation, accessibleViewVerbosityEnabled } from './accessibilityConfiguration.js'; -import { resolveContentAndKeybindingItems } from './accessibleViewKeybindingResolver.js'; -import { AccessibilityCommandId } from '../common/accessibilityCommands.js'; +import { FloatingEditorClickMenu } from '../../../browser/codeeditor.js'; import { IChatCodeBlockContextProviderService } from '../../chat/browser/chat.js'; import { ICodeBlockActionContext } from '../../chat/browser/codeBlockPart.js'; import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOptions.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { ITextModelContentProvider, ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { AccessibilityCommandId } from '../common/accessibilityCommands.js'; +import { AccessibilityVerbositySettingId, AccessibilityWorkbenchSettingId, accessibilityHelpIsShown, accessibleViewContainsCodeBlocks, accessibleViewCurrentProviderId, accessibleViewGoToSymbolSupported, accessibleViewHasAssignedKeybindings, accessibleViewHasUnassignedKeybindings, accessibleViewInCodeBlock, accessibleViewIsShown, accessibleViewOnLastLine, accessibleViewSupportsNavigation, accessibleViewVerbosityEnabled } from './accessibilityConfiguration.js'; +import { resolveContentAndKeybindingItems } from './accessibleViewKeybindingResolver.js'; const enum DIMENSIONS { MAX_WIDTH = 600 @@ -60,6 +62,7 @@ interface ICodeBlock { endLine: number; code: string; languageId?: string; + chatSessionId: string | undefined; } export class AccessibleView extends Disposable implements ITextModelContentProvider { @@ -108,7 +111,8 @@ export class AccessibleView extends Disposable implements ITextModelContentProvi @IChatCodeBlockContextProviderService private readonly _codeBlockContextProviderService: IChatCodeBlockContextProviderService, @IStorageService private readonly _storageService: IStorageService, @ITextModelService private readonly textModelResolverService: ITextModelService, - @IQuickInputService private readonly _quickInputService: IQuickInputService + @IQuickInputService private readonly _quickInputService: IQuickInputService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, ) { super(); @@ -130,7 +134,7 @@ export class AccessibleView extends Disposable implements ITextModelContentProvi this._container.classList.add('hide'); } const codeEditorWidgetOptions: ICodeEditorWidgetOptions = { - contributions: EditorExtensionsRegistry.getEditorContributions().filter(c => c.id !== CodeActionController.ID) + contributions: EditorExtensionsRegistry.getEditorContributions().filter(c => c.id !== CodeActionController.ID && c.id !== FloatingEditorClickMenu.ID) }; const titleBar = document.createElement('div'); titleBar.classList.add('accessible-view-title-bar'); @@ -184,15 +188,29 @@ export class AccessibleView extends Disposable implements ITextModelContentProvi this._register(this._editorWidget.onDidDispose(() => this._resetContextKeys())); this._register(this._editorWidget.onDidChangeCursorPosition(() => { this._onLastLine.set(this._editorWidget.getPosition()?.lineNumber === this._editorWidget.getModel()?.getLineCount()); - })); - this._register(this._editorWidget.onDidChangeCursorPosition(() => { const cursorPosition = this._editorWidget.getPosition()?.lineNumber; if (this._codeBlocks && cursorPosition !== undefined) { const inCodeBlock = this._codeBlocks.find(c => c.startLine <= cursorPosition && c.endLine >= cursorPosition) !== undefined; this._accessibleViewInCodeBlock.set(inCodeBlock); } + this._playDiffSignals(); })); } + + private _playDiffSignals(): void { + const position = this._editorWidget.getPosition(); + const model = this._editorWidget.getModel(); + if (!position || !model) { + return undefined; + } + const lineContent = model.getLineContent(position.lineNumber); + if (lineContent?.startsWith('+')) { + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineInserted); + } else if (lineContent?.startsWith('-')) { + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineDeleted); + } + } + provideTextContent(resource: URI): Promise | null { return this._getTextModel(resource); } @@ -238,7 +256,7 @@ export class AccessibleView extends Disposable implements ITextModelContentProvi if (!codeBlock || codeBlockIndex === undefined) { return; } - return { code: codeBlock.code, languageId: codeBlock.languageId, codeBlockIndex, element: undefined }; + return { code: codeBlock.code, languageId: codeBlock.languageId, codeBlockIndex, element: undefined, chatSessionId: codeBlock.chatSessionId }; } navigateToCodeBlock(type: 'next' | 'previous'): void { @@ -382,7 +400,7 @@ export class AccessibleView extends Disposable implements ITextModelContentProvi inBlock = false; const endLine = i; const code = lines.slice(startLine, endLine).join('\n'); - this._codeBlocks?.push({ startLine, endLine, code, languageId }); + this._codeBlocks?.push({ startLine, endLine, code, languageId, chatSessionId: undefined }); } }); this._accessibleViewContainsCodeBlocks.set(this._codeBlocks.length > 0); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleViewActions.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleViewActions.ts index f587bd7cb1e..6e9adf5de61 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleViewActions.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleViewActions.ts @@ -330,7 +330,7 @@ class AccessibleViewAcceptInlineCompletionAction extends Action2 { return; } const model = InlineCompletionsController.get(editor)?.model.get(); - const state = model?.inlineCompletionState.get(); + const state = model?.state.get(); if (!model || !state) { return; } diff --git a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek.ts b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek.ts index 0afedce845a..75da6466a36 100644 --- a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek.ts +++ b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek.ts @@ -96,7 +96,7 @@ export class CallHierarchyTreePeekWidget extends peekView.PeekViewWidget { this.create(); this._peekViewService.addExclusiveWidget(editor, this); this._applyTheme(themeService.getColorTheme()); - this._disposables.add(themeService.onDidColorThemeChange(e => this._applyTheme(e.theme))); + this._disposables.add(themeService.onDidColorThemeChange(this._applyTheme, this)); this._disposables.add(this._previewDisposable); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 8bca2a852b3..c9e3ad06aaf 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -51,7 +51,18 @@ export class EditsChatAccessibilityHelp implements IAccessibleViewImplementation } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView', keybindingService: IKeybindingService): string { +export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 120; + readonly name = 'agentView'; + readonly type = AccessibleViewType.Help; + readonly when = ContextKeyExpr.and(ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent), ChatContextKeys.inChatInput); + getProvider(accessor: ServicesAccessor) { + const codeEditor = accessor.get(ICodeEditorService).getActiveCodeEditor() || accessor.get(ICodeEditorService).getFocusedCodeEditor(); + return getChatAccessibilityHelpProvider(accessor, codeEditor ?? undefined, 'agentView'); + } +} + +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService): string { const content = []; if (type === 'panelChat' || type === 'quickChat') { if (type === 'quickChat') { @@ -72,21 +83,30 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('workbench.action.chat.newChat', 'To create a new chat session, invoke the New Chat command{0}.', '')); } } - if (type === 'editsView') { - content.push(localize('chatEditing.overview', 'The chat editing view is used to apply edits across files.')); + if (type === 'editsView' || type === 'agentView') { + if (type === 'agentView') { + content.push(localize('chatAgent.overview', 'The chat agent view is used to apply edits across files in your workspace, enable running commands in the terminal, and more.')); + } else { + content.push(localize('chatEditing.overview', 'The chat editing view is used to apply edits across files.')); + } content.push(localize('chatEditing.format', 'It is comprised of an input box and a file working set (Shift+Tab).')); content.push(localize('chatEditing.expectation', 'When a request is made, a progress indicator will play while the edits are being applied.')); content.push(localize('chatEditing.review', 'Once the edits are applied, a sound will play to indicate the document has been opened and is ready for review. The sound can be disabled with accessibility.signals.chatEditModifiedFile.')); content.push(localize('chatEditing.sections', 'Navigate between edits in the editor with navigate previous{0} and next{1}', '', '')); - content.push(localize('chatEditing.acceptHunk', 'In the editor, Accept{0}, Reject{1}, or Toggle the Diff{2} for the current Change.', '', '', '')); - content.push(localize('chatEditing.helpfulCommands', 'When in the edits view, some helpful commands include:')); + content.push(localize('chatEditing.acceptHunk', 'In the editor, Keep{0}, Undo{1}, or Toggle the Diff{2} for the current Change.', '', '', '')); + content.push(localize('chatEditing.undoKeepSounds', 'Sounds will play when a change is accepted or undone. The sounds can be disabled with accessibility.signals.editsKept and accessibility.signals.editsUndone.')); + if (type === 'agentView') { + content.push(localize('chatAgent.userActionRequired', 'An alert will indicate when user action is required. For example, if the agent wants to run something in the terminal, you will hear Action Required: Run Command in Terminal.')); + content.push(localize('chatAgent.runCommand', 'To take the action, use the accept tool command{0}.', '')); + } + content.push(localize('chatEditing.helpfulCommands', 'Some helpful commands include:')); content.push(localize('workbench.action.chat.undoEdits', '- Undo Edits{0}.', '')); content.push(localize('workbench.action.chat.editing.attachFiles', '- Attach Files{0}.', '')); content.push(localize('chatEditing.removeFileFromWorkingSet', '- Remove File from Working Set{0}.', '')); - content.push(localize('chatEditing.acceptFile', '- Accept{0} and Discard File{1}.', '', '')); + content.push(localize('chatEditing.acceptFile', '- Keep{0} and Undo File{1}.', '', '')); content.push(localize('chatEditing.saveAllFiles', '- Save All Files{0}.', '')); - content.push(localize('chatEditing.acceptAllFiles', '- Accept All Edits{0}.', '')); - content.push(localize('chatEditing.discardAllFiles', '- Discard All Edits{0}.', '')); + content.push(localize('chatEditing.acceptAllFiles', '- Keep All Edits{0}.', '')); + content.push(localize('chatEditing.discardAllFiles', '- Undo All Edits{0}.', '')); content.push(localize('chatEditing.openFileInDiff', '- Open File in Diff{0}.', '')); content.push(localize('chatEditing.viewChanges', '- View Changes{0}.', '')); } @@ -104,7 +124,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui return content.join('\n'); } -export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView') { +export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView'): AccessibleContentProvider | undefined { const widgetService = accessor.get(IChatWidgetService); const keybindingService = accessor.get(IKeybindingService); const inputEditor: ICodeEditor | undefined = type === 'panelChat' || type === 'editsView' || type === 'quickChat' ? widgetService.lastFocusedWidget?.inputEditor : editor; @@ -121,7 +141,7 @@ export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, edi inputEditor.getSupportedActions(); const helpText = getAccessibilityHelpText(type, keybindingService); return new AccessibleContentProvider( - type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : AccessibleViewProviderId.QuickChat, + type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : AccessibleViewProviderId.QuickChat, { type: AccessibleViewType.Help }, () => helpText, () => { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 9ffd0a5d730..53475c29f17 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { toAction } from '../../../../../base/common/actions.js'; +import { isAncestorOfActiveElement } from '../../../../../base/browser/dom.js'; +import { toAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; import { coalesce } from '../../../../../base/common/arrays.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { fromNowByDay, safeIntl } from '../../../../../base/common/date.js'; @@ -21,18 +22,20 @@ import { SuggestController } from '../../../../../editor/contrib/suggest/browser import { localize, localize2 } from '../../../../../nls.js'; import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { DropdownWithPrimaryActionViewItem } from '../../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; -import { Action2, MenuId, MenuItemAction, MenuRegistry, registerAction2, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { Action2, ICommandPaletteOptions, MenuId, MenuItemAction, MenuRegistry, registerAction2, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsLinuxContext, IsWindowsContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import product from '../../../../../platform/product/common/product.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; +import { IsCompactTitleBarContext } from '../../../../common/contextkeys.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; @@ -41,17 +44,17 @@ import { IHostService } from '../../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { EXTENSIONS_CATEGORY, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; -import { IChatAgentService } from '../../common/chatAgents.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; +import { IChatEditingSession, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { ChatEntitlement, ChatSentiment, IChatEntitlementService } from '../../common/chatEntitlementService.js'; import { extractAgentAndCommand } from '../../common/chatParserTypes.js'; import { IChatDetail, IChatService } from '../../common/chatService.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/chatWidgetHistoryService.js'; -import { ChatMode } from '../../common/constants.js'; +import { ChatConfiguration, ChatMode, modeToString, validateChatMode } from '../../common/constants.js'; import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js'; import { ILanguageModelToolsService } from '../../common/languageModelToolsService.js'; -import { ChatViewId, EditsViewId, IChatWidget, IChatWidgetService, showChatView, showCopilotView } from '../chat.js'; +import { ChatViewId, IChatWidget, IChatWidgetService, showChatView, showCopilotView } from '../chat.js'; import { IChatEditorOptions } from '../chatEditor.js'; import { ChatEditorInput } from '../chatEditorInput.js'; import { ChatViewPane } from '../chatViewPane.js'; @@ -66,7 +69,7 @@ const TOGGLE_CHAT_ACTION_ID = 'workbench.action.chat.toggle'; export interface IChatViewOpenOptions { /** - * The query for quick chat. + * The query for chat. */ query: string; /** @@ -81,11 +84,14 @@ export interface IChatViewOpenOptions { * Any previous chat requests and responses that should be shown in the chat view. */ previousRequests?: IChatViewOpenRequestEntry[]; - /** * Whether a screenshot of the focused window should be taken and attached */ attachScreenshot?: boolean; + /** + * The mode to open the chat in. + */ + mode?: ChatMode; } export interface IChatViewOpenRequestEntry { @@ -93,83 +99,142 @@ export interface IChatViewOpenRequestEntry { response: string; } -export const OPEN_CHAT_QUOTA_EXCEEDED_DIALOG = 'workbench.action.chat.openQuotaExceededDialog'; +const OPEN_CHAT_QUOTA_EXCEEDED_DIALOG = 'workbench.action.chat.openQuotaExceededDialog'; + +abstract class OpenChatGlobalAction extends Action2 { + constructor(overrides: Pick, private readonly mode?: ChatMode) { + super({ + ...overrides, + icon: Codicon.copilot, + f1: true, + category: CHAT_CATEGORY, + precondition: ChatContextKeys.Setup.hidden.negate(), + }); + } + + override async run(accessor: ServicesAccessor, opts?: string | IChatViewOpenOptions): Promise { + opts = typeof opts === 'string' ? { query: opts } : opts; + + const chatService = accessor.get(IChatService); + const widgetService = accessor.get(IChatWidgetService); + const toolsService = accessor.get(ILanguageModelToolsService); + const viewsService = accessor.get(IViewsService); + const hostService = accessor.get(IHostService); + + + let chatWidget = widgetService.lastFocusedWidget; + // When this was invoked to switch to a mode via keybinding, and some chat widget is focused, use that one. + // Otherwise, open the view. + if (!this.mode || !chatWidget || !isAncestorOfActiveElement(chatWidget.domNode)) { + chatWidget = await showChatView(viewsService); + } + + if (!chatWidget) { + return; + } + + const mode = opts?.mode ?? this.mode; + if (mode && validateChatMode(mode)) { + chatWidget.input.setChatMode(mode); + } + if (opts?.previousRequests?.length && chatWidget.viewModel) { + for (const { request, response } of opts.previousRequests) { + chatService.addCompleteRequest(chatWidget.viewModel.sessionId, request, undefined, 0, { message: response }); + } + } + if (opts?.attachScreenshot) { + const screenshot = await hostService.getScreenshot(); + if (screenshot) { + chatWidget.attachmentModel.addContext(convertBufferToScreenshotVariable(screenshot)); + } + } + if (opts?.query) { + if (opts.query.startsWith('@') && (chatWidget.input.currentMode === ChatMode.Agent || chatService.edits2Enabled)) { + chatWidget.input.setChatMode(ChatMode.Ask); + } + if (opts.isPartialQuery) { + chatWidget.setInput(opts.query); + } else { + await chatWidget.waitForReady(); + chatWidget.acceptInput(opts.query); + } + } + if (opts?.toolIds && opts.toolIds.length > 0) { + for (const toolId of opts.toolIds) { + const tool = toolsService.getTool(toolId); + if (tool) { + chatWidget.attachmentModel.addContext({ + id: tool.id, + name: tool.displayName, + fullName: tool.displayName, + value: undefined, + icon: ThemeIcon.isThemeIcon(tool.icon) ? tool.icon : undefined, + kind: 'tool' + }); + } + } + } + + chatWidget.focusInput(); + } +} + +class PrimaryOpenChatGlobalAction extends OpenChatGlobalAction { + constructor() { + super({ + id: CHAT_OPEN_ACTION_ID, + title: localize2('openChat', "Open Chat"), + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyI, + mac: { + primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyI + } + }, + menu: [{ + id: MenuId.ChatTitleBarMenu, + group: 'a_open', + order: 1 + }] + }); + } +} + +export function getOpenChatActionIdForMode(mode: ChatMode): string { + const modeStr = modeToString(mode); + return `workbench.action.chat.open${modeStr}`; +} + +abstract class ModeOpenChatGlobalAction extends OpenChatGlobalAction { + constructor(mode: ChatMode, keybinding?: ICommandPaletteOptions['keybinding']) { + super({ + id: getOpenChatActionIdForMode(mode), + title: localize2('openChatMode', "Open Chat ({0})", modeToString(mode)), + keybinding + }, mode); + } +} export function registerChatActions() { - registerAction2(class OpenChatGlobalAction extends Action2 { - + registerAction2(PrimaryOpenChatGlobalAction); + registerAction2(class extends ModeOpenChatGlobalAction { + constructor() { super(ChatMode.Ask); } + }); + registerAction2(class extends ModeOpenChatGlobalAction { constructor() { - super({ - id: CHAT_OPEN_ACTION_ID, - title: localize2('openChat', "Open Chat"), - icon: Codicon.copilot, - f1: true, - category: CHAT_CATEGORY, - precondition: ChatContextKeys.Setup.hidden.toNegated(), - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyI, - mac: { - primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyI - } - }, - menu: { - id: MenuId.ChatTitleBarMenu, - group: 'a_open', - order: 1 + super(ChatMode.Agent, { + when: ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyI, + linux: { + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.KeyI } - }); - } - - override async run(accessor: ServicesAccessor, opts?: string | IChatViewOpenOptions): Promise { - opts = typeof opts === 'string' ? { query: opts } : opts; - - const chatService = accessor.get(IChatService); - const toolsService = accessor.get(ILanguageModelToolsService); - const viewsService = accessor.get(IViewsService); - const hostService = accessor.get(IHostService); - - const chatWidget = await showChatView(viewsService); - if (!chatWidget) { - return; - } - if (opts?.previousRequests?.length && chatWidget.viewModel) { - for (const { request, response } of opts.previousRequests) { - chatService.addCompleteRequest(chatWidget.viewModel.sessionId, request, undefined, 0, { message: response }); - } - } - if (opts?.attachScreenshot) { - const screenshot = await hostService.getScreenshot(); - if (screenshot) { - chatWidget.attachmentModel.addContext(convertBufferToScreenshotVariable(screenshot)); - } - } - if (opts?.query) { - if (opts.isPartialQuery) { - chatWidget.setInput(opts.query); - } else { - chatWidget.acceptInput(opts.query); - } - } - if (opts?.toolIds && opts.toolIds.length > 0) { - for (const toolId of opts.toolIds) { - const tool = toolsService.getTool(toolId); - if (tool) { - chatWidget.attachmentModel.addContext({ - id: tool.id, - name: tool.displayName, - fullName: tool.displayName, - value: undefined, - icon: ThemeIcon.isThemeIcon(tool.icon) ? tool.icon : undefined, - isTool: true - }); - } - } - } - - chatWidget.focusInput(); + },); } }); + registerAction2(class extends ModeOpenChatGlobalAction { + constructor() { super(ChatMode.Edit); } + }); registerAction2(class ToggleChatAction extends Action2 { constructor() { @@ -186,9 +251,8 @@ export function registerChatActions() { const viewDescriptorService = accessor.get(IViewDescriptorService); const chatLocation = viewDescriptorService.getViewLocationById(ChatViewId); - const editsLocation = viewDescriptorService.getViewLocationById(EditsViewId); - if (viewsService.isViewVisible(ChatViewId) || (chatLocation === editsLocation && viewsService.isViewVisible(EditsViewId))) { + if (viewsService.isViewVisible(ChatViewId)) { this.updatePartVisibility(layoutService, chatLocation, false); } else { this.updatePartVisibility(layoutService, chatLocation, true); @@ -239,6 +303,25 @@ export function registerChatActions() { const quickInputService = accessor.get(IQuickInputService); const viewsService = accessor.get(IViewsService); const editorService = accessor.get(IEditorService); + const dialogService = accessor.get(IDialogService); + + const view = await viewsService.openView(ChatViewId); + if (!view) { + return; + } + + const chatSessionId = view.widget.viewModel?.model.sessionId; + if (!chatSessionId) { + return; + } + + const editingSession = view.widget.viewModel?.model.editingSession; + if (editingSession) { + const phrase = localize('switchChat.confirmPhrase', "Switching chats will end your current edit session."); + if (!await handleCurrentEditingSession(editingSession, phrase, dialogService)) { + return; + } + } const showPicker = async () => { const openInEditorButton: IQuickInputButton = { @@ -314,7 +397,6 @@ export function registerChatActions() { try { const item = picker.selectedItems[0]; const sessionId = item.chat.sessionId; - const view = await viewsService.openView(ChatViewId) as ChatViewPane; await view.loadSession(sessionId); } finally { picker.hide(); @@ -332,7 +414,7 @@ export function registerChatActions() { constructor() { super({ id: `workbench.action.openChat`, - title: localize2('interactiveSession.open', "Open Editor"), + title: localize2('interactiveSession.open', "New Chat Editor"), f1: true, category: CHAT_CATEGORY, precondition: ChatContextKeys.enabled @@ -418,11 +500,12 @@ export function registerChatActions() { } async run(accessor: ServicesAccessor, ...args: any[]) { const editorGroupsService = accessor.get(IEditorGroupsService); - const chatService = accessor.get(IChatService); + const instantiationService = accessor.get(IInstantiationService); + const widgetService = accessor.get(IChatWidgetService); + await chatService.clearAllHistoryEntries(); - const widgetService = accessor.get(IChatWidgetService); widgetService.getAllWidgets().forEach(widget => { widget.clear(); }); @@ -432,7 +515,7 @@ export function registerChatActions() { editorGroupsService.groups.forEach(group => { group.editors.forEach(editor => { if (editor instanceof ChatEditorInput) { - clearChatEditor(accessor, editor); + instantiationService.invokeFunction(clearChatEditor, editor); } }); }); @@ -572,12 +655,12 @@ export function registerChatActions() { } }); - registerAction2(class ShowLimitReachedDialogAction extends Action2 { + registerAction2(class ShowQuotaExceededDialogAction extends Action2 { constructor() { super({ id: OPEN_CHAT_QUOTA_EXCEEDED_DIALOG, - title: localize('upgradeChat', "Upgrade to Copilot Pro") + title: localize('upgradeChat', "Upgrade Copilot Plan") }); } @@ -585,40 +668,51 @@ export function registerChatActions() { const chatEntitlementService = accessor.get(IChatEntitlementService); const commandService = accessor.get(ICommandService); const dialogService = accessor.get(IDialogService); - - const dateFormatter = safeIntl.DateTimeFormat(language, { year: 'numeric', month: 'long', day: 'numeric' }); + const telemetryService = accessor.get(ITelemetryService); let message: string; - const { chatQuotaExceeded, completionsQuotaExceeded } = chatEntitlementService.quotas; + const chatQuotaExceeded = chatEntitlementService.quotas.chat?.percentRemaining === 0; + const completionsQuotaExceeded = chatEntitlementService.quotas.completions?.percentRemaining === 0; if (chatQuotaExceeded && !completionsQuotaExceeded) { - message = localize('chatQuotaExceeded', "You've run out of free chat messages. You still have free code completions available in the Copilot Free plan. These limits will reset on {0}.", dateFormatter.format(chatEntitlementService.quotas.quotaResetDate)); + message = localize('chatQuotaExceeded', "You've reached your monthly chat requests quota. You still have free code completions available."); } else if (completionsQuotaExceeded && !chatQuotaExceeded) { - message = localize('completionsQuotaExceeded', "You've run out of free code completions. You still have free chat messages available in the Copilot Free plan. These limits will reset on {0}.", dateFormatter.format(chatEntitlementService.quotas.quotaResetDate)); + message = localize('completionsQuotaExceeded', "You've reached your monthly code completions quota. You still have free chat requests available."); } else { - message = localize('chatAndCompletionsQuotaExceeded', "You've reached the limit of the Copilot Free plan. These limits will reset on {0}.", dateFormatter.format(chatEntitlementService.quotas.quotaResetDate)); + message = localize('chatAndCompletionsQuotaExceeded', "You've reached your monthly chat requests and code completions quota."); } - const upgradeToPro = localize('upgradeToPro', "Upgrade to Copilot Pro (your first 30 days are free) for:\n- Unlimited code completions\n- Unlimited chat messages\n- Access to additional models"); + if (chatEntitlementService.quotas.resetDate) { + const dateFormatter = safeIntl.DateTimeFormat(language, { year: 'numeric', month: 'long', day: 'numeric' }); + const quotaResetDate = new Date(chatEntitlementService.quotas.resetDate); + message = [message, localize('quotaResetDate', "The allowance will renew on {0}.", dateFormatter.format(quotaResetDate))].join(' '); + } + + const limited = chatEntitlementService.entitlement === ChatEntitlement.Limited; + const upgradeToPro = limited ? localize('upgradeToPro', "Upgrade to Copilot Pro (your first 30 days are free) for:\n- Unlimited code completions\n- Unlimited basic chat requests\n- Access to premium models") : undefined; await dialogService.prompt({ type: 'none', - message: localize('copilotFree', "Copilot Limit Reached"), + message: localize('copilotQuotaReached', "Copilot Quota Reached"), cancelButton: { label: localize('dismiss', "Dismiss"), run: () => { /* noop */ } }, buttons: [ { - label: localize('upgradePro', "Upgrade to Copilot Pro"), - run: () => commandService.executeCommand('workbench.action.chat.upgradePlan', 'chat-dialog') + label: limited ? localize('upgradePro', "Upgrade to Copilot Pro") : localize('upgradePlan', "Upgrade Copilot Plan"), + run: () => { + const commandId = 'workbench.action.chat.upgradePlan'; + telemetryService.publicLog2('workbenchActionExecuted', { id: commandId, from: 'chat-dialog' }); + commandService.executeCommand(commandId); + } }, ], custom: { icon: Codicon.copilotWarningLarge, - markdownDetails: [ + markdownDetails: coalesce([ { markdown: new MarkdownString(message, true) }, - { markdown: new MarkdownString(upgradeToPro, true) } - ] + upgradeToPro ? { markdown: new MarkdownString(upgradeToPro, true) } : undefined + ]) } }); } @@ -652,6 +746,7 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { icon: Codicon.copilot, when: ContextKeyExpr.and( ChatContextKeys.supported, + ChatContextKeys.Setup.hidden.negate(), ContextKeyExpr.has('config.chat.commandCenter.enabled') ), order: 10001 // to the right of command center @@ -665,6 +760,7 @@ MenuRegistry.appendMenuItem(MenuId.TitleBar, { icon: Codicon.copilot, when: ContextKeyExpr.and( ChatContextKeys.supported, + ChatContextKeys.Setup.hidden.negate(), ContextKeyExpr.has('config.chat.commandCenter.enabled'), ContextKeyExpr.has('config.window.commandCenter').negate(), ), @@ -676,22 +772,39 @@ registerAction2(class ToggleCopilotControl extends ToggleTitleBarConfigAction { super( 'chat.commandCenter.enabled', localize('toggle.chatControl', 'Copilot Controls'), - localize('toggle.chatControlsDescription', "Toggle visibility of the Copilot Controls in title bar"), 5, false, - ChatContextKeys.supported + localize('toggle.chatControlsDescription', "Toggle visibility of the Copilot Controls in title bar"), 5, + ContextKeyExpr.and( + ChatContextKeys.Setup.hidden.negate(), + IsCompactTitleBarContext.negate(), + ChatContextKeys.supported + ) ); } }); +registerAction2(class ResetTrustedToolsAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.resetTrustedTools', + title: localize2('resetTrustedTools', "Reset Tool Confirmations"), + category: CHAT_CATEGORY, + f1: true, + }); + } + override run(accessor: ServicesAccessor): void { + accessor.get(ILanguageModelToolsService).resetToolAutoConfirmation(); + accessor.get(INotificationService).info(localize('resetTrustedToolsSuccess', "Tool confirmation preferences have been reset.")); + } +}); + export class CopilotTitleBarMenuRendering extends Disposable implements IWorkbenchContribution { - static readonly ID = 'copilot.titleBarMenuRendering'; + static readonly ID = 'workbench.contrib.copilotTitleBarMenuRendering'; constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, - @IChatAgentService agentService: IChatAgentService, @IInstantiationService instantiationService: IInstantiationService, @IChatEntitlementService chatEntitlementService: IChatEntitlementService, - @IConfigurationService configurationService: IConfigurationService, ) { super(); @@ -707,35 +820,23 @@ export class CopilotTitleBarMenuRendering extends Disposable implements IWorkben }); const chatExtensionInstalled = chatEntitlementService.sentiment === ChatSentiment.Installed; - const { chatQuotaExceeded, completionsQuotaExceeded } = chatEntitlementService.quotas; + const chatQuotaExceeded = chatEntitlementService.quotas.chat?.percentRemaining === 0; const signedOut = chatEntitlementService.entitlement === ChatEntitlement.Unknown; - const setupFromDialog = configurationService.getValue('chat.experimental.setupFromDialog'); + const limited = chatEntitlementService.entitlement === ChatEntitlement.Limited; - let primaryActionId: string; - let primaryActionTitle: string; - let primaryActionIcon: ThemeIcon; - if (!chatExtensionInstalled && !setupFromDialog) { - primaryActionId = CHAT_SETUP_ACTION_ID; - primaryActionTitle = localize('triggerChatSetup', "Use AI Features with Copilot Free..."); - primaryActionIcon = Codicon.copilot; - } else if (chatExtensionInstalled && signedOut) { - primaryActionId = TOGGLE_CHAT_ACTION_ID; - primaryActionTitle = localize('signInToChatSetup', "Sign in to use Copilot..."); - primaryActionIcon = Codicon.copilotNotConnected; - } else if (chatExtensionInstalled && (chatQuotaExceeded || completionsQuotaExceeded)) { - primaryActionId = OPEN_CHAT_QUOTA_EXCEEDED_DIALOG; - if (chatQuotaExceeded && !completionsQuotaExceeded) { - primaryActionTitle = localize('chatQuotaExceededButton', "Monthly chat messages limit reached. Click for details."); - } else if (completionsQuotaExceeded && !chatQuotaExceeded) { - primaryActionTitle = localize('completionsQuotaExceededButton', "Monthly code completions limit reached. Click for details."); - } else { - primaryActionTitle = localize('chatAndCompletionsQuotaExceededButton', "Copilot Free plan limit reached. Click for details."); + let primaryActionId = TOGGLE_CHAT_ACTION_ID; + let primaryActionTitle = localize('toggleChat', "Toggle Chat"); + let primaryActionIcon = Codicon.copilot; + if (chatExtensionInstalled) { + if (signedOut) { + primaryActionId = CHAT_SETUP_ACTION_ID; + primaryActionTitle = localize('signInToChatSetup', "Sign in to use Copilot..."); + primaryActionIcon = Codicon.copilotNotConnected; + } else if (chatQuotaExceeded && limited) { + primaryActionId = OPEN_CHAT_QUOTA_EXCEEDED_DIALOG; + primaryActionTitle = localize('chatQuotaExceededButton', "Copilot Free plan chat requests quota reached. Click for details."); + primaryActionIcon = Codicon.copilotWarning; } - primaryActionIcon = Codicon.copilotWarning; - } else { - primaryActionId = TOGGLE_CHAT_ACTION_ID; - primaryActionTitle = localize('toggleChat', "Toggle Chat"); - primaryActionIcon = Codicon.copilot; } return instantiationService.createInstance(DropdownWithPrimaryActionViewItem, instantiationService.createInstance(MenuItemAction, { id: primaryActionId, @@ -745,8 +846,7 @@ export class CopilotTitleBarMenuRendering extends Disposable implements IWorkben }, Event.any( chatEntitlementService.onDidChangeSentiment, chatEntitlementService.onDidChangeQuotaExceeded, - chatEntitlementService.onDidChangeEntitlement, - Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('chat.experimental.setupFromDialog')) + chatEntitlementService.onDidChangeEntitlement )); // Reduces flicker a bit on reload/restart @@ -754,7 +854,65 @@ export class CopilotTitleBarMenuRendering extends Disposable implements IWorkben } } -export function getEditsViewId(accessor: ServicesAccessor): string { - const chatService = accessor.get(IChatService); - return chatService.unifiedViewEnabled ? ChatViewId : EditsViewId; +/** + * Returns whether we can continue clearing/switching chat sessions, false to cancel. + */ +export async function handleCurrentEditingSession(currentEditingSession: IChatEditingSession, phrase: string | undefined, dialogService: IDialogService): Promise { + if (shouldShowClearEditingSessionConfirmation(currentEditingSession)) { + return showClearEditingSessionConfirmation(currentEditingSession, dialogService, { messageOverride: phrase }); + } + + return true; +} + +export interface IClearEditingSessionConfirmationOptions { + titleOverride?: string; + messageOverride?: string; +} + +export async function showClearEditingSessionConfirmation(editingSession: IChatEditingSession, dialogService: IDialogService, options?: IClearEditingSessionConfirmationOptions): Promise { + const defaultPhrase = localize('chat.startEditing.confirmation.pending.message.default', "Starting a new chat will end your current edit session."); + const defaultTitle = localize('chat.startEditing.confirmation.title', "Start new chat?"); + const phrase = options?.messageOverride ?? defaultPhrase; + const title = options?.titleOverride ?? defaultTitle; + + const currentEdits = editingSession.entries.get(); + const undecidedEdits = currentEdits.filter((edit) => edit.state.get() === ModifiedFileEntryState.Modified); + + const { result } = await dialogService.prompt({ + title, + message: phrase + ' ' + localize('chat.startEditing.confirmation.pending.message.2', "Do you want to keep pending edits to {0} files?", undecidedEdits.length), + type: 'info', + cancelButton: true, + buttons: [ + { + label: localize('chat.startEditing.confirmation.acceptEdits', "Keep & Continue"), + run: async () => { + await editingSession.accept(); + return true; + } + }, + { + label: localize('chat.startEditing.confirmation.discardEdits', "Undo & Continue"), + run: async () => { + await editingSession.reject(); + return true; + } + } + ], + }); + + return Boolean(result); +} + +export function shouldShowClearEditingSessionConfirmation(editingSession: IChatEditingSession): boolean { + const currentEdits = editingSession.entries.get(); + const currentEditCount = currentEdits.length; + + if (currentEditCount) { + const undecidedEdits = currentEdits.filter((edit) => edit.state.get() === ModifiedFileEntryState.Modified); + return !!undecidedEdits.length; + } + + return false; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAttachPromptAction/chatAttachPromptAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAttachPromptAction/chatAttachPromptAction.ts deleted file mode 100644 index a5c08661755..00000000000 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAttachPromptAction/chatAttachPromptAction.ts +++ /dev/null @@ -1,66 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CHAT_CATEGORY } from '../chatActions.js'; -import { localize2 } from '../../../../../../nls.js'; -import { Action2 } from '../../../../../../platform/actions/common/actions.js'; -import { IPromptsService } from '../../../common/promptSyntax/service/types.js'; -import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; -import { IViewsService } from '../../../../../services/views/common/viewsService.js'; -import { ServicesAccessor } from '../../../../../../editor/browser/editorExtensions.js'; -import { ISelectPromptOptions, askToSelectPrompt } from './dialogs/askToSelectPrompt.js'; -import { IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js'; -import { ChatContextKeys } from '../../../common/chatContextKeys.js'; - -/** - * Action ID for the `Attach Prompt` action. - */ -export const ATTACH_PROMPT_ACTION_ID = 'workbench.action.chat.attach.prompt'; - -/** - * Options for the {@link AttachPromptAction} action. - */ -export interface IChatAttachPromptActionOptions extends Pick< - ISelectPromptOptions, 'resource' | 'widget' | 'viewsService' -> { } - -/** - * Action to attach a prompt to a chat widget input. - */ -export class AttachPromptAction extends Action2 { - constructor() { - super({ - id: ATTACH_PROMPT_ACTION_ID, - title: localize2('workbench.action.chat.attach.prompt.label', "Use Prompt"), - f1: false, - precondition: ChatContextKeys.enabled, - category: CHAT_CATEGORY, - }); - } - - public override async run( - accessor: ServicesAccessor, - options: IChatAttachPromptActionOptions, - ): Promise { - const labelService = accessor.get(ILabelService); - const viewsService = accessor.get(IViewsService); - const openerService = accessor.get(IOpenerService); - const promptsService = accessor.get(IPromptsService); - const quickInputService = accessor.get(IQuickInputService); - - // find all prompt files in the user workspace - const promptFiles = await promptsService.listPromptFiles(); - - await askToSelectPrompt({ - ...options, - promptFiles, - labelService, - viewsService, - openerService, - quickInputService, - }); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAttachPromptAction/dialogs/askToSelectPrompt.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAttachPromptAction/dialogs/askToSelectPrompt.ts deleted file mode 100644 index 79d7ceccd25..00000000000 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAttachPromptAction/dialogs/askToSelectPrompt.ts +++ /dev/null @@ -1,331 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { localize } from '../../../../../../../nls.js'; -import { URI } from '../../../../../../../base/common/uri.js'; -import { assert } from '../../../../../../../base/common/assert.js'; -import { IChatWidget, showChatView, showEditsView } from '../../../chat.js'; -import { IChatAttachPromptActionOptions } from '../chatAttachPromptAction.js'; -import { IPromptPath } from '../../../../common/promptSyntax/service/types.js'; -import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; -import { dirname, extUri } from '../../../../../../../base/common/resources.js'; -import { DOCUMENTATION_URL } from '../../../../common/promptSyntax/constants.js'; -import { isLinux, isWindows } from '../../../../../../../base/common/platform.js'; -import { ILabelService } from '../../../../../../../platform/label/common/label.js'; -import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; -import { IViewsService } from '../../../../../../services/views/common/viewsService.js'; -import { assertDefined, WithUriValue } from '../../../../../../../base/common/types.js'; -import { getCleanPromptName } from '../../../../../../../platform/prompts/common/constants.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../../../../platform/quickinput/common/quickInput.js'; - -/** - * Options for the {@link askToSelectPrompt} function. - */ -export interface ISelectPromptOptions { - /** - * Prompt resource `URI` to attach to the chat input, if any. - * If provided the resource will be pre-selected in the prompt picker dialog, - * otherwise the dialog will show the prompts list without any pre-selection. - */ - readonly resource?: URI; - - /** - * Target chat widget reference to attach the prompt to. If not provided, the command - * attaches the prompt to a `chat panel` widget by default (either the last focused, - * or a new one). If the `alt` (`option` on mac) key was pressed when the prompt is - * selected, the `edits` widget is used instead (likewise, either the last focused, - * or a new one). - */ - readonly widget?: IChatWidget; - - /** - * List of prompt files to show in the selection dialog. - */ - readonly promptFiles: readonly IPromptPath[]; - - readonly labelService: ILabelService; - readonly viewsService: IViewsService; - readonly openerService: IOpenerService; - readonly quickInputService: IQuickInputService; -} - -/** - * A special quick pick item that links to the documentation. - */ -const DOCS_OPTION: WithUriValue = { - type: 'item', - label: localize( - 'commands.prompts.use.select-dialog.docs-label', - 'Learn how to create reusable prompts', - ), - description: DOCUMENTATION_URL, - tooltip: DOCUMENTATION_URL, - value: URI.parse(DOCUMENTATION_URL), -}; - -/** - * Shows the prompt selection dialog to the user that allows to select a prompt file(s). - * - * If {@link ISelectPromptOptions.resource resource} is provided, the dialog will have - * the resource pre-selected in the prompts list. - */ -export const askToSelectPrompt = async ( - options: ISelectPromptOptions, -): Promise => { - const { promptFiles, resource, quickInputService, labelService } = options; - - const fileOptions = promptFiles.map((promptFile) => { - return createPickItem(promptFile, labelService); - }); - - /** - * Add a link to the documentation to the end of prompts list. - */ - fileOptions.push(DOCS_OPTION); - - // if a resource is provided, create an `activeItem` for it to pre-select - // it in the UI, and sort the list so the active item appears at the top - let activeItem: WithUriValue | undefined; - if (resource) { - activeItem = fileOptions.find((file) => { - return extUri.isEqual(file.value, resource); - }); - - // if no item for the `resource` was found, it means that the resource is not - // in the list of prompt files, so add a new item for it; this ensures that - // the currently active prompt file is always available in the selection dialog, - // even if it is not included in the prompts list otherwise(from location setting) - if (!activeItem) { - activeItem = createPickItem({ - uri: resource, - // "user" prompts are always registered in the prompts list, hence it - // should be safe to assume that `resource` is not "user" prompt here - type: 'local', - }, labelService); - fileOptions.push(activeItem); - } - - fileOptions.sort((file1, file2) => { - if (extUri.isEqual(file1.value, resource)) { - return -1; - } - - if (extUri.isEqual(file2.value, resource)) { - return 1; - } - - return 0; - }); - } - - /** - * If still no active item present, fall back to the first item in the list. - * This can happen only if command was invoked not from a focused prompt file - * (hence the `resource` is not provided in the options). - * - * Fixes the two main cases: - * - when no prompt files found it, pre-selects the documentation link - * - when there is only a single prompt file, pre-selects it - */ - if (!activeItem) { - activeItem = fileOptions[0]; - } - - // otherwise show the prompt file selection dialog - const { openerService } = options; - - const quickPick = quickInputService.createQuickPick>(); - quickPick.activeItems = activeItem ? [activeItem] : []; - quickPick.placeholder = createPlaceholderText(options); - quickPick.canAcceptInBackground = true; - quickPick.matchOnDescription = true; - quickPick.items = fileOptions; - - return await new Promise(resolve => { - const disposables = new DisposableStore(); - - let lastActiveWidget = options.widget; - disposables.add({ - dispose() { - quickPick.dispose(); - resolve(); - - // if something was attached, focus on the target chat input - lastActiveWidget?.focusInput(); - }, - }); - - disposables.add(quickPick.onDidAccept(async (event) => { - const { selectedItems } = quickPick; - const { alt, ctrlCmd } = quickPick.keyMods; - - // sanity check to confirm our expectations - assert( - selectedItems.length === 1, - `Only one item can be accepted, got '${selectedItems.length}'.`, - ); - - // whether user selected the docs link option - const docsSelected = (selectedItems[0] === DOCS_OPTION); - - // if `super` key was pressed, open the selected prompt file(s) - if (ctrlCmd || docsSelected) { - return await openFiles(selectedItems, openerService); - } - - // otherwise attach the selected prompt to a chat input - lastActiveWidget = await attachFiles(selectedItems, options, alt); - - // if user submitted their selection, close the dialog - if (!event.inBackground) { - disposables.dispose(); - } - })); - - disposables.add(quickPick.onDidHide( - disposables.dispose.bind(disposables), - )); - - quickPick.show(); - }); -}; - -/** - * Creates a quick pick item for a prompt. - */ -const createPickItem = ( - promptFile: IPromptPath, - labelService: ILabelService, -): WithUriValue => { - const { uri, type } = promptFile; - const fileWithoutExtension = getCleanPromptName(uri); - - // if a "user" prompt, don't show its filesystem path in - // the user interface, but do that for all the "local" ones - const description = (type === 'user') - ? localize( - 'user-prompt.capitalized', - 'User prompt', - ) - : labelService.getUriLabel(dirname(uri), { relative: true }); - - const tooltip = (type === 'user') - ? description - : uri.fsPath; - - return { - type: 'item', - label: fileWithoutExtension, - description, - tooltip, - value: uri, - id: uri.toString(), - }; -}; - -/** - * Creates a placeholder text to show in the prompt selection dialog. - */ -const createPlaceholderText = (options: ISelectPromptOptions): string => { - const { widget } = options; - - let text = localize( - 'commands.prompts.use.select-dialog.placeholder', - 'Select a prompt to use', - ); - - // if no widget reference is provided, add the note about `options` - // and `cmd` modifiers users can use to alter the command behavior - if (!widget) { - const altOptionkey = (isWindows || isLinux) ? 'Alt' : 'Option'; - - const altOptionModifierNote = localize( - 'commands.prompts.use.select-dialog.alt-modifier-note', - '{0}-key to use in Edits', - altOptionkey, - ); - - const cmdCtrlkey = (isWindows || isLinux) ? 'Ctrl' : 'Cmd'; - const superModifierNote = localize( - 'commands.prompts.use.select-dialog.super-modifier-note', - '{0}-key to open in editor', - cmdCtrlkey, - ); - - text += localize( - 'commands.prompts.use.select-dialog.modifier-notes', - ' (hold {0} or {1})', - altOptionModifierNote, - superModifierNote, - ); - } - - return text; -}; - -/** - * Opens provided files in the editor. - */ -const openFiles = async ( - files: readonly WithUriValue[], - openerService: IOpenerService, -) => { - for (const file of files) { - await openerService.open(file.value); - } -}; - -/** - * Attaches provided files to a chat input. - */ -const attachFiles = async ( - files: readonly WithUriValue[], - options: ISelectPromptOptions, - altOption: boolean, -): Promise => { - const widget = await getChatWidgetObject(options, altOption); - - for (const file of files) { - widget - .attachmentModel - .promptInstructions - .add(file.value); - } - - return widget; -}; - -/** - * Gets a chat widget based on the provided {@link IChatAttachPromptActionOptions.widget widget} - * reference. If no widget reference is provided, the function will reveal a `chat panel` by default - * (either a last focused, or a new one), but if the {@link altOption} is set to `true`, a `chat edits` - * panel will be revealed instead (likewise either a last focused, or a new one). - * - * @throws if failed to reveal a chat widget. - */ -const getChatWidgetObject = async ( - options: IChatAttachPromptActionOptions, - altOption: boolean, -): Promise => { - const { widget, viewsService } = options; - - // if no widget reference is present, the command was triggered from outside of - // an active chat input, so we reveal a chat widget window based on the `alt` - // key modifier state when a prompt was selected from the picker UI dialog - if (!widget) { - const widget = (altOption) - ? await showEditsView(viewsService) - : await showChatView(viewsService); - - assertDefined( - widget, - 'Revealed chat widget must be defined.', - ); - - return widget; - } - - return widget; -}; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts index 3022b4b40a6..562a79ee3e5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts @@ -6,25 +6,21 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; -import { localize, localize2 } from '../../../../../nls.js'; +import { localize2 } from '../../../../../nls.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; -import { IViewsService } from '../../../../services/views/common/viewsService.js'; -import { isChatViewTitleActionContext } from '../../common/chatActions.js'; -import { ChatContextKeyExprs, ChatContextKeys } from '../../common/chatContextKeys.js'; -import { hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingSession, WorkingSetEntryState } from '../../common/chatEditingService.js'; -import { ChatAgentLocation, ChatMode } from '../../common/constants.js'; -import { ChatViewId, EditsViewId, IChatWidget, IChatWidgetService } from '../chat.js'; +import { ChatContextKeys } from '../../common/chatContextKeys.js'; +import { IChatEditingSession } from '../../common/chatEditingService.js'; +import { ChatMode } from '../../common/constants.js'; +import { ChatViewId, IChatWidget } from '../chat.js'; import { EditingSessionAction } from '../chatEditing/chatEditingActions.js'; -import { ctxIsGlobalEditingSession } from '../chatEditing/chatEditingEditorContextKeys.js'; import { ChatEditorInput } from '../chatEditorInput.js'; -import { ChatViewPane } from '../chatViewPane.js'; -import { CHAT_CATEGORY, getEditsViewId, IChatViewOpenOptions } from './chatActions.js'; +import { CHAT_CATEGORY, handleCurrentEditingSession } from './chatActions.js'; import { clearChatEditor } from './chatClear.js'; export const ACTION_ID_NEW_CHAT = `workbench.action.chat.newChat`; @@ -72,63 +68,11 @@ export function registerNewChatActions() { } }); - registerAction2(class GlobalClearChatAction extends Action2 { + registerAction2(class NewChatAction extends EditingSessionAction { constructor() { super({ id: ACTION_ID_NEW_CHAT, - title: localize2('chat.newChat.label', "New Chat"), - category: CHAT_CATEGORY, - icon: Codicon.plus, - precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.location.notEqualsTo(ChatAgentLocation.EditingSession)), - f1: true, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyCode.KeyL, - mac: { - primary: KeyMod.WinCtrl | KeyCode.KeyL - }, - when: ChatContextKeys.inChatSession - }, - menu: [{ - id: MenuId.ChatContext, - group: 'z_clear' - }, - { - id: MenuId.ViewTitle, - when: ContextKeyExpr.and( - ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), - ChatContextKeys.inUnifiedChat.negate()), - group: 'navigation', - order: -1 - }] - }); - } - - async run(accessor: ServicesAccessor, ...args: any[]) { - const context = args[0]; - const accessibilitySignalService = accessor.get(IAccessibilitySignalService); - const widgetService = accessor.get(IChatWidgetService); - - let widget = widgetService.lastFocusedWidget; - - if (isChatViewTitleActionContext(context)) { - // Is running in the Chat view title - widget = widgetService.getWidgetBySessionId(context.sessionId); - } - - if (widget) { - announceChatCleared(accessibilitySignalService); - widget.clear(); - widget.focusInput(); - } - } - }); - - registerAction2(class NewEditSessionAction extends EditingSessionAction { - constructor() { - super({ - id: ACTION_ID_NEW_EDIT_SESSION, - title: localize2('chat.newEdits.label', "New Edit Session"), + title: localize2('chat.newEdits.label', "New Chat"), category: CHAT_CATEGORY, icon: Codicon.plus, precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.editingParticipantRegistered), @@ -139,7 +83,7 @@ export function registerNewChatActions() { }, { id: MenuId.ViewTitle, - when: ChatContextKeyExprs.inEditsOrUnified, + when: ContextKeyExpr.equals('view', ChatViewId), group: 'navigation', order: -1 }], @@ -149,86 +93,27 @@ export function registerNewChatActions() { mac: { primary: KeyMod.WinCtrl | KeyCode.KeyL }, - when: ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeyExprs.inEditingMode) + when: ChatContextKeys.inChatSession } }); } - /** - * - * @returns false if the user had edits and did not action the dialog to take action on them, true otherwise - */ - private async _handleCurrentEditingSession(currentEditingSession: IChatEditingSession, dialogService: IDialogService): Promise { - const currentEdits = currentEditingSession?.entries.get(); - const currentEditCount = currentEdits?.length; - if (currentEditingSession && currentEditCount) { - const undecidedEdits = currentEdits.filter((edit) => edit.state.get() === WorkingSetEntryState.Modified); - if (undecidedEdits.length) { - const { result } = await dialogService.prompt({ - title: localize('chat.startEditing.confirmation.title', "Start new editing session?"), - message: localize('chat.startEditing.confirmation.pending.message.2', "Starting a new editing session will end your current session. Do you want to accept pending edits to {0} files?", undecidedEdits.length), - type: 'info', - cancelButton: true, - buttons: [ - { - label: localize('chat.startEditing.confirmation.acceptEdits', "Accept & Continue"), - run: async () => { - await currentEditingSession.accept(); - return true; - } - }, - { - label: localize('chat.startEditing.confirmation.discardEdits', "Discard & Continue"), - run: async () => { - await currentEditingSession.reject(); - return true; - } - } - ], - }); - - return Boolean(result); - } - } - - return true; - } - - async runEditingSessionAction(accessor: ServicesAccessor, editingSession: IChatEditingSession, chatWidget: IChatWidget, ...args: any[]) { + async runEditingSessionAction(accessor: ServicesAccessor, editingSession: IChatEditingSession, widget: IChatWidget, ...args: any[]) { const context: INewEditSessionActionContext | undefined = args[0]; const accessibilitySignalService = accessor.get(IAccessibilitySignalService); - const widgetService = accessor.get(IChatWidgetService); const dialogService = accessor.get(IDialogService); - const viewsService = accessor.get(IViewsService); - const instaSevice = accessor.get(IInstantiationService); - if (!(await this._handleCurrentEditingSession(editingSession, dialogService))) { + if (!(await handleCurrentEditingSession(editingSession, undefined, dialogService))) { return; } - const isChatViewTitleAction = isChatViewTitleActionContext(context); - - let widget: IChatWidget | undefined; - if (isChatViewTitleAction) { - // Is running in the Chat view title - widget = widgetService.getWidgetBySessionId(context.sessionId); - } else { - // Is running from f1 or keybinding - const view = instaSevice.invokeFunction(getEditsViewId); - const chatView = await viewsService.openView(view); - widget = chatView?.widget; - } - announceChatCleared(accessibilitySignalService); - if (!widget) { - return; - } - - await editingSession.stop(true); + await editingSession.stop(); widget.clear(); - widget.attachmentModel.clear(); + await widget.waitForReady(); + widget.attachmentModel.clear(true); widget.input.relatedFiles?.clear(); widget.focusInput(); @@ -236,7 +121,7 @@ export function registerNewChatActions() { return; } - if (!isChatViewTitleAction && typeof context.agentMode === 'boolean') { + if (typeof context.agentMode === 'boolean') { widget.input.setChatMode(context.agentMode ? ChatMode.Agent : ChatMode.Edit); } @@ -249,69 +134,21 @@ export function registerNewChatActions() { } } }); + CommandsRegistry.registerCommandAlias(ACTION_ID_NEW_EDIT_SESSION, ACTION_ID_NEW_CHAT); - registerAction2(class GlobalEditsDoneAction extends Action2 { - constructor() { - super({ - id: ChatDoneActionId, - title: localize2('chat.done.label', "Done"), - category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.editingParticipantRegistered), - f1: false, - menu: [{ - id: MenuId.ChatEditingWidgetToolbar, - when: ContextKeyExpr.and(hasUndecidedChatEditingResourceContextKey.negate(), hasAppliedChatEditsContextKey, ChatContextKeys.editingParticipantRegistered, ChatContextKeyExprs.inEditsOrUnified), - group: 'navigation', - order: 0 - }] - }); - } - - async run(accessor: ServicesAccessor, ...args: any[]) { - const context = args[0]; - const accessibilitySignalService = accessor.get(IAccessibilitySignalService); - const widgetService = accessor.get(IChatWidgetService); - if (isChatViewTitleActionContext(context)) { - // Is running in the Chat view title - announceChatCleared(accessibilitySignalService); - const widget = widgetService.getWidgetBySessionId(context.sessionId); - if (widget) { - widget.clear(); - widget.attachmentModel.clear(); - widget.focusInput(); - } - } else { - // Is running from f1 or keybinding - const viewsService = accessor.get(IViewsService); - - const viewId = getEditsViewId(accessor); - const chatView = await viewsService.openView(viewId); - if (!chatView) { - return; - } - - const widget = chatView.widget; - - announceChatCleared(accessibilitySignalService); - widget.clear(); - widget.attachmentModel.clear(); - widget.focusInput(); - } - } - }); registerAction2(class UndoChatEditInteractionAction extends EditingSessionAction { constructor() { super({ id: 'workbench.action.chat.undoEdit', - title: localize2('chat.undoEdit.label', "Undo Last Edit"), + title: localize2('chat.undoEdit.label', "Undo Last Request"), category: CHAT_CATEGORY, icon: Codicon.discard, precondition: ContextKeyExpr.and(ChatContextKeys.chatEditingCanUndo, ChatContextKeys.enabled, ChatContextKeys.editingParticipantRegistered), f1: true, menu: [{ id: MenuId.ViewTitle, - when: ChatContextKeyExprs.inEditingMode, + when: ContextKeyExpr.equals('view', ChatViewId), group: 'navigation', order: -3 }] @@ -327,14 +164,14 @@ export function registerNewChatActions() { constructor() { super({ id: 'workbench.action.chat.redoEdit', - title: localize2('chat.redoEdit.label', "Redo Last Edit"), + title: localize2('chat.redoEdit.label', "Redo Last Request"), category: CHAT_CATEGORY, icon: Codicon.redo, precondition: ContextKeyExpr.and(ChatContextKeys.chatEditingCanRedo, ChatContextKeys.enabled, ChatContextKeys.editingParticipantRegistered), f1: true, menu: [{ id: MenuId.ViewTitle, - when: ChatContextKeyExprs.inEditingMode, + when: ContextKeyExpr.equals('view', ChatViewId), group: 'navigation', order: -2 }] @@ -345,69 +182,6 @@ export function registerNewChatActions() { await editingSession.redoInteraction(); } }); - - registerAction2(class GlobalOpenEditsAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.openEditSession', - title: localize2('chat.openEdits.label', "Open {0}", 'Copilot Edits'), - category: CHAT_CATEGORY, - icon: Codicon.goToEditingSession, - f1: true, - precondition: ChatContextKeys.Setup.hidden.toNegated(), - menu: [{ - id: MenuId.ViewTitle, - when: ContextKeyExpr.and( - ContextKeyExpr.equals('view', ChatViewId), - ChatContextKeys.editingParticipantRegistered, - ContextKeyExpr.equals(`view.${EditsViewId}.visible`, false), - ContextKeyExpr.or( - ContextKeyExpr.and(ContextKeyExpr.equals(`workbench.panel.chat.defaultViewContainerLocation`, true), ContextKeyExpr.equals(`workbench.panel.chatEditing.defaultViewContainerLocation`, false)), - ContextKeyExpr.and(ContextKeyExpr.equals(`workbench.panel.chat.defaultViewContainerLocation`, false), ContextKeyExpr.equals(`workbench.panel.chatEditing.defaultViewContainerLocation`, true)), - ), - ChatContextKeys.inUnifiedChat.negate() - ), - group: 'navigation', - order: 1 - }, { - id: MenuId.ChatTitleBarMenu, - group: 'a_open', - order: 2, - when: ChatContextKeyExprs.unifiedChatEnabled.negate() - }, { - id: MenuId.ChatEditingEditorContent, - when: ctxIsGlobalEditingSession, - group: 'navigate', - order: 4, - }], - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyI, - linux: { - primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.KeyI - }, - when: ContextKeyExpr.and(ContextKeyExpr.notEquals('view', EditsViewId), ChatContextKeys.editingParticipantRegistered) - } - }); - } - - async run(accessor: ServicesAccessor, opts?: string | IChatViewOpenOptions) { - opts = typeof opts === 'string' ? { query: opts } : opts; - const viewsService = accessor.get(IViewsService); - const chatView = await viewsService.openView(EditsViewId) - ?? await viewsService.openView(ChatViewId); - - if (opts?.query) { - if (opts.isPartialQuery) { - chatView?.widget.setInput(opts.query); - } else { - chatView?.widget.acceptInput(opts.query); - } - } - - chatView?.widget.focusInput(); - } - }); } function announceChatCleared(accessibilitySignalService: IAccessibilitySignalService): void { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts index 7e6a74fba21..e6f04e259e8 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts @@ -538,7 +538,8 @@ function getContextFromEditor(editor: ICodeEditor, accessor: ServicesAccessor): codeBlockIndex: codeBlockInfo.codeBlockIndex, code: editor.getValue(), languageId: editor.getModel()!.getLanguageId(), - codemapperUri: codeBlockInfo.codemapperUri + codemapperUri: codeBlockInfo.codemapperUri, + chatSessionId: codeBlockInfo.chatSessionId, }; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts index ed203d960c1..b101670c286 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts @@ -3,24 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { groupBy } from '../../../../../base/common/arrays.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ResolvedKeybinding } from '../../../../../base/common/keybindings.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { isElectron } from '../../../../../base/common/platform.js'; -import { basename, dirname } from '../../../../../base/common/resources.js'; -import { compare } from '../../../../../base/common/strings.js'; +import { basename, dirname, extUri } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { WithUriValue } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { IRange, Range } from '../../../../../editor/common/core/range.js'; -import { Command } from '../../../../../editor/common/languages.js'; +import { Command, SymbolKinds } from '../../../../../editor/common/languages.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { AbstractGotoSymbolQuickAccessProvider, IGotoSymbolQuickPickItem } from '../../../../../editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.js'; import { localize, localize2 } from '../../../../../nls.js'; -import { Action2, IAction2Options, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -29,6 +30,8 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IMarkerService, MarkerSeverity } from '../../../../../platform/markers/common/markers.js'; import { AnythingQuickAccessProviderRunOptions } from '../../../../../platform/quickinput/common/quickAccess.js'; import { IQuickInputService, IQuickPickItem, IQuickPickItemWithResource, IQuickPickSeparator, QuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { ActiveEditorContext, TextCompareEditorActiveContext } from '../../../../common/contextkeys.js'; @@ -49,39 +52,62 @@ import { SearchView } from '../../../search/browser/searchView.js'; import { ISymbolQuickPickItem, SymbolsQuickAccessProvider } from '../../../search/browser/symbolsQuickAccess.js'; import { SearchContext } from '../../../search/common/constants.js'; import { IChatAgentService } from '../../common/chatAgents.js'; -import { ChatContextKeyExprs, ChatContextKeys } from '../../common/chatContextKeys.js'; +import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { IChatEditingService } from '../../common/chatEditingService.js'; -import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData } from '../../common/chatModel.js'; +import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, OmittedState } from '../../common/chatModel.js'; import { ChatRequestAgentPart } from '../../common/chatParserTypes.js'; -import { IChatVariablesService } from '../../common/chatVariables.js'; import { ChatAgentLocation } from '../../common/constants.js'; -import { ILanguageModelToolsService } from '../../common/languageModelToolsService.js'; -import { IChatWidget, IChatWidgetService, IQuickChatService, showChatView, showEditsView } from '../chat.js'; +import { IToolData } from '../../common/languageModelToolsService.js'; +import { IChatWidget, IChatWidgetService, IQuickChatService, showChatView } from '../chat.js'; import { imageToHash, isImage } from '../chatPasteProviders.js'; import { isQuickChat } from '../chatWidget.js'; -import { createFolderQuickPick, createMarkersQuickPick } from '../contrib/chatDynamicVariables.js'; +import { createFilesAndFolderQuickPick } from '../contrib/chatDynamicVariables.js'; import { convertBufferToScreenshotVariable, ScreenshotVariableId } from '../contrib/screenshot.js'; import { resizeImage } from '../imageUtils.js'; -import { COMMAND_ID as USE_PROMPT_COMMAND_ID } from '../promptSyntax/contributions/usePromptCommand.js'; +import { INSTRUCTIONS_COMMAND_ID } from '../promptSyntax/contributions/attachInstructionsCommand.js'; import { CHAT_CATEGORY } from './chatActions.js'; -import { ATTACH_PROMPT_ACTION_ID, AttachPromptAction, IChatAttachPromptActionOptions } from './chatAttachPromptAction/chatAttachPromptAction.js'; +import { runAttachInstructionsAction, registerPromptActions } from './promptActions/index.js'; export function registerChatContextActions() { registerAction2(AttachContextAction); registerAction2(AttachFileToChatAction); registerAction2(AttachFolderToChatAction); registerAction2(AttachSelectionToChatAction); - registerAction2(AttachFileToEditingSessionAction); - registerAction2(AttachFolderToEditingSessionAction); - registerAction2(AttachSelectionToEditingSessionAction); + registerAction2(AttachSearchResultAction); } /** * We fill the quickpick with these types, and enable some quick access providers */ -type IAttachmentQuickPickItem = ICommandVariableQuickPickItem | IQuickAccessQuickPickItem | IToolQuickPickItem | - IImageQuickPickItem | IOpenEditorsQuickPickItem | ISearchResultsQuickPickItem | - IScreenShotQuickPickItem | IRelatedFilesQuickPickItem | IReusablePromptQuickPickItem | IFolderQuickPickItem | IDiagnosticsQuickPickItem; +type IAttachmentQuickPickItem = ICommandVariableQuickPickItem | IWorkspaceSymbolsQuickPickItem + | IToolsQuickPickItem | IToolQuickPickItem + | IImageQuickPickItem | IOpenEditorsQuickPickItem | ISearchResultsQuickPickItem + | IScreenShotQuickPickItem | IRelatedFilesQuickPickItem | IInstructionsQuickPickItem + | IFolderQuickPickItem | IFolderResultQuickPickItem + | IDiagnosticsQuickPickItem | IDiagnosticsQuickPickItemWithFilter; + +function isIAttachmentQuickPickItem(obj: unknown): obj is IAttachmentQuickPickItem { + return ( + typeof obj === 'object' + && obj !== null + && typeof (obj).kind === 'string' + ); +} + +const attachmentsOrdinals: (IAttachmentQuickPickItem['kind'])[] = [ + // bottom-most + 'tools', + 'command', + 'screenshot', + 'image', + 'workspaceSymbol', + 'diagnostic', + 'instructions', + 'related-files', + 'folder', + 'open-editors', + // top-most +]; /** * These are the types that we can get out of the quick pick @@ -103,18 +129,6 @@ function isISymbolQuickPickItem(obj: unknown): obj is ISymbolQuickPickItem { && !!(obj as ISymbolQuickPickItem).symbol); } -function isIFolderSearchResultQuickPickItem(obj: unknown): obj is IFolderResultQuickPickItem { - return ( - typeof obj === 'object' - && (obj as IFolderResultQuickPickItem).kind === 'folder-search-result'); -} - -function isIDiagnosticsQuickPickItemWithFilter(obj: unknown): obj is IDiagnosticsQuickPickItemWithFilter { - return ( - typeof obj === 'object' - && (obj as IDiagnosticsQuickPickItemWithFilter).kind === 'diagnostic-filter'); -} - function isIQuickPickItemWithResource(obj: unknown): obj is IQuickPickItemWithResource { return ( typeof obj === 'object' @@ -122,40 +136,11 @@ function isIQuickPickItemWithResource(obj: unknown): obj is IQuickPickItemWithRe && URI.isUri((obj as IQuickPickItemWithResource).resource)); } -function isIOpenEditorsQuickPickItem(obj: unknown): obj is IOpenEditorsQuickPickItem { - return ( - typeof obj === 'object' - && (obj as IOpenEditorsQuickPickItem).id === 'open-editors'); -} -function isISearchResultsQuickPickItem(obj: unknown): obj is ISearchResultsQuickPickItem { - return ( - typeof obj === 'object' - && (obj as ISearchResultsQuickPickItem).kind === 'search-results'); -} - -function isScreenshotQuickPickItem(obj: unknown): obj is IScreenShotQuickPickItem { - return ( - typeof obj === 'object' - && (obj as IScreenShotQuickPickItem).kind === 'screenshot'); -} - -function isRelatedFileQuickPickItem(obj: unknown): obj is IRelatedFilesQuickPickItem { - return ( - typeof obj === 'object' - && (obj as IRelatedFilesQuickPickItem).kind === 'related-files' - ); -} - -/** - * Checks is a provided object is a prompt instructions quick pick item. - */ -function isPromptInstructionsQuickPickItem(obj: unknown): obj is IReusablePromptQuickPickItem { - if (!obj || typeof obj !== 'object') { - return false; - } - - return ('kind' in obj && obj.kind === 'reusable-prompt'); +interface IToolsQuickPickItem extends IQuickPickItem { + kind: 'tools'; + id: string; + label: string; } interface IRelatedFilesQuickPickItem extends IQuickPickItem { @@ -187,7 +172,6 @@ interface ICommandVariableQuickPickItem extends IQuickPickItem { command: Command; name?: string; value: unknown; - icon?: ThemeIcon; } @@ -196,12 +180,12 @@ interface IToolQuickPickItem extends IQuickPickItem { id: string; name?: string; icon?: ThemeIcon; + tool: IToolData; } -interface IQuickAccessQuickPickItem extends IQuickPickItem { - kind: 'quickaccess'; +interface IWorkspaceSymbolsQuickPickItem extends IQuickPickItem { + kind: 'workspaceSymbol'; id: string; - prefix: string; } interface IOpenEditorsQuickPickItem extends IQuickPickItem { @@ -236,19 +220,19 @@ interface IDiagnosticsQuickPickItemWithFilter extends IQuickPickItem { } /** - * Quick pick item for reusable prompt attachment. + * Quick pick item for instructions attachment. */ -const REUSABLE_PROMPT_PICK_ID = 'reusable-prompt'; -interface IReusablePromptQuickPickItem extends IQuickPickItem { +const INSTRUCTION_PICK_ID = 'instructions'; +interface IInstructionsQuickPickItem extends IQuickPickItem { /** * The ID of the quick pick item. */ - id: typeof REUSABLE_PROMPT_PICK_ID; + id: typeof INSTRUCTION_PICK_ID; /** - * Unique kind identifier of the reusable prompt attachment. + * Unique kind identifier of the instructions attachment. */ - kind: typeof REUSABLE_PROMPT_PICK_ID; + kind: typeof INSTRUCTION_PICK_ID; /** * Keybinding of the command. @@ -297,19 +281,22 @@ class AttachFileToChatAction extends AttachResourceAction { id: MenuId.SearchContext, group: 'z_chat', order: 1, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.or(ActiveEditorContext.isEqualTo(TEXT_FILE_EDITOR_ID), TextCompareEditorActiveContext)), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.or(ActiveEditorContext.isEqualTo(TEXT_FILE_EDITOR_ID), TextCompareEditorActiveContext), SearchContext.SearchResultHeaderFocused.negate()), }] }); } override async run(accessor: ServicesAccessor, ...args: any[]): Promise { - const variablesService = accessor.get(IChatVariablesService); + const viewsService = accessor.get(IViewsService); const files = this.getResources(accessor, ...args); - - if (files.length) { - (await showChatView(accessor.get(IViewsService)))?.focusInput(); + if (!files.length) { + return; + } + const widget = await showChatView(viewsService); + if (widget) { + widget.focusInput(); for (const file of files) { - variablesService.attachContext('file', file, ChatAgentLocation.Panel); + widget.attachmentModel.addFile(file); } } } @@ -329,13 +316,17 @@ class AttachFolderToChatAction extends AttachResourceAction { } override async run(accessor: ServicesAccessor, ...args: any[]): Promise { - const variablesService = accessor.get(IChatVariablesService); - const folders = this.getResources(accessor, ...args); + const viewsService = accessor.get(IViewsService); - if (folders.length) { - (await showChatView(accessor.get(IViewsService)))?.focusInput(); + const folders = this.getResources(accessor, ...args); + if (!folders.length) { + return; + } + const widget = await showChatView(viewsService); + if (widget) { + widget.focusInput(); for (const folder of folders) { - variablesService.attachContext('folder', folder, ChatAgentLocation.Panel); + widget.attachmentModel.addFolder(folder); } } } @@ -355,8 +346,14 @@ class AttachSelectionToChatAction extends Action2 { } override async run(accessor: ServicesAccessor, ...args: any[]): Promise { - const variablesService = accessor.get(IChatVariablesService); const editorService = accessor.get(IEditorService); + const viewsService = accessor.get(IViewsService); + + const widget = await showChatView(viewsService); + if (!widget) { + return; + } + const [_, matches] = args; // If we have search matches, it means this is coming from the search widget if (matches && matches.length > 0) { @@ -370,7 +367,7 @@ class AttachSelectionToChatAction extends Action2 { if (!range || range.startLineNumber !== context.range.startLineNumber && range.endLineNumber !== context.range.endLineNumber) { uris.set(context.uri, context.range); - variablesService.attachContext('file', context, ChatAgentLocation.Panel); + widget.attachmentModel.addFile(context.uri, context.range); } } } @@ -378,150 +375,96 @@ class AttachSelectionToChatAction extends Action2 { for (const uri of uris) { const [resource, range] = uri; if (!range) { - variablesService.attachContext('file', { uri: resource }, ChatAgentLocation.Panel); + widget.attachmentModel.addFile(resource); } } } else { const activeEditor = editorService.activeTextEditorControl; const activeUri = EditorResourceAccessor.getCanonicalUri(editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); - if (editorService.activeTextEditorControl && activeUri && [Schemas.file, Schemas.vscodeRemote, Schemas.untitled].includes(activeUri.scheme)) { - const selection = activeEditor?.getSelection(); + if (activeEditor && activeUri && [Schemas.file, Schemas.vscodeRemote, Schemas.untitled].includes(activeUri.scheme)) { + const selection = activeEditor.getSelection(); if (selection) { - (await showChatView(accessor.get(IViewsService)))?.focusInput(); + widget.focusInput(); const range = selection.isEmpty() ? new Range(selection.startLineNumber, 1, selection.startLineNumber + 1, 1) : selection; - variablesService.attachContext('file', { uri: activeUri, range }, ChatAgentLocation.Panel); + widget.attachmentModel.addFile(activeUri, range); } } } } } -class AttachFileToEditingSessionAction extends AttachResourceAction { +export class AttachSearchResultAction extends Action2 { - static readonly ID = 'workbench.action.edits.attachFile'; + private static readonly Name = 'searchResults'; constructor() { super({ - id: AttachFileToEditingSessionAction.ID, - title: localize2('workbench.action.edits.attachFile.label', "Add File to {0}", 'Copilot Edits'), + id: 'workbench.action.chat.insertSearchResults', + title: localize2('chat.insertSearchResults', 'Add Search Results to Chat'), category: CHAT_CATEGORY, f1: false, menu: [{ id: MenuId.SearchContext, group: 'z_chat', - order: 2, + order: 3, when: ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.or(ActiveEditorContext.isEqualTo(TEXT_FILE_EDITOR_ID), TextCompareEditorActiveContext), - ChatContextKeyExprs.unifiedChatEnabled.negate()), + SearchContext.SearchResultHeaderFocused), }] }); } + async run(accessor: ServicesAccessor) { + const logService = accessor.get(ILogService); + const widget = await showChatView(accessor.get(IViewsService)); - override async run(accessor: ServicesAccessor, ...args: any[]): Promise { - const variablesService = accessor.get(IChatVariablesService); - const files = this.getResources(accessor, ...args); - - if (files.length) { - (await showEditsView(accessor.get(IViewsService)))?.focusInput(); - for (const file of files) { - variablesService.attachContext('file', file, ChatAgentLocation.EditingSession); - } + if (!widget) { + logService.trace('InsertSearchResultAction: no chat view available'); + return; } - } -} -class AttachFolderToEditingSessionAction extends AttachResourceAction { + const editor = widget.inputEditor; + const originalRange = editor.getSelection() ?? editor.getModel()?.getFullModelRange().collapseToEnd(); - static readonly ID = 'workbench.action.edits.attachFolder'; - - constructor() { - super({ - id: AttachFolderToEditingSessionAction.ID, - title: localize2('workbench.action.edits.attachFolder.label', "Add Folder to {0}", 'Copilot Edits'), - category: CHAT_CATEGORY, - f1: false, - precondition: ContextKeyExpr.and( - ChatContextKeys.enabled, - ChatContextKeyExprs.unifiedChatEnabled.negate()), - }); - } - - override async run(accessor: ServicesAccessor, ...args: any[]): Promise { - const variablesService = accessor.get(IChatVariablesService); - const folders = this.getResources(accessor, ...args); - - if (folders.length) { - (await showEditsView(accessor.get(IViewsService)))?.focusInput(); - for (const folder of folders) { - variablesService.attachContext('folder', folder, ChatAgentLocation.EditingSession); - } + if (!originalRange) { + logService.trace('InsertSearchResultAction: no selection'); + return; } - } -} -class AttachSelectionToEditingSessionAction extends Action2 { - - static readonly ID = 'workbench.action.edits.attachSelection'; - - constructor() { - super({ - id: AttachSelectionToEditingSessionAction.ID, - title: localize2('workbench.action.edits.attachSelection.label', "Add Selection to {0}", 'Copilot Edits'), - category: CHAT_CATEGORY, - f1: false, - precondition: ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.or(ActiveEditorContext.isEqualTo(TEXT_FILE_EDITOR_ID), TextCompareEditorActiveContext), - ChatContextKeyExprs.unifiedChatEnabled.negate() - ) - }); - } - - override async run(accessor: ServicesAccessor, ...args: any[]): Promise { - const variablesService = accessor.get(IChatVariablesService); - const editorService = accessor.get(IEditorService); - - const activeEditor = editorService.activeTextEditorControl; - const activeUri = EditorResourceAccessor.getCanonicalUri(editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); - if (editorService.activeTextEditorControl && activeUri && [Schemas.file, Schemas.vscodeRemote, Schemas.untitled].includes(activeUri.scheme)) { - const selection = activeEditor?.getSelection(); - if (selection) { - (await showEditsView(accessor.get(IViewsService)))?.focusInput(); - const range = selection.isEmpty() ? new Range(selection.startLineNumber, 1, selection.startLineNumber + 1, 1) : selection; - variablesService.attachContext('file', { uri: activeUri, range }, ChatAgentLocation.EditingSession); - } + let insertText = `#${AttachSearchResultAction.Name}`; + const varRange = new Range(originalRange.startLineNumber, originalRange.startColumn, originalRange.endLineNumber, originalRange.startLineNumber + insertText.length); + // check character before the start of the range. If it's not a space, add a space + const model = editor.getModel(); + if (model && model.getValueInRange(new Range(originalRange.startLineNumber, originalRange.startColumn - 1, originalRange.startLineNumber, originalRange.startColumn)) !== ' ') { + insertText = ' ' + insertText; + } + const success = editor.executeEdits('chatInsertSearch', [{ range: varRange, text: insertText + ' ' }]); + if (!success) { + logService.trace(`InsertSearchResultAction: failed to insert "${insertText}"`); + return; } } } export class AttachContextAction extends Action2 { - static readonly ID = 'workbench.action.chat.attachContext'; - - constructor(desc: Readonly = { - id: AttachContextAction.ID, - title: localize2('workbench.action.chat.attachContext.label.2', "Add Context"), - icon: Codicon.attach, - category: CHAT_CATEGORY, - keybinding: { - when: ContextKeyExpr.and( - ChatContextKeys.location.notEqualsTo(ChatAgentLocation.EditingSession), - ChatContextKeys.inChatInput, - ChatContextKeyExprs.inNonUnifiedPanel), - primary: KeyMod.CtrlCmd | KeyCode.Slash, - weight: KeybindingWeight.EditorContrib - }, - menu: [ - { - when: ChatContextKeyExprs.inNonUnifiedPanel, + constructor() { + super({ + id: 'workbench.action.chat.attachContext', + title: localize2('workbench.action.chat.attachContext.label.2', "Add Context..."), + icon: Codicon.attach, + category: CHAT_CATEGORY, + keybinding: { + when: ContextKeyExpr.and(ChatContextKeys.inChatInput, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel)), + primary: KeyMod.CtrlCmd | KeyCode.Slash, + weight: KeybindingWeight.EditorContrib + }, + menu: { + when: ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), id: MenuId.ChatInputAttachmentToolbar, group: 'navigation', - order: 2 - } - ] - }) { - super(desc); + order: 3 + }, + }); } private _getFileContextId(item: { resource: URI } | { uri: URI; range: IRange }) { @@ -534,37 +477,145 @@ export class AttachContextAction extends Action2 { `:${item.range.startLineNumber}`); } - private async _attachContext(widget: IChatWidget, quickInputService: IQuickInputService, commandService: ICommandService, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, fileService: IFileService, textModelService: ITextModelService, isInBackground?: boolean, ...picks: IChatContextQuickPickItem[]) { + private async _attachContext(accessor: ServicesAccessor, widget: IChatWidget, isInBackground?: boolean, ...picks: IChatContextQuickPickItem[]) { + const commandService = accessor.get(ICommandService); + const clipboardService = accessor.get(IClipboardService); + const editorService = accessor.get(IEditorService); + const labelService = accessor.get(ILabelService); + const viewsService = accessor.get(IViewsService); + const chatEditingService = accessor.get(IChatEditingService); + const hostService = accessor.get(IHostService); + const fileService = accessor.get(IFileService); + const textModelService = accessor.get(ITextModelService); + const quickInputService = accessor.get(IQuickInputService); const toAttach: IChatRequestVariableEntry[] = []; for (const pick of picks) { - if (isISymbolQuickPickItem(pick) && pick.symbol) { + + if (isIAttachmentQuickPickItem(pick)) { + if (pick.kind === 'folder-search-result') { + toAttach.push({ + kind: 'directory', + id: pick.id, + value: pick.resource, + name: basename(pick.resource), + }); + } else if (pick.kind === 'diagnostic-filter') { + toAttach.push({ + id: pick.id, + name: pick.label, + value: pick.filter, + kind: 'diagnostic', + icon: pick.icon, + ...pick.filter, + }); + + } else if (pick.kind === 'open-editors') { + for (const editor of editorService.editors.filter(e => e instanceof FileEditorInput || e instanceof DiffEditorInput || e instanceof UntitledTextEditorInput || e instanceof NotebookEditorInput)) { + const uri = editor instanceof DiffEditorInput ? editor.modified.resource : editor.resource; + if (uri) { + toAttach.push({ + kind: 'file', + id: this._getFileContextId({ resource: uri }), + value: uri, + name: labelService.getUriBasenameLabel(uri), + }); + } + } + } else if (pick.kind === 'search-results') { + const searchView = viewsService.getViewWithId(SEARCH_VIEW_ID) as SearchView; + for (const result of searchView.model.searchResult.matches()) { + toAttach.push({ + kind: 'file', + id: this._getFileContextId({ resource: result.resource }), + value: result.resource, + name: labelService.getUriBasenameLabel(result.resource), + }); + } + } else if (pick.kind === 'related-files') { + // Get all provider results and show them in a second tier picker + const chatSessionId = widget.viewModel?.sessionId; + if (!chatSessionId || !chatEditingService) { + continue; + } + const relatedFiles = await chatEditingService.getRelatedFiles(chatSessionId, widget.getInput(), widget.attachmentModel.fileAttachments, CancellationToken.None); + if (!relatedFiles) { + continue; + } + const attachments = widget.attachmentModel.getAttachmentIDs(); + const itemsPromise = chatEditingService.getRelatedFiles(chatSessionId, widget.getInput(), widget.attachmentModel.fileAttachments, CancellationToken.None) + .then((files) => (files ?? []).reduce<(WithUriValue | IQuickPickSeparator)[]>((acc, cur) => { + acc.push({ type: 'separator', label: cur.group }); + for (const file of cur.files) { + acc.push({ + type: 'item', + label: labelService.getUriBasenameLabel(file.uri), + description: labelService.getUriLabel(dirname(file.uri), { relative: true }), + value: file.uri, + disabled: attachments.has(this._getFileContextId({ resource: file.uri })), + picked: true + }); + } + return acc; + }, [])); + const selectedFile = await quickInputService.pick(itemsPromise, { placeHolder: localize('relatedFiles', 'Add related files to your working set') }); + if (selectedFile) { + toAttach.push({ + kind: 'file', + id: this._getFileContextId({ resource: selectedFile.value }), + value: selectedFile.value, + name: selectedFile.label, + omittedState: OmittedState.NotOmitted + }); + } + } else if (pick.kind === 'screenshot') { + const blob = await hostService.getScreenshot(); + if (blob) { + toAttach.push(convertBufferToScreenshotVariable(blob)); + } + } else if (pick.kind === 'command') { + // Dynamic variable with a followup command + const selection = await commandService.executeCommand(pick.command.id, ...(pick.command.arguments ?? [])); + if (!selection) { + // User made no selection, skip this variable + continue; + } + toAttach.push({ + ...pick, + value: pick.value, + name: `${typeof pick.value === 'string' && pick.value.startsWith('#') ? pick.value.slice(1) : ''}${selection}`, + // Apply the original icon with the new name + fullName: selection + }); + } else if (pick.kind === 'tool') { + toAttach.push({ + id: pick.id, + name: pick.tool.displayName, + fullName: pick.tool.displayName, + value: undefined, + icon: pick.icon, + kind: 'tool' + }); + } else if (pick.kind === 'image') { + const fileBuffer = await clipboardService.readImage(); + toAttach.push({ + id: await imageToHash(fileBuffer), + name: localize('pastedImage', 'Pasted Image'), + fullName: localize('pastedImage', 'Pasted Image'), + value: fileBuffer, + kind: 'image', + }); + } + } else if (isISymbolQuickPickItem(pick) && pick.symbol) { // Workspace symbol toAttach.push({ kind: 'symbol', id: this._getFileContextId(pick.symbol.location), value: pick.symbol.location, symbolKind: pick.symbol.kind, + icon: SymbolKinds.toIcon(pick.symbol.kind), fullName: pick.label, name: pick.symbol.name, }); - } else if (isIFolderSearchResultQuickPickItem(pick)) { - const folder = pick.resource; - toAttach.push({ - id: pick.id, - value: folder, - name: basename(folder), - isFile: false, - isDirectory: true, - }); - } else if (isIDiagnosticsQuickPickItemWithFilter(pick)) { - toAttach.push({ - id: pick.id, - name: pick.label, - value: pick.filter, - kind: 'diagnostic', - icon: pick.icon, - ...pick.filter, - }); } else if (isIQuickPickItemWithResource(pick) && pick.resource) { if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(pick.resource.path)) { // checks if the file is an image @@ -577,136 +628,35 @@ export class AttachContextAction extends Action2 { name: pick.label, fullName: pick.label, value: resizedImage, - isImage: true + kind: 'image', + references: [{ reference: pick.resource, kind: 'reference' }] }); } } else { - let isOmitted = false; + let omittedState = OmittedState.NotOmitted; try { const createdModel = await textModelService.createModelReference(pick.resource); createdModel.dispose(); } catch { - isOmitted = true; + omittedState = OmittedState.Full; } toAttach.push({ + kind: 'file', id: this._getFileContextId({ resource: pick.resource }), value: pick.resource, name: pick.label, - isFile: true, - isOmitted + omittedState }); } } else if (isIGotoSymbolQuickPickItem(pick) && pick.uri && pick.range) { toAttach.push({ - range: undefined, + kind: 'generic', id: this._getFileContextId({ uri: pick.uri, range: pick.range.decoration }), value: { uri: pick.uri, range: pick.range.decoration }, fullName: pick.label, name: pick.symbolName!, }); - } else if (isIOpenEditorsQuickPickItem(pick)) { - for (const editor of editorService.editors.filter(e => e instanceof FileEditorInput || e instanceof DiffEditorInput || e instanceof UntitledTextEditorInput || e instanceof NotebookEditorInput)) { - const uri = editor instanceof DiffEditorInput ? editor.modified.resource : editor.resource; - if (uri) { - toAttach.push({ - id: this._getFileContextId({ resource: uri }), - value: uri, - name: labelService.getUriBasenameLabel(uri), - isFile: true, - }); - } - } - } else if (isISearchResultsQuickPickItem(pick)) { - const searchView = viewsService.getViewWithId(SEARCH_VIEW_ID) as SearchView; - for (const result of searchView.model.searchResult.matches()) { - toAttach.push({ - id: this._getFileContextId({ resource: result.resource }), - value: result.resource, - name: labelService.getUriBasenameLabel(result.resource), - isFile: true, - }); - } - } else if (isRelatedFileQuickPickItem(pick)) { - // Get all provider results and show them in a second tier picker - const chatSessionId = widget.viewModel?.sessionId; - if (!chatSessionId || !chatEditingService) { - continue; - } - const relatedFiles = await chatEditingService.getRelatedFiles(chatSessionId, widget.getInput(), widget.attachmentModel.fileAttachments, CancellationToken.None); - if (!relatedFiles) { - continue; - } - const attachments = widget.attachmentModel.getAttachmentIDs(); - const itemsPromise = chatEditingService.getRelatedFiles(chatSessionId, widget.getInput(), widget.attachmentModel.fileAttachments, CancellationToken.None) - .then((files) => (files ?? []).reduce<(WithUriValue | IQuickPickSeparator)[]>((acc, cur) => { - acc.push({ type: 'separator', label: cur.group }); - for (const file of cur.files) { - acc.push({ - type: 'item', - label: labelService.getUriBasenameLabel(file.uri), - description: labelService.getUriLabel(dirname(file.uri), { relative: true }), - value: file.uri, - disabled: attachments.has(this._getFileContextId({ resource: file.uri })), - picked: true - }); - } - return acc; - }, [])); - const selectedFiles = await quickInputService.pick(itemsPromise, { placeHolder: localize('relatedFiles', 'Add related files to your working set'), canPickMany: true }); - for (const file of selectedFiles ?? []) { - toAttach.push({ - id: this._getFileContextId({ resource: file.value }), - value: file.value, - name: file.label, - isFile: true, - isOmitted: false - }); - } - } else if (isScreenshotQuickPickItem(pick)) { - const blob = await hostService.getScreenshot(); - if (blob) { - toAttach.push(convertBufferToScreenshotVariable(blob)); - } - } else if (isPromptInstructionsQuickPickItem(pick)) { - const options: IChatAttachPromptActionOptions = { widget, viewsService }; - await commandService.executeCommand(ATTACH_PROMPT_ACTION_ID, options); - } else { - // Anything else is an attachment - const attachmentPick = pick as IAttachmentQuickPickItem; - if (attachmentPick.kind === 'command') { - // Dynamic variable with a followup command - const selection = await commandService.executeCommand(attachmentPick.command.id, ...(attachmentPick.command.arguments ?? [])); - if (!selection) { - // User made no selection, skip this variable - continue; - } - toAttach.push({ - ...attachmentPick, - value: attachmentPick.value, - name: `${typeof attachmentPick.value === 'string' && attachmentPick.value.startsWith('#') ? attachmentPick.value.slice(1) : ''}${selection}`, - // Apply the original icon with the new name - fullName: selection - }); - } else if (attachmentPick.kind === 'tool') { - toAttach.push({ - id: attachmentPick.id, - name: attachmentPick.label, - fullName: attachmentPick.label, - value: undefined, - icon: attachmentPick.icon, - isTool: true - }); - } else if (attachmentPick.kind === 'image') { - const fileBuffer = await clipboardService.readImage(); - toAttach.push({ - id: await imageToHash(fileBuffer), - name: localize('pastedImage', 'Pasted Image'), - fullName: localize('pastedImage', 'Pasted Image'), - value: fileBuffer, - isImage: true - }); - } } } @@ -719,30 +669,21 @@ export class AttachContextAction extends Action2 { } override async run(accessor: ServicesAccessor, ...args: any[]): Promise { - const quickInputService = accessor.get(IQuickInputService); const chatAgentService = accessor.get(IChatAgentService); - const commandService = accessor.get(ICommandService); const widgetService = accessor.get(IChatWidgetService); - const languageModelToolsService = accessor.get(ILanguageModelToolsService); - const quickChatService = accessor.get(IQuickChatService); const clipboardService = accessor.get(IClipboardService); const editorService = accessor.get(IEditorService); - const labelService = accessor.get(ILabelService); const contextKeyService = accessor.get(IContextKeyService); - const viewsService = accessor.get(IViewsService); - const hostService = accessor.get(IHostService); const extensionService = accessor.get(IExtensionService); - const fileService = accessor.get(IFileService); - const textModelService = accessor.get(ITextModelService); const instantiationService = accessor.get(IInstantiationService); const keybindingService = accessor.get(IKeybindingService); + const chatEditingService = accessor.get(IChatEditingService); - const context: { widget?: IChatWidget; showFilesOnly?: boolean; placeholder?: string } | undefined = args[0]; + const context: { widget?: IChatWidget; placeholder?: string } | undefined = args[0]; const widget = context?.widget ?? widgetService.lastFocusedWidget; if (!widget) { return; } - const chatEditingService = widget.location === ChatAgentLocation.EditingSession || widget.isUnifiedPanelWidget ? accessor.get(IChatEditingService) : undefined; const quickPickItems: IAttachmentQuickPickItem[] = []; if (extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData'))) { @@ -790,42 +731,30 @@ export class AttachContextAction extends Action2 { } } - for (const tool of languageModelToolsService.getTools()) { - if (tool.canBeReferencedInPrompt) { - const item: IToolQuickPickItem = { - kind: 'tool', - label: tool.displayName ?? '', - id: tool.id, - icon: ThemeIcon.isThemeIcon(tool.icon) ? tool.icon : undefined // TODO need to support icon path? - }; - if (ThemeIcon.isThemeIcon(tool.icon)) { - item.iconClass = ThemeIcon.asClassName(tool.icon); - } else if (tool.icon) { - item.iconPath = tool.icon; - } - - quickPickItems.push(item); - } - } + quickPickItems.push({ + kind: 'tools', + label: localize('chatContext.tools', 'Tools...'), + iconClass: ThemeIcon.asClassName(Codicon.tools), + id: 'tools', + }); quickPickItems.push({ - kind: 'quickaccess', - label: localize('chatContext.symbol', 'Symbol...'), + kind: 'workspaceSymbol', + label: localize('chatContext.symbol', 'Symbols...'), iconClass: ThemeIcon.asClassName(Codicon.symbolField), - prefix: SymbolsQuickAccessProvider.PREFIX, id: 'symbol' }); quickPickItems.push({ kind: 'folder', - label: localize('chatContext.folder', 'Folder...'), + label: localize('chatContext.folder', 'Files & Folders...'), iconClass: ThemeIcon.asClassName(Codicon.folder), id: 'folder', }); quickPickItems.push({ kind: 'diagnostic', - label: localize('chatContext.diagnstic', 'Problem...'), + label: localize('chatContext.diagnstic', 'Problems...'), iconClass: ThemeIcon.asClassName(Codicon.error), id: 'diagnostic' }); @@ -846,65 +775,54 @@ export class AttachContextAction extends Action2 { }); } - if (context?.showFilesOnly) { - if (chatEditingService?.hasRelatedFilesProviders() && (widget.getInput() || widget.attachmentModel.fileAttachments.length > 0)) { - quickPickItems.unshift({ - kind: 'related-files', - id: 'related-files', - label: localize('chatContext.relatedFiles', 'Related Files'), - iconClass: ThemeIcon.asClassName(Codicon.sparkle), - }); - } - if (editorService.editors.filter(e => e instanceof FileEditorInput || e instanceof DiffEditorInput || e instanceof UntitledTextEditorInput).length > 0) { - quickPickItems.unshift({ - kind: 'open-editors', - id: 'open-editors', - label: localize('chatContext.editors', 'Open Editors'), - iconClass: ThemeIcon.asClassName(Codicon.files), - }); - } - if (SearchContext.HasSearchResults.getValue(contextKeyService)) { - quickPickItems.unshift({ - kind: 'search-results', - id: 'search-results', - label: localize('chatContext.searchResults', 'Search Results'), - iconClass: ThemeIcon.asClassName(Codicon.search), - }); - } + if (chatEditingService?.hasRelatedFilesProviders() && (widget.getInput() || widget.attachmentModel.fileAttachments.length > 0)) { + quickPickItems.push({ + kind: 'related-files', + id: 'related-files', + label: localize('chatContext.relatedFiles', 'Related Files'), + iconClass: ThemeIcon.asClassName(Codicon.sparkle), + }); + } + if (editorService.editors.filter(e => e instanceof FileEditorInput || e instanceof DiffEditorInput || e instanceof UntitledTextEditorInput).length > 0) { + quickPickItems.push({ + kind: 'open-editors', + id: 'open-editors', + label: localize('chatContext.editors', 'Open Editors'), + iconClass: ThemeIcon.asClassName(Codicon.files), + }); + } + if (SearchContext.HasSearchResults.getValue(contextKeyService)) { + quickPickItems.push({ + kind: 'search-results', + id: 'search-results', + label: localize('chatContext.searchResults', 'Search Results'), + iconClass: ThemeIcon.asClassName(Codicon.search), + }); } // if the `reusable prompts` feature is enabled, add // the appropriate attachment type to the list if (widget.attachmentModel.promptInstructions.featureEnabled) { - const keybinding = keybindingService.lookupKeybinding(USE_PROMPT_COMMAND_ID, contextKeyService); + const keybinding = keybindingService.lookupKeybinding(INSTRUCTIONS_COMMAND_ID, contextKeyService); quickPickItems.push({ - id: REUSABLE_PROMPT_PICK_ID, - kind: REUSABLE_PROMPT_PICK_ID, - label: localize('chatContext.attach.prompt.label', 'Prompt...'), + id: INSTRUCTION_PICK_ID, + kind: INSTRUCTION_PICK_ID, + label: localize('chatContext.attach.instructions.label', 'Instructions...'), iconClass: ThemeIcon.asClassName(Codicon.bookmark), keybinding, }); } - function extractTextFromIconLabel(label: string | undefined): string { - if (!label) { - return ''; + quickPickItems.sort((a, b) => { + let result = attachmentsOrdinals.indexOf(b.kind) - attachmentsOrdinals.indexOf(a.kind); + if (result === 0) { + result = a.label.localeCompare(b.label); } - const match = label.match(/\$\([^\)]+\)\s*(.+)/); - return match ? match[1] : label; - } + return result; + }); - this._show(quickInputService, commandService, widget, quickChatService, quickPickItems.sort(function (a, b) { - - if (a.kind === 'open-editors') { return -1; } - if (b.kind === 'open-editors') { return 1; } - - const first = extractTextFromIconLabel(a.label).toUpperCase(); - const second = extractTextFromIconLabel(b.label).toUpperCase(); - - return compare(first, second); - }), clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, fileService, textModelService, instantiationService, '', context?.placeholder); + instantiationService.invokeFunction(this._show.bind(this), widget, quickPickItems, '', context?.placeholder); } private async _showDiagnosticsPick(instantiationService: IInstantiationService, onBackgroundAccept: (item: IChatContextQuickPickItem[]) => void): Promise { @@ -916,48 +834,61 @@ export class AttachContextAction extends Action2 { filter: item, }); - const filter = await instantiationService.invokeFunction(accessor => - createMarkersQuickPick(accessor, 'problem', items => onBackgroundAccept(items.map(convert)))); + const filter = await instantiationService.invokeFunction(createMarkersQuickPick, items => onBackgroundAccept(items.map(convert))); return filter && convert(filter); } - private _show(quickInputService: IQuickInputService, commandService: ICommandService, widget: IChatWidget, quickChatService: IQuickChatService, quickPickItems: (IChatContextQuickPickItem | QuickPickItem)[] | undefined, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, fileService: IFileService, textModelService: ITextModelService, instantiationService: IInstantiationService, query: string = '', placeholder?: string) { + private _show(accessor: ServicesAccessor, widget: IChatWidget, quickPickItems: (IChatContextQuickPickItem | QuickPickItem)[] | undefined, query: string = '', placeholder?: string) { + const quickInputService = accessor.get(IQuickInputService); + const quickChatService = accessor.get(IQuickChatService); + const editorService = accessor.get(IEditorService); + const commandService = accessor.get(ICommandService); + const instantiationService = accessor.get(IInstantiationService); + const attach = (isBackgroundAccept: boolean, ...items: IChatContextQuickPickItem[]) => { - this._attachContext(widget, quickInputService, commandService, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, fileService, textModelService, isBackgroundAccept, ...items); + instantiationService.invokeFunction(this._attachContext.bind(this), widget, isBackgroundAccept, ...items); }; const providerOptions: AnythingQuickAccessProviderRunOptions = { + additionPicks: quickPickItems, handleAccept: async (inputItem: IChatContextQuickPickItem, isBackgroundAccept: boolean) => { let item: IChatContextQuickPickItem | undefined = inputItem; - if ('kind' in item && item.kind === 'folder') { - item = await this._showFolders(instantiationService); - } else if ('kind' in item && item.kind === 'diagnostic') { - item = await this._showDiagnosticsPick(instantiationService, i => attach(true, ...i)); - } - if (!item) { - this._show(quickInputService, commandService, widget, quickChatService, quickPickItems, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, fileService, textModelService, instantiationService, '', placeholder); - return; - } + if (isIAttachmentQuickPickItem(item)) { - if ('prefix' in item) { - this._show(quickInputService, commandService, widget, quickChatService, quickPickItems, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, fileService, textModelService, instantiationService, item.prefix, placeholder); - } else { - if (!clipboardService) { + if (item.kind === 'workspaceSymbol') { + instantiationService.invokeFunction(this._show.bind(this), widget, quickPickItems, SymbolsQuickAccessProvider.PREFIX, placeholder); + return; + } else if (item.kind === 'instructions') { + runAttachInstructionsAction(commandService, { widget }); return; } - attach(isBackgroundAccept, item); - if (isQuickChat(widget)) { - quickChatService.open(); + + if (item.kind === 'folder') { + item = await this._showFolders(instantiationService); + } else if (item.kind === 'diagnostic') { + item = await this._showDiagnosticsPick(instantiationService, i => attach(true, ...i)); + } else if (item.kind === 'tools') { + item = await instantiationService.invokeFunction(showToolsPick, widget); } + if (!item) { + // restart picker when sub-picker didn't return anything + instantiationService.invokeFunction(this._show.bind(this), widget, quickPickItems, '', placeholder); + return; + } + } + attach(isBackgroundAccept, item); + if (isQuickChat(widget)) { + quickChatService.open(); + } + }, - additionPicks: quickPickItems, filter: (item: IChatContextQuickPickItem | IQuickPickSeparator) => { // Avoid attaching the same context twice const attachedContext = widget.attachmentModel.getAttachmentIDs(); - if (isIOpenEditorsQuickPickItem(item)) { + if (isIAttachmentQuickPickItem(item) && item.kind === 'open-editors') { for (const editor of editorService.editors.filter(e => e instanceof FileEditorInput || e instanceof DiffEditorInput || e instanceof UntitledTextEditorInput)) { // There is an open editor that hasn't yet been attached to the chat if (editor.resource && !attachedContext.has(this._getFileContextId({ resource: editor.resource }))) { @@ -1004,7 +935,7 @@ export class AttachContextAction extends Action2 { } private async _showFolders(instantiationService: IInstantiationService): Promise { - const folder = await instantiationService.invokeFunction(accessor => createFolderQuickPick(accessor)); + const folder = await instantiationService.invokeFunction(createFilesAndFolderQuickPick); if (!folder) { return undefined; } @@ -1018,35 +949,125 @@ export class AttachContextAction extends Action2 { } } -registerAction2(class AttachFilesAction extends AttachContextAction { - constructor() { - super({ - id: 'workbench.action.chat.editing.attachContext', - title: localize2('workbench.action.chat.editing.attachContext.label', "Add Context to Copilot Edits"), - shortTitle: localize2('workbench.action.chat.editing.attachContext.shortLabel', "Add Context..."), - f1: false, - category: CHAT_CATEGORY, - menu: { - when: ChatContextKeyExprs.inEditsOrUnified, - id: MenuId.ChatInputAttachmentToolbar, - group: 'navigation', - order: 3 - }, - icon: Codicon.attach, - precondition: ChatContextKeyExprs.inEditsOrUnified, - keybinding: { - when: ContextKeyExpr.and(ChatContextKeys.inChatInput, ChatContextKeyExprs.inEditsOrUnified), - primary: KeyMod.CtrlCmd | KeyCode.Slash, - weight: KeybindingWeight.EditorContrib +async function createMarkersQuickPick(accessor: ServicesAccessor, onBackgroundAccept?: (item: IDiagnosticVariableEntryFilterData[]) => void): Promise { + const quickInputService = accessor.get(IQuickInputService); + const markerService = accessor.get(IMarkerService); + const labelService = accessor.get(ILabelService); + + const markers = markerService.read({ severities: MarkerSeverity.Error | MarkerSeverity.Warning | MarkerSeverity.Info }); + const grouped = groupBy(markers, (a, b) => extUri.compare(a.resource, b.resource)); + + const severities = new Set(); + type MarkerPickItem = IQuickPickItem & { resource?: URI; entry: IDiagnosticVariableEntryFilterData }; + const items: (MarkerPickItem | IQuickPickSeparator)[] = []; + + let pickCount = 0; + for (const group of grouped) { + const resource = group[0].resource; + + items.push({ type: 'separator', label: labelService.getUriLabel(resource, { relative: true }) }); + for (const marker of group) { + pickCount++; + severities.add(marker.severity); + items.push({ + type: 'item', + resource: marker.resource, + label: marker.message, + description: localize('markers.panel.at.ln.col.number', "[Ln {0}, Col {1}]", '' + marker.startLineNumber, '' + marker.startColumn), + entry: IDiagnosticVariableEntryFilterData.fromMarker(marker), + }); + } + } + + items.unshift({ type: 'item', label: localize('markers.panel.allErrors', 'All Problems'), entry: { filterSeverity: MarkerSeverity.Info } }); + + const store = new DisposableStore(); + const quickPick = store.add(quickInputService.createQuickPick({ useSeparators: true })); + quickPick.canAcceptInBackground = !onBackgroundAccept; + quickPick.placeholder = localize('pickAProblem', 'Pick a problem to attach...'); + quickPick.items = items; + + return new Promise(resolve => { + store.add(quickPick.onDidHide(() => resolve(undefined))); + store.add(quickPick.onDidAccept(ev => { + if (ev.inBackground) { + onBackgroundAccept?.(quickPick.selectedItems.map(i => i.entry)); + } else { + resolve(quickPick.selectedItems[0]?.entry); + quickPick.dispose(); } - }); + })); + quickPick.show(); + }).finally(() => store.dispose()); +} + +async function showToolsPick(accessor: ServicesAccessor, widget: IChatWidget): Promise { + + const quickPickService = accessor.get(IQuickInputService); + + + function classify(tool: IToolData) { + if (tool.source.type === 'internal' || tool.source.type === 'extension' && !tool.source.isExternalTool) { + return { ordinal: 1, groupLabel: localize('chatContext.tools.internal', 'Built-In') }; + } else if (tool.source.type === 'mcp') { + return { ordinal: 2, groupLabel: localize('chatContext.tools.mcp', 'MCP Servers') }; + } else { + return { ordinal: 3, groupLabel: localize('chatContext.tools.extension', 'Extensions') }; + } } - override async run(accessor: ServicesAccessor, ...args: any[]): Promise { - const context = args[0]; - const attachFilesContext = { ...context, showFilesOnly: true }; - return super.run(accessor, attachFilesContext); - } -}); + type Pick = IToolQuickPickItem & { ordinal: number; groupLabel: string }; + const items: Pick[] = []; -registerAction2(AttachPromptAction); + for (const tool of widget.input.selectedToolsModel.tools.get()) { + if (!tool.canBeReferencedInPrompt) { + continue; + } + const item: Pick = { + tool, + ...classify(tool), + kind: 'tool', + label: tool.toolReferenceName ?? tool.id, + description: (tool.toolReferenceName ?? tool.id) !== tool.displayName ? tool.displayName : undefined, + id: tool.id, + }; + // if (ThemeIcon.isThemeIcon(tool.icon)) { + // item.iconClass = ThemeIcon.asClassName(tool.icon); + // } else if (tool.icon) { + // item.iconPath = tool.icon; + // } + items.push(item); + } + + items.sort((a, b) => { + let res = a.ordinal - b.ordinal; + if (res === 0) { + res = a.label.localeCompare(b.label); + } + return res; + }); + + let lastGroupLabel: string | undefined; + const picks: (IQuickPickSeparator | Pick)[] = []; + + + for (const item of items) { + if (lastGroupLabel !== item.groupLabel) { + picks.push({ type: 'separator', label: item.groupLabel }); + lastGroupLabel = item.groupLabel; + } + picks.push(item); + } + + const result = await quickPickService.pick(picks, { + placeHolder: localize('chatContext.tools.placeholder', 'Select a tool'), + canPickMany: false + }); + + return result; +} + +/** + * Register all actions related to reusable prompt files. + */ +registerPromptActions(); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts index 1c09419c4c6..f19d7f56cc3 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts @@ -22,7 +22,7 @@ export function registerChatCopyActions() { category: CHAT_CATEGORY, menu: { id: MenuId.ChatContext, - when: ChatContextKeys.responseIsFiltered.toNegated(), + when: ChatContextKeys.responseIsFiltered.negate(), group: 'copy', } }); @@ -54,7 +54,7 @@ export function registerChatCopyActions() { category: CHAT_CATEGORY, menu: { id: MenuId.ChatContext, - when: ChatContextKeys.responseIsFiltered.toNegated(), + when: ChatContextKeys.responseIsFiltered.negate(), group: 'copy', } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 63657afcd08..cfe11bcd49c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -5,25 +5,28 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { assertType } from '../../../../../base/common/types.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; -import { IChatAgentService } from '../../common/chatAgents.js'; -import { ChatContextKeyExprs, ChatContextKeys } from '../../common/chatContextKeys.js'; -import { IChatEditingService, IChatEditingSession, WorkingSetEntryState } from '../../common/chatEditingService.js'; -import { chatAgentLeader, extractAgentAndCommand } from '../../common/chatParserTypes.js'; +import { ChatContextKeys } from '../../common/chatContextKeys.js'; +import { ModifiedFileEntryState } from '../../common/chatEditingService.js'; +import { chatVariableLeader } from '../../common/chatParserTypes.js'; import { IChatService } from '../../common/chatService.js'; -import { ChatAgentLocation, ChatMode } from '../../common/constants.js'; -import { EditsViewId, IChatWidget, IChatWidgetService } from '../chat.js'; -import { discardAllEditsWithConfirmation, getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; -import { ChatViewPane } from '../chatViewPane.js'; -import { CHAT_CATEGORY } from './chatActions.js'; -import { ACTION_ID_NEW_CHAT, ChatDoneActionId } from './chatClearActions.js'; +import { ChatAgentLocation, ChatConfiguration, ChatMode, validateChatMode } from '../../common/constants.js'; +import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; +import { ILanguageModelToolsService } from '../../common/languageModelToolsService.js'; +import { IChatWidget, IChatWidgetService, showChatView } from '../chat.js'; +import { getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; +import { CHAT_CATEGORY, handleCurrentEditingSession } from './chatActions.js'; +import { ACTION_ID_NEW_CHAT } from './chatClearActions.js'; export interface IVoiceChatExecuteActionContext { readonly disableTimeout?: boolean; @@ -51,13 +54,7 @@ export class ChatSubmitAction extends SubmitAction { static readonly ID = 'workbench.action.chat.submit'; constructor() { - const precondition = ContextKeyExpr.and( - // if the input has prompt instructions attached, allow submitting requests even - // without text present - having instructions is enough context for a request - ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.instructionsAttached), - whenNotInProgressOrPaused, - ChatContextKeys.chatMode.isEqualTo(ChatMode.Ask), - ); + const precondition = ChatContextKeys.chatMode.isEqualTo(ChatMode.Ask); super({ id: ChatSubmitAction.ID, @@ -75,14 +72,15 @@ export class ChatSubmitAction extends SubmitAction { { id: MenuId.ChatExecuteSecondary, group: 'group_1', - order: 1 + order: 1, + when: precondition }, { id: MenuId.ChatExecute, order: 4, when: ContextKeyExpr.and( whenNotInProgressOrPaused, - ChatContextKeys.chatMode.isEqualTo(ChatMode.Ask), + precondition, ), group: 'navigation', }, @@ -104,20 +102,17 @@ class ToggleChatModeAction extends Action2 { constructor() { super({ id: ToggleChatModeAction.ID, - title: localize2('interactive.toggleAgent.label', "Set Chat Mode (Experimental)"), + title: localize2('interactive.toggleAgent.label', "Set Chat Mode"), f1: true, category: CHAT_CATEGORY, precondition: ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.or( - ChatContextKeys.Editing.hasToolsAgent, - ChatContextKeyExprs.unifiedChatEnabled), ChatContextKeys.requestInProgress.negate()), - tooltip: localize('setChatMode', "Set Mode (Experimental)"), + tooltip: localize('setChatMode', "Set Mode"), keybinding: { when: ContextKeyExpr.and( ChatContextKeys.inChatInput, - ChatContextKeyExprs.inEditsOrUnified), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel)), primary: KeyMod.CtrlCmd | KeyCode.Period, weight: KeybindingWeight.EditorContrib }, @@ -125,15 +120,11 @@ class ToggleChatModeAction extends Action2 { { id: MenuId.ChatExecute, order: 1, - // Either in edits with agent mode available, or in unified chat view when: ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.or( - ContextKeyExpr.and( - ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), - ChatContextKeys.Editing.hasToolsAgent, - ), - ChatContextKeys.inUnifiedChat)), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), + ChatContextKeys.inQuickChat.negate(), + ), group: 'navigation', }, ] @@ -141,8 +132,8 @@ class ToggleChatModeAction extends Action2 { } async run(accessor: ServicesAccessor, ...args: any[]) { - const chatService = accessor.get(IChatService); const commandService = accessor.get(ICommandService); + const configurationService = accessor.get(IConfigurationService); const dialogService = accessor.get(IDialogService); const context = getEditingSessionContext(accessor, args); @@ -151,51 +142,58 @@ class ToggleChatModeAction extends Action2 { } const arg = args.at(0) as IToggleChatModeArgs | undefined; - if (arg?.mode === context.chatWidget.input.currentMode) { + const chatSession = context.chatWidget.viewModel?.model; + const requestCount = chatSession?.getRequests().length ?? 0; + const switchToMode = validateChatMode(arg?.mode) ?? this.getNextMode(context.chatWidget, requestCount, configurationService); + const needToClearEdits = (!configurationService.getValue(ChatConfiguration.Edits2Enabled) && (context.chatWidget.input.currentMode === ChatMode.Edit || switchToMode === ChatMode.Edit)) && requestCount > 0; + + if (switchToMode === context.chatWidget.input.currentMode) { return; } - if (!chatService.unifiedViewEnabled) { - // TODO will not require discarding the session when we are able to switch modes mid-session - const entries = context.editingSession?.entries.get(); - if (context.editingSession && entries && entries.length > 0 && entries.some(entry => entry.state.get() === WorkingSetEntryState.Modified)) { - if (!await discardAllEditsWithConfirmation(accessor, context.editingSession)) { - // User cancelled + if (needToClearEdits) { + // If not using edits2 and switching into or out of edit mode, ask to discard the session + const phrase = localize('switchMode.confirmPhrase', "Switching chat modes will end your current edit session."); + if (!context.editingSession) { + return; + } + + const currentEdits = context.editingSession.entries.get(); + const undecidedEdits = currentEdits.filter((edit) => edit.state.get() === ModifiedFileEntryState.Modified); + if (undecidedEdits.length > 0) { + if (!await handleCurrentEditingSession(context.editingSession, phrase, dialogService)) { return; } } else { - const chatSession = context.chatWidget.viewModel?.model; - if (chatSession?.getRequests().length) { - const confirmation = await dialogService.confirm({ - title: localize('agent.newSession', "Start new session?"), - message: localize('agent.newSessionMessage', "Changing the chat mode will start a new session. Would you like to continue?"), - primaryButton: localize('agent.newSession.confirm', "Yes"), - type: 'info' - }); - if (!confirmation.confirmed) { - return; - } + const confirmation = await dialogService.confirm({ + title: localize('agent.newSession', "Start new session?"), + message: localize('agent.newSessionMessage', "Changing the chat mode will end your current edit session. Would you like to change the chat mode?"), + primaryButton: localize('agent.newSession.confirm', "Yes"), + type: 'info' + }); + if (!confirmation.confirmed) { + return; } } } - if (arg?.mode) { - context.chatWidget.input.setChatMode(arg.mode); - } else { - const modes = [ChatMode.Agent, ChatMode.Edit]; - if (context.chatWidget.location === ChatAgentLocation.Panel) { - modes.push(ChatMode.Ask); - } + context.chatWidget.input.setChatMode(switchToMode); - const modeIndex = modes.indexOf(context.chatWidget.input.currentMode); - const newMode = modes[(modeIndex + 1) % modes.length]; - context.chatWidget.input.setChatMode(newMode); + if (needToClearEdits) { + await commandService.executeCommand(ACTION_ID_NEW_CHAT); } + } - if (!chatService.unifiedViewEnabled && context.chatWidget.viewModel?.model.getRequests().length) { - const clearAction = chatService.unifiedViewEnabled ? ACTION_ID_NEW_CHAT : ChatDoneActionId; - await commandService.executeCommand(clearAction); + private getNextMode(chatWidget: IChatWidget, requestCount: number, configurationService: IConfigurationService): ChatMode { + const modes = [ChatMode.Ask]; + if (configurationService.getValue(ChatConfiguration.Edits2Enabled) || requestCount === 0) { + modes.push(ChatMode.Edit); } + modes.push(ChatMode.Agent); + + const modeIndex = modes.indexOf(chatWidget.input.currentMode); + const newMode = modes[(modeIndex + 1) % modes.length]; + return newMode; } } @@ -222,7 +220,7 @@ export class ToggleRequestPausedAction extends Action2 { when: ContextKeyExpr.and( ChatContextKeys.canRequestBePaused, ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent), - ChatContextKeyExprs.inEditsOrUnified, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), ContextKeyExpr.or(ChatContextKeys.isRequestPaused.negate(), ChatContextKeys.inputHasText.negate()), ), group: 'navigation', @@ -239,9 +237,8 @@ export class ToggleRequestPausedAction extends Action2 { } } -export const ChatSwitchToNextModelActionId = 'workbench.action.chat.switchToNextModel'; -export class SwitchToNextModelAction extends Action2 { - static readonly ID = ChatSwitchToNextModelActionId; +class SwitchToNextModelAction extends Action2 { + static readonly ID = 'workbench.action.chat.switchToNextModel'; constructor() { super({ @@ -249,6 +246,27 @@ export class SwitchToNextModelAction extends Action2 { title: localize2('interactive.switchToNextModel.label', "Switch to Next Model"), category: CHAT_CATEGORY, f1: true, + precondition: ChatContextKeys.enabled, + }); + } + + override run(accessor: ServicesAccessor, ...args: any[]): void { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + widget?.input.switchToNextModel(); + } +} + +export const ChatOpenModelPickerActionId = 'workbench.action.chat.openModelPicker'; +class OpenModelPickerAction extends Action2 { + static readonly ID = ChatOpenModelPickerActionId; + + constructor() { + super({ + id: OpenModelPickerAction.ID, + title: localize2('interactive.openModelPicker.label', "Open Model Picker"), + category: CHAT_CATEGORY, + f1: true, keybinding: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Period, weight: KeybindingWeight.WorkbenchContrib, @@ -263,7 +281,6 @@ export class SwitchToNextModelAction extends Action2 { ChatContextKeys.languageModelsAreUserSelectable, ContextKeyExpr.or( ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.Panel), - ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.EditingSession), ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.Editor), ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.Notebook), ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.Terminal) @@ -273,10 +290,41 @@ export class SwitchToNextModelAction extends Action2 { }); } - override run(accessor: ServicesAccessor, ...args: any[]): void { + override async run(accessor: ServicesAccessor, ...args: any[]): Promise { const widgetService = accessor.get(IChatWidgetService); - const widget = widgetService.lastFocusedWidget; - widget?.input.switchToNextModel(); + let widget = widgetService.lastFocusedWidget; + if (widget?.location !== ChatAgentLocation.Notebook && widget?.location !== ChatAgentLocation.Terminal) { + widget = await showChatView(accessor.get(IViewsService)); + } + if (widget) { + widget.input.openModelPicker(); + } + } +} + +export const ChangeChatModelActionId = 'workbench.action.chat.changeModel'; +class ChangeChatModelAction extends Action2 { + static readonly ID = ChangeChatModelActionId; + + constructor() { + super({ + id: ChangeChatModelAction.ID, + title: localize2('interactive.changeModel.label', "Change Model"), + category: CHAT_CATEGORY, + f1: false, + precondition: ChatContextKeys.enabled, + }); + } + + override run(accessor: ServicesAccessor, ...args: any[]): void { + const modelInfo: Pick = args[0]; + // Type check the arg + assertType(typeof modelInfo.vendor === 'string' && typeof modelInfo.id === 'string' && typeof modelInfo.family === 'string'); + const widgetService = accessor.get(IChatWidgetService); + const widgets = widgetService.getAllWidgets(); + for (const widget of widgets) { + widget.input.switchModel(modelInfo); + } } } @@ -284,13 +332,7 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { static readonly ID = 'workbench.action.edits.submit'; constructor() { - const precondition = ContextKeyExpr.and( - // if the input has prompt instructions attached, allow submitting requests even - // without text present - having instructions is enough context for a request - ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.instructionsAttached), - whenNotInProgressOrPaused, - ChatContextKeys.chatMode.notEqualsTo(ChatMode.Ask), - ); + const precondition = ChatContextKeys.chatMode.notEqualsTo(ChatMode.Ask); super({ id: ChatEditingSessionSubmitAction.ID, @@ -308,7 +350,7 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { { id: MenuId.ChatExecuteSecondary, group: 'group_1', - when: ContextKeyExpr.and(whenNotInProgressOrPaused, ChatContextKeys.chatMode.notEqualsTo(ChatMode.Ask),), + when: ContextKeyExpr.and(whenNotInProgressOrPaused, precondition), order: 1 }, { @@ -319,7 +361,7 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { ContextKeyExpr.and(ChatContextKeys.isRequestPaused, ChatContextKeys.inputHasText), ChatContextKeys.requestInProgress.negate(), ), - ChatContextKeys.chatMode.notEqualsTo(ChatMode.Ask),), + precondition), group: 'navigation', }, ] @@ -334,7 +376,7 @@ class SubmitWithoutDispatchingAction extends Action2 { const precondition = ContextKeyExpr.and( // if the input has prompt instructions attached, allow submitting requests even // without text present - having instructions is enough context for a request - ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.instructionsAttached), + ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.hasPromptFile), whenNotInProgressOrPaused, ChatContextKeys.chatMode.isEqualTo(ChatMode.Ask), ); @@ -370,31 +412,26 @@ class SubmitWithoutDispatchingAction extends Action2 { } } -export class ChatSubmitSecondaryAgentAction extends Action2 { - static readonly ID = 'workbench.action.chat.submitSecondaryAgent'; +export class ChatSubmitWithCodebaseAction extends Action2 { + static readonly ID = 'workbench.action.chat.submitWithCodebase'; constructor() { const precondition = ContextKeyExpr.and( // if the input has prompt instructions attached, allow submitting requests even // without text present - having instructions is enough context for a request - ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.instructionsAttached), - ChatContextKeys.inputHasAgent.negate(), + ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.hasPromptFile), whenNotInProgressOrPaused, - ChatContextKeys.chatMode.isEqualTo(ChatMode.Ask), ); super({ - id: ChatSubmitSecondaryAgentAction.ID, - title: localize2({ key: 'actions.chat.submitSecondaryAgent', comment: ['Send input from the chat input box to the secondary agent'] }, "Submit to Secondary Agent"), + id: ChatSubmitWithCodebaseAction.ID, + title: localize2('actions.chat.submitWithCodebase', "Send with {0}", `${chatVariableLeader}codebase`), precondition, menu: { id: MenuId.ChatExecuteSecondary, group: 'group_1', order: 3, - when: ContextKeyExpr.and( - ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.Panel), - ChatContextKeys.chatMode.isEqualTo(ChatMode.Ask), - ), + when: ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.Panel), }, keybinding: { when: ChatContextKeys.inChatInput, @@ -406,11 +443,6 @@ export class ChatSubmitSecondaryAgentAction extends Action2 { run(accessor: ServicesAccessor, ...args: any[]) { const context: IChatExecuteActionContext | undefined = args[0]; - const agentService = accessor.get(IChatAgentService); - const secondaryAgent = agentService.getSecondaryAgent(); - if (!secondaryAgent) { - return; - } const widgetService = accessor.get(IChatWidgetService); const widget = context?.widget ?? widgetService.lastFocusedWidget; @@ -418,104 +450,21 @@ export class ChatSubmitSecondaryAgentAction extends Action2 { return; } - if (extractAgentAndCommand(widget.parsedInput).agentPart) { - widget.acceptInput(); - } else { - widget.lastSelectedAgent = secondaryAgent; - widget.acceptInputWithPrefix(`${chatAgentLeader}${secondaryAgent.name}`); + const languageModelToolsService = accessor.get(ILanguageModelToolsService); + const codebaseTool = languageModelToolsService.getToolByName('codebase'); + if (!codebaseTool) { + return; } - } -} -class SendToChatEditingAction extends Action2 { - constructor() { - const precondition = ContextKeyExpr.and( - // if the input has prompt instructions attached, allow submitting requests even - // without text present - having instructions is enough context for a request - ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.instructionsAttached), - ChatContextKeys.inputHasAgent.negate(), - whenNotInProgressOrPaused, - ChatContextKeyExprs.inNonUnifiedPanel - ); - - super({ - id: 'workbench.action.chat.sendToChatEditing', - title: localize2('chat.sendToChatEditing.label', "Send to Copilot Edits"), - precondition, - category: CHAT_CATEGORY, - f1: false, - menu: { - id: MenuId.ChatExecuteSecondary, - group: 'group_1', - order: 4, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ChatContextKeys.editingParticipantRegistered, - ChatContextKeys.location.notEqualsTo(ChatAgentLocation.EditingSession), - ChatContextKeys.location.notEqualsTo(ChatAgentLocation.Editor), - ChatContextKeyExprs.inNonUnifiedPanel - ) - }, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Enter, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ChatContextKeys.editingParticipantRegistered, - ChatContextKeys.location.notEqualsTo(ChatAgentLocation.EditingSession), - ChatContextKeys.location.notEqualsTo(ChatAgentLocation.Editor) - ) - } + widget.input.attachmentModel.addContext({ + id: codebaseTool.id, + name: codebaseTool.displayName ?? '', + fullName: codebaseTool.displayName ?? '', + value: undefined, + icon: ThemeIcon.isThemeIcon(codebaseTool.icon) ? codebaseTool.icon : undefined, + kind: 'tool' }); - } - - async run(accessor: ServicesAccessor, ...args: any[]) { - if (!accessor.get(IChatAgentService).getDefaultAgent(ChatAgentLocation.EditingSession)) { - return; - } - - const widget = args.length > 0 && args[0].widget ? args[0].widget : accessor.get(IChatWidgetService).lastFocusedWidget; - - const viewsService = accessor.get(IViewsService); - const dialogService = accessor.get(IDialogService); - const chatEditingService = accessor.get(IChatEditingService); - const currentEditingSession: IChatEditingSession | undefined = chatEditingService.editingSessionsObs.get().at(0); - - const currentEditCount = currentEditingSession?.entries.get().length; - if (currentEditCount) { - const result = await dialogService.confirm({ - title: localize('chat.startEditing.confirmation.title', "Start new editing session?"), - message: currentEditCount === 1 - ? localize('chat.startEditing.confirmation.message.one', "Starting a new editing session will end your current editing session containing {0} file. Do you wish to proceed?", currentEditCount) - : localize('chat.startEditing.confirmation.message.many', "Starting a new editing session will end your current editing session containing {0} files. Do you wish to proceed?", currentEditCount), - type: 'info', - primaryButton: localize('chat.startEditing.confirmation.primaryButton', "Yes") - }); - - if (!result.confirmed) { - return; - } - - await currentEditingSession?.stop(true); - } - - const { widget: editingWidget } = await viewsService.openView(EditsViewId) as ChatViewPane; - if (!editingWidget.viewModel?.sessionId) { - return; - } - const chatEditingSession = await chatEditingService.startOrContinueGlobalEditingSession(editingWidget.viewModel.sessionId); - if (!chatEditingSession) { - return; - } - for (const attachment of widget.attachmentModel.attachments) { - editingWidget.attachmentModel.addContext(attachment); - } - - editingWidget.setInput(widget.getInput()); - widget.setInput(''); - widget.attachmentModel.clear(); - editingWidget.acceptInput(); - editingWidget.focusInput(); + widget.acceptInput(); } } @@ -524,7 +473,7 @@ class SendToNewChatAction extends Action2 { const precondition = ContextKeyExpr.and( // if the input has prompt instructions attached, allow submitting requests even // without text present - having instructions is enough context for a request - ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.instructionsAttached), + ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.hasPromptFile), whenNotInProgressOrPaused, ); @@ -552,12 +501,21 @@ class SendToNewChatAction extends Action2 { const context: IChatExecuteActionContext | undefined = args[0]; const widgetService = accessor.get(IChatWidgetService); + const dialogService = accessor.get(IDialogService); const widget = context?.widget ?? widgetService.lastFocusedWidget; if (!widget) { return; } + const editingSession = widget.viewModel?.model.editingSession; + if (editingSession) { + if (!(await handleCurrentEditingSession(editingSession, undefined, dialogService))) { + return; + } + } + widget.clear(); + await widget.waitForReady(); widget.acceptInput(context?.inputValue); } } @@ -608,9 +566,10 @@ export function registerChatExecuteActions() { registerAction2(SubmitWithoutDispatchingAction); registerAction2(CancelAction); registerAction2(SendToNewChatAction); - registerAction2(ChatSubmitSecondaryAgentAction); - registerAction2(SendToChatEditingAction); + registerAction2(ChatSubmitWithCodebaseAction); registerAction2(ToggleChatModeAction); registerAction2(ToggleRequestPausedAction); registerAction2(SwitchToNextModelAction); + registerAction2(OpenModelPickerAction); + registerAction2(ChangeChatModelAction); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts b/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts index 09b0c9510e1..e87ed5854b6 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts @@ -11,12 +11,9 @@ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/e import { IExtensionManagementService, InstallOperation } from '../../../../../platform/extensionManagement/common/extensionManagement.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IDefaultChatAgent } from '../../../../../base/common/product.js'; -import { IViewDescriptorService } from '../../../../common/views.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; -import { ensureSideBarChatViewSize, showCopilotView } from '../chat.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { showCopilotView } from '../chat.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; -import { IStatusbarService } from '../../../../services/statusbar/browser/statusbar.js'; export class ChatGettingStartedContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatGettingStarted'; @@ -30,10 +27,7 @@ export class ChatGettingStartedContribution extends Disposable implements IWorkb @IViewsService private readonly viewsService: IViewsService, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, @IStorageService private readonly storageService: IStorageService, - @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IStatusbarService private readonly statusbarService: IStatusbarService, ) { super(); @@ -74,17 +68,9 @@ export class ChatGettingStartedContribution extends Disposable implements IWorkb // Open Copilot view showCopilotView(this.viewsService, this.layoutService); - const setupFromDialog = this.configurationService.getValue('chat.experimental.setupFromDialog'); - if (!setupFromDialog) { - ensureSideBarChatViewSize(this.viewDescriptorService, this.layoutService, this.viewsService); - } // Only do this once this.storageService.store(ChatGettingStartedContribution.hideWelcomeView, true, StorageScope.APPLICATION, StorageTarget.MACHINE); this.recentlyInstalled = false; - - // Enable Copilot related UI if previously disabled - this.statusbarService.updateEntryVisibility('chat.statusBarEntry', true); - this.configurationService.updateValue('chat.commandCenter.enabled', true); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts index 6502bcbdaf5..804fd736053 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts @@ -8,17 +8,17 @@ import { Action2, MenuId, registerAction2 } from '../../../../../platform/action import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; -import { CHAT_CATEGORY } from './chatActions.js'; -import { ChatViewId, IChatWidgetService } from '../chat.js'; -import { ChatEditor, IChatEditorOptions } from '../chatEditor.js'; -import { ChatEditorInput } from '../chatEditorInput.js'; -import { ChatViewPane } from '../chatViewPane.js'; -import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { ACTIVE_GROUP, AUX_WINDOW_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { isChatViewTitleActionContext } from '../../common/chatActions.js'; +import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { ChatAgentLocation } from '../../common/constants.js'; +import { ChatViewId, IChatWidgetService } from '../chat.js'; +import { ChatEditor, IChatEditorOptions } from '../chatEditor.js'; +import { ChatEditorInput } from '../chatEditorInput.js'; +import { ChatViewPane } from '../chatViewPane.js'; +import { CHAT_CATEGORY } from './chatActions.js'; enum MoveToNewLocation { Editor = 'Editor', @@ -101,15 +101,17 @@ async function executeMoveToAction(accessor: ServicesAccessor, moveTo: MoveToNew const widget = (_sessionId ? widgetService.getWidgetBySessionId(_sessionId) : undefined) ?? widgetService.lastFocusedWidget; if (!widget || !widget.viewModel || widget.location !== ChatAgentLocation.Panel) { - await editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options: { pinned: true } }, moveTo === MoveToNewLocation.Window ? AUX_WINDOW_GROUP : ACTIVE_GROUP); + await editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options: { pinned: true, compact: moveTo === MoveToNewLocation.Window } }, moveTo === MoveToNewLocation.Window ? AUX_WINDOW_GROUP : ACTIVE_GROUP); return; } const sessionId = widget.viewModel.sessionId; const viewState = widget.getViewState(); - widget.clear(); - const options: IChatEditorOptions = { target: { sessionId }, pinned: true, viewState: viewState }; + widget.clear(); + await widget.waitForReady(); + + const options: IChatEditorOptions = { target: { sessionId }, pinned: true, viewState, compact: moveTo === MoveToNewLocation.Window }; await editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options }, moveTo === MoveToNewLocation.Window ? AUX_WINDOW_GROUP : ACTIVE_GROUP); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index f5734cdeb53..ca8dd5b0af0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -4,43 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; -import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ResourceSet } from '../../../../../base/common/map.js'; import { marked } from '../../../../../base/common/marked/marked.js'; -import { observableFromEvent, waitForState } from '../../../../../base/common/observable.js'; import { basename } from '../../../../../base/common/resources.js'; -import { URI } from '../../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { IBulkEditService } from '../../../../../editor/browser/services/bulkEditService.js'; -import { isLocation } from '../../../../../editor/common/languages.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; -import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ResourceNotebookCellEdit } from '../../../bulkEdit/browser/bulkCellEdits.js'; import { MENU_INLINE_CHAT_WIDGET_SECONDARY } from '../../../inlineChat/common/inlineChat.js'; import { INotebookEditor } from '../../../notebook/browser/notebookBrowser.js'; import { CellEditType, CellKind, NOTEBOOK_EDITOR_ID } from '../../../notebook/common/notebookCommon.js'; import { NOTEBOOK_IS_ACTIVE_EDITOR } from '../../../notebook/common/notebookContextKeys.js'; -import { IChatAgentService } from '../../common/chatAgents.js'; -import { ChatContextKeyExprs, ChatContextKeys } from '../../common/chatContextKeys.js'; -import { applyingChatEditsFailedContextKey, ChatEditingSessionState, IChatEditingService, isChatEditingActionContext } from '../../common/chatEditingService.js'; -import { IChatRequestModel } from '../../common/chatModel.js'; +import { ChatContextKeys } from '../../common/chatContextKeys.js'; +import { applyingChatEditsFailedContextKey, isChatEditingActionContext } from '../../common/chatEditingService.js'; import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatService } from '../../common/chatService.js'; -import { isRequestVM, isResponseVM } from '../../common/chatViewModel.js'; -import { ChatAgentLocation, ChatMode } from '../../common/constants.js'; -import { ChatTreeItem, EditsViewId, IChatWidgetService } from '../chat.js'; -import { ChatViewPane } from '../chatViewPane.js'; +import { isResponseVM } from '../../common/chatViewModel.js'; +import { ChatMode } from '../../common/constants.js'; +import { IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY } from './chatActions.js'; export const MarkUnhelpfulActionId = 'workbench.action.chat.markUnhelpful'; +const enableFeedbackConfig = 'config.telemetry.feedback.enabled'; export function registerChatTitleActions() { registerAction2(class MarkHelpfulAction extends Action2 { @@ -56,12 +44,12 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', order: 1, - when: ContextKeyExpr.and(ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate()) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig)) }, { id: MENU_INLINE_CHAT_WIDGET_SECONDARY, group: 'navigation', order: 1, - when: ContextKeyExpr.and(ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate()) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig)) }] }); } @@ -103,12 +91,12 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.isResponse) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig)) }, { id: MENU_INLINE_CHAT_WIDGET_SECONDARY, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate()) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig)) }] }); } @@ -155,12 +143,12 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', order: 3, - when: ContextKeyExpr.and(ChatContextKeys.responseSupportsIssueReporting, ChatContextKeys.isResponse) + when: ContextKeyExpr.and(ChatContextKeys.responseSupportsIssueReporting, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig)) }, { id: MENU_INLINE_CHAT_WIDGET_SECONDARY, group: 'navigation', order: 3, - when: ContextKeyExpr.and(ChatContextKeys.responseSupportsIssueReporting, ChatContextKeys.isResponse) + when: ContextKeyExpr.and(ChatContextKeys.responseSupportsIssueReporting, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig)) }] }); } @@ -232,11 +220,10 @@ export function registerChatTitleActions() { const itemIndex = chatRequests?.findIndex(request => request.id === item.requestId); const widget = chatWidgetService.getWidgetBySessionId(item.sessionId); const mode = widget?.input.currentMode; - if (chatModel?.initialLocation === ChatAgentLocation.EditingSession || chatModel && (mode === ChatMode.Edit || mode === ChatMode.Agent)) { + if (chatModel && (mode === ChatMode.Edit || mode === ChatMode.Agent)) { const configurationService = accessor.get(IConfigurationService); const dialogService = accessor.get(IDialogService); - const chatEditingService = accessor.get(IChatEditingService); - const currentEditingSession = chatEditingService.getEditingSession(chatModel.sessionId); + const currentEditingSession = widget?.viewModel?.model.editingSession; if (!currentEditingSession) { return; } @@ -273,7 +260,12 @@ export function registerChatTitleActions() { const request = chatModel?.getRequests().find(candidate => candidate.id === item.requestId); const languageModelId = widget?.input.currentLanguageModel; const userSelectedTools = widget?.input.currentMode === ChatMode.Agent ? widget.input.selectedToolsModel.tools.get().map(tool => tool.id) : undefined; - chatService.resendRequest(request!, { userSelectedModelId: languageModelId, userSelectedTools, attempt: (request?.attempt ?? -1) + 1 }); + chatService.resendRequest(request!, { + userSelectedModelId: languageModelId, + userSelectedTools, + attempt: (request?.attempt ?? -1) + 1, + mode: widget?.input.currentMode, + }); } }); @@ -348,279 +340,6 @@ export function registerChatTitleActions() { } } }); - - - registerAction2(class RemoveAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.remove', - title: localize2('chat.removeRequest.label', "Remove Request"), - f1: false, - category: CHAT_CATEGORY, - icon: Codicon.x, - precondition: ChatContextKeys.chatMode.isEqualTo(ChatMode.Ask), - keybinding: { - primary: KeyCode.Delete, - mac: { - primary: KeyMod.CtrlCmd | KeyCode.Backspace, - }, - when: ContextKeyExpr.and(ChatContextKeys.chatMode.isEqualTo(ChatMode.Ask), ChatContextKeys.inChatSession, ChatContextKeys.inChatInput.negate()), - weight: KeybindingWeight.WorkbenchContrib, - }, - menu: { - id: MenuId.ChatMessageTitle, - group: 'navigation', - order: 2, - when: ContextKeyExpr.and(ChatContextKeys.chatMode.isEqualTo(ChatMode.Ask), ChatContextKeys.isRequest) - } - }); - } - - run(accessor: ServicesAccessor, ...args: any[]) { - let item: ChatTreeItem | undefined = args[0]; - if (!isRequestVM(item)) { - const chatWidgetService = accessor.get(IChatWidgetService); - const widget = chatWidgetService.lastFocusedWidget; - item = widget?.getFocus(); - } - - if (!item) { - return; - } - - const chatService = accessor.get(IChatService); - const chatModel = chatService.getSession(item.sessionId); - if (chatModel?.initialLocation === ChatAgentLocation.EditingSession) { - return; - } - - const requestId = isRequestVM(item) ? item.id : - isResponseVM(item) ? item.requestId : undefined; - - if (requestId) { - const chatService = accessor.get(IChatService); - chatService.removeRequest(item.sessionId, requestId); - } - } - }); - - registerAction2(class ContinueEditingAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.startEditing', - title: localize2('chat.startEditing.label2', "Edit with Copilot"), - f1: false, - category: CHAT_CATEGORY, - icon: Codicon.goToEditingSession, - precondition: ContextKeyExpr.and( - ChatContextKeys.editingParticipantRegistered, - ChatContextKeys.requestInProgress.toNegated(), - ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), - ChatContextKeyExprs.unifiedChatEnabled.negate() - ), - menu: { - id: MenuId.ChatMessageFooter, - group: 'navigation', - order: 4, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.isResponse, ChatContextKeys.editingParticipantRegistered, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), ChatContextKeyExprs.unifiedChatEnabled.negate()) - } - }); - } - - async run(accessor: ServicesAccessor, ...args: any[]) { - - const logService = accessor.get(ILogService); - const chatWidgetService = accessor.get(IChatWidgetService); - const chatService = accessor.get(IChatService); - const chatAgentService = accessor.get(IChatAgentService); - const viewsService = accessor.get(IViewsService); - const chatEditingService = accessor.get(IChatEditingService); - const quickPickService = accessor.get(IQuickInputService); - - const editAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.EditingSession); - if (!editAgent) { - logService.trace('[CHAT_MOVE] No edit agent found'); - return; - } - - const sourceWidget = chatWidgetService.lastFocusedWidget; - if (!sourceWidget || !sourceWidget.viewModel) { - logService.trace('[CHAT_MOVE] NO source model'); - return; - } - - const sourceModel = sourceWidget.viewModel.model; - let sourceRequests = sourceModel.getRequests().slice(); - - // when a response is passed (clicked on) ignore all item after it - const [first] = args; - if (isResponseVM(first)) { - const idx = sourceRequests.findIndex(candidate => candidate.id === first.requestId); - if (idx >= 0) { - sourceRequests.length = idx + 1; - } - } - - // when having multiple turns, let the user pick - if (sourceRequests.length > 1) { - sourceRequests = await this._pickTurns(quickPickService, sourceRequests); - } - - if (sourceRequests.length === 0) { - logService.trace('[CHAT_MOVE] NO requests to move'); - return; - } - - const editsView = await viewsService.openView(EditsViewId); - - if (!(editsView instanceof ChatViewPane)) { - return; - } - - const viewModelObs = observableFromEvent(this, editsView.widget.onDidChangeViewModel, () => editsView.widget.viewModel); - const chatSessionId = (await waitForState(viewModelObs)).sessionId; - const editingSession = chatEditingService.getEditingSession(chatSessionId); - - if (!editingSession) { - return; - } - - const state = editingSession.state.get(); - if (state === ChatEditingSessionState.Disposed) { - return; - } - - // adopt request items and collect new working set entries - const workingSetAdditions = new ResourceSet(); - for (const request of sourceRequests) { - await chatService.adoptRequest(editingSession.chatSessionId, request); - this._collectWorkingSetAdditions(request, workingSetAdditions); - } - workingSetAdditions.forEach(uri => editsView.widget.attachmentModel.addFile(uri)); - - // make request - await chatService.sendRequest(editingSession.chatSessionId, '', { - agentId: editAgent.id, - acceptedConfirmationData: [{ _type: 'toEditTransfer', transferredTurnResults: sourceRequests.map(v => v.response?.result) }], // TODO@jrieken HACKY - confirmation: typeof this.desc.title === 'string' ? this.desc.title : this.desc.title.value - }); - } - - private _collectWorkingSetAdditions(request: IChatRequestModel, bucket: ResourceSet) { - for (const item of request.response?.response.value ?? []) { - if (item.kind === 'inlineReference') { - bucket.add(isLocation(item.inlineReference) - ? item.inlineReference.uri - : URI.isUri(item.inlineReference) - ? item.inlineReference - : item.inlineReference.location.uri - ); - } - } - } - - private async _pickTurns(quickPickService: IQuickInputService, requests: IChatRequestModel[]): Promise { - - const timeThreshold = 2 * 60000; // 2 minutes - const lastRequestTimestamp = requests[requests.length - 1].timestamp; - const relatedRequests = requests.filter(request => request.timestamp >= 0 && lastRequestTimestamp - request.timestamp <= timeThreshold); - - const lastPick: IQuickPickItem = { - label: localize('chat.startEditing.last', "The last {0} requests", relatedRequests.length), - detail: relatedRequests.map(req => req.message.text).join(', ') - }; - - const allPick: IQuickPickItem = { - label: localize('chat.startEditing.pickAll', "All requests from the conversation") - }; - - const customPick: IQuickPickItem = { - label: localize('chat.startEditing.pickCustom', "Manually select requests...") - }; - - const picks: IQuickPickItem[] = relatedRequests.length !== 0 - ? [lastPick, allPick, customPick] - : [allPick, customPick]; - - const firstPick = await quickPickService.pick(picks, { - placeHolder: localize('chat.startEditing.pickRequest', "Select requests that you want to use for editing") - }); - - if (!firstPick) { - return []; - } else if (firstPick === allPick) { - return requests; - } else if (firstPick === lastPick) { - return relatedRequests; - } - - // custom pick - type PickType = (IQuickPickItem & { request: IChatRequestModel }); - const customPicks: (IQuickPickItem & { request: IChatRequestModel })[] = requests.map(request => ({ - - picked: false, - request: request, - label: request.message.text, - detail: request.response?.response.toString(), - })); - - - return await new Promise(_resolve => { - - const resolve = (value: IChatRequestModel[]) => { - store.dispose(); - _resolve(value); - qp.hide(); - }; - - const store = new DisposableStore(); - - const qp = quickPickService.createQuickPick(); - qp.placeholder = localize('chat.startEditing.pickRequest', "Select requests that you want to use for editing"); - qp.canSelectMany = true; - qp.items = customPicks; - - let ignore = false; - store.add(qp.onDidChangeSelection(e => { - if (ignore) { - return; - } - ignore = true; - try { - const [first] = e; - - const selected: typeof customPicks = []; - let disabled = false; - - for (let i = 0; i < customPicks.length; i++) { - const oldItem = customPicks[i]; - customPicks[i] = { - ...oldItem, - disabled, - }; - - disabled = disabled || oldItem === first; - - if (disabled) { - selected.push(customPicks[i]); - } - } - qp.items = customPicks; - qp.selectedItems = selected; - - } finally { - ignore = false; - } - })); - - store.add(qp.onDidAccept(_e => resolve(qp.selectedItems.map(i => i.request)))); - store.add(qp.onDidHide(_ => resolve([]))); - store.add(qp); - qp.show(); - }); - } - - }); } interface MarkdownContent { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts index 5d71ac109d1..e574f8e143e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts @@ -3,28 +3,48 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { assertNever } from '../../../../../base/common/assert.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { diffSets } from '../../../../../base/common/collections.js'; import { Event } from '../../../../../base/common/event.js'; +import { Iterable } from '../../../../../base/common/iterator.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; -import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; -import { IMcpService, IMcpServer, McpConnectionState } from '../../../mcp/common/mcpTypes.js'; +import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; +import { AddConfigurationAction } from '../../../mcp/browser/mcpCommands.js'; +import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; +import { IMcpServer, IMcpService, McpConnectionState } from '../../../mcp/common/mcpTypes.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { IChatToolInvocation } from '../../common/chatService.js'; import { isResponseVM } from '../../common/chatViewModel.js'; import { ChatMode } from '../../common/constants.js'; -import { ILanguageModelToolsService, IToolData } from '../../common/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../common/languageModelToolsService.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY } from './chatActions.js'; + +type SelectedToolData = { + enabled: number; + total: number; +}; +type SelectedToolClassification = { + owner: 'connor4312'; + comment: 'Details the capabilities of the MCP server'; + enabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of enabled chat tools' }; + total: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of total chat tools' }; +}; + export const AcceptToolConfirmationActionId = 'workbench.action.chat.acceptTool'; class AcceptToolConfirmation extends Action2 { @@ -72,18 +92,12 @@ export class AttachToolsAction extends Action2 { icon: Codicon.tools, f1: false, category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and( - ContextKeyExpr.or(ChatContextKeys.Tools.toolsCount.greater(0)), - ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent) - ), + precondition: ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent), menu: { - when: ContextKeyExpr.and( - ContextKeyExpr.or(ChatContextKeys.Tools.toolsCount.greater(0)), - ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent) - ), - id: MenuId.ChatInputAttachmentToolbar, + when: ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent), + id: MenuId.ChatInput, group: 'navigation', - order: 1 + order: 100 }, keybinding: { when: ContextKeyExpr.and(ChatContextKeys.inChatInput, ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent)), @@ -97,9 +111,13 @@ export class AttachToolsAction extends Action2 { const quickPickService = accessor.get(IQuickInputService); const mcpService = accessor.get(IMcpService); + const mcpRegistry = accessor.get(IMcpRegistry); const toolsService = accessor.get(ILanguageModelToolsService); - const extensionService = accessor.get(IExtensionService); const chatWidgetService = accessor.get(IChatWidgetService); + const telemetryService = accessor.get(ITelemetryService); + const commandService = accessor.get(ICommandService); + const extensionWorkbenchService = accessor.get(IExtensionsWorkbenchService); + const editorService = accessor.get(IEditorService); let widget = chatWidgetService.lastFocusedWidget; if (!widget) { @@ -125,14 +143,32 @@ export class AttachToolsAction extends Action2 { } const enum BucketOrdinal { Extension, Mcp, Other } - type BucketPick = IQuickPickItem & { picked: boolean; ordinal: BucketOrdinal; status?: string; children: ToolPick[] }; + type BucketPick = IQuickPickItem & { picked: boolean; ordinal: BucketOrdinal; status?: string; children: ToolPick[]; source: ToolDataSource }; type ToolPick = IQuickPickItem & { picked: boolean; tool: IToolData; parent: BucketPick }; - type MyPick = ToolPick | BucketPick; + type AddPick = IQuickPickItem & { pickable: false; run: () => void }; + type MyPick = ToolPick | BucketPick | AddPick; + type ActionableButton = IQuickInputButton & { action: () => void }; + + const addMcpPick: AddPick = { type: 'item', label: localize('addServer', "Add MCP Server..."), iconClass: ThemeIcon.asClassName(Codicon.add), pickable: false, run: () => commandService.executeCommand(AddConfigurationAction.ID) }; + const addExpPick: AddPick = { type: 'item', label: localize('addExtension', "Install Extension..."), iconClass: ThemeIcon.asClassName(Codicon.add), pickable: false, run: () => extensionWorkbenchService.openSearch('@tag:language-model-tools') }; + const addPick: AddPick = { + type: 'item', label: localize('addAny', "Add More Tools..."), iconClass: ThemeIcon.asClassName(Codicon.add), pickable: false, run: async () => { + const pick = await quickPickService.pick( + [addMcpPick, addExpPick], + { + canPickMany: false, + title: localize('noTools', "Add tools to chat") + } + ); + pick?.run(); + } + }; const defaultBucket: BucketPick = { type: 'item', children: [], label: localize('defaultBucketLabel', "Other Tools"), + source: { type: 'internal' }, ordinal: BucketOrdinal.Other, picked: true, }; @@ -141,37 +177,68 @@ export class AttachToolsAction extends Action2 { const toolBuckets = new Map(); for (const tool of toolsService.getTools()) { - - if (!tool.canBeReferencedInPrompt) { + if (!tool.supportsToolPicker) { continue; } - let bucket: BucketPick; + let bucket: BucketPick | undefined; - const mcpServer = mcpServerByTool.get(tool.id); - const ext = extensionService.extensions.find(value => ExtensionIdentifier.equals(value.identifier, tool.extensionId)); - if (mcpServer) { - bucket = toolBuckets.get(mcpServer.definition.id) ?? { + if (tool.source.type === 'mcp') { + const mcpServer = mcpServerByTool.get(tool.id); + if (!mcpServer) { + continue; + } + const key = tool.source.type + mcpServer.definition.id; + bucket = toolBuckets.get(key); + + if (!bucket) { + const collection = mcpRegistry.collections.get().find(c => c.id === mcpServer.collection.id); + const buttons: ActionableButton[] = []; + if (collection?.presentation?.origin) { + buttons.push({ + iconClass: ThemeIcon.asClassName(Codicon.settingsGear), + tooltip: localize('configMcpCol', "Configure {0}", collection.label), + action: () => editorService.openEditor({ + resource: collection!.presentation!.origin, + }) + }); + } + if (mcpServer.connectionState.get().state === McpConnectionState.Kind.Error) { + buttons.push({ + iconClass: ThemeIcon.asClassName(Codicon.warning), + tooltip: localize('mcpShowOutput', "Show Output"), + action: () => mcpServer.showOutput(), + }); + } + + bucket = { + type: 'item', + label: localize('mcplabel', "MCP Server: {0}", mcpServer?.definition.label), + status: localize('mcpstatus', "from {0}", mcpServer.collection.label), + ordinal: BucketOrdinal.Mcp, + source: tool.source, + picked: false, + children: [], + buttons, + }; + toolBuckets.set(key, bucket); + } + } else if (tool.source.type === 'extension') { + const key = tool.source.type + ExtensionIdentifier.toKey(tool.source.extensionId); + + bucket = toolBuckets.get(key) ?? { type: 'item', - label: mcpServer.definition.label, - // description: mcpServer.definition., - status: localize('desc', "MCP - {0} ({1})", mcpServer.collection.label, McpConnectionState.toString(mcpServer.connectionState.get())), - ordinal: BucketOrdinal.Mcp, - picked: false, - children: [] - }; - toolBuckets.set(mcpServer.definition.id, bucket); - } else if (ext) { - bucket = toolBuckets.get(ExtensionIdentifier.toKey(ext.identifier)) ?? { - type: 'item', - label: ext.displayName ?? ext.name, + label: tool.source.label, ordinal: BucketOrdinal.Extension, picked: false, + source: tool.source, children: [] }; - toolBuckets.set(ExtensionIdentifier.toKey(ext.identifier), bucket); - } else { + toolBuckets.set(key, bucket); + } else if (tool.source.type === 'internal') { bucket = defaultBucket; + } else { + assertNever(tool.source); } const picked = nowSelectedTools.has(tool); @@ -180,10 +247,10 @@ export class AttachToolsAction extends Action2 { tool, parent: bucket, type: 'item', - label: `$(tools) ${tool.displayName}`, + label: tool.displayName, description: tool.userDescription, picked, - iconClasses: ['tool-pick'] + indented: true, }); if (picked) { @@ -197,9 +264,14 @@ export class AttachToolsAction extends Action2 { function isToolPick(obj: any): obj is ToolPick { return Boolean((obj as ToolPick).tool); } + function isAddPick(obj: any): obj is AddPick { + return Boolean((obj as AddPick).run); + } + function isActionableButton(obj: IQuickInputButton): obj is ActionableButton { + return typeof (obj as ActionableButton).action === 'function'; + } const store = new DisposableStore(); - const picker = store.add(quickPickService.createQuickPick({ useSeparators: true })); const picks: (MyPick | IQuickPickSeparator)[] = []; @@ -213,9 +285,26 @@ export class AttachToolsAction extends Action2 { picks.push(...bucket.children); } - + const picker = store.add(quickPickService.createQuickPick({ useSeparators: true })); picker.placeholder = localize('placeholder', "Select tools that are available to chat"); picker.canSelectMany = true; + picker.keepScrollPosition = true; + picker.matchOnDescription = true; + + if (picks.length === 0) { + picker.placeholder = localize('noTools', "Add tools to chat"); + picker.canSelectMany = false; + picks.push( + addMcpPick, + addExpPick, + ); + } else { + picks.push( + { type: 'separator' }, + addPick, + ); + } + let lastSelectedItems = new Set(); let ignoreEvent = false; @@ -225,24 +314,49 @@ export class AttachToolsAction extends Action2 { try { const items = picks.filter((p): p is MyPick => p.type === 'item' && Boolean(p.picked)); lastSelectedItems = new Set(items); - picker.items = picks; picker.selectedItems = items; - widget.input.selectedToolsModel.update(items.filter(isToolPick).map(tool => tool.tool)); + const disableBuckets: ToolDataSource[] = []; + const disableTools: IToolData[] = []; + for (const item of picks) { + if (item.type === 'item' && !item.picked) { + if (isBucketPick(item)) { + disableBuckets.push(item.source); + } else if (isToolPick(item) && item.parent.picked) { + disableTools.push(item.tool); + } + } + } + widget.input.selectedToolsModel.update(disableBuckets, disableTools); } finally { ignoreEvent = false; } }; _update(); + picker.items = picks; picker.show(); + store.add(picker.onDidTriggerItemButton(e => { + if (isActionableButton(e.button)) { + e.button.action(); + store.dispose(); + } + })); + store.add(picker.onDidChangeSelection(selectedPicks => { if (ignoreEvent) { return; } + const addPick = selectedPicks.find(isAddPick); + if (addPick) { + addPick.run(); + picker.hide(); + return; + } + const { added, removed } = diffSets(lastSelectedItems, new Set(selectedPicks)); for (const item of added) { @@ -276,7 +390,15 @@ export class AttachToolsAction extends Action2 { _update(); })); + store.add(picker.onDidAccept(() => { + picker.activeItems.find(isAddPick)?.run(); + })); + await Promise.race([Event.toPromise(Event.any(picker.onDidAccept, picker.onDidHide))]); + telemetryService.publicLog2('chat/selectedTools', { + enabled: widget.input.selectedToolsModel.tools.get().length, + total: Iterable.length(toolsService.getTools()), + }); store.dispose(); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTransfer.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTransfer.ts index 52bc3680170..8825330db97 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTransfer.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTransfer.ts @@ -14,6 +14,6 @@ export class ChatTransferContribution extends Disposable implements IWorkbenchCo @IChatTransferService chatTransferService: IChatTransferService, ) { super(); - chatTransferService.checkAndSetWorkspaceTrust(); + chatTransferService.checkAndSetTransferredWorkspaceTrust(); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts index f6d1050cbd5..91581f38c9a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts @@ -35,6 +35,7 @@ import { ICodeBlockActionContext } from '../codeBlockPart.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotebookService } from '../../../notebook/common/notebookService.js'; export class InsertCodeBlockOperation { constructor( @@ -117,6 +118,7 @@ export class ApplyCodeBlockOperation { @IQuickInputService private readonly quickInputService: IQuickInputService, @ILabelService private readonly labelService: ILabelService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @INotebookService private readonly notebookService: INotebookService, ) { } @@ -128,7 +130,7 @@ export class ApplyCodeBlockOperation { return; } - if (codemapperUri && !isEqual(activeEditorControl?.getModel().uri, codemapperUri)) { + if (codemapperUri && !isEqual(activeEditorControl?.getModel().uri, codemapperUri) && !this.notebookService.hasSupportedNotebooks(codemapperUri)) { // reveal the target file try { const editorPane = await this.editorService.openEditor({ resource: codemapperUri }); @@ -148,8 +150,8 @@ export class ApplyCodeBlockOperation { let result: IComputeEditsResult | undefined = undefined; - if (activeEditorControl) { - result = await this.handleTextEditor(activeEditorControl, context.code); + if (activeEditorControl && !this.notebookService.hasSupportedNotebooks(codemapperUri)) { + result = await this.handleTextEditor(activeEditorControl, context.chatSessionId, context.code); } else { const activeNotebookEditor = getActiveNotebookEditor(this.editorService); if (activeNotebookEditor) { @@ -224,14 +226,14 @@ export class ApplyCodeBlockOperation { return undefined; } - private async handleTextEditor(codeEditor: IActiveCodeEditor, code: string): Promise { + private async handleTextEditor(codeEditor: IActiveCodeEditor, chatSessionId: string | undefined, code: string): Promise { const activeModel = codeEditor.getModel(); if (isReadOnly(activeModel, this.textFileService)) { this.notify(localize('applyCodeBlock.readonly', "Cannot apply code block to read-only file.")); return undefined; } - const codeBlock = { code, resource: activeModel.uri, markdownBeforeBlock: undefined }; + const codeBlock = { code, resource: activeModel.uri, chatSessionId, markdownBeforeBlock: undefined }; const codeMapper = this.codeMapperService.providers[0]?.displayName; if (!codeMapper) { @@ -245,7 +247,7 @@ export class ApplyCodeBlockOperation { { location: ProgressLocation.Notification, delay: 500, sticky: true, cancellable: true }, async progress => { progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) }); - const editsIterable = this.getEdits(codeBlock, cancellationTokenSource.token); + const editsIterable = this.getEdits(codeBlock, chatSessionId, cancellationTokenSource.token); return await this.waitForFirstElement(editsIterable); }, () => cancellationTokenSource.cancel() @@ -265,10 +267,11 @@ export class ApplyCodeBlockOperation { }; } - private getEdits(codeBlock: ICodeMapperCodeBlock, token: CancellationToken): AsyncIterable { + private getEdits(codeBlock: ICodeMapperCodeBlock, chatSessionId: string | undefined, token: CancellationToken): AsyncIterable { return new AsyncIterableObject(async executor => { const request: ICodeMapperRequest = { - codeBlocks: [codeBlock] + codeBlocks: [codeBlock], + chatSessionId }; const response: ICodeMapperResponse = { textEdit: (target: URI, edit: TextEdit[]) => { diff --git a/src/vs/workbench/contrib/chat/browser/actions/promptActions/chatAttachInstructionsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/promptActions/chatAttachInstructionsAction.ts new file mode 100644 index 00000000000..a7b27406fa0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/promptActions/chatAttachInstructionsAction.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatWidget } from '../../chat.js'; +import { CHAT_CATEGORY } from '../chatActions.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../../../nls.js'; +import { ChatContextKeys } from '../../../common/chatContextKeys.js'; +import { assertDefined } from '../../../../../../base/common/types.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/types.js'; +import { PromptsConfig } from '../../../../../../platform/prompts/common/config.js'; +import { IViewsService } from '../../../../../services/views/common/viewsService.js'; +import { PromptFilePickers } from './dialogs/askToSelectPrompt/promptFilePickers.js'; +import { ServicesAccessor } from '../../../../../../editor/browser/editorExtensions.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { attachInstructionsFiles, IAttachOptions } from './dialogs/askToSelectPrompt/utils/attachInstructions.js'; + +/** + * Action ID for the `Attach Instruction` action. + */ +const ATTACH_INSTRUCTIONS_ACTION_ID = 'workbench.action.chat.attach.instructions'; + +/** + * Options for the {@link AttachInstructionsAction} action. + */ +export interface IAttachInstructionsActionOptions { + + /** + * Target chat widget reference to attach the instruction to. If the reference is + * provided, the command will attach the instruction as attachment of the widget. + * Otherwise, the command will re-use an existing one. + */ + readonly widget?: IChatWidget; + + /** + * Instruction resource `URI` to attach to the chat input, if any. + * If provided the resource will be pre-selected in the prompt picker dialog, + * otherwise the dialog will show the prompts list without any pre-selection. + */ + readonly resource?: URI; + + /** + * Whether to skip the instructions files selection dialog. + * + * Note! if this option is set to `true`, the {@link resource} + * option `must be defined`. + */ + readonly skipSelectionDialog?: boolean; +} + +/** + * Action to attach a prompt to a chat widget input. + */ +class AttachInstructionsAction extends Action2 { + constructor() { + super({ + id: ATTACH_INSTRUCTIONS_ACTION_ID, + title: localize2('attach-instructions.capitalized.ellipses', "Attach Instructions..."), + f1: false, + precondition: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), + category: CHAT_CATEGORY, + }); + } + + public override async run( + accessor: ServicesAccessor, + options: IAttachInstructionsActionOptions, + ): Promise { + const viewsService = accessor.get(IViewsService); + const promptsService = accessor.get(IPromptsService); + const commandService = accessor.get(ICommandService); + const instaService = accessor.get(IInstantiationService); + + const pickers = instaService.createInstance(PromptFilePickers); + + const { skipSelectionDialog, resource } = options; + + const attachOptions: IAttachOptions = { + widget: options.widget, + viewsService, + commandService, + }; + + if (skipSelectionDialog === true) { + assertDefined( + resource, + 'Resource must be defined when skipping prompt selection dialog.', + ); + + const widget = await attachInstructionsFiles( + [resource], + attachOptions, + ); + + widget.focusInput(); + + return; + } + + // find all prompt files in the user workspace + const promptFiles = await promptsService.listPromptFiles('instructions'); + const placeholder = localize( + 'commands.instructions.select-dialog.placeholder', + 'Select instructions files to attach', + ); + + const instructions = await pickers.selectInstructionsFiles({ promptFiles, placeholder }); + + if (instructions !== undefined) { + const widget = await attachInstructionsFiles( + instructions, + attachOptions, + ); + widget.focusInput(); + } + } +} + +/** + * Runs the `Attach Instructions` action with provided options. We export this + * function instead of {@link ATTACH_INSTRUCTIONS_ACTION_ID} directly to + * encapsulate/enforce the correct options to be passed to the action. + */ +export const runAttachInstructionsAction = async ( + commandService: ICommandService, + options: IAttachInstructionsActionOptions, +): Promise => { + return await commandService.executeCommand( + ATTACH_INSTRUCTIONS_ACTION_ID, + options, + ); +}; + +/** + * Helper to register the `Attach Prompt` action. + */ +export const registerAttachPromptActions = () => { + registerAction2(AttachInstructionsAction); +}; diff --git a/src/vs/workbench/contrib/chat/browser/actions/promptActions/chatRunPromptAction.ts b/src/vs/workbench/contrib/chat/browser/actions/promptActions/chatRunPromptAction.ts new file mode 100644 index 00000000000..0f5157fc38f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/promptActions/chatRunPromptAction.ts @@ -0,0 +1,306 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatWidget } from '../../chat.js'; +import { CHAT_CATEGORY } from '../chatActions.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { OS } from '../../../../../../base/common/platform.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ChatContextKeys } from '../../../common/chatContextKeys.js'; +import { assertDefined } from '../../../../../../base/common/types.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { ResourceContextKey } from '../../../../../common/contextkeys.js'; +import { KeyCode, KeyMod } from '../../../../../../base/common/keyCodes.js'; +import { PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/constants.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/types.js'; +import { ILocalizedString, localize, localize2 } from '../../../../../../nls.js'; +import { UILabelProvider } from '../../../../../../base/common/keybindingLabels.js'; +import { ICommandAction } from '../../../../../../platform/action/common/action.js'; +import { PromptsConfig } from '../../../../../../platform/prompts/common/config.js'; +import { IViewsService } from '../../../../../services/views/common/viewsService.js'; +import { PromptFilePickers } from './dialogs/askToSelectPrompt/promptFilePickers.js'; +import { ServicesAccessor } from '../../../../../../editor/browser/editorExtensions.js'; +import { EditorContextKeys } from '../../../../../../editor/common/editorContextKeys.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IRunPromptOptions, runPromptFile } from './dialogs/askToSelectPrompt/utils/runPrompt.js'; +import { ICodeEditorService } from '../../../../../../editor/browser/services/codeEditorService.js'; +import { KeybindingWeight } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; + +/** + * Condition for the `Run Current Prompt` action. + */ +const EDITOR_ACTIONS_CONDITION = ContextKeyExpr.and( + ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), + ResourceContextKey.HasResource, + ResourceContextKey.LangId.isEqualTo(PROMPT_LANGUAGE_ID), +); + +/** + * Keybinding of the action. + */ +const COMMAND_KEY_BINDING = KeyMod.WinCtrl | KeyCode.Slash | KeyMod.Alt; + +/** + * Action ID for the `Run Current Prompt` action. + */ +const RUN_CURRENT_PROMPT_ACTION_ID = 'workbench.action.chat.run.prompt.current'; + +/** + * Action ID for the `Run Prompt...` action. + */ +const RUN_SELECTED_PROMPT_ACTION_ID = 'workbench.action.chat.run.prompt'; + +/** + * Constructor options for the `Run Prompt` base action. + */ +interface IRunPromptBaseActionConstructorOptions { + /** + * ID of the action to be registered. + */ + id: string; + + /** + * Title of the action. + */ + title: ILocalizedString; + + /** + * Icon of the action. + */ + icon: ThemeIcon; + + /** + * Keybinding of the action. + */ + keybinding: number; + + /** + * Alt action of the UI menu item. + */ + alt?: ICommandAction; +} + +/** + * Base class of the `Run Prompt` action. + */ +abstract class RunPromptBaseAction extends Action2 { + constructor( + options: IRunPromptBaseActionConstructorOptions, + ) { + super({ + id: options.id, + title: options.title, + f1: false, + precondition: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), + category: CHAT_CATEGORY, + icon: options.icon, + keybinding: { + when: ContextKeyExpr.and( + EditorContextKeys.editorTextFocus, + EDITOR_ACTIONS_CONDITION, + ), + weight: KeybindingWeight.WorkbenchContrib, + primary: options.keybinding, + }, + menu: [ + { + id: MenuId.EditorTitleRun, + group: 'navigation', + order: options.alt ? 0 : 1, + alt: options.alt, + when: EDITOR_ACTIONS_CONDITION, + }, + ], + }); + } + + /** + * Executes the run prompt action with provided options. + */ + public async execute( + resource: URI | undefined, + inNewChat: boolean, + accessor: ServicesAccessor, + ): Promise { + const viewsService = accessor.get(IViewsService); + const commandService = accessor.get(ICommandService); + + resource ||= getActivePromptFileUri(accessor); + assertDefined( + resource, + 'Cannot find URI resource for an active text editor.', + ); + + const { widget } = await runPromptFile( + resource, + { + inNewChat, + commandService, + viewsService, + }, + ); + + return widget; + } +} + +const RUN_CURRENT_PROMPT_ACTION_TITLE = localize2( + 'run-prompt.capitalized', + "Run Prompt in Current Chat" +); +const RUN_CURRENT_PROMPT_ACTION_ICON = Codicon.playCircle; + +/** + * The default `Run Current Prompt` action. + */ +class RunCurrentPromptAction extends RunPromptBaseAction { + constructor() { + super({ + id: RUN_CURRENT_PROMPT_ACTION_ID, + title: RUN_CURRENT_PROMPT_ACTION_TITLE, + icon: RUN_CURRENT_PROMPT_ACTION_ICON, + keybinding: COMMAND_KEY_BINDING, + }); + } + + public override async run( + accessor: ServicesAccessor, + resource: URI | undefined, + ): Promise { + return await super.execute( + resource, + false, + accessor, + ); + } +} + +class RunSelectedPromptAction extends Action2 { + constructor() { + super({ + id: RUN_SELECTED_PROMPT_ACTION_ID, + title: localize2('run-prompt.capitalized.ellipses', "Run Prompt..."), + icon: Codicon.bookmark, + f1: true, + precondition: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), + keybinding: { + when: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), + weight: KeybindingWeight.WorkbenchContrib, + primary: COMMAND_KEY_BINDING, + }, + category: CHAT_CATEGORY, + }); + } + + public override async run( + accessor: ServicesAccessor, + ): Promise { + const viewsService = accessor.get(IViewsService); + const promptsService = accessor.get(IPromptsService); + const commandService = accessor.get(ICommandService); + const instaService = accessor.get(IInstantiationService); + + const pickers = instaService.createInstance(PromptFilePickers); + + // find all prompt files in the user workspace + const promptFiles = await promptsService.listPromptFiles('prompt'); + const placeholder = localize( + 'commands.prompt.select-dialog.placeholder', + 'Select the prompt file to run (hold {0}-key to use in new chat)', + UILabelProvider.modifierLabels[OS].ctrlKey + ); + + const result = await pickers.selectPromptFile({ promptFiles, placeholder }); + + if (result === undefined) { + return; + } + + const { promptFile, keyMods } = result; + const runPromptOptions: IRunPromptOptions = { + inNewChat: keyMods.ctrlCmd, + viewsService, + commandService, + }; + const { widget } = await runPromptFile( + promptFile, + runPromptOptions, + ); + widget.focusInput(); + } +} + + +/** + * Gets `URI` of a prompt file open in an active editor instance, if any. + */ +export const getActivePromptFileUri = ( + accessor: ServicesAccessor, +): URI | undefined => { + const codeEditorService = accessor.get(ICodeEditorService); + const model = codeEditorService.getActiveCodeEditor()?.getModel(); + if (model?.getLanguageId() === PROMPT_LANGUAGE_ID) { + return model.uri; + } + return undefined; +}; + + +/** + * Action ID for the `Run Current Prompt In New Chat` action. + */ +const RUN_CURRENT_PROMPT_IN_NEW_CHAT_ACTION_ID = 'workbench.action.chat.run-in-new-chat.prompt.current'; + +const RUN_IN_NEW_CHAT_ACTION_TITLE = localize2( + 'run-prompt-in-new-chat.capitalized', + "Run Prompt In New Chat", +); + +/** + * Icon for the `Run Current Prompt In New Chat` action. + */ +const RUN_IN_NEW_CHAT_ACTION_ICON = Codicon.play; + +/** + * `Run Current Prompt In New Chat` action. + */ +class RunCurrentPromptInNewChatAction extends RunPromptBaseAction { + constructor() { + super({ + id: RUN_CURRENT_PROMPT_IN_NEW_CHAT_ACTION_ID, + title: RUN_IN_NEW_CHAT_ACTION_TITLE, + icon: RUN_IN_NEW_CHAT_ACTION_ICON, + keybinding: COMMAND_KEY_BINDING | KeyMod.CtrlCmd, + alt: { + id: RUN_CURRENT_PROMPT_ACTION_ID, + title: RUN_CURRENT_PROMPT_ACTION_TITLE, + icon: RUN_CURRENT_PROMPT_ACTION_ICON, + }, + }); + } + + public override async run( + accessor: ServicesAccessor, + resource: URI, + ): Promise { + return await super.execute( + resource, + true, + accessor, + ); + } +} + +/** + * Helper to register all the `Run Current Prompt` actions. + */ +export const registerRunPromptActions = () => { + registerAction2(RunCurrentPromptInNewChatAction); + registerAction2(RunCurrentPromptAction); + registerAction2(RunSelectedPromptAction); +}; diff --git a/src/vs/workbench/contrib/chat/browser/actions/promptActions/chatSaveToPromptAction.ts b/src/vs/workbench/contrib/chat/browser/actions/promptActions/chatSaveToPromptAction.ts new file mode 100644 index 00000000000..52a38b7ed80 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/promptActions/chatSaveToPromptAction.ts @@ -0,0 +1,291 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatWidget } from '../../chat.js'; +import { CHAT_CATEGORY } from '../chatActions.js'; +import { localize2 } from '../../../../../../nls.js'; +import { IEditorPane } from '../../../../../common/editor.js'; +import { ChatContextKeys } from '../../../common/chatContextKeys.js'; +import { assertDefined } from '../../../../../../base/common/types.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/constants.js'; +import { PromptsConfig } from '../../../../../../platform/prompts/common/config.js'; +import { ServicesAccessor } from '../../../../../../editor/browser/editorExtensions.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { ILanguageModelToolsService } from '../../../common/languageModelToolsService.js'; +import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { chatSubcommandLeader, IParsedChatRequest } from '../../../common/chatParserTypes.js'; +import { Action2, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; + +/** + * Action ID for the `Save Prompt` action. + */ +const SAVE_TO_PROMPT_ACTION_ID = 'workbench.action.chat.save-to-prompt'; + +/** + * Name of the in-chat slash command associated with this action. + */ +export const SAVE_TO_PROMPT_SLASH_COMMAND_NAME = 'save'; + +/** + * Options for the {@link SaveToPromptAction} action. + */ +interface ISaveToPromptActionOptions { + /** + * Chat widget reference to save session of. + */ + chat: IChatWidget; +} + +/** + * Class that defines the `Save Prompt` action. + */ +class SaveToPromptAction extends Action2 { + constructor() { + super({ + id: SAVE_TO_PROMPT_ACTION_ID, + title: localize2( + 'workbench.actions.save-to-prompt.label', + "Save chat session to a prompt file", + ), + f1: false, + precondition: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), + category: CHAT_CATEGORY, + }); + } + + public async run( + accessor: ServicesAccessor, + options: ISaveToPromptActionOptions, + ): Promise { + const logService = accessor.get(ILogService); + const editorService = accessor.get(IEditorService); + const toolsService = accessor.get(ILanguageModelToolsService); + + const logPrefix = 'save to prompt'; + const { chat } = options; + + const { viewModel } = chat; + assertDefined( + viewModel, + 'No view model found on currently the active chat widget.', + ); + + const { model } = viewModel; + + const turns: ITurn[] = []; + for (const request of model.getRequests()) { + const { message, response: responseModel } = request; + + if (isSaveToPromptSlashCommand(message)) { + continue; + } + + if (responseModel === undefined) { + logService.warn( + `[${logPrefix}]: skipping request '${request.id}' with no response`, + ); + + continue; + } + + const { response } = responseModel; + + const tools = new Set(); + for (const record of response.value) { + if (('toolId' in record === false) || !record.toolId) { + continue; + } + + const tool = toolsService.getTool(record.toolId); + if ((tool === undefined) || (!tool.toolReferenceName)) { + continue; + } + + tools.add(tool.toolReferenceName); + } + + turns.push({ + request: message.text, + response: response.getMarkdown(), + tools, + }); + } + + const promptText = renderPrompt(turns); + + const editor = await editorService.openEditor({ + resource: undefined, + contents: promptText, + languageId: PROMPT_LANGUAGE_ID, + }); + + assertDefined( + editor, + 'Failed to open untitled editor for the prompt.', + ); + + editor.focus(); + + return editor; + } +} + +/** + * Check if provided message belongs to the `save to prompt` slash + * command itself that was run in the chat to invoke this action. + */ +const isSaveToPromptSlashCommand = ( + message: IParsedChatRequest, +): boolean => { + const { parts } = message; + if (parts.length < 1) { + return false; + } + + const firstPart = parts[0]; + if (firstPart.kind !== 'slash') { + return false; + } + + if (firstPart.text !== `${chatSubcommandLeader}${SAVE_TO_PROMPT_SLASH_COMMAND_NAME}`) { + return false; + } + + return true; +}; + +/** + * Render the response part of a `request`/`response` turn pair. + */ +const renderResponse = ( + response: string, +): string => { + // if response starts with a code block, add an extra new line + // before it, to prevent full blockquote from being be broken + const delimiter = (response.startsWith('```')) + ? '\n>' + : ' '; + + // add `>` to the beginning of each line of the response + // so it looks like a blockquote citing Copilot + const quotedResponse = response.replaceAll('\n', '\n> '); + + return `> Copilot:${delimiter}${quotedResponse}`; +}; + +/** + * Render a single `request`/`response` turn of the chat session. + */ +const renderTurn = ( + turn: ITurn, +): string => { + const { request, response } = turn; + + return `\n${request}\n\n${renderResponse(response)}`; +}; + +/** + * Render the entire chat session as a markdown prompt. + */ +const renderPrompt = ( + turns: readonly ITurn[], +): string => { + const content: string[] = []; + const allTools = new Set(); + + // render each turn and collect tool names + // that were used in the each turn + for (const turn of turns) { + content.push(renderTurn(turn)); + + // collect all used tools into a set of strings + for (const tool of turn.tools) { + allTools.add(tool); + } + } + + const result = []; + + // add prompt header + if (allTools.size !== 0) { + result.push(renderHeader(allTools)); + } + + // add chat request/response turns + result.push( + content.join('\n'), + ); + + // add trailing empty line + result.push(''); + + return result.join('\n'); +}; + + +/** + * Render the `tools` metadata inside prompt header. + */ +const renderTools = ( + tools: Set, +): string => { + const toolStrings = [...tools].map((tool) => { + return `'${tool}'`; + }); + + return `tools: [${toolStrings.join(', ')}]`; +}; + +/** + * Render prompt header. + */ +const renderHeader = ( + tools: Set, +): string => { + // skip rendering the header if no tools provided + if (tools.size === 0) { + return ''; + } + + return [ + '---', + renderTools(tools), + '---', + ].join('\n'); +}; + +/** + * Interface for a single `request`/`response` turn + * of a chat session. + */ +interface ITurn { + request: string; + response: string; + tools: Set; +} + +/** + * Runs the `Save To Prompt` action with provided options. We export this + * function instead of {@link SAVE_TO_PROMPT_ACTION_ID} directly to + * encapsulate/enforce the correct options to be passed to the action. + */ +export const runSaveToPromptAction = async ( + options: ISaveToPromptActionOptions, + commandService: ICommandService, +) => { + return await commandService.executeCommand( + SAVE_TO_PROMPT_ACTION_ID, + options, + ); +}; + +/** + * Helper to register all the `Save Prompt` actions. + */ +export const registerSaveToPromptActions = () => { + registerAction2(SaveToPromptAction); +}; diff --git a/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/promptFilePickers.ts new file mode 100644 index 00000000000..9e72b65eea7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/promptFilePickers.ts @@ -0,0 +1,430 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../../../../nls.js'; +import { URI } from '../../../../../../../../base/common/uri.js'; +import { OS } from '../../../../../../../../base/common/platform.js'; +import { assert } from '../../../../../../../../base/common/assert.js'; +import { Codicon } from '../../../../../../../../base/common/codicons.js'; +import { WithUriValue } from '../../../../../../../../base/common/types.js'; +import { ThemeIcon } from '../../../../../../../../base/common/themables.js'; +import { IPromptPath } from '../../../../../common/promptSyntax/service/types.js'; +import { dirname, extUri } from '../../../../../../../../base/common/resources.js'; +import { DisposableStore } from '../../../../../../../../base/common/lifecycle.js'; +import { IFileService } from '../../../../../../../../platform/files/common/files.js'; +import { ILabelService } from '../../../../../../../../platform/label/common/label.js'; +import { IOpenerService } from '../../../../../../../../platform/opener/common/opener.js'; +import { UILabelProvider } from '../../../../../../../../base/common/keybindingLabels.js'; +import { IDialogService } from '../../../../../../../../platform/dialogs/common/dialogs.js'; +import { getCleanPromptName } from '../../../../../../../../platform/prompts/common/constants.js'; +import { INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL } from '../../../../../common/promptSyntax/constants.js'; +import { IKeyMods, IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickItemButtonEvent } from '../../../../../../../../platform/quickinput/common/quickInput.js'; +import { ICommandService } from '../../../../../../../../platform/commands/common/commands.js'; +import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID } from '../../../../promptSyntax/contributions/createPromptCommand/createPromptCommand.js'; + +/** + * Options for the {@link askToSelectInstructions} function. + */ +export interface ISelectOptions { + + /** + * The text shows as placeholder in the selection dialog. + */ + readonly placeholder: string; + + /** + * Prompt resource `URI` to attach to the chat input, if any. + * If provided the resource will be pre-selected in the prompt picker dialog, + * otherwise the dialog will show the prompts list without any pre-selection. + */ + readonly resource?: URI; + + /** + * List of prompt files to show in the selection dialog. + */ + readonly promptFiles: readonly IPromptPath[]; +} + +export interface ISelectPromptResult { + /** + * The selected prompt file. + */ + readonly promptFile: URI; + + /** + * The key modifiers that were pressed when the prompt was selected. + */ + readonly keyMods: IKeyMods; +} + +/** + * Button that opems the documentation. + */ +const HELP_BUTTON: IQuickInputButton = Object.freeze({ + tooltip: localize('help', "help"), + iconClass: ThemeIcon.asClassName(Codicon.question), +}); + +/** + * A quick pick item that starts the 'New Prompt File' command. + */ +const NEW_PROMPT_FILE_OPTION: WithUriValue = Object.freeze({ + type: 'item', + label: `$(plus) ${localize( + 'commands.new-promptfile.select-dialog.label', + 'New prompt file...' + )}`, + value: URI.parse(PROMPT_DOCUMENTATION_URL), + pickable: false, + buttons: [HELP_BUTTON], +}); + +/** + * A quick pick item that starts the 'New Instructions File' command. + */ +const NEW_INSTRUCTIONS_FILE_OPTION: WithUriValue = Object.freeze({ + type: 'item', + label: `$(plus) ${localize( + 'commands.new-instructionsfile.select-dialog.label', + 'New instructions file...', + )}`, + value: URI.parse(INSTRUCTIONS_DOCUMENTATION_URL), + pickable: false, + buttons: [HELP_BUTTON], +}); + + +/** + * Button that opens a prompt file in the editor. + */ +const EDIT_BUTTON: IQuickInputButton = Object.freeze({ + tooltip: localize( + 'commands.prompts.use.select-dialog.open-button.tooltip', + "edit ({0}-key + enter)", + UILabelProvider.modifierLabels[OS].ctrlKey + ), + iconClass: ThemeIcon.asClassName(Codicon.edit), +}); + +/** + * Button that deletes a prompt file. + */ +const DELETE_BUTTON: IQuickInputButton = Object.freeze({ + tooltip: localize('delete', "delete"), + iconClass: ThemeIcon.asClassName(Codicon.trash), +}); + + +export class PromptFilePickers { + constructor( + @ILabelService private readonly _labelService: ILabelService, + @IQuickInputService private readonly _quickInputService: IQuickInputService, + @IOpenerService private readonly _openerService: IOpenerService, + @IFileService private readonly _fileService: IFileService, + @IDialogService private readonly _dialogService: IDialogService, + @ICommandService private readonly _commandService: ICommandService, + ) { + } + /** + * Shows the instructions selection dialog to the user that allows to select a instructions file(s). + * + * If {@link ISelectOptions.resource resource} is provided, the dialog will have + * the resource pre-selected in the prompts list. + */ + public async selectInstructionsFiles(options: ISelectOptions): Promise { + + const fileOptions = this._createPromptPickItems(options); + fileOptions.splice(0, 0, NEW_INSTRUCTIONS_FILE_OPTION); + + const quickPick = this._quickInputService.createQuickPick>(); + quickPick.activeItems = fileOptions.length ? [fileOptions[0]] : []; + quickPick.placeholder = options.placeholder; + quickPick.canAcceptInBackground = true; + quickPick.matchOnDescription = true; + quickPick.items = fileOptions; + + return new Promise(resolve => { + const disposables = new DisposableStore(); + + let isResolved = false; + + // then the dialog is hidden or disposed for other reason, + // dispose everything and resolve the main promise + disposables.add({ + dispose() { + quickPick.dispose(); + if (!isResolved) { + resolve(undefined); + isResolved = true; + } + }, + }); + + // handle the prompt `accept` event + disposables.add(quickPick.onDidAccept(async (event) => { + const { selectedItems } = quickPick; + + if (selectedItems[0] === NEW_INSTRUCTIONS_FILE_OPTION) { + await this._commandService.executeCommand(NEW_INSTRUCTIONS_COMMAND_ID); + return; + } + + resolve(selectedItems.map(item => item.value)); + isResolved = true; + + // if user submitted their selection, close the dialog + if (!event.inBackground) { + disposables.dispose(); + } + })); + + // handle the `button click` event on a list item (edit, delete, etc.) + disposables.add(quickPick.onDidTriggerItemButton( + e => this._handleButtonClick(quickPick, e)) + ); + + // when the dialog is hidden, dispose everything + disposables.add(quickPick.onDidHide( + disposables.dispose.bind(disposables), + )); + + // finally, reveal the dialog + quickPick.show(); + }); + } + + /** + * Shows the instructions selection dialog to the user that allows to select a instructions file(s). + * + * If {@link ISelectOptions.resource resource} is provided, the dialog will have + * the resource pre-selected in the prompts list. + */ + public async selectPromptFile(options: ISelectOptions): Promise { + + const fileOptions = this._createPromptPickItems(options); + fileOptions.splice(0, 0, NEW_PROMPT_FILE_OPTION); + + const quickPick = this._quickInputService.createQuickPick>(); + quickPick.activeItems = fileOptions.length ? [fileOptions[0]] : []; + quickPick.placeholder = options.placeholder; + quickPick.canAcceptInBackground = true; + quickPick.matchOnDescription = true; + quickPick.items = fileOptions; + + return new Promise(resolve => { + const disposables = new DisposableStore(); + + let isResolved = false; + + // then the dialog is hidden or disposed for other reason, + // dispose everything and resolve the main promise + disposables.add({ + dispose() { + quickPick.dispose(); + if (!isResolved) { + resolve(undefined); + isResolved = true; + } + }, + }); + + // handle the prompt `accept` event + disposables.add(quickPick.onDidAccept(async (event) => { + const { selectedItems } = quickPick; + const { keyMods } = quickPick; + + const selectedItem = selectedItems[0]; + if (selectedItem === NEW_PROMPT_FILE_OPTION) { + await this._commandService.executeCommand(NEW_PROMPT_COMMAND_ID); + return; + } + + if (selectedItem) { + resolve({ promptFile: selectedItem.value, keyMods: { ...keyMods } }); + isResolved = true; + } + + // if user submitted their selection, close the dialog + if (!event.inBackground) { + disposables.dispose(); + } + })); + + // handle the `button click` event on a list item (edit, delete, etc.) + disposables.add(quickPick.onDidTriggerItemButton( + e => this._handleButtonClick(quickPick, e)) + ); + + // when the dialog is hidden, dispose everything + disposables.add(quickPick.onDidHide( + disposables.dispose.bind(disposables), + )); + + // finally, reveal the dialog + quickPick.show(); + }); + } + + private _createPromptPickItems(options: ISelectOptions): WithUriValue[] { + const { promptFiles, resource } = options; + + const fileOptions = promptFiles.map((promptFile) => { + return this._createPromptPickItem(promptFile); + }); + + // if a resource is provided, create an `activeItem` for it to pre-select + // it in the UI, and sort the list so the active item appears at the top + let activeItem: WithUriValue | undefined; + if (resource) { + activeItem = fileOptions.find((file) => { + return extUri.isEqual(file.value, resource); + }); + + // if no item for the `resource` was found, it means that the resource is not + // in the list of prompt files, so add a new item for it; this ensures that + // the currently active prompt file is always available in the selection dialog, + // even if it is not included in the prompts list otherwise(from location setting) + if (!activeItem) { + activeItem = this._createPromptPickItem({ + uri: resource, + // "user" prompts are always registered in the prompts list, hence it + // should be safe to assume that `resource` is not "user" prompt here + storage: 'local', + type: 'instructions', + }); + fileOptions.push(activeItem); + } + + fileOptions.sort((file1, file2) => { + if (extUri.isEqual(file1.value, resource)) { + return -1; + } + + if (extUri.isEqual(file2.value, resource)) { + return 1; + } + + return 0; + }); + } + return fileOptions; + } + + private _createPromptPickItem(promptFile: IPromptPath): WithUriValue { + const { uri, storage } = promptFile; + const fileWithoutExtension = getCleanPromptName(uri); + + // if a "user" prompt, don't show its filesystem path in + // the user interface, but do that for all the "local" ones + const description = (storage === 'user') + ? localize('user-data-dir.capitalized', 'User data folder') + : this._labelService.getUriLabel(dirname(uri), { relative: true }); + + const tooltip = (storage === 'user') + ? description + : uri.fsPath; + + return { + id: uri.toString(), + type: 'item', + label: fileWithoutExtension, + description, + tooltip, + value: uri, + buttons: [EDIT_BUTTON, DELETE_BUTTON], + }; + } + + private async _handleButtonClick(quickPick: IQuickPick>, context: IQuickPickItemButtonEvent>) { + const { item, button } = context; + const { value } = item; + + // `edit` button was pressed, open the prompt file in editor + if (button === EDIT_BUTTON) { + return await this._openerService.open(value); + } + + // `delete` button was pressed, delete the prompt file + if (button === DELETE_BUTTON) { + // sanity check to confirm our expectations + assert( + (quickPick.activeItems.length < 2), + `Expected maximum one active item, got '${quickPick.activeItems.length}'.`, + ); + + const activeItem: WithUriValue | undefined = quickPick.activeItems[0]; + + // sanity checks - prompt file exists and is not a folder + const info = await this._fileService.stat(value); + assert( + info.isDirectory === false, + `'${value.fsPath}' points to a folder.`, + ); + + // don't close the main prompt selection dialog by the confirmation dialog + const previousIgnoreFocusOut = quickPick.ignoreFocusOut; + quickPick.ignoreFocusOut = true; + + const filename = getCleanPromptName(value); + const { confirmed } = await this._dialogService.confirm({ + message: localize( + 'commands.prompts.use.select-dialog.delete-prompt.confirm.message', + "Are you sure you want to delete '{0}'?", + filename, + ), + }); + + // restore the previous value of the `ignoreFocusOut` property + quickPick.ignoreFocusOut = previousIgnoreFocusOut; + + // if prompt deletion was not confirmed, nothing to do + if (!confirmed) { + return; + } + + // prompt deletion was confirmed so delete the prompt file + await this._fileService.del(value); + + // remove the deleted prompt from the selection dialog list + let removedIndex = -1; + quickPick.items = quickPick.items.filter((option, index) => { + if (option === item) { + removedIndex = index; + + return false; + } + + return true; + }); + + // if the deleted item was active item, find a new item to set as active + if (activeItem && (activeItem === item)) { + assert( + removedIndex >= 0, + 'Removed item index must be a valid index.', + ); + + // we set the previous item as new active, or the next item + // if removed prompt item was in the beginning of the list + const newActiveItemIndex = Math.max(removedIndex - 1, 0); + const newActiveItem: WithUriValue | undefined = quickPick.items[newActiveItemIndex]; + + quickPick.activeItems = newActiveItem ? [newActiveItem] : []; + } + + return; + } + + if (button === HELP_BUTTON) { + // open the documentation + await this._openerService.open(item.value); + return; + } + + throw new Error(`Unknown button '${JSON.stringify(button)}'.`); + } + +} + diff --git a/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/utils/attachInstructions.ts b/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/utils/attachInstructions.ts new file mode 100644 index 00000000000..14bd701edee --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/utils/attachInstructions.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatWidget, showChatView } from '../../../../../chat.js'; +import { URI } from '../../../../../../../../../base/common/uri.js'; +import { ACTION_ID_NEW_CHAT } from '../../../../chatClearActions.js'; +import { assertDefined } from '../../../../../../../../../base/common/types.js'; +import { IAttachInstructionsActionOptions } from '../../../chatAttachInstructionsAction.js'; +import { IViewsService } from '../../../../../../../../services/views/common/viewsService.js'; +import { ICommandService } from '../../../../../../../../../platform/commands/common/commands.js'; + +/** + * Options for the {@link attachInstructionsFiles} function. + */ +export interface IAttachOptions { + /** + * Chat widget instance to attach the prompt to. + */ + readonly widget?: IChatWidget; + /** + * Whether to create a new chat session and + * attach the prompt to it. + */ + readonly inNewChat?: boolean; + + readonly viewsService: IViewsService; + readonly commandService: ICommandService; +} + +/** + * Attaches provided instructions to a chat input. + */ +export const attachInstructionsFiles = async ( + files: URI[], + options: IAttachOptions, +): Promise => { + const widget = await getChatWidgetObject(options); + + for (const file of files) { + widget.attachmentModel.promptInstructions.add(file); + } + + return widget; +}; + +/** + * Gets a chat widget based on the provided {@link IAttachInstructionsActionOptions.widget widget} + * reference and the `inNewChat` flag. + * + * @throws if failed to reveal a chat widget. + */ +export const getChatWidgetObject = async ( + options: IAttachOptions, +): Promise => { + const { widget, inNewChat } = options; + + // if a new chat sessions needs to be created, or there is no + // chat widget reference provided, show a chat view, otherwise + // re-use the existing chat widget + if ((inNewChat === true) || (widget === undefined)) { + return await showChat(options, inNewChat); + } + + return widget; +}; + +/** + * Reveals an existing one or creates a new one based on + * the provided `createNew` flag. + */ +const showChat = async ( + options: IAttachOptions, + createNew: boolean = false, +): Promise => { + const { commandService, viewsService } = options; + + if (createNew === true) { + await commandService.executeCommand(ACTION_ID_NEW_CHAT); + } + + const widget = await showChatView(viewsService); + + assertDefined( + widget, + 'Chat widget must be defined.', + ); + + return widget; +}; diff --git a/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/utils/detachPrompt.ts b/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/utils/detachPrompt.ts new file mode 100644 index 00000000000..4f56b99be1b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/utils/detachPrompt.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatWidget } from '../../../../../chat.js'; +import { URI } from '../../../../../../../../../base/common/uri.js'; + +/** + * Options for the {@link detachPrompt} function. + */ +export interface IDetachPromptOptions { + /** + * Chat widget instance to attach the prompt to. + */ + readonly widget: IChatWidget; +} + +/** + * Detaches provided prompts to a chat input. + */ +export const detachPrompt = async ( + file: URI, + options: IDetachPromptOptions, +): Promise => { + const { widget } = options; + + widget + .attachmentModel + .promptInstructions + .remove(file); + + return widget; +}; diff --git a/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/utils/runPrompt.ts b/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/utils/runPrompt.ts new file mode 100644 index 00000000000..f3a49ad2f3d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/promptActions/dialogs/askToSelectPrompt/utils/runPrompt.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatWidget } from '../../../../../chat.js'; +import { getChatWidgetObject } from './attachInstructions.js'; +import { URI } from '../../../../../../../../../base/common/uri.js'; +import { IViewsService } from '../../../../../../../../services/views/common/viewsService.js'; +import { ICommandService } from '../../../../../../../../../platform/commands/common/commands.js'; +import { getPromptCommandName } from '../../../../../../common/promptSyntax/service/promptsService.js'; + +/** + * Options for the {@link runPromptFile} function. + */ +export interface IRunPromptOptions { + /** + * Chat widget instance to attach the prompt to. + */ + readonly widget?: IChatWidget; + /** + * Whether to create a new chat session and + * attach the instructions file to it. + */ + readonly inNewChat?: boolean; + + readonly viewsService: IViewsService; + readonly commandService: ICommandService; +} + +/** + * Return value of the {@link runPromptFile} function. + */ +interface IRunPromptResult { + readonly widget: IChatWidget; +} + +/** + * Runs the prompt file. + */ +export const runPromptFile = async ( + file: URI, + options: IRunPromptOptions, +): Promise => { + + const widget = await getChatWidgetObject(options); + + widget.setInput(`/${getPromptCommandName(file.path)}`); + // submit the prompt immediately + await widget.acceptInput(); + + + return { widget }; +}; diff --git a/src/vs/workbench/contrib/chat/browser/actions/promptActions/index.ts b/src/vs/workbench/contrib/chat/browser/actions/promptActions/index.ts new file mode 100644 index 00000000000..629a85d1ee8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/promptActions/index.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerRunPromptActions } from './chatRunPromptAction.js'; +import { registerSaveToPromptActions } from './chatSaveToPromptAction.js'; +import { registerAttachPromptActions } from './chatAttachInstructionsAction.js'; +export { runAttachInstructionsAction } from './chatAttachInstructionsAction.js'; + +/** + * Helper to register all actions related to reusable prompt files. + */ +export const registerPromptActions = () => { + registerRunPromptActions(); + registerAttachPromptActions(); + registerSaveToPromptActions(); +}; diff --git a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts index 73ab2960823..e620ed6bc7a 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts @@ -10,6 +10,7 @@ import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/ho import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { basename, dirname } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; @@ -52,6 +53,10 @@ export class ImplicitContextAttachmentWidget extends Disposable { dom.clearNode(this.domNode); this.renderDisposables.clear(); + const attachmentTypeName = (this.attachment.isPromptFile === false) + ? localize('file.lowercase', "file") + : localize('prompt.lowercase', "prompt"); + this.domNode.classList.toggle('disabled', !this.attachment.enabled); const label = this.resourceLabels.create(this.domNode, { supportIcons: true }); const file = URI.isUri(this.attachment.value) ? this.attachment.value : this.attachment.value!.uri; @@ -60,25 +65,33 @@ export class ImplicitContextAttachmentWidget extends Disposable { const fileBasename = basename(file); const fileDirname = dirname(file); const friendlyName = `${fileBasename} ${fileDirname}`; - const ariaLabel = range ? localize('chat.fileAttachmentWithRange', "Attached file, {0}, line {1} to line {2}", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment', "Attached file, {0}", friendlyName); + const ariaLabel = range ? localize('chat.fileAttachmentWithRange', "Attached {0}, {1}, line {2} to line {3}", attachmentTypeName, friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment', "Attached {0}, {1}", attachmentTypeName, friendlyName); const uriLabel = this.labelService.getUriLabel(file, { relative: true }); - const currentFile = localize('openEditor', "Current file context"); + const currentFile = localize('openEditor', "Current {0} context", attachmentTypeName); const inactive = localize('enableHint', "disabled"); const currentFileHint = currentFile + (this.attachment.enabled ? '' : ` (${inactive})`); const title = `${currentFileHint}\n${uriLabel}`; + + const icon = this.attachment.isPromptFile + ? ThemeIcon.fromId(Codicon.bookmark.id) + : undefined; + label.setFile(file, { fileKind: FileKind.FILE, hidePath: true, range, - title + title, + icon, }); this.domNode.ariaLabel = ariaLabel; this.domNode.tabIndex = 0; - const hintElement = dom.append(this.domNode, dom.$('span.chat-implicit-hint', undefined, 'Current file')); + + const hintLabel = localize('hint.label.current', "Current {0}", attachmentTypeName); + const hintElement = dom.append(this.domNode, dom.$('span.chat-implicit-hint', undefined, hintLabel)); this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), hintElement, title)); - const buttonMsg = this.attachment.enabled ? localize('disable', "Disable current file context") : localize('enable', "Enable current file context"); + const buttonMsg = this.attachment.enabled ? localize('disable', "Disable current {0} context", attachmentTypeName) : localize('enable', "Enable current {0} context", attachmentTypeName); const toggleButton = this.renderDisposables.add(new Button(this.domNode, { supportIcons: true, title: buttonMsg })); toggleButton.icon = this.attachment.enabled ? Codicon.eye : Codicon.eyeClosed; this.renderDisposables.add(toggleButton.onDidClick((e) => { diff --git a/src/vs/workbench/contrib/chat/browser/attachments/promptAttachments/promptAttachmentsCollectionWidget.ts b/src/vs/workbench/contrib/chat/browser/attachments/promptInstructions/promptInstructionsCollectionWidget.ts similarity index 72% rename from src/vs/workbench/contrib/chat/browser/attachments/promptAttachments/promptAttachmentsCollectionWidget.ts rename to src/vs/workbench/contrib/chat/browser/attachments/promptInstructions/promptInstructionsCollectionWidget.ts index d05558a0489..8c13e9f61ac 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/promptAttachments/promptAttachmentsCollectionWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/promptInstructions/promptInstructionsCollectionWidget.ts @@ -6,34 +6,37 @@ import { URI } from '../../../../../../base/common/uri.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { ResourceLabels } from '../../../../../browser/labels.js'; -import { PromptAttachmentWidget } from './promptAttachmentWidget.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { InstructionsAttachmentWidget } from './promptInstructionsWidget.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { INSTRUCTIONS_LANGUAGE_ID } from '../../../common/promptSyntax/constants.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ChatPromptAttachmentsCollection } from '../../chatAttachmentModel/chatPromptAttachmentsCollection.js'; /** * Widget for a collection of prompt instructions attachments. - * See {@linkcode PromptAttachmentWidget}. + * See {@link InstructionsAttachmentWidget}. */ -export class PromptAttachmentsCollectionWidget extends Disposable { +export class PromptInstructionsAttachmentsCollectionWidget extends Disposable { /** * List of child instruction attachment widgets. */ - private children: PromptAttachmentWidget[] = []; + private children: InstructionsAttachmentWidget[] = []; /** * Event that fires when number of attachments change * - * See {@linkcode onAttachmentsCountChange}. + * See {@link onAttachmentsChange}. */ - private _onAttachmentsCountChange = this._register(new Emitter()); + private _onAttachmentsChange = this._register(new Emitter()); /** - * Subscribe to the `onAttachmentsCountChange` event. + * Subscribe to the `onAttachmentsChange` event. * @param callback Function to invoke when number of attachments change. */ - public onAttachmentsCountChange(callback: () => unknown): this { - this._register(this._onAttachmentsCountChange.event(callback)); + public onAttachmentsChange(callback: () => unknown): this { + this._register(this._onAttachmentsChange.event(callback)); return this; } @@ -59,6 +62,14 @@ export class PromptAttachmentsCollectionWidget extends Disposable { return this.model.chatAttachments; } + /** + * Get a promise that resolves when parsing/resolving processes + * are fully completed, including all possible nested child references. + */ + public allSettled() { + return this.model.allSettled(); + } + /** * Check if child widget list is empty (no attachments present). */ @@ -66,10 +77,23 @@ export class PromptAttachmentsCollectionWidget extends Disposable { return this.children.length === 0; } + /** + * Check if any of the attachments is a prompt file. + */ + public get hasInstructions(): boolean { + return this.references.some((uri) => { + const model = this.modelService.getModel(uri); + const languageId = model ? model.getLanguageId() : this.languageService.guessLanguageIdByFilepathOrFirstLine(uri); + return languageId === INSTRUCTIONS_LANGUAGE_ID; + }); + } + constructor( private readonly model: ChatPromptAttachmentsCollection, private readonly resourceLabels: ResourceLabels, @IInstantiationService private readonly initService: IInstantiationService, + @ILanguageService private readonly languageService: ILanguageService, + @IModelService private readonly modelService: IModelService, @ILogService private readonly logService: ILogService, ) { super(); @@ -77,9 +101,9 @@ export class PromptAttachmentsCollectionWidget extends Disposable { this.render = this.render.bind(this); // when a new attachment model is added, create a new child widget for it - this.model.onAdd((attachment) => { + this._register(this.model.onAdd((attachment) => { const widget = this.initService.createInstance( - PromptAttachmentWidget, + InstructionsAttachmentWidget, attachment, this.resourceLabels, ); @@ -90,22 +114,22 @@ export class PromptAttachmentsCollectionWidget extends Disposable { // register the new child widget this.children.push(widget); - // if parent node is present - append the wiget to it, otherwise wait + // if parent node is present - append the widget to it, otherwise wait // until the `render` method will be called if (this.parentNode) { this.parentNode.appendChild(widget.domNode); } // fire the event to notify about the change in the number of attachments - this._onAttachmentsCountChange.fire(); - }); + this._onAttachmentsChange.fire(); + })); } /** * Handle child widget disposal. * @param widget The child widget that was disposed. */ - public handleAttachmentDispose(widget: PromptAttachmentWidget): this { + public handleAttachmentDispose(widget: InstructionsAttachmentWidget): this { // common prefix for all log messages const logPrefix = `[onChildDispose] Widget for instructions attachment '${widget.uri.path}'`; @@ -148,7 +172,7 @@ export class PromptAttachmentsCollectionWidget extends Disposable { this.parentNode?.removeChild(widget.domNode); // fire the event to notify about the change in the number of attachments - this._onAttachmentsCountChange.fire(); + this._onAttachmentsChange.fire(); return this; } diff --git a/src/vs/workbench/contrib/chat/browser/attachments/promptAttachments/promptAttachmentWidget.ts b/src/vs/workbench/contrib/chat/browser/attachments/promptInstructions/promptInstructionsWidget.ts similarity index 92% rename from src/vs/workbench/contrib/chat/browser/attachments/promptAttachments/promptAttachmentWidget.ts rename to src/vs/workbench/contrib/chat/browser/attachments/promptInstructions/promptInstructionsWidget.ts index ac4fcefcd20..e03aeb95a21 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/promptAttachments/promptAttachmentWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/promptInstructions/promptInstructionsWidget.ts @@ -31,7 +31,7 @@ import { getFlatContextMenuActions } from '../../../../../../platform/actions/br /** * Widget for a single prompt instructions attachment. */ -export class PromptAttachmentWidget extends Disposable { +export class InstructionsAttachmentWidget extends Disposable { /** * The root DOM node of the widget. */ @@ -106,12 +106,18 @@ export class PromptAttachmentWidget extends Disposable { const fileBasename = basename(file); const fileDirname = dirname(file); const friendlyName = `${fileBasename} ${fileDirname}`; - const ariaLabel = localize('chat.promptAttachment', "Prompt attachment, {0}", friendlyName); + const isPrompt = this.languageService.guessLanguageIdByFilepathOrFirstLine(file) === 'prompt'; + const ariaLabel = isPrompt + ? localize('chat.promptAttachment', "Prompt file, {0}", friendlyName) + : localize('chat.instructionsAttachment', "Instructions attachment, {0}", friendlyName); + const typeLabel = isPrompt + ? localize('prompt', "Prompt") + : localize('instructions', "Instructions"); + const uriLabel = this.labelService.getUriLabel(file, { relative: true }); - const promptLabel = localize('prompt', "Prompt"); - let title = `${promptLabel} ${uriLabel}`; + let title = `${typeLabel} ${uriLabel}`; // if there are some errors/warning during the process of resolving // attachment references (including all the nested child references), @@ -144,7 +150,7 @@ export class PromptAttachmentWidget extends Disposable { this.domNode.ariaLabel = ariaLabel; this.domNode.tabIndex = 0; - const hintElement = dom.append(this.domNode, dom.$('span.chat-implicit-hint', undefined, promptLabel)); + const hintElement = dom.append(this.domNode, dom.$('span.chat-implicit-hint', undefined, typeLabel)); this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), hintElement, title)); // create the `remove` button diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index a091ff5f469..63e0ed975ec 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -9,19 +9,17 @@ import { MarkdownString, isMarkdownString } from '../../../../base/common/htmlCo import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; +import { assertDefined } from '../../../../base/common/types.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationNode, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import product from '../../../../platform/product/common/product.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; import { PromptsConfig } from '../../../../platform/prompts/common/config.js'; -import { PROMPT_FILE_EXTENSION } from '../../../../platform/prompts/common/constants.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../platform/prompts/common/constants.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; import { Extensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js'; @@ -30,11 +28,10 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; import { mcpSchemaId } from '../../../services/configuration/common/configuration.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; -import { mcpConfigurationSection, mcpDiscoverySection, mcpSchemaExampleServers } from '../../mcp/common/mcpConfiguration.js'; +import { allDiscoverySources, discoverySourceLabel, mcpConfigurationSection, mcpDiscoverySection, mcpEnabledSection, mcpSchemaExampleServers } from '../../mcp/common/mcpConfiguration.js'; import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAgentService } from '../common/chatAgents.js'; import { CodeMapperService, ICodeMapperService } from '../common/chatCodeMapperService.js'; import '../common/chatColors.js'; -import { ChatContextKeys } from '../common/chatContextKeys.js'; import { IChatEditingService } from '../common/chatEditingService.js'; import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../common/chatEntitlementService.js'; import { chatVariableLeader } from '../common/chatParserTypes.js'; @@ -44,21 +41,19 @@ import { ChatSlashCommandService, IChatSlashCommandService } from '../common/cha import { ChatTransferService, IChatTransferService } from '../common/chatTransferService.js'; import { IChatVariablesService } from '../common/chatVariables.js'; import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/chatWidgetHistoryService.js'; -import { ChatAgentLocation } from '../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatMode } from '../common/constants.js'; import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; import { ILanguageModelsService, LanguageModelsService } from '../common/languageModels.js'; import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { ILanguageModelToolsService } from '../common/languageModelToolsService.js'; -import { DOCUMENTATION_URL } from '../common/promptSyntax/constants.js'; -import '../common/promptSyntax/languageFeatures/promptLinkDiagnosticsProvider.js'; -import '../common/promptSyntax/languageFeatures/promptLinkProvider.js'; -import '../common/promptSyntax/languageFeatures/promptPathAutocompletion.js'; +import { INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL } from '../common/promptSyntax/constants.js'; +import { registerReusablePromptLanguageFeatures } from '../common/promptSyntax/languageFeatures/providers/index.js'; import { PromptsService } from '../common/promptSyntax/service/promptsService.js'; import { IPromptsService } from '../common/promptSyntax/service/types.js'; import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; import { BuiltinToolsContribution } from '../common/tools/tools.js'; import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js'; -import { EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; +import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; import { CopilotTitleBarMenuRendering, registerChatActions } from './actions/chatActions.js'; import { ACTION_ID_NEW_CHAT, registerNewChatActions } from './actions/chatClearActions.js'; import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/chatCodeblockActions.js'; @@ -74,6 +69,7 @@ import { registerQuickChatActions } from './actions/chatQuickInputActions.js'; import { registerChatTitleActions } from './actions/chatTitleActions.js'; import { registerChatToolActions } from './actions/chatToolActions.js'; import { ChatTransferContribution } from './actions/chatTransfer.js'; +import { SAVE_TO_PROMPT_SLASH_COMMAND_NAME, runSaveToPromptAction } from './actions/promptActions/chatSaveToPromptAction.js'; import { IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService } from './chat.js'; import { ChatAccessibilityService } from './chatAccessibilityService.js'; import './chatAttachmentModel.js'; @@ -85,6 +81,7 @@ import { ChatEditingEditorContextKeys } from './chatEditing/chatEditingEditorCon import { ChatEditingEditorOverlay } from './chatEditing/chatEditingEditorOverlay.js'; import { ChatEditingService } from './chatEditing/chatEditingServiceImpl.js'; import { ChatEditingNotebookFileSystemProviderContrib } from './chatEditing/notebook/chatEditingNotebookFileSystemProvider.js'; +import { SimpleBrowserOverlay } from './chatEditing/simpleBrowserEditorOverlay.js'; import { ChatEditor, IChatEditorOptions } from './chatEditor.js'; import { ChatEditorInput, ChatEditorInputSerializer } from './chatEditorInput.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './chatMarkdownDecorationsRenderer.js'; @@ -103,8 +100,8 @@ import './contrib/chatInputEditorContrib.js'; import './contrib/chatInputEditorHover.js'; import { ChatRelatedFilesContribution } from './contrib/chatInputRelatedFilesContrib.js'; import { LanguageModelToolsService } from './languageModelToolsService.js'; +import './promptSyntax/contributions/attachInstructionsCommand.js'; import './promptSyntax/contributions/createPromptCommand/createPromptCommand.js'; -import './promptSyntax/contributions/usePromptCommand.js'; import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler.js'; // Register configuration @@ -161,7 +158,6 @@ configurationRegistry.registerConfiguration({ }, default: { 'panel': 'always', - 'editing-session': 'first' } }, 'chat.editing.autoAcceptDelay': { @@ -199,17 +195,40 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.renderRelatedFiles', "Controls whether related files should be rendered in the chat input."), default: false }, - 'chat.experimental.statusIndicator.enabled': { // TODO@bpasero remove this eventually + 'chat.focusWindowOnConfirmation': { type: 'boolean', - description: nls.localize('chat.statusIndicator', "Controls whether a Copilot related status indicator appears in the lower right corner."), - default: product.quality !== 'stable', - tags: ['experimental', 'onExp'] + description: nls.localize('chat.focusWindowOnConfirmation', "Controls whether the Copilot window should be focused when a confirmation is needed."), + default: true, }, - 'chat.experimental.setupFromDialog': { // TODO@bpasero remove this eventually + 'chat.tools.autoApprove': { + default: false, + description: nls.localize('chat.tools.autoApprove', "Controls whether tool use should be automatically approved."), type: 'boolean', - description: nls.localize('chat.setupFromChat', "Controls whether Copilot setup starts from a dialog or from the welcome view."), - default: product.quality !== 'stable', - tags: ['experimental', 'onExp'] + tags: ['experimental'], + policy: { + name: 'ChatToolsAutoApprove', + minimumVersion: '1.99', + previewFeature: true, + defaultValue: false + } + }, + 'chat.sendElementsToChat.enabled': { + default: true, + description: nls.localize('chat.sendElementsToChat.enabled', "Controls whether elements can be sent to chat from the Simple Browser."), + type: 'boolean', + tags: ['preview'] + }, + [mcpEnabledSection]: { + type: 'boolean', + description: nls.localize('chat.mcp.enabled', "Enables integration with Model Context Protocol servers to provide additional tools and functionality."), + default: true, + tags: ['preview'], + policy: { + name: 'ChatMCP', + minimumVersion: '1.99', + previewFeature: true, + defaultValue: false + } }, [mcpConfigurationSection]: { type: 'object', @@ -220,18 +239,58 @@ configurationRegistry.registerConfiguration({ description: nls.localize('workspaceConfig.mcp.description', "Model Context Protocol server configurations"), $ref: mcpSchemaId }, - // [ChatConfiguration.UnifiedChatView]: { - // type: 'boolean', - // description: nls.localize('chat.experimental.unifiedChatView', "Enables the unified view with Chat, Edit, and Agent in one place."), - // default: false, - // tags: ['experimental'], - // }, - [mcpDiscoverySection]: { + [ChatConfiguration.UseFileStorage]: { type: 'boolean', - default: false, - description: nls.localize('mpc.discovery.enabled', "Enable discovery of Model Context Protocol servers on the machine."), + description: nls.localize('chat.useFileStorage', "Enables storing chat sessions on disk instead of in the storage service. Enabling this does a one-time per-workspace migration of existing sessions to the new format."), + default: true, + tags: ['experimental'], }, - [PromptsConfig.CONFIG_KEY]: { + [ChatConfiguration.Edits2Enabled]: { + type: 'boolean', + description: nls.localize('chat.edits2Enabled', "Enable the new Edits mode that is based on tool-calling. When this is enabled, models that don't support tool-calling are unavailable for Edits mode."), + default: true, + tags: ['onExp'], + }, + [ChatConfiguration.ExtensionToolsEnabled]: { + type: 'boolean', + description: nls.localize('chat.extensionToolsEnabled', "Enable using tools contributed by third-party extensions."), + default: true, + policy: { + name: 'ChatAgentExtensionTools', + minimumVersion: '1.99', + description: nls.localize('chat.extensionToolsPolicy', "Enable using tools contributed by third-party extensions."), + previewFeature: true, + defaultValue: false + } + }, + [ChatConfiguration.AgentEnabled]: { + type: 'boolean', + description: nls.localize('chat.agent.enabled.description', "Enable agent mode for {0}. When this is enabled, a dropdown appears in the view to toggle agent mode.", 'Copilot Chat'), + default: true, + tags: ['onExp'], + policy: { + name: 'ChatAgentMode', + minimumVersion: '1.99', + previewFeature: false, + defaultValue: false + } + }, + [mcpDiscoverySection]: { + oneOf: [ + { type: 'boolean' }, + { + type: 'object', + default: Object.fromEntries(allDiscoverySources.map(k => [k, true])), + properties: Object.fromEntries(allDiscoverySources.map(k => [ + k, + { type: 'boolean', description: nls.localize('mcp.discovery.source', "Enables discovery of {0} servers", discoverySourceLabel[k]) } + ])), + } + ], + default: true, + markdownDescription: nls.localize('mpc.discovery.enabled', "Configures discovery of Model Context Protocol servers on the machine. It may be set to `true` or `false` to disable or enable all sources, and an mapping sources you wish to enable."), + }, + [PromptsConfig.KEY]: { type: 'boolean', title: nls.localize( 'chat.reusablePrompts.config.enabled.title', @@ -239,16 +298,52 @@ configurationRegistry.registerConfiguration({ ), markdownDescription: nls.localize( 'chat.reusablePrompts.config.enabled.description', - "Enable reusable prompt files (`*{0}`) in Chat, Edits, and Inline Chat sessions. [Learn More]({1}).", + "Enable reusable prompt (`*{0}`) and instruction files in Chat, Edits, and Inline Chat sessions. [Learn More]({1}).", PROMPT_FILE_EXTENSION, - DOCUMENTATION_URL, + INSTRUCTION_FILE_EXTENSION, + PROMPT_DOCUMENTATION_URL, ), default: true, restricted: true, disallowConfigurationDefault: true, tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + policy: { + name: 'ChatPromptFiles', + minimumVersion: '1.99', + description: nls.localize('chat.promptFiles.policy', "Enables reusable prompt and instruction files in Chat, Edits, and Inline Chat sessions."), + previewFeature: true, + defaultValue: false + } }, - [PromptsConfig.LOCATIONS_CONFIG_KEY]: { + [PromptsConfig.INSTRUCTIONS_LOCATION_KEY]: { + type: 'object', + title: nls.localize( + 'chat.instructions.config.locations.title', + "Instructions File Locations", + ), + markdownDescription: nls.localize( + 'chat.instructions.config.locations.description', + "Specify location(s) of instructions files (`*{0}`) that can be attached in Chat, Edits, and Inline Chat sessions. [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", + INSTRUCTION_FILE_EXTENSION, + INSTRUCTIONS_DOCUMENTATION_URL, + ), + default: { + [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, + }, + additionalProperties: { type: 'boolean' }, + restricted: true, + tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + { + [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, + }, + { + [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, + '/Users/vscode/repos/instructions': true, + }, + ], + }, + [PromptsConfig.PROMPT_LOCATIONS_KEY]: { type: 'object', title: nls.localize( 'chat.reusablePrompts.config.locations.title', @@ -256,25 +351,23 @@ configurationRegistry.registerConfiguration({ ), markdownDescription: nls.localize( 'chat.reusablePrompts.config.locations.description', - "Specify location(s) of reusable prompt files (`*{0}`) that can be attached in Chat, Edits, and Inline Chat sessions. [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", + "Specify location(s) of reusable prompt files (`*{0}`) that can be run in Chat, Edits, and Inline Chat sessions. [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", PROMPT_FILE_EXTENSION, - DOCUMENTATION_URL, + PROMPT_DOCUMENTATION_URL, ), default: { - [PromptsConfig.DEFAULT_SOURCE_FOLDER]: true, + [PROMPT_DEFAULT_SOURCE_FOLDER]: true, }, - required: [PromptsConfig.DEFAULT_SOURCE_FOLDER], additionalProperties: { type: 'boolean' }, unevaluatedProperties: { type: 'boolean' }, restricted: true, - disallowConfigurationDefault: true, tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'], examples: [ { - [PromptsConfig.DEFAULT_SOURCE_FOLDER]: true, + [PROMPT_DEFAULT_SOURCE_FOLDER]: true, }, { - [PromptsConfig.DEFAULT_SOURCE_FOLDER]: true, + [PROMPT_DEFAULT_SOURCE_FOLDER]: true, '/Users/vscode/repos/prompts': true, }, ], @@ -335,62 +428,14 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr static readonly ID = 'workbench.contrib.chatAgentSetting'; - private registeredNode: IConfigurationNode | undefined; - constructor( @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, - @IProductService private readonly productService: IProductService, - @IContextKeyService contextKeyService: IContextKeyService, @IChatEntitlementService private readonly entitlementService: IChatEntitlementService, ) { super(); - - if (this.productService.quality !== 'stable') { - this.registerEnablementSetting(); - } - - const expDisabledKey = ChatContextKeys.Editing.agentModeDisallowed.bindTo(contextKeyService); - experimentService.getTreatment('chatAgentEnabled').then(enabled => { - if (enabled) { - this.registerEnablementSetting(); - expDisabledKey.set(false); - } else if (this.productService.quality === 'stable') { - // undefined treatment- on stable, fall back to disabled - this.deregisterSetting(); - expDisabledKey.set(true); - } - }); - this.registerMaxRequestsSetting(); } - private registerEnablementSetting() { - if (this.registeredNode) { - return; - } - - this.registeredNode = { - id: 'chatAgent', - title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), - type: 'object', - properties: { - 'chat.agent.enabled': { - type: 'boolean', - description: nls.localize('chat.agent.enabled.description', "Enable agent mode for {0}. When this is enabled, a dropdown appears in the {0} view to toggle agent mode.", 'Copilot Edits'), - default: this.productService.quality !== 'stable', - tags: ['experimental', 'onExp'], - }, - } - }; - configurationRegistry.registerConfiguration(this.registeredNode); - } - - private deregisterSetting() { - if (this.registeredNode) { - configurationRegistry.deregisterConfigurations([this.registeredNode]); - this.registeredNode = undefined; - } - } private registerMaxRequestsSetting(): void { let lastNode: IConfigurationNode | undefined; @@ -409,7 +454,6 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr type: 'number', markdownDescription: nls.localize('chat.agent.maxRequests', "The maximum number of requests to allow Copilot Edits to use per-turn in agent mode. When the limit is reached, Copilot will ask the user to confirm that it should keep working. \n\n> **Note**: For users on the Copilot Free plan, note that each agent mode request currently uses one chat request."), default: defaultValue, - tags: ['experimental'] }, } }; @@ -425,6 +469,7 @@ AccessibleViewRegistry.register(new ChatResponseAccessibleView()); AccessibleViewRegistry.register(new PanelChatAccessibilityHelp()); AccessibleViewRegistry.register(new QuickChatAccessibilityHelp()); AccessibleViewRegistry.register(new EditsChatAccessibilityHelp()); +AccessibleViewRegistry.register(new AgentChatAccessibilityHelp()); registerEditorFeature(ChatInputBoxContentProvider); @@ -436,7 +481,7 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { @IChatSlashCommandService slashCommandService: IChatSlashCommandService, @ICommandService commandService: ICommandService, @IChatAgentService chatAgentService: IChatAgentService, - @IChatVariablesService chatVariablesService: IChatVariablesService, + @IChatWidgetService chatWidgetService: IChatWidgetService, @IInstantiationService instantiationService: IInstantiationService, ) { super(); @@ -449,12 +494,29 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { }, async () => { commandService.executeCommand(ACTION_ID_NEW_CHAT); })); + this._store.add(slashCommandService.registerSlashCommand({ + command: SAVE_TO_PROMPT_SLASH_COMMAND_NAME, + detail: nls.localize('save-chat-to-prompt-file', "Save chat to a prompt file"), + sortText: `z3_${SAVE_TO_PROMPT_SLASH_COMMAND_NAME}`, + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Panel] + }, async () => { + const { lastFocusedWidget } = chatWidgetService; + assertDefined( + lastFocusedWidget, + 'No currently active chat widget found.', + ); + + runSaveToPromptAction({ chat: lastFocusedWidget }, commandService); + })); this._store.add(slashCommandService.registerSlashCommand({ command: 'help', detail: '', sortText: 'z1_help', executeImmediately: true, - locations: [ChatAgentLocation.Panel] + locations: [ChatAgentLocation.Panel], + modes: [ChatMode.Ask] }, async (prompt, progress) => { const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); const agents = chatAgentService.getAgents(); @@ -471,7 +533,7 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { // Report agent list const agentText = (await Promise.all(agents - .filter(a => a.id !== defaultAgent?.id) + .filter(a => a.id !== defaultAgent?.id && !a.isCore) .filter(a => a.locations.includes(ChatAgentLocation.Panel)) .map(async a => { const description = a.description ? `- ${a.description}` : ''; @@ -537,9 +599,10 @@ registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingSta registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatEditingEditorAccessibility.ID, ChatEditingEditorAccessibility, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatEditingEditorOverlay.ID, ChatEditingEditorOverlay, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SimpleBrowserOverlay.ID, SimpleBrowserOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEditorContextKeys, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); @@ -562,6 +625,7 @@ registerChatToolActions(); registerEditorFeature(ChatPasteProvidersFeature); +registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); registerSingleton(IChatService, ChatService, InstantiationType.Delayed); registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delayed); registerSingleton(IQuickChatService, QuickChatService, InstantiationType.Delayed); @@ -582,6 +646,7 @@ registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, Instant registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); registerSingleton(IChatEntitlementService, ChatEntitlementService, InstantiationType.Delayed); registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); -registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); + +registerReusablePromptLanguageFeatures(); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 062067c99a8..9db1a4c3aee 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -11,8 +11,7 @@ import { Selection } from '../../../../editor/common/core/selection.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../../common/views.js'; -import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { IChatAgentCommand, IChatAgentData } from '../common/chatAgents.js'; import { IChatResponseModel } from '../common/chatModel.js'; @@ -49,18 +48,6 @@ export async function showChatView(viewsService: IViewsService): Promise(ChatViewId))?.widget; } -export async function showEditsView(viewsService: IViewsService): Promise { - return (await viewsService.openView(EditsViewId))?.widget; -} - -export function preferCopilotEditsView(viewsService: IViewsService): boolean { - if (viewsService.getFocusedView()?.id === ChatViewId || !!viewsService.getActiveViewWithId(ChatViewId)) { - return false; - } - - return !!viewsService.getActiveViewWithId(EditsViewId); -} - export function showCopilotView(viewsService: IViewsService, layoutService: IWorkbenchLayoutService): Promise { // Ensure main window is in front @@ -68,35 +55,7 @@ export function showCopilotView(viewsService: IViewsService, layoutService: IWor layoutService.mainContainer.focus(); } - // Bring up the correct view - if (preferCopilotEditsView(viewsService)) { - return showEditsView(viewsService); - } else { - return showChatView(viewsService); - } -} - -export function ensureSideBarChatViewSize(viewDescriptorService: IViewDescriptorService, layoutService: IWorkbenchLayoutService, viewsService: IViewsService): void { - const viewId = preferCopilotEditsView(viewsService) ? EditsViewId : ChatViewId; - - const location = viewDescriptorService.getViewLocationById(viewId); - if (location === ViewContainerLocation.Panel) { - return; // panel is typically very wide - } - - const viewPart = location === ViewContainerLocation.Sidebar ? Parts.SIDEBAR_PART : Parts.AUXILIARYBAR_PART; - const partSize = layoutService.getSize(viewPart); - - let adjustedChatWidth: number | undefined; - if (partSize.width < 400 && layoutService.mainContainerDimension.width > 1200) { - adjustedChatWidth = 400; // up to 400px if window bounds permit - } else if (partSize.width < 300) { - adjustedChatWidth = 300; // at minimum 300px - } - - if (typeof adjustedChatWidth === 'number') { - layoutService.setSize(viewPart, { width: adjustedChatWidth, height: partSize.height }); - } + return showChatView(viewsService); } export const IQuickChatService = createDecorator('quickChatService'); @@ -142,6 +101,7 @@ export interface IChatCodeBlockInfo { readonly uriPromise: Promise; codemapperUri: URI | undefined; readonly isStreaming: boolean; + readonly chatSessionId: string; focus(): void; } @@ -157,7 +117,6 @@ export interface IChatListItemRendererOptions { readonly renderStyle?: 'compact' | 'minimal'; readonly noHeader?: boolean; readonly editableCodeBlock?: boolean; - readonly renderCodeBlockPills?: boolean | ((mode: ChatMode) => boolean); readonly renderDetectedCommandsWithRequest?: boolean; readonly renderTextEditsAsSummary?: (uri: URI) => boolean; readonly referencesExpandedWhenEmptyResponse?: boolean | ((mode: ChatMode) => boolean); @@ -170,7 +129,6 @@ export interface IChatWidgetViewOptions { renderFollowups?: boolean; renderStyle?: 'compact' | 'minimal'; supportsFileReferences?: boolean; - supportsAdditionalParticipants?: boolean; filter?: (item: ChatTreeItem) => boolean; rendererOptions?: IChatListItemRendererOptions; menus?: { @@ -192,6 +150,7 @@ export interface IChatWidgetViewOptions { enableImplicitContext?: boolean; enableWorkingSet?: 'explicit' | 'implicit'; supportsChangingModes?: boolean; + dndContainer?: HTMLElement; } export interface IChatViewViewContext { @@ -210,6 +169,7 @@ export interface IChatAcceptInputOptions { } export interface IChatWidget { + readonly domNode: HTMLElement; readonly onDidChangeViewModel: Event; readonly onDidAcceptInput: Event; readonly onDidHide: Event; @@ -227,8 +187,7 @@ export interface IChatWidget { readonly input: ChatInputPart; readonly attachmentModel: ChatAttachmentModel; - // TODO I don't like this - readonly isUnifiedPanelWidget: boolean; + readonly supportsChangingModes: boolean; getContrib(id: string): T | undefined; reveal(item: ChatTreeItem): void; @@ -241,7 +200,6 @@ export interface IChatWidget { logInputHistory(): void; acceptInput(query?: string, options?: IChatAcceptInputOptions): Promise; rerunLastRequest(): Promise; - acceptInputWithPrefix(prefix: string): void; setInputPlaceholder(placeholder: string): void; resetInputPlaceholder(): void; focusLastMessage(): void; @@ -252,6 +210,11 @@ export interface IChatWidget { getFileTreeInfosForResponse(response: IChatResponseViewModel): IChatFileTreeInfo[]; getLastFocusedFileTreeForResponse(response: IChatResponseViewModel): IChatFileTreeInfo | undefined; clear(): void; + /** + * Wait for this widget to have a VM with a fully initialized model and editing session. + * Sort of a hack. See https://github.com/microsoft/vscode/issues/247484 + */ + waitForReady(): Promise; getViewState(): IChatViewState; togglePaused(): void; } @@ -269,5 +232,3 @@ export interface IChatCodeBlockContextProviderService { } export const ChatViewId = `workbench.panel.chat.view.${CHAT_PROVIDER_ID}`; - -export const EditsViewId = 'workbench.panel.chat.view.edits'; diff --git a/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts b/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts index 80adb520f3c..957cba60d4e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts @@ -17,7 +17,6 @@ export class ChatAccessibilityProvider implements IListAccessibilityProvider !('value' in v))?.length ?? 0; + + const toolInvocation = element.response.value.filter(v => v.kind === 'toolInvocation').filter(v => !v.isComplete); + let toolInvocationHint = ''; + if (toolInvocation.length) { + const titles = toolInvocation.map(v => v.confirmationMessages?.title).filter(v => !!v); + if (titles.length) { + toolInvocationHint = localize('toolInvocationsHint', "Action required: {0} ", titles.join(', ')); + } + } + const tableCount = marked.lexer(element.response.toString()).filter(token => token.type === 'table')?.length ?? 0; + let tableCountHint = ''; + switch (tableCount) { + case 0: + break; + case 1: + tableCountHint = localize('singleTableHint', "1 table "); + break; + default: + tableCountHint = localize('multiTableHint', "{0} tables ", tableCount); + break; + } + + const fileTreeCount = element.response.value.filter(v => v.kind === 'treeData').length ?? 0; let fileTreeCountHint = ''; switch (fileTreeCount) { case 0: break; case 1: - fileTreeCountHint = localize('singleFileTreeHint', "1 file tree"); + fileTreeCountHint = localize('singleFileTreeHint', "1 file tree "); break; default: - fileTreeCountHint = localize('multiFileTreeHint', "{0} file trees", fileTreeCount); + fileTreeCountHint = localize('multiFileTreeHint', "{0} file trees ", fileTreeCount); break; } const codeBlockCount = marked.lexer(element.response.toString()).filter(token => token.type === 'code')?.length ?? 0; switch (codeBlockCount) { case 0: - label = accessibleViewHint ? localize('noCodeBlocksHint', "{0} {1} {2}", fileTreeCountHint, element.response.toString(), accessibleViewHint) : localize('noCodeBlocks', "{0} {1}", fileTreeCountHint, element.response.toString()); + label = accessibleViewHint ? localize('noCodeBlocksHint', "{0}{1}{2}{3} {4}", toolInvocationHint, fileTreeCountHint, tableCountHint, element.response.toString(), accessibleViewHint) : localize('noCodeBlocks', "{0} {1}", fileTreeCountHint, element.response.toString()); break; case 1: - label = accessibleViewHint ? localize('singleCodeBlockHint', "{0} 1 code block: {1} {2}", fileTreeCountHint, element.response.toString(), accessibleViewHint) : localize('singleCodeBlock', "{0} 1 code block: {1}", fileTreeCountHint, element.response.toString()); + label = accessibleViewHint ? localize('singleCodeBlockHint', "{0}{1}1 code block: {2} {3}{4}", toolInvocationHint, fileTreeCountHint, tableCountHint, element.response.toString(), accessibleViewHint) : localize('singleCodeBlock', "{0} 1 code block: {1}", fileTreeCountHint, element.response.toString()); break; default: - label = accessibleViewHint ? localize('multiCodeBlockHint', "{0} {1} code blocks: {2}", fileTreeCountHint, codeBlockCount, element.response.toString(), accessibleViewHint) : localize('multiCodeBlock', "{0} {1} code blocks", fileTreeCountHint, codeBlockCount, element.response.toString()); + label = accessibleViewHint ? localize('multiCodeBlockHint', "{0}{1}{2} code blocks: {3}{4}", toolInvocationHint, fileTreeCountHint, tableCountHint, codeBlockCount, element.response.toString(), accessibleViewHint) : localize('multiCodeBlock', "{0} {1} code blocks", fileTreeCountHint, codeBlockCount, element.response.toString()); break; } return label; diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts index 24317550c93..7801b63b223 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts @@ -11,6 +11,19 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IChatRequestVariableEntry } from '../common/chatModel.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ChatPromptAttachmentsCollection } from './chatAttachmentModel/chatPromptAttachmentsCollection.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { resolveImageEditorAttachContext } from './chatAttachmentResolve.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { equals } from '../../../../base/common/objects.js'; + +export interface IChatAttachmentChangeEvent { + readonly deleted: readonly string[]; + readonly added: readonly IChatRequestVariableEntry[]; + readonly updated: readonly IChatRequestVariableEntry[]; +} export class ChatAttachmentModel extends Disposable { /** @@ -20,14 +33,15 @@ export class ChatAttachmentModel extends Disposable { constructor( @IInstantiationService private readonly initService: IInstantiationService, + @IFileService private readonly fileService: IFileService, + @IDialogService private readonly dialogService: IDialogService, + @ISharedWebContentExtractorService private readonly webContentExtractorService: ISharedWebContentExtractorService, ) { super(); this.promptInstructions = this._register( this.initService.createInstance(ChatPromptAttachmentsCollection), - ).onUpdate(() => { - this._onDidChangeContext.fire(); - }); + ); } private _attachments = new Map(); @@ -35,78 +49,147 @@ export class ChatAttachmentModel extends Disposable { return Array.from(this._attachments.values()); } - protected _onDidChangeContext = this._register(new Emitter()); - readonly onDidChangeContext = this._onDidChangeContext.event; + private _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; get size(): number { return this._attachments.size; } get fileAttachments(): URI[] { - return this.attachments.reduce((acc, file) => { - if (file.isFile && URI.isUri(file.value)) { - acc.push(file.value); - } - return acc; - }, []); + return this.attachments.filter(file => file.kind === 'file' && URI.isUri(file.value)) + .map(file => file.value as URI); } getAttachmentIDs() { return new Set(this._attachments.keys()); } - clear(): void { + clear( + clearStickyAttachments: boolean = false, + ): void { + const deleted = Array.from(this._attachments.keys()); this._attachments.clear(); - this._onDidChangeContext.fire(); + + if (clearStickyAttachments) { + this.promptInstructions.clear(); + } + + this._onDidChange.fire({ deleted, added: [], updated: [] }); } delete(...variableEntryIds: string[]) { + const deleted: string[] = []; + for (const variableEntryId of variableEntryIds) { - this._attachments.delete(variableEntryId); + if (this._attachments.delete(variableEntryId)) { + deleted.push(variableEntryId); + } + } + + if (deleted.length > 0) { + this._onDidChange.fire({ deleted, added: [], updated: [] }); } - this._onDidChangeContext.fire(); } - addFile(uri: URI, range?: IRange) { + async addFile(uri: URI, range?: IRange) { + if (/\.(png|jpe?g|gif|bmp|webp)$/i.test(uri.path)) { + const context = await this.asImageVariableEntry(uri); + if (context) { + this.addContext(context); + } + return; + } + this.addContext(this.asVariableEntry(uri, range)); } addFolder(uri: URI) { this.addContext({ + kind: 'directory', value: uri, id: uri.toString(), name: basename(uri), - isFile: false, - isDirectory: true, }); } asVariableEntry(uri: URI, range?: IRange): IChatRequestVariableEntry { return { + kind: 'file', value: range ? { uri, range } : uri, id: uri.toString() + (range?.toString() ?? ''), name: basename(uri), - isFile: true, }; } + // Gets an image variable for a given URI, which may be a file or a web URL + async asImageVariableEntry(uri: URI): Promise { + if (uri.scheme === Schemas.file && await this.fileService.canHandleResource(uri)) { + return await resolveImageEditorAttachContext(this.fileService, this.dialogService, uri); + } else if (uri.scheme === Schemas.http || uri.scheme === Schemas.https) { + const extractedImages = await this.webContentExtractorService.readImage(uri, CancellationToken.None); + if (extractedImages) { + return await resolveImageEditorAttachContext(this.fileService, this.dialogService, uri, extractedImages); + } + } + + return undefined; + } + addContext(...attachments: IChatRequestVariableEntry[]) { - let hasAdded = false; + const added: IChatRequestVariableEntry[] = []; for (const attachment of attachments) { if (!this._attachments.has(attachment.id)) { this._attachments.set(attachment.id, attachment); - hasAdded = true; + added.push(attachment); } } - if (hasAdded) { - this._onDidChangeContext.fire(); + if (added.length > 0) { + this._onDidChange.fire({ deleted: [], added, updated: [] }); } } clearAndSetContext(...attachments: IChatRequestVariableEntry[]) { - this.clear(); - this.addContext(...attachments); + const deleted = Array.from(this._attachments.keys()); + this._attachments.clear(); + + const added: IChatRequestVariableEntry[] = []; + for (const attachment of attachments) { + this._attachments.set(attachment.id, attachment); + added.push(attachment); + } + + if (deleted.length > 0 || added.length > 0) { + this._onDidChange.fire({ deleted, added, updated: [] }); + } + } + + updateContent(toDelete: Iterable, upsert: Iterable) { + const deleted: string[] = []; + const added: IChatRequestVariableEntry[] = []; + const updated: IChatRequestVariableEntry[] = []; + + for (const id of toDelete) { + if (this._attachments.delete(id)) { + deleted.push(id); + } + } + + for (const item of upsert) { + const oldItem = this._attachments.get(item.id); + if (!oldItem) { + this._attachments.set(item.id, item); + added.push(item); + } else if (!equals(oldItem, item)) { + this._attachments.set(item.id, item); + updated.push(item); + } + } + + if (deleted.length > 0 || added.length > 0 || updated.length > 0) { + this._onDidChange.fire({ deleted, added, updated }); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachmentModel.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachmentModel.ts index bb41b884ec6..3227f8e8984 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachmentModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachmentModel.ts @@ -6,9 +6,16 @@ import { URI } from '../../../../../base/common/uri.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { FilePromptParser } from '../../common/promptSyntax/parsers/filePromptParser.js'; +import { PromptParser } from '../../common/promptSyntax/parsers/promptParser.js'; +import { BasePromptParser } from '../../common/promptSyntax/parsers/basePromptParser.js'; +import { IPromptContentsProvider } from '../../common/promptSyntax/contentProviders/types.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +/** + * Type for a generic prompt parser object. + */ +type TPromptParser = BasePromptParser; + /** * Model for a single chat prompt instructions attachment. */ @@ -17,11 +24,12 @@ export class ChatPromptAttachmentModel extends Disposable { * Private reference of the underlying prompt instructions * reference instance. */ - private readonly _reference: FilePromptParser; + private readonly _reference: TPromptParser; + /** * Get the prompt instructions reference instance. */ - public get reference(): FilePromptParser { + public get reference(): TPromptParser { return this._reference; } @@ -31,7 +39,7 @@ export class ChatPromptAttachmentModel extends Disposable { */ public get references(): readonly URI[] { const { reference } = this; - const { errorCondition } = this.reference; + const { errorCondition } = reference; // return no references if the attachment is disabled // or if this object itself has an error @@ -47,11 +55,24 @@ export class ChatPromptAttachmentModel extends Disposable { ]; } + /** + * Get list of all tools associated with the prompt. + * + * Note! This property returns pont-in-time state of the tools metadata + * and does not take into account if the prompt or its nested child + * references are still being resolved. Please use the {@link settled} + * or {@link allSettled} properties if you need to retrieve the final + * list of the tools available. + */ + public get toolsMetadata(): readonly string[] | null { + return this.reference.allToolsMetadata; + } + /** * Promise that resolves when the prompt is fully parsed, * including all its possible nested child references. */ - public get allSettled(): Promise { + public get allSettled(): Promise { return this.reference.allSettled(); } @@ -67,7 +88,7 @@ export class ChatPromptAttachmentModel extends Disposable { * Event that fires when the error condition of the prompt * reference changes. * - * See {@linkcode onUpdate}. + * See {@link onUpdate}. */ protected _onUpdate = this._register(new Emitter()); /** @@ -83,7 +104,7 @@ export class ChatPromptAttachmentModel extends Disposable { /** * Event that fires when the object is disposed. * - * See {@linkcode onDispose}. + * See {@link onDispose}. */ protected _onDispose = this._register(new Emitter()); /** @@ -97,14 +118,25 @@ export class ChatPromptAttachmentModel extends Disposable { } constructor( - uri: URI, + public readonly uri: URI, @IInstantiationService private readonly initService: IInstantiationService, ) { super(); - this._onUpdate.fire = this._onUpdate.fire.bind(this._onUpdate); - this._reference = this._register(this.initService.createInstance(FilePromptParser, uri, [])) - .onUpdate(this._onUpdate.fire); + this._reference = this._register( + this.initService.createInstance( + PromptParser, + this.uri, + // in this case we know that the attached file must have been a + // prompt file, hence we pass the `allowNonPromptFiles` option + // to the provider to allow for non-prompt files to be attached + { allowNonPromptFiles: true }, + ) + ); + + this._reference.onUpdate( + this._onUpdate.fire.bind(this._onUpdate), + ); } /** diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachmentsCollection.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachmentsCollection.ts index a659cb8080b..471d468650e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachmentsCollection.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachmentsCollection.ts @@ -5,13 +5,43 @@ import { URI } from '../../../../../base/common/uri.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { IChatRequestVariableEntry } from '../../common/chatModel.js'; +import { basename } from '../../../../../base/common/resources.js'; import { ChatPromptAttachmentModel } from './chatPromptAttachmentModel.js'; import { PromptsConfig } from '../../../../../platform/prompts/common/config.js'; import { IPromptFileReference } from '../../common/promptSyntax/parsers/types.js'; import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IChatRequestVariableEntry, IPromptVariableEntry, isChatRequestFileEntry } from '../../common/chatModel.js'; + +/** + * Prefix for all prompt instruction variable IDs. + */ +const PROMPT_VARIABLE_ID_PREFIX = 'vscode.prompt.instructions'; + +/** + * Prompt IDs start with a well-defined prefix that is used by + * the copilot extension to identify prompt references. + * + * @param uri The URI of the prompt file. + * @param isRoot Whether the prompt file is the root file, or a + * child reference that is nested inside the root file. + */ +export const createPromptVariableId = ( + uri: URI, + isRoot: boolean, +): string => { + // the default prefix that is used for all prompt files + let prefix = PROMPT_VARIABLE_ID_PREFIX; + // if the reference is the root object, add the `.root` suffix + if (isRoot) { + prefix += '.root'; + } + + // final `id` for all `prompt files` starts with the well-defined + // part that the copilot extension(or other chatbot) can rely on + return `${prefix}__${uri}`; +}; /** * Utility to convert a {@link reference} to a chat variable entry. @@ -26,43 +56,83 @@ import { IConfigurationService } from '../../../../../platform/configuration/com * This object most likely was explicitly attached by the user. */ export const toChatVariable = ( - reference: Pick, + reference: Pick, isRoot: boolean, -): IChatRequestVariableEntry => { - const { uri, isPromptSnippet } = reference; +): IPromptVariableEntry => { + const { uri, isPromptFile } = reference; // default `id` is the stringified `URI` let id = `${uri}`; - // for prompt files, we add a prefix to the `id` - if (isPromptSnippet) { - // the default prefix that is used for all prompt files - let prefix = 'vscode.prompt.instructions'; - // if the reference is the root object, add the `.root` suffix - if (isRoot) { - prefix += '.root'; - } - - // final `id` for all `prompt files` starts with the well-defined - // part that the copilot extension(or other chatbot) can rely on - id = `${prefix}__${id}`; + // prompts have special `id`s that are used by the copilot extension + if (isPromptFile) { + id = createPromptVariableId(uri, isRoot); } + const name = (isPromptFile) + ? `prompt:${basename(uri)}` + : `file:${basename(uri)}`; + + const modelDescription = (isPromptFile) + ? 'Prompt instructions file' + : 'File attachment'; + return { id, - name: uri.fsPath, + name, value: uri, - isSelection: false, - enabled: true, - isFile: true, + kind: 'file', + modelDescription, + isRoot, }; }; +/** + * Checks of a provided chat variable is a `prompt file` variable. + */ +export function isPromptFileChatVariable( + variable: IChatRequestVariableEntry, +): variable is IPromptVariableEntry { + return isChatRequestFileEntry(variable) + && variable.id.startsWith(PROMPT_VARIABLE_ID_PREFIX); +} + /** * Model for a collection of prompt instruction attachments. * See {@linkcode ChatPromptAttachmentModel} for individual attachment. */ export class ChatPromptAttachmentsCollection extends Disposable { + /** + * Event that fires then this model is updated. + * + * See {@linkcode onUpdate}. + */ + protected _onUpdate = this._register(new Emitter()); + /** + * Subscribe to the `onUpdate` event. + */ + public onUpdate = this._onUpdate.event; + + /** + * Event that fires when a new prompt instruction attachment is added. + * See {@linkcode onAdd}. + */ + protected _onAdd = this._register(new Emitter()); + /** + * The `onAdd` event fires when a new prompt instruction attachment is added. + */ + public onAdd = this._onAdd.event; + + /** + * Event that fires when a new prompt instruction attachment is removed. + * See {@linkcode onRemove}. + */ + protected _onRemove = this._register(new Emitter()); + /** + * The `onRemove` event fires when a new prompt instruction attachment is removed. + */ + public onRemove = this._onRemove.event; + /** * List of all prompt instruction attachments. */ @@ -83,6 +153,26 @@ export class ChatPromptAttachmentsCollection extends Disposable { return result; } + /** + * Get list of tools associated with all attached prompt files. + */ + public get toolsMetadata(): readonly string[] | null { + const result = []; + + for (const child of this.attachments.values()) { + const { toolsMetadata } = child; + + if (toolsMetadata === null) { + continue; + } + + result.push(...toolsMetadata); + } + + // return unique list of all tools + return [...new Set(result)]; + } + /** * Get the list of all prompt instruction attachment variables, including all * nested child references of each attachment explicitly attached by user. @@ -95,7 +185,7 @@ export class ChatPromptAttachmentsCollection extends Disposable { const { reference } = attachment; // the usual URIs list of prompt instructions is `bottom-up`, therefore - // we do the same herfe - first add all child references of the model + // we do the same here - first add all child references of the model result.push( ...reference.allValidReferences.map((link) => { return toChatVariable(link, false); @@ -104,7 +194,13 @@ export class ChatPromptAttachmentsCollection extends Disposable { // then add the root reference of the model itself result.push( - toChatVariable(reference, true), + toChatVariable({ + uri: reference.uri, + // the attached file must have been a prompt file therefore + // we force that assumption here; this makes sure that prompts + // in untitled documents can be also attached to the chat input + isPromptFile: true, + }, true), ); } @@ -115,7 +211,7 @@ export class ChatPromptAttachmentsCollection extends Disposable { * Promise that resolves when parsing of all attached prompt instruction * files completes, including parsing of all its possible child references. */ - public async allSettled(): Promise { + public async allSettled(): Promise { const attachments = [...this.attachments.values()]; await Promise.allSettled( @@ -123,36 +219,6 @@ export class ChatPromptAttachmentsCollection extends Disposable { return attachment.allSettled; }), ); - } - - /** - * Event that fires then this model is updated. - * - * See {@linkcode onUpdate}. - */ - protected _onUpdate = this._register(new Emitter()); - /** - * Subscribe to the `onUpdate` event. - * @param callback Function to invoke on update. - */ - public onUpdate(callback: () => unknown): this { - this._register(this._onUpdate.event(callback)); - - return this; - } - - /** - * Event that fires when a new prompt instruction attachment is added. - * See {@linkcode onAdd}. - */ - protected _onAdd = this._register(new Emitter()); - /** - * The `onAdd` event fires when a new prompt instruction attachment is added. - * - * @param callback Function to invoke on add. - */ - public onAdd(callback: (attachment: ChatPromptAttachmentModel) => unknown): this { - this._register(this._onAdd.event(callback)); return this; } @@ -170,28 +236,34 @@ export class ChatPromptAttachmentsCollection extends Disposable { * Add a prompt instruction attachment instance with the provided `URI`. * @param uri URI of the prompt instruction attachment to add. */ - public add(uri: URI): this { - // if already exists, nothing to do - if (this.attachments.has(uri.path)) { - return this; + public add(uris: URI | readonly URI[]) { + const uriList = Array.isArray(uris) ? uris : [uris]; + + // if no URIs provided, nothing to do + if (uriList.length === 0) { + return; } - const instruction = this.initService.createInstance(ChatPromptAttachmentModel, uri) - .onUpdate(this._onUpdate.fire) - .onDispose(() => { - // note! we have to use `deleteAndLeak` here, because the `*AndDispose` - // alternative results in an infinite loop of calling this callback - this.attachments.deleteAndLeak(uri.path); - this._onUpdate.fire(); - }); + for (const uri of uriList) { + // if already exists, nothing to do + if (this.attachments.has(uri.path)) { + continue; + } - this.attachments.set(uri.path, instruction); - instruction.resolve(); + const instruction = this.initService.createInstance(ChatPromptAttachmentModel, uri) + .onUpdate(this._onUpdate.fire) + .onDispose(() => { + // note! we have to use `deleteAndLeak` here, because the `*AndDispose` + // alternative results in an infinite loop of calling this callback + this.attachments.deleteAndLeak(uri.path); + this._onUpdate.fire(); + this._onRemove.fire(instruction); + }).resolve(); - this._onAdd.fire(instruction); - this._onUpdate.fire(); - - return this; + this.attachments.set(uri.path, instruction); + this._onAdd.fire(instruction); + this._onUpdate.fire(); + } } /** @@ -215,4 +287,16 @@ export class ChatPromptAttachmentsCollection extends Disposable { public get featureEnabled(): boolean { return PromptsConfig.enabled(this.configService); } + + /** + * Clear all prompt instruction attachments. + */ + public clear(): this { + for (const attachment of this.attachments.values()) { + this.remove(attachment.uri); + } + + this._onUpdate.fire(); + return this; + } } diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentResolve.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentResolve.ts new file mode 100644 index 00000000000..fefca2429fd --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentResolve.ts @@ -0,0 +1,264 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { basename } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { SymbolKinds } from '../../../../editor/common/languages.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../nls.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IDraggedResourceEditorInput, MarkerTransferData, DocumentSymbolTransferData, NotebookCellOutputTransferData } from '../../../../platform/dnd/browser/dnd.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { MarkerSeverity } from '../../../../platform/markers/common/markers.js'; +import { isUntitledResourceEditorInput } from '../../../common/editor.js'; +import { EditorInput } from '../../../common/editor/editorInput.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js'; +import { UntitledTextEditorInput } from '../../../services/untitled/common/untitledTextEditorInput.js'; +import { createNotebookOutputVariableEntry, NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT_CONST } from '../../notebook/browser/contrib/chat/notebookChatUtils.js'; +import { getOutputViewModelFromId } from '../../notebook/browser/controller/cellOutputActions.js'; +import { getNotebookEditorFromEditorPane } from '../../notebook/browser/notebookBrowser.js'; +import { IChatRequestVariableEntry, IDiagnosticVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry, OmittedState } from '../common/chatModel.js'; +import { imageToHash } from './chatPasteProviders.js'; +import { resizeImage } from './imageUtils.js'; + +// --- EDITORS --- + +export async function resolveEditorAttachContext(editor: EditorInput | IDraggedResourceEditorInput, fileService: IFileService, editorService: IEditorService, textModelService: ITextModelService, extensionService: IExtensionService, dialogService: IDialogService): Promise { + // untitled editor + if (isUntitledResourceEditorInput(editor)) { + return await resolveUntitledEditorAttachContext(editor, editorService, textModelService); + } + + if (!editor.resource) { + return undefined; + } + + let stat; + try { + stat = await fileService.stat(editor.resource); + } catch { + return undefined; + } + + if (!stat.isDirectory && !stat.isFile) { + return undefined; + } + + const imageContext = await resolveImageEditorAttachContext(fileService, dialogService, editor.resource); + if (imageContext) { + return extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? imageContext : undefined; + } + + return await resolveResourceAttachContext(editor.resource, stat.isDirectory, textModelService); +} + +async function resolveUntitledEditorAttachContext(editor: IDraggedResourceEditorInput, editorService: IEditorService, textModelService: ITextModelService): Promise { + // If the resource is known, we can use it directly + if (editor.resource) { + return await resolveResourceAttachContext(editor.resource, false, textModelService); + } + + // Otherwise, we need to check if the contents are already open in another editor + const openUntitledEditors = editorService.editors.filter(editor => editor instanceof UntitledTextEditorInput) as UntitledTextEditorInput[]; + for (const canidate of openUntitledEditors) { + const model = await canidate.resolve(); + const contents = model.textEditorModel?.getValue(); + if (contents === editor.contents) { + return await resolveResourceAttachContext(canidate.resource, false, textModelService); + } + } + + return undefined; +} + +export async function resolveResourceAttachContext(resource: URI, isDirectory: boolean, textModelService: ITextModelService): Promise { + let omittedState = OmittedState.NotOmitted; + + if (!isDirectory) { + try { + const createdModel = await textModelService.createModelReference(resource); + createdModel.dispose(); + } catch { + omittedState = OmittedState.Full; + } + + if (/\.(svg)$/i.test(resource.path)) { + omittedState = OmittedState.Full; + } + } + + return { + kind: isDirectory ? 'directory' : 'file', + value: resource, + id: resource.toString(), + name: basename(resource), + omittedState + }; +} + +// --- IMAGES --- + +export type ImageTransferData = { + data: Uint8Array; + name: string; + icon?: ThemeIcon; + resource?: URI; + id?: string; + mimeType?: string; + omittedState?: OmittedState; +}; +const SUPPORTED_IMAGE_EXTENSIONS_REGEX = /\.(png|jpg|jpeg|gif|webp)$/i; + +export async function resolveImageEditorAttachContext(fileService: IFileService, dialogService: IDialogService, resource: URI, data?: VSBuffer): Promise { + if (!resource) { + return undefined; + } + + const match = SUPPORTED_IMAGE_EXTENSIONS_REGEX.exec(resource.path); + if (!match) { + return undefined; + } + + const mimeType = getMimeTypeFromPath(match); + const fileName = basename(resource); + + let dataBuffer: VSBuffer | undefined; + if (data) { + dataBuffer = data; + } else { + + let stat; + try { + stat = await fileService.stat(resource); + } catch { + return undefined; + } + + const readFile = await fileService.readFile(resource); + + if (stat.size > 30 * 1024 * 1024) { // 30 MB + dialogService.error(localize('imageTooLarge', 'Image is too large'), localize('imageTooLargeMessage', 'The image {0} is too large to be attached.', fileName)); + throw new Error('Image is too large'); + } + + dataBuffer = readFile.value; + } + + const isPartiallyOmitted = /\.gif$/i.test(resource.path); + const imageFileContext = await resolveImageAttachContext([{ + id: resource.toString(), + name: fileName, + data: dataBuffer.buffer, + icon: Codicon.fileMedia, + resource: resource, + mimeType: mimeType, + omittedState: isPartiallyOmitted ? OmittedState.Partial : OmittedState.NotOmitted + }]); + + return imageFileContext[0]; +} + +export async function resolveImageAttachContext(images: ImageTransferData[]): Promise { + return Promise.all(images.map(async image => ({ + id: image.id || await imageToHash(image.data), + name: image.name, + fullName: image.resource ? image.resource.path : undefined, + value: await resizeImage(image.data, image.mimeType), + icon: image.icon, + kind: 'image', + isFile: false, + isDirectory: false, + omittedState: image.omittedState || OmittedState.NotOmitted, + references: image.resource ? [{ reference: image.resource, kind: 'reference' }] : [] + }))); +} + +const MIME_TYPES: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', +}; + +function getMimeTypeFromPath(match: RegExpExecArray): string | undefined { + const ext = match[1].toLowerCase(); + return MIME_TYPES[ext]; +} + +// --- MARKERS --- + +export function resolveMarkerAttachContext(markers: MarkerTransferData[]): IDiagnosticVariableEntry[] { + return markers.map((marker): IDiagnosticVariableEntry => { + let filter: IDiagnosticVariableEntryFilterData; + if (!('severity' in marker)) { + filter = { filterUri: URI.revive(marker.uri), filterSeverity: MarkerSeverity.Warning }; + } else { + filter = IDiagnosticVariableEntryFilterData.fromMarker(marker); + } + + return IDiagnosticVariableEntryFilterData.toEntry(filter); + }); +} + +// --- SYMBOLS --- + +export function resolveSymbolsAttachContext(symbols: DocumentSymbolTransferData[]): ISymbolVariableEntry[] { + return symbols.map(symbol => { + const resource = URI.file(symbol.fsPath); + return { + kind: 'symbol', + id: symbolId(resource, symbol.range), + value: { uri: resource, range: symbol.range }, + symbolKind: symbol.kind, + icon: SymbolKinds.toIcon(symbol.kind), + fullName: symbol.name, + name: symbol.name, + }; + }); +} + +function symbolId(resource: URI, range?: IRange): string { + let rangePart = ''; + if (range) { + rangePart = `:${range.startLineNumber}`; + if (range.startLineNumber !== range.endLineNumber) { + rangePart += `-${range.endLineNumber}`; + } + } + return resource.fsPath + rangePart; +} + +// --- NOTEBOOKS --- + +export function resolveNotebookOutputAttachContext(data: NotebookCellOutputTransferData, editorService: IEditorService): IChatRequestVariableEntry[] { + const notebookEditor = getNotebookEditorFromEditorPane(editorService.activeEditorPane); + if (!notebookEditor) { + return []; + } + + const outputViewModel = getOutputViewModelFromId(data.outputId, notebookEditor); + if (!outputViewModel) { + return []; + } + + const mimeType = outputViewModel.pickedMimeType?.mimeType; + if (mimeType && NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT_CONST.includes(mimeType)) { + + const entry = createNotebookOutputVariableEntry(outputViewModel, mimeType, notebookEditor); + if (!entry) { + return []; + } + + return [entry]; + } + + return []; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts index 40691c17594..24856a00fee 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { $, addDisposableListener } from '../../../../base/browser/dom.js'; +import * as event from '../../../../base/common/event.js'; +import { $ } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js'; import { IManagedHoverTooltipMarkdownString } from '../../../../base/browser/ui/hover/hover.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; @@ -25,22 +25,25 @@ import { IOpenerService, OpenInternalOptions } from '../../../../platform/opener import { IThemeService, FolderThemeIcon } from '../../../../platform/theme/common/themeService.js'; import { IResourceLabel, ResourceLabels, IFileLabelOptions } from '../../../browser/labels.js'; import { revealInSideBarCommand } from '../../files/browser/fileActions.contribution.js'; -import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, ILinkVariableEntry, isImageVariableEntry } from '../common/chatModel.js'; +import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, OmittedState } from '../common/chatModel.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js'; import { hookUpResourceAttachmentDragAndContextMenu, hookUpSymbolAttachmentDragAndContextMenu } from './chatContentParts/chatAttachmentsContentPart.js'; -import { convertUint8ArrayToString } from './imageUtils.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { basename, dirname } from '../../../../base/common/path.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { INotebookService } from '../../notebook/common/notebookService.js'; +import { CellUri } from '../../notebook/common/notebookCommon.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; abstract class AbstractChatAttachmentWidget extends Disposable { public readonly element: HTMLElement; public readonly label: IResourceLabel; - private readonly _onDidDelete: Emitter = this._register(new Emitter()); - get onDidDelete(): Event { + private readonly _onDidDelete: event.Emitter = this._register(new event.Emitter()); + get onDidDelete(): event.Event { return this._onDidDelete.event; } @@ -62,10 +65,17 @@ abstract class AbstractChatAttachmentWidget extends Disposable { } protected modelSupportsVision() { - return this.currentLanguageModel?.metadata.capabilities?.vision ?? false; + return modelSupportsVision(this.currentLanguageModel); } protected attachClearButton() { + + if (this.attachment.range) { + // no clear button for attachments with ranges because range means + // referenced from prompt + return; + } + const clearButton = new Button(this.element, { supportIcons: true, hoverDelegate: this.hoverDelegate, @@ -73,9 +83,14 @@ abstract class AbstractChatAttachmentWidget extends Disposable { }); clearButton.icon = Codicon.close; this._register(clearButton); - this._register(Event.once(clearButton.onDidClick)((e) => { + this._register(event.Event.once(clearButton.onDidClick)((e) => { this._onDidDelete.fire(e); })); + this._register(dom.addStandardDisposableListener(this.element, dom.EventType.KEY_DOWN, e => { + if (e.keyCode === KeyCode.Backspace || e.keyCode === KeyCode.Delete) { + this._onDidDelete.fire(e.browserEvent); + } + })); if (this.shouldFocusClearButton) { clearButton.focus(); } @@ -85,7 +100,7 @@ abstract class AbstractChatAttachmentWidget extends Disposable { this.element.style.cursor = 'pointer'; this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, (e: MouseEvent) => { dom.EventHelper.stop(e, true); - if (this.attachment.isDirectory) { + if (this.attachment.kind === 'directory') { this.openResource(resource, true); } else { this.openResource(resource, false, range); @@ -96,7 +111,7 @@ abstract class AbstractChatAttachmentWidget extends Disposable { const event = new StandardKeyboardEvent(e); if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { dom.EventHelper.stop(e, true); - if (this.attachment.isDirectory) { + if (this.attachment.kind === 'directory') { this.openResource(resource, true); } else { this.openResource(resource, false, range); @@ -124,6 +139,10 @@ abstract class AbstractChatAttachmentWidget extends Disposable { } } +function modelSupportsVision(currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined) { + return currentLanguageModel?.metadata.capabilities?.vision ?? false; +} + export class FileAttachmentWidget extends AbstractChatAttachmentWidget { constructor( @@ -150,11 +169,11 @@ export class FileAttachmentWidget extends AbstractChatAttachmentWidget { const ariaLabel = range ? localize('chat.fileAttachmentWithRange', "Attached file, {0}, line {1} to line {2}", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment', "Attached file, {0}", friendlyName); this.element.ariaLabel = ariaLabel; - if (attachment.isOmitted) { + if (attachment.omittedState === OmittedState.Full) { this.renderOmittedWarning(friendlyName, ariaLabel, hoverDelegate); } else { const fileOptions: IFileLabelOptions = { hidePath: true }; - this.label.setFile(resource, attachment.isFile ? { + this.label.setFile(resource, attachment.kind === 'file' ? { ...fileOptions, fileKind: FileKind.FILE, range, @@ -174,19 +193,18 @@ export class FileAttachmentWidget extends AbstractChatAttachmentWidget { } private renderOmittedWarning(friendlyName: string, ariaLabel: string, hoverDelegate: IHoverDelegate) { - const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$(this.modelSupportsVision() ? 'span.codicon.codicon-file-media' : 'span.codicon.codicon-warning')); + const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$('span.codicon.codicon-warning')); const textLabel = dom.$('span.chat-attached-context-custom-text', {}, friendlyName); this.element.appendChild(pillIcon); this.element.appendChild(textLabel); const hoverElement = dom.$('div.chat-attached-context-hover'); hoverElement.setAttribute('aria-label', ariaLabel); + this.element.classList.add('warning'); + + hoverElement.textContent = localize('chat.fileAttachmentHover', "{0} does not support this {1} type.", this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name : this.currentLanguageModel, 'file'); + this._register(this.hoverService.setupManagedHover(hoverDelegate, this.element, hoverElement, { trapFocus: true })); - if (!this.modelSupportsVision()) { - this.element.classList.add('warning'); - hoverElement.textContent = localize('chat.fileAttachmentHover', "{0} does not support this {1} type.", this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name : this.currentLanguageModel, 'file'); - this._register(this.hoverService.setupManagedHover(hoverDelegate, this.element, hoverElement, { trapFocus: true })); - } } } @@ -208,28 +226,22 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { ) { super(attachment, shouldFocusClearButton, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService); - const ariaLabel = localize('chat.imageAttachment', "Attached image, {0}", attachment.name); - this.element.ariaLabel = ariaLabel; - this.element.style.position = 'relative'; - - if (attachment.references) { - this.element.style.cursor = 'pointer'; - const clickHandler = () => { - if (attachment.references && URI.isUri(attachment.references[0].reference)) { - this.openResource(attachment.references[0].reference, false, undefined); - } - }; - this._register(addDisposableListener(this.element, 'click', clickHandler)); + let ariaLabel: string; + if (attachment.omittedState === OmittedState.Full) { + ariaLabel = localize('chat.omittedImageAttachment', "Omitted this image: {0}", attachment.name); + } else if (attachment.omittedState === OmittedState.Partial) { + ariaLabel = localize('chat.partiallyOmittedImageAttachment', "Partially omitted this image: {0}", attachment.name); + } else { + ariaLabel = localize('chat.imageAttachment', "Attached image, {0}", attachment.name); } - const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$(this.modelSupportsVision() ? 'span.codicon.codicon-file-media' : 'span.codicon.codicon-warning')); - const textLabel = dom.$('span.chat-attached-context-custom-text', {}, attachment.name); - this.element.appendChild(pillIcon); - this.element.appendChild(textLabel); - - const hoverElement = dom.$('div.chat-attached-context-hover'); - hoverElement.setAttribute('aria-label', ariaLabel); - + const ref = attachment.references?.[0]?.reference; + resource = ref && URI.isUri(ref) ? ref : undefined; + const clickHandler = () => { + if (resource) { + this.openResource(resource, false, undefined); + } + }; type AttachImageEvent = { currentModel: string; supportsVision: boolean; @@ -249,20 +261,8 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { supportsVision: supportsVision }); - if (!supportsVision && this.currentLanguageModel) { - this.element.classList.add('warning'); - hoverElement.textContent = localize('chat.fileAttachmentHover', "{0} does not support this {1} type.", currentLanguageModelName, 'image'); - this._register(this.hoverService.setupManagedHover(hoverDelegate, this.element, hoverElement, { trapFocus: true })); - } else { - const buffer = attachment.value as Uint8Array; - const isURL = isImageVariableEntry(attachment) && attachment.isURL; - if (isURL) { - hoverElement.textContent = localize('chat.imageAttachmentHover', "{0}", convertUint8ArrayToString(buffer)); - this._register(this.hoverService.setupManagedHover(hoverDelegate, this.element, hoverElement, { trapFocus: true })); - } - this.createImageElements(buffer, this.element, hoverElement); - this._register(this.hoverService.setupManagedHover(hoverDelegate, this.element, hoverElement, { trapFocus: false })); - } + const fullName = resource?.toString() || attachment.fullName || attachment.name; + this._register(createImageElements(resource, attachment.name, fullName, this.element, attachment.value as Uint8Array, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState)); if (resource) { this.addResourceOpenHandlers(resource, undefined); @@ -270,37 +270,79 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { this.attachClearButton(); } +} + +function createImageElements(resource: URI | undefined, name: string, fullName: string, + element: HTMLElement, + buffer: ArrayBuffer | Uint8Array, + hoverService: IHoverService, ariaLabel: string, + currentLanguageModelName: string, + clickHandler: () => void, + currentLanguageModel?: ILanguageModelChatMetadataAndIdentifier, + omittedState?: OmittedState): IDisposable { + + const disposable = new DisposableStore(); + if (omittedState === OmittedState.Partial) { + element.classList.add('partial-warning'); + } + + element.ariaLabel = ariaLabel; + element.style.position = 'relative'; + + if (resource) { + element.style.cursor = 'pointer'; + disposable.add(dom.addDisposableListener(element, 'click', clickHandler)); + } + const supportsVision = modelSupportsVision(currentLanguageModel); + const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$(supportsVision ? 'span.codicon.codicon-file-media' : 'span.codicon.codicon-warning')); + const textLabel = dom.$('span.chat-attached-context-custom-text', {}, name); + element.appendChild(pillIcon); + element.appendChild(textLabel); + + const hoverElement = dom.$('div.chat-attached-context-hover'); + hoverElement.setAttribute('aria-label', ariaLabel); + + if (!supportsVision && currentLanguageModel) { + element.classList.add('warning'); + hoverElement.textContent = localize('chat.fileAttachmentHover', "{0} does not support this {1} type.", currentLanguageModelName, 'image'); + disposable.add(hoverService.setupDelayedHover(element, { content: hoverElement, appearance: { showPointer: true } })); + } else { + disposable.add(hoverService.setupDelayedHover(element, { content: hoverElement, appearance: { showPointer: true } })); + - private createImageElements(buffer: ArrayBuffer | Uint8Array, widget: HTMLElement, hoverElement: HTMLElement) { const blob = new Blob([buffer], { type: 'image/png' }); const url = URL.createObjectURL(blob); const pillImg = dom.$('img.chat-attached-context-pill-image', { src: url, alt: '' }); const pill = dom.$('div.chat-attached-context-pill', {}, pillImg); - const existingPill = widget.querySelector('.chat-attached-context-pill'); + const existingPill = element.querySelector('.chat-attached-context-pill'); if (existingPill) { existingPill.replaceWith(pill); } const hoverImage = dom.$('img.chat-attached-context-image', { src: url, alt: '' }); + const imageContainer = dom.$('div.chat-attached-context-image-container', {}, hoverImage); + hoverElement.appendChild(imageContainer); - // Update hover image - hoverElement.appendChild(hoverImage); - - hoverImage.onload = () => { - URL.revokeObjectURL(url); - }; + if (resource) { + const urlContainer = dom.$('a.chat-attached-context-url', {}, omittedState === OmittedState.Partial ? localize('chat.imageAttachmentWarning', "This GIF was partially omitted - current frame will be sent.") : fullName); + const separator = dom.$('div.chat-attached-context-url-separator'); + disposable.add(dom.addDisposableListener(urlContainer, 'click', () => clickHandler())); + hoverElement.append(separator, urlContainer); + } + hoverImage.onload = () => { URL.revokeObjectURL(url); }; hoverImage.onerror = () => { // reset to original icon on error or invalid image const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$('span.codicon.codicon-file-media')); const pill = dom.$('div.chat-attached-context-pill', {}, pillIcon); - const existingPill = widget.querySelector('.chat-attached-context-pill'); + const existingPill = element.querySelector('.chat-attached-context-pill'); if (existingPill) { existingPill.replaceWith(pill); } }; } + return disposable; } export class PasteAttachmentWidget extends AbstractChatAttachmentWidget { @@ -349,7 +391,7 @@ export class PasteAttachmentWidget extends AbstractChatAttachmentWidget { const copiedFromResource = attachment.copiedFrom?.uri; if (copiedFromResource) { - this._register(this.instantiationService.invokeFunction(accessor => hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, copiedFromResource))); + this._register(this.instantiationService.invokeFunction(hookUpResourceAttachmentDragAndContextMenu, this.element, copiedFromResource)); this.addResourceOpenHandlers(copiedFromResource, range); } @@ -357,25 +399,6 @@ export class PasteAttachmentWidget extends AbstractChatAttachmentWidget { } } -export class LinkAttachmentWidget extends AbstractChatAttachmentWidget { - constructor( - attachment: ILinkVariableEntry, - currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined, - shouldFocusClearButton: boolean, - container: HTMLElement, - contextResourceLabels: ResourceLabels, - hoverDelegate: IHoverDelegate, - @ICommandService commandService: ICommandService, - @IOpenerService openerService: IOpenerService, - ) { - super(attachment, shouldFocusClearButton, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService); - - this.element.ariaLabel = localize('chat.attachment.link', "Attached link, {0}", attachment.name); - this.label.setResource({ resource: attachment.value, name: attachment.name }, { icon: Codicon.link, title: attachment.value.toString() }); - this.attachClearButton(); - } -} - export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget { constructor( resource: URI | undefined, @@ -394,7 +417,7 @@ export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget { super(attachment, shouldFocusClearButton, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService); const attachmentLabel = attachment.fullName ?? attachment.name; - const withIcon = attachment.icon?.id ? `$(${attachment.icon.id}) ${attachmentLabel}` : attachmentLabel; + const withIcon = attachment.icon?.id ? `$(${attachment.icon.id})\u00A0${attachmentLabel}` : attachmentLabel; this.label.setLabel(withIcon, undefined); this.element.ariaLabel = localize('chat.attachment', "Attached context, {0}", attachment.name); @@ -412,7 +435,7 @@ export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget { if (attachment.kind === 'symbol') { const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.element)); - this._register(this.instantiationService.invokeFunction(accessor => hookUpSymbolAttachmentDragAndContextMenu(accessor, this.element, scopedContextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext))); + this._register(this.instantiationService.invokeFunction(hookUpSymbolAttachmentDragAndContextMenu, this.element, scopedContextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext)); } if (resource) { @@ -422,3 +445,139 @@ export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget { this.attachClearButton(); } } + +export class NotebookCellOutputChatAttachmentWidget extends AbstractChatAttachmentWidget { + constructor( + resource: URI, + attachment: INotebookOutputVariableEntry, + currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined, + shouldFocusClearButton: boolean, + container: HTMLElement, + contextResourceLabels: ResourceLabels, + hoverDelegate: IHoverDelegate, + @ICommandService commandService: ICommandService, + @IOpenerService openerService: IOpenerService, + @IHoverService private readonly hoverService: IHoverService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @INotebookService private readonly notebookService: INotebookService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(attachment, shouldFocusClearButton, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService); + + switch (attachment.mimeType) { + case 'application/vnd.code.notebook.error': { + this.renderErrorOutput(resource, attachment); + break; + } + case 'image/png': + case 'image/jpeg': + case 'image/svg': { + this.renderImageOutput(resource, attachment); + break; + } + default: { + this.renderGenericOutput(resource, attachment); + } + } + + this.instantiationService.invokeFunction(accessor => { + this._register(hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, resource)); + }); + this.addResourceOpenHandlers(resource, undefined); + this.attachClearButton(); + } + getAriaLabel(attachment: INotebookOutputVariableEntry): string { + return localize('chat.NotebookImageAttachment', "Attached Notebook output, {0}", attachment.name); + } + private renderErrorOutput(resource: URI, attachment: INotebookOutputVariableEntry) { + const attachmentLabel = attachment.name; + const withIcon = attachment.icon?.id ? `$(${attachment.icon.id})\u00A0${attachmentLabel}` : attachmentLabel; + const buffer = this.getOutputItem(resource, attachment)?.data.buffer ?? new Uint8Array(); + let title: string | undefined = undefined; + try { + const error = JSON.parse(new TextDecoder().decode(buffer)) as Error; + if (error.name && error.message) { + title = `${error.name}: ${error.message}`; + } + } catch { + // + } + this.label.setLabel(withIcon, undefined, { title }); + this.element.ariaLabel = this.getAriaLabel(attachment); + } + private renderGenericOutput(resource: URI, attachment: INotebookOutputVariableEntry) { + this.element.ariaLabel = this.getAriaLabel(attachment); + this.label.setFile(resource, { hidePath: true, icon: ThemeIcon.fromId('output') }); + } + private renderImageOutput(resource: URI, attachment: INotebookOutputVariableEntry) { + let ariaLabel: string; + if (attachment.omittedState === OmittedState.Full) { + ariaLabel = localize('chat.omittedNotebookImageAttachment', "Omitted this Notebook ouput: {0}", attachment.name); + } else if (attachment.omittedState === OmittedState.Partial) { + ariaLabel = localize('chat.partiallyOmittedNotebookImageAttachment', "Partially omitted this Notebook output: {0}", attachment.name); + } else { + ariaLabel = this.getAriaLabel(attachment); + } + + const clickHandler = () => this.openResource(resource, false, undefined); + const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : 'unknown'; + const buffer = this.getOutputItem(resource, attachment)?.data.buffer ?? new Uint8Array(); + this._register(createImageElements(resource, attachment.name, attachment.name, this.element, buffer, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState)); + } + + private getOutputItem(resource: URI, attachment: INotebookOutputVariableEntry) { + const parsedInfo = CellUri.parseCellOutputUri(resource); + if (!parsedInfo || typeof parsedInfo.cellHandle !== 'number' || typeof parsedInfo.outputIndex !== 'number') { + return undefined; + } + const notebook = this.notebookService.getNotebookTextModel(parsedInfo.notebook); + if (!notebook) { + return undefined; + } + const cell = notebook.cells.find(c => c.handle === parsedInfo.cellHandle); + if (!cell) { + return undefined; + } + const output = cell.outputs.length > parsedInfo.outputIndex ? cell.outputs[parsedInfo.outputIndex] : undefined; + return output?.outputs.find(o => o.mime === attachment.mimeType); + } + +} + +export class ElementChatAttachmentWidget extends AbstractChatAttachmentWidget { + constructor( + attachment: IElementVariableEntry, + currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined, + shouldFocusClearButton: boolean, + container: HTMLElement, + contextResourceLabels: ResourceLabels, + hoverDelegate: IHoverDelegate, + @ICommandService commandService: ICommandService, + @IOpenerService openerService: IOpenerService, + @IEditorService editorService: IEditorService, + ) { + super(attachment, shouldFocusClearButton, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService); + + const ariaLabel = localize('chat.elementAttachment', "Attached element, {0}", attachment.name); + this.element.ariaLabel = ariaLabel; + + this.element.style.position = 'relative'; + this.element.style.cursor = 'pointer'; + const attachmentLabel = attachment.name; + const withIcon = attachment.icon?.id ? `$(${attachment.icon.id})\u00A0${attachmentLabel}` : attachmentLabel; + this.label.setLabel(withIcon, undefined, { title: localize('chat.clickToViewContents', "Click to view the contents of: {0}", attachmentLabel) }); + + this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, async () => { + const content = attachment.value?.toString() || ''; + await editorService.openEditor({ + resource: undefined, + contents: content, + options: { + pinned: true + } + }); + })); + + this.attachClearButton(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts index bd7fd35870c..c43c4cced1e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts @@ -8,10 +8,10 @@ import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { IManagedHoverTooltipMarkdownString } from '../../../../../base/browser/ui/hover/hover.js'; import { IHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegate.js'; import { createInstantHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { basename, dirname } from '../../../../../base/common/path.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { IRange, Range } from '../../../../../editor/common/core/range.js'; @@ -39,8 +39,11 @@ import { FolderThemeIcon, IThemeService } from '../../../../../platform/theme/co import { fillEditorsDragData } from '../../../../browser/dnd.js'; import { ResourceLabels } from '../../../../browser/labels.js'; import { ResourceContextKey } from '../../../../common/contextkeys.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { revealInSideBarCommand } from '../../../files/browser/fileActions.contribution.js'; -import { IChatRequestVariableEntry, isImageVariableEntry, isLinkVariableEntry, isPasteVariableEntry } from '../../common/chatModel.js'; +import { CellUri } from '../../../notebook/common/notebookCommon.js'; +import { INotebookService } from '../../../notebook/common/notebookService.js'; +import { IChatRequestVariableEntry, INotebookOutputVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, OmittedState } from '../../common/chatModel.js'; import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../common/chatService.js'; import { convertUint8ArrayToString } from '../imageUtils.js'; @@ -64,6 +67,8 @@ export class ChatAttachmentsContentPart extends Disposable { @ICommandService private readonly commandService: ICommandService, @IThemeService private readonly themeService: IThemeService, @ILabelService private readonly labelService: ILabelService, + @INotebookService private readonly notebookService: INotebookService, + @IEditorService private readonly editorService: IEditorService, ) { super(); @@ -93,7 +98,99 @@ export class ChatAttachmentsContentPart extends Disposable { let ariaLabel: string | undefined; - if (resource && (attachment.isFile || attachment.isDirectory)) { + const renderFileAttachment = (ariaLabel: string, friendlyName: string, resource: URI, icon?: ThemeIcon) => { + + if (attachment.omittedState === OmittedState.Full) { + this.customAttachment(widget, friendlyName, hoverDelegate, ariaLabel, isAttachmentOmitted); + } else { + const fileOptions = { + hidePath: true, + title: correspondingContentReference?.options?.status?.description + }; + label.setFile(resource, attachment.kind === 'file' ? { + ...fileOptions, + fileKind: FileKind.FILE, + range, + } : { + ...fileOptions, + fileKind: FileKind.FOLDER, + icon: icon || (!this.themeService.getFileIconTheme().hasFolderIcons ? FolderThemeIcon : undefined) + }); + } + + this.instantiationService.invokeFunction(accessor => { + if (resource) { + this.attachedContextDisposables.add(hookUpResourceAttachmentDragAndContextMenu(accessor, widget, resource)); + } + }); + }; + + const renderImageAttachment = (ariaLabel: string, resource: URI | undefined, fullName: string, buffer: Uint8Array) => { + const isURL = isImageVariableEntry(attachment) && attachment.isURL; + const hoverElement = this.customAttachment(widget, attachment.name, hoverDelegate, ariaLabel, isAttachmentOmitted, true, isURL, attachment.value as Uint8Array); + + if (resource) { + widget.style.cursor = 'pointer'; + const clickHandler = () => { + this.openResource(resource, false, undefined); + }; + this.attachedContextDisposables.add(dom.addDisposableListener(widget, 'click', clickHandler)); + } + const omissionType = attachment.omittedState === OmittedState.Partial ? OmittedState.Partial : isAttachmentOmitted ? OmittedState.Full : undefined; + this.createImageElements(buffer, widget, hoverElement, fullName, resource, omissionType); + this.attachedContextDisposables.add(this.hoverService.setupDelayedHover(widget, { content: hoverElement, appearance: { showPointer: true } })); + widget.style.position = 'relative'; + }; + + const renderLabelWithIcon = (attachment: IChatRequestVariableEntry) => { + const attachmentLabel = attachment.fullName ?? attachment.name; + const withIcon = attachment.icon?.id ? `$(${attachment.icon.id}) ${attachmentLabel}` : attachmentLabel; + label.setLabel(withIcon, correspondingContentReference?.options?.status?.description); + }; + + if (resource && isNotebookOutputVariableEntry(attachment)) { + const friendlyName = attachment.name; + const output = this.getOutputItem(resource, attachment); + if (output?.mime.startsWith('image/')) { + if (attachment.omittedState === OmittedState.Full) { + ariaLabel = localize('chat.notebookOutputOmittedImageAttachment', "Omitted: {0}", friendlyName); + } else if (attachment.omittedState === OmittedState.Partial) { + ariaLabel = localize('chat.notebookOutputPartiallyOmittedImageAttachment', "Partially omitted: {0}", friendlyName); + } else { + ariaLabel = localize('chat.notebookOutputImageAttachment', "Attached: {0}", friendlyName); + } + } else { + if (isAttachmentOmitted) { + ariaLabel = localize('chat.notebookOutputOmittedFileAttachment', "Omitted: {0}.", friendlyName); + } else if (isAttachmentPartialOrOmitted) { + ariaLabel = localize('chat.notebookOutputPartialFileAttachment', "Partially attached: {0}.", friendlyName); + } else { + ariaLabel = localize('chat.notebookOutputFileAttachment3', "Attached: {0}.", friendlyName); + } + } + + switch (output?.mime) { + case 'application/vnd.code.notebook.error': { + renderLabelWithIcon(attachment); + break; + } + case 'image/png': + case 'image/jpeg': + case 'image/svg': { + renderImageAttachment(ariaLabel, resource, attachment.name, output.data.buffer); + break; + } + default: { + renderFileAttachment(ariaLabel, attachment.name, resource, ThemeIcon.fromId('output')); + } + } + + this.instantiationService.invokeFunction(accessor => { + if (resource) { + this.attachedContextDisposables.add(hookUpResourceAttachmentDragAndContextMenu(accessor, widget, resource)); + } + }); + } else if (resource && (attachment.kind === 'file' || attachment.kind === 'directory')) { const fileBasename = basename(resource.path); const fileDirname = dirname(resource.path); const friendlyName = `${fileBasename} ${fileDirname}`; @@ -106,51 +203,36 @@ export class ChatAttachmentsContentPart extends Disposable { ariaLabel = range ? localize('chat.fileAttachmentWithRange3', "Attached: {0}, line {1} to line {2}.", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment3', "Attached: {0}.", friendlyName); } - if (attachment.isOmitted) { - this.customAttachment(widget, friendlyName, hoverDelegate, ariaLabel, isAttachmentOmitted); + renderFileAttachment(ariaLabel, friendlyName, resource); + } else if (isImageVariableEntry(attachment)) { + if (attachment.omittedState === OmittedState.Full) { + ariaLabel = localize('chat.omittedImageAttachment', "Omitted this image: {0}", attachment.name); + } else if (attachment.omittedState === OmittedState.Partial) { + ariaLabel = localize('chat.partiallyOmittedImageAttachment', "Partially omitted this image: {0}", attachment.name); } else { - const fileOptions = { - hidePath: true, - title: correspondingContentReference?.options?.status?.description - }; - label.setFile(resource, attachment.isFile ? { - ...fileOptions, - fileKind: FileKind.FILE, - range, - } : { - ...fileOptions, - fileKind: FileKind.FOLDER, - icon: !this.themeService.getFileIconTheme().hasFolderIcons ? FolderThemeIcon : undefined - }); + ariaLabel = localize('chat.imageAttachment', "Attached image, {0}", attachment.name); } - this.instantiationService.invokeFunction(accessor => { - if (resource) { - this.attachedContextDisposables.add(hookUpResourceAttachmentDragAndContextMenu(accessor, widget, resource)); - } - }); - } else if (attachment.isImage) { - ariaLabel = localize('chat.imageAttachment', "Attached image, {0}", attachment.name); + const ref = attachment.references?.[0]?.reference; + const resource = ref && URI.isUri(ref) ? ref : undefined; + renderImageAttachment(ariaLabel, resource, resource?.toString() ?? '', attachment.value as Uint8Array); + } else if (isElementVariableEntry(attachment)) { + ariaLabel = localize('chat.elementAttachment', "Attached element, {0}", attachment.name); + widget.style.cursor = 'pointer'; + const attachmentLabel = attachment.name; + const withIcon = attachment.icon?.id ? `$(${attachment.icon.id})\u00A0${attachmentLabel}` : attachmentLabel; + label.setLabel(withIcon, undefined, { title: localize('chat.clickToViewContents', "Click to view the contents of: {0}", attachmentLabel) }); - const isURL = isImageVariableEntry(attachment) && attachment.isURL; - const hoverElement = this.customAttachment(widget, attachment.name, hoverDelegate, ariaLabel, isAttachmentOmitted, attachment.isImage, isURL, attachment.value as Uint8Array); - - if (attachment.references) { - widget.style.cursor = 'pointer'; - const clickHandler = () => { - if (attachment.references && URI.isUri(attachment.references[0].reference)) { - this.openResource(attachment.references[0].reference, false, undefined); + this._register(dom.addDisposableListener(widget, dom.EventType.CLICK, async () => { + const content = attachment.value?.toString() || ''; + await this.editorService.openEditor({ + resource: undefined, + contents: content, + options: { + pinned: true } - }; - this.attachedContextDisposables.add(dom.addDisposableListener(widget, 'click', clickHandler)); - } - - if (!isAttachmentPartialOrOmitted) { - const buffer = attachment.value as Uint8Array; - this.createImageElements(buffer, widget, hoverElement); - this.attachedContextDisposables.add(this.hoverService.setupManagedHover(hoverDelegate, widget, hoverElement, { trapFocus: false })); - } - widget.style.position = 'relative'; + }); + })); } else if (isPasteVariableEntry(attachment)) { ariaLabel = localize('chat.attachment', "Attached context, {0}", attachment.name); @@ -182,16 +264,9 @@ export class ChatAttachmentsContentPart extends Disposable { this.attachedContextDisposables.add(this.instantiationService.invokeFunction(accessor => hookUpResourceAttachmentDragAndContextMenu(accessor, widget, resource))); } } - } else if (isLinkVariableEntry(attachment)) { - ariaLabel = localize('chat.attachment.link', "Attached link, {0}", attachment.name); - - label.setResource({ resource: attachment.value, name: attachment.name }, { icon: Codicon.link, title: attachment.value.toString() }); } else { - const attachmentLabel = attachment.fullName ?? attachment.name; - const withIcon = attachment.icon?.id ? `$(${attachment.icon.id}) ${attachmentLabel}` : attachmentLabel; - label.setLabel(withIcon, correspondingContentReference?.options?.status?.description); - ariaLabel = localize('chat.attachment3', "Attached context: {0}.", attachment.name); + renderLabelWithIcon(attachment); } if (attachment.kind === 'symbol') { @@ -222,7 +297,7 @@ export class ChatAttachmentsContentPart extends Disposable { if (!this.attachedContextDisposables.isDisposed) { this.attachedContextDisposables.add(dom.addDisposableListener(widget, dom.EventType.CLICK, async (e: MouseEvent) => { dom.EventHelper.stop(e, true); - if (attachment.isDirectory) { + if (attachment.kind === 'directory') { this.openResource(resource, true); } else { this.openResource(resource, false, range); @@ -236,6 +311,23 @@ export class ChatAttachmentsContentPart extends Disposable { }); } + private getOutputItem(resource: URI, attachment: INotebookOutputVariableEntry) { + const parsedInfo = CellUri.parseCellOutputUri(resource); + if (!parsedInfo || typeof parsedInfo.cellHandle !== 'number' || typeof parsedInfo.outputIndex !== 'number') { + return undefined; + } + const notebook = this.notebookService.getNotebookTextModel(parsedInfo.notebook); + if (!notebook) { + return undefined; + } + const cell = notebook.cells.find(c => c.handle === parsedInfo.cellHandle); + if (!cell) { + return undefined; + } + const output = cell.outputs.length > parsedInfo.outputIndex ? cell.outputs[parsedInfo.outputIndex] : undefined; + return output?.outputs.find(o => o.mime === attachment.mimeType); + } + private customAttachment(widget: HTMLElement, friendlyName: string, hoverDelegate: IHoverDelegate, ariaLabel: string, isAttachmentOmitted: boolean, isImage?: boolean, isURL?: boolean, value?: Uint8Array): HTMLElement { const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$(isAttachmentOmitted ? 'span.codicon.codicon-warning' : 'span.codicon.codicon-file-media')); const textLabel = dom.$('span.chat-attached-context-custom-text', {}, friendlyName); @@ -247,14 +339,13 @@ export class ChatAttachmentsContentPart extends Disposable { if (isURL && !isAttachmentOmitted && value) { hoverElement.textContent = localize('chat.imageAttachmentHover', "{0}", convertUint8ArrayToString(value)); - this.attachedContextDisposables.add(this.hoverService.setupManagedHover(hoverDelegate, widget, hoverElement, { trapFocus: true })); + this.attachedContextDisposables.add(this.hoverService.setupDelayedHover(widget, { content: hoverElement, appearance: { showPointer: true } })); } - if (isAttachmentOmitted) { widget.classList.add('warning'); hoverElement.textContent = localize('chat.fileAttachmentHover', "Selected model does not support this {0} type.", isImage ? 'image' : 'file'); - this.attachedContextDisposables.add(this.hoverService.setupManagedHover(hoverDelegate, widget, hoverElement, { trapFocus: true })); + this.attachedContextDisposables.add(this.hoverService.setupDelayedHover(widget, { content: hoverElement, appearance: { showPointer: true } })); } return hoverElement; @@ -279,10 +370,17 @@ export class ChatAttachmentsContentPart extends Disposable { } // Helper function to create and replace image - private async createImageElements(buffer: ArrayBuffer | Uint8Array, widget: HTMLElement, hoverElement: HTMLElement) { + private createImageElements(buffer: ArrayBuffer | Uint8Array, widget: HTMLElement, hoverElement: HTMLElement, fullName: string, reference?: URI, omittedState?: OmittedState): void { + if (omittedState === OmittedState.Full) { + return; + } + + if (omittedState === OmittedState.Partial) { + widget.classList.add('partial-warning'); + } + const blob = new Blob([buffer], { type: 'image/png' }); const url = URL.createObjectURL(blob); - const img = dom.$('img.chat-attached-context-image', { src: url, alt: '' }); const pillImg = dom.$('img.chat-attached-context-pill-image', { src: url, alt: '' }); const pill = dom.$('div.chat-attached-context-pill', {}, pillImg); @@ -291,14 +389,22 @@ export class ChatAttachmentsContentPart extends Disposable { existingPill.replaceWith(pill); } - // Update hover image - hoverElement.appendChild(img); + const hoverImage = dom.$('img.chat-attached-context-image', { src: url, alt: '' }); + const imageContainer = dom.$('div.chat-attached-context-image-container', {}, hoverImage); + hoverElement.appendChild(imageContainer); - img.onload = () => { + if (reference) { + const urlContainer = dom.$('a.chat-attached-context-url', {}, omittedState === OmittedState.Partial ? localize('chat.imageAttachmentWarning', "This GIF was partially omitted - current frame was be sent.") : fullName); + const separator = dom.$('div.chat-attached-context-url-separator'); + this._register(dom.addDisposableListener(urlContainer, 'click', () => this.openResource(reference, false, undefined))); + hoverElement.append(separator, urlContainer); + } + + hoverImage.onload = () => { URL.revokeObjectURL(url); }; - img.onerror = () => { + hoverImage.onerror = () => { // reset to original icon on error or invalid image const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$('span.codicon.codicon-file-media')); const pill = dom.$('div.chat-attached-context-pill', {}, pillIcon); diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatCollapsibleContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatCollapsibleContentPart.ts new file mode 100644 index 00000000000..976b3b74674 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatCollapsibleContentPart.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ButtonWithIcon } from '../../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { localize } from '../../../../../nls.js'; +import { IChatRendererContent } from '../../common/chatViewModel.js'; +import { ChatTreeItem } from '../chat.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { $ } from './chatReferencesContentPart.js'; + + +export abstract class ChatCollapsibleContentPart extends Disposable implements IChatContentPart { + + private _domNode?: HTMLElement; + + protected readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + protected readonly hasFollowingContent: boolean; + protected _isExpanded = observableValue(this, false); + + constructor( + private readonly title: IMarkdownString | string, + protected readonly context: IChatContentPartRenderContext, + ) { + super(); + this.hasFollowingContent = this.context.contentIndex + 1 < this.context.content.length; + } + + get domNode(): HTMLElement { + this._domNode ??= this.init(); + return this._domNode; + } + + protected init(): HTMLElement { + const referencesLabel = this.title; + + + const buttonElement = $('.chat-used-context-label', undefined); + + const collapseButton = this._register(new ButtonWithIcon(buttonElement, { + buttonBackground: undefined, + buttonBorder: undefined, + buttonForeground: undefined, + buttonHoverBackground: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSeparator: undefined + })); + this._domNode = $('.chat-used-context', undefined, buttonElement); + collapseButton.label = referencesLabel; + + this._register(collapseButton.onDidClick(() => { + const value = this._isExpanded.get(); + this._isExpanded.set(!value, undefined); + })); + + this._register(autorun(r => { + const value = this._isExpanded.read(r); + collapseButton.icon = value ? Codicon.chevronDown : Codicon.chevronRight; + this._domNode?.classList.toggle('chat-used-context-collapsed', !value); + this.updateAriaLabel(collapseButton.element, typeof referencesLabel === 'string' ? referencesLabel : referencesLabel.value, this.isExpanded()); + + if (this._domNode?.isConnected) { + queueMicrotask(() => { + this._onDidChangeHeight.fire(); + }); + } + })); + + const content = this.initContent(); + this._domNode.appendChild(content); + return this._domNode; + } + + protected abstract initContent(): HTMLElement; + + abstract hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean; + + private updateAriaLabel(element: HTMLElement, label: string, expanded?: boolean): void { + element.ariaLabel = expanded ? localize('usedReferencesExpanded', "{0}, expanded", label) : localize('usedReferencesCollapsed', "{0}, collapsed", label); + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } + + get expanded(): IObservable { + return this._isExpanded; + } + + protected isExpanded(): boolean { + return this._isExpanded.get(); + } + + protected setExpanded(value: boolean): void { + this._isExpanded.set(value, undefined); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts index c3361ccfd3b..1019e32cfc6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts @@ -39,7 +39,7 @@ export class ChatConfirmationContentPart extends Disposable implements IChatCont { label: localize('accept', "Accept"), data: confirmation.data }, { label: localize('dismiss', "Dismiss"), data: confirmation.data, isSecondary: true }, ]; - const confirmationWidget = this._register(this.instantiationService.createInstance(ChatConfirmationWidget, confirmation.title, confirmation.message, buttons)); + const confirmationWidget = this._register(this.instantiationService.createInstance(ChatConfirmationWidget, confirmation.title, undefined, confirmation.message, buttons)); confirmationWidget.setShowButtons(!confirmation.isUsed); this._register(confirmationWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts index 7bdb9b23499..2298b1dd4f4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts @@ -4,20 +4,88 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import './media/chatConfirmationWidget.css'; -import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { Button, ButtonWithDropdown, IButton, IButtonOptions } from '../../../../../base/browser/ui/button/button.js'; +import { Action } from '../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { IMarkdownRenderResult, MarkdownRenderer, openLinkFromMarkdown } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; +import './media/chatConfirmationWidget.css'; export interface IChatConfirmationButton { label: string; isSecondary?: boolean; tooltip?: string; data: any; + moreActions?: IChatConfirmationButton[]; +} + +export class ChatQueryTitlePart extends Disposable { + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + private readonly _renderedTitle = this._register(new MutableDisposable()); + + public get title() { + return this._title; + } + + public set title(value: string | IMarkdownString) { + this._title = value; + + const next = this._renderer.render(this.toMdString(value), { + asyncRenderCallback: () => this._onDidChangeHeight.fire(), + }); + + const previousEl = this._renderedTitle.value?.element; + if (previousEl?.parentElement) { + previousEl.parentElement.replaceChild(next.element, previousEl); + } else { + this.element.appendChild(next.element); // unreachable? + } + + this._renderedTitle.value = next; + } + + constructor( + private readonly element: HTMLElement, + private _title: IMarkdownString | string, + subtitle: string | IMarkdownString | undefined, + private readonly _renderer: MarkdownRenderer, + @IOpenerService private readonly _openerService: IOpenerService, + ) { + super(); + + element.classList.add('chat-query-title-part'); + + this._renderedTitle.value = _renderer.render(this.toMdString(_title), { + asyncRenderCallback: () => this._onDidChangeHeight.fire(), + }); + element.append(this._renderedTitle.value.element); + if (subtitle) { + const str = this.toMdString(subtitle); + const renderedTitle = this._register(_renderer.render(str, { + asyncRenderCallback: () => this._onDidChangeHeight.fire(), + actionHandler: { callback: link => openLinkFromMarkdown(this._openerService, link, str.isTrusted), disposables: this._store }, + })); + const wrapper = document.createElement('small'); + wrapper.appendChild(renderedTitle.element); + element.append(wrapper); + } + } + + private toMdString(value: string | IMarkdownString) { + if (typeof value === 'string') { + return new MarkdownString('', { supportThemeIcons: true }).appendText(value); + } else { + return new MarkdownString(value.value, { supportThemeIcons: true, isTrusted: value.isTrusted }); + } + } } abstract class BaseChatConfirmationWidget extends Disposable { @@ -41,8 +109,12 @@ abstract class BaseChatConfirmationWidget extends Disposable { constructor( title: string, + subtitle: string | IMarkdownString | undefined, buttons: IChatConfirmationButton[], @IInstantiationService protected readonly instantiationService: IInstantiationService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IHostService private readonly _hostService: IHostService, ) { super(); @@ -54,13 +126,42 @@ abstract class BaseChatConfirmationWidget extends Disposable { this._domNode = elements.root; this.markdownRenderer = this.instantiationService.createInstance(MarkdownRenderer, {}); - const renderedTitle = this._register(this.markdownRenderer.render(new MarkdownString(title, { supportThemeIcons: true }), { - asyncRenderCallback: () => this._onDidChangeHeight.fire(), - })); - elements.title.append(renderedTitle.element); + const titlePart = this._register(instantiationService.createInstance( + ChatQueryTitlePart, + elements.title, + title, + subtitle, + this.markdownRenderer, + )); + + this._register(titlePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + this.messageElement = elements.message; buttons.forEach(buttonData => { - const button = this._register(new Button(elements.buttonsContainer, { ...defaultButtonStyles, secondary: buttonData.isSecondary, title: buttonData.tooltip })); + const buttonOptions: IButtonOptions = { ...defaultButtonStyles, secondary: buttonData.isSecondary, title: buttonData.tooltip }; + + let button: IButton; + if (buttonData.moreActions) { + button = new ButtonWithDropdown(elements.buttonsContainer, { + ...buttonOptions, + contextMenuProvider: contextMenuService, + addPrimaryActionToDropdown: false, + actions: buttonData.moreActions.map(action => this._register(new Action( + action.label, + action.label, + undefined, + true, + () => { + this._onDidClick.fire(action); + return Promise.resolve(); + }, + ))), + }); + } else { + button = new Button(elements.buttonsContainer, buttonOptions); + } + + this._register(button); button.label = buttonData.label; this._register(button.onDidClick(() => this._onDidClick.fire(buttonData))); }); @@ -68,17 +169,28 @@ abstract class BaseChatConfirmationWidget extends Disposable { protected renderMessage(element: HTMLElement): void { this.messageElement.append(element); + + if (this._configurationService.getValue('chat.focusWindowOnConfirmation')) { + const targetWindow = dom.getWindow(element); + if (!targetWindow.document.hasFocus()) { + this._hostService.focus(targetWindow, { force: true /* Application may not be active */ }); + } + } } } export class ChatConfirmationWidget extends BaseChatConfirmationWidget { constructor( title: string, + subtitle: string | IMarkdownString | undefined, private readonly message: string | IMarkdownString, buttons: IChatConfirmationButton[], @IInstantiationService instantiationService: IInstantiationService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IHostService hostService: IHostService, ) { - super(title, buttons, instantiationService); + super(title, subtitle, buttons, instantiationService, contextMenuService, configurationService, hostService); const renderedMessage = this._register(this.markdownRenderer.render( typeof this.message === 'string' ? new MarkdownString(this.message) : this.message, @@ -91,11 +203,15 @@ export class ChatConfirmationWidget extends BaseChatConfirmationWidget { export class ChatCustomConfirmationWidget extends BaseChatConfirmationWidget { constructor( title: string, + subtitle: string | IMarkdownString | undefined, messageElement: HTMLElement, buttons: IChatConfirmationButton[], @IInstantiationService instantiationService: IInstantiationService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IHostService hostService: IHostService, ) { - super(title, buttons, instantiationService); + super(title, subtitle, buttons, instantiationService, contextMenuService, configurationService, hostService); this.renderMessage(messageElement); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatExtensionsContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatExtensionsContentPart.ts new file mode 100644 index 00000000000..c042eecfc03 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatExtensionsContentPart.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatExtensionsContent.css'; +import * as dom from '../../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ExtensionsList, getExtensions } from '../../../extensions/browser/extensionsViewer.js'; +import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; +import { IChatExtensionsContent } from '../../common/chatService.js'; +import { IChatRendererContent } from '../../common/chatViewModel.js'; +import { ChatTreeItem, ChatViewId, IChatCodeBlockInfo } from '../chat.js'; +import { IChatContentPart } from './chatContentParts.js'; +import { PagedModel } from '../../../../../base/common/paging.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; + +export class ChatExtensionsContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + private _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + public get codeblocks(): IChatCodeBlockInfo[] { + return []; + } + + public get codeblocksPartId(): string | undefined { + return undefined; + } + + constructor( + private readonly extensionsContent: IChatExtensionsContent, + @IExtensionsWorkbenchService extensionsWorkbenchService: IExtensionsWorkbenchService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this.domNode = dom.$('.chat-extensions-content-part'); + const loadingElement = dom.append(this.domNode, dom.$('.loading-extensions-element')); + dom.append(loadingElement, dom.$(ThemeIcon.asCSSSelector(ThemeIcon.modify(Codicon.loading, 'spin'))), dom.$('span.loading-message', undefined, localize('chat.extensions.loading', 'Loading extensions...'))); + + const extensionsList = dom.append(this.domNode, dom.$('.extensions-list')); + const list = this._register(instantiationService.createInstance(ExtensionsList, extensionsList, ChatViewId, { alwaysConsumeMouseWheel: false }, { onFocus: Event.None, onBlur: Event.None, filters: {} })); + getExtensions(extensionsContent.extensions, extensionsWorkbenchService).then(extensions => { + loadingElement.remove(); + if (this._store.isDisposed) { + return; + } + list.setModel(new PagedModel(extensions)); + list.layout(); + this._onDidChangeHeight.fire(); + }); + } + + hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { + return other.kind === 'extensions' && other.extensions.length === this.extensionsContent.extensions.length && other.extensions.every(ext => this.extensionsContent.extensions.includes(ext)); + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts index d9238e060d8..295e2a0223a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts @@ -10,7 +10,7 @@ import { findLast } from '../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, IObservable } from '../../../../../base/common/observable.js'; import { equalsIgnoreCase } from '../../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -32,11 +32,11 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IMarkdownVulnerability } from '../../common/annotations.js'; -import { IChatEditingService, IEditSessionEntryDiff } from '../../common/chatEditingService.js'; +import { IEditSessionEntryDiff } from '../../common/chatEditingService.js'; import { IChatProgressRenderableResponseContent } from '../../common/chatModel.js'; -import { IChatMarkdownContent, IChatUndoStop } from '../../common/chatService.js'; +import { IChatMarkdownContent, IChatService, IChatUndoStop } from '../../common/chatService.js'; import { isRequestVM, isResponseVM } from '../../common/chatViewModel.js'; -import { CodeBlockModelCollection } from '../../common/codeBlockModelCollection.js'; +import { CodeBlockEntry, CodeBlockModelCollection } from '../../common/codeBlockModelCollection.js'; import { IChatCodeBlockInfo } from '../chat.js'; import { IChatRendererDelegate } from '../chatListRenderer.js'; import { ChatMarkdownDecorationsRenderer } from '../chatMarkdownDecorationsRenderer.js'; @@ -45,12 +45,12 @@ import { CodeBlockPart, ICodeBlockData, ICodeBlockRenderOptions, localFileLangua import '../media/chatCodeBlockPill.css'; import { IDisposableReference, ResourcePool } from './chatCollections.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { ChatExtensionsContentPart } from './chatExtensionsContentPart.js'; const $ = dom.$; export interface IChatMarkdownContentPartOptions { readonly codeBlockRenderOptions?: ICodeBlockRenderOptions; - readonly renderCodeBlockPills?: boolean; } export class ChatMarkdownContentPart extends Disposable implements IChatContentPart { @@ -90,21 +90,33 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP // and within this part to find it within the codeblocks array let globalCodeBlockIndexStart = codeBlockStartIndex; let thisPartCodeBlockIndexStart = 0; + + // Don't set to 'false' for responses, respect defaults + const markedOpts = isRequestVM(element) ? { + gfm: true, + breaks: true, + } : undefined; + const result = this._register(renderer.render(markdown.content, { fillInIncompleteTokens, codeBlockRendererSync: (languageId, text, raw) => { const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || codeblockHasClosingBackticks(raw); - if ((!text || (text.startsWith('') && !text.includes('\n'))) && !isCodeBlockComplete && rendererOptions.renderCodeBlockPills) { + if ((!text || (text.startsWith(' this._onDidChangeHeight.fire())); + return chatExtensions.domNode; + } const globalIndex = globalCodeBlockIndexStart++; const thisPartIndex = thisPartCodeBlockIndexStart++; let textModel: Promise; let range: Range | undefined; let vulns: readonly IMarkdownVulnerability[] | undefined; - let codemapperUri: URI | undefined; + let codeblockEntry: CodeBlockEntry | undefined; if (equalsIgnoreCase(languageId, localFileLanguageId)) { try { const parsedBody = parseLocalFileData(text); @@ -118,7 +130,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, globalIndex); const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, globalIndex, { text, languageId, isComplete: isCodeBlockComplete }); vulns = modelEntry.vulns; - codemapperUri = fastUpdateModelEntry.codemapperUri; + codeblockEntry = fastUpdateModelEntry; textModel = modelEntry.model; } @@ -129,9 +141,9 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP if (hideToolbar !== undefined) { renderOptions.hideToolbar = hideToolbar; } - const codeBlockInfo: ICodeBlockData = { languageId, textModel, codeBlockIndex: globalIndex, codeBlockPartIndex: thisPartIndex, element, range, parentContextKeyService: contextKeyService, vulns, codemapperUri, renderOptions }; + const codeBlockInfo: ICodeBlockData = { languageId, textModel, codeBlockIndex: globalIndex, codeBlockPartIndex: thisPartIndex, element, range, parentContextKeyService: contextKeyService, vulns, codemapperUri: codeblockEntry?.codemapperUri, renderOptions, chatSessionId: element.sessionId }; - if (!rendererOptions.renderCodeBlockPills || element.isCompleteAddedRequest || !codemapperUri) { + if (element.isCompleteAddedRequest || !codeblockEntry?.codemapperUri || !codeblockEntry.isEdit) { const ref = this.renderCodeBlock(codeBlockInfo, text, isCodeBlockComplete, currentWidth); this.allRefs.push(ref); @@ -144,7 +156,8 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP readonly ownerMarkdownPartId = ownerMarkdownPartId; readonly codeBlockIndex = globalIndex; readonly elementId = element.id; - readonly isStreaming = !rendererOptions.renderCodeBlockPills; + readonly isStreaming = false; + readonly chatSessionId = element.sessionId; codemapperUri = undefined; // will be set async public get uri() { // here we must do a getter because the ref.object is rendered @@ -177,7 +190,8 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP readonly codeBlockIndex = globalIndex; readonly elementId = element.id; readonly isStreaming = !isCodeBlockComplete; - readonly codemapperUri = codemapperUri; + readonly codemapperUri = codeblockEntry?.codemapperUri; + readonly chatSessionId = element.sessionId; public get uri() { return undefined; } @@ -192,7 +206,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } }, asyncRenderCallback: () => this._onDidChangeHeight.fire(), - })); + }, markedOpts)); const markdownDecorationsRenderer = instantiationService.createInstance(ChatMarkdownDecorationsRenderer); this._register(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(markdown, result.element)); @@ -231,7 +245,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP hasSameContent(other: IChatProgressRenderableResponseContent): boolean { return other.kind === 'markdownContent' && !!(other.content.value === this.markdown.content.value - || this.rendererOptions.renderCodeBlockPills && this.codeblocks.at(-1)?.isStreaming && this.codeblocks.at(-1)?.codemapperUri !== undefined && other.content.value.lastIndexOf('```') === this.markdown.content.value.lastIndexOf('```')); + || this.codeblocks.at(-1)?.isStreaming && this.codeblocks.at(-1)?.codemapperUri !== undefined && other.content.value.lastIndexOf('```') === this.markdown.content.value.lastIndexOf('```')); } layout(width: number): void { @@ -319,8 +333,8 @@ class CollapsedCodeBlock extends Disposable { @IContextMenuService private readonly contextMenuService: IContextMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IMenuService private readonly menuService: IMenuService, - @IChatEditingService private readonly chatEditingService: IChatEditingService, @IHoverService private readonly hoverService: IHoverService, + @IChatService private readonly chatService: IChatService, ) { super(); this.element = $('.chat-codeblock-pill-widget'); @@ -356,10 +370,13 @@ class CollapsedCodeBlock extends Disposable { this._uri = uri; + const session = this.chatService.getSession(this.sessionId); const iconText = this.labelService.getUriBasenameLabel(uri); - const editSession = this.chatEditingService.getEditingSession(this.sessionId); - const modifiedEntry = editSession?.getEntry(uri); - const isComplete = !modifiedEntry?.isCurrentlyBeingModifiedBy.get(); + + let editSession = session?.editingSessionObs?.promiseResult.get()?.data; + let modifiedEntry = editSession?.getEntry(uri); + let modifiedByResponse = modifiedEntry?.isCurrentlyBeingModifiedBy.get(); + const isComplete = !modifiedByResponse || modifiedByResponse.requestId !== this.requestId; let iconClasses: string[] = []; if (isStreaming || !isComplete) { @@ -398,16 +415,21 @@ class CollapsedCodeBlock extends Disposable { } }; - const diffBetweenStops = modifiedEntry && editSession - ? editSession.getEntryDiffBetweenStops(modifiedEntry.modifiedURI, this.requestId, this.inUndoStop) - : undefined; + let diffBetweenStops: IObservable | undefined; // Show a percentage progress that is driven by the rewrite this._progressStore.add(autorun(r => { + if (!editSession) { + editSession = session?.editingSessionObs?.promiseResult.read(r)?.data; + modifiedEntry = editSession?.getEntry(uri); + } + + modifiedByResponse = modifiedEntry?.isCurrentlyBeingModifiedBy.read(r); + let diffValue = diffBetweenStops?.read(r); + const isComplete = !!diffValue || !modifiedByResponse || modifiedByResponse.requestId !== this.requestId; const rewriteRatio = modifiedEntry?.rewriteRatio.read(r); - const isComplete = !modifiedEntry?.isCurrentlyBeingModifiedBy.read(r); if (!isStreaming && !isComplete) { const value = rewriteRatio; labelDetail.textContent = value === 0 || !value ? localize('chat.codeblock.generating', "Generating edits...") : localize('chat.codeblock.applyingPercentage', "Applying edits ({0}%)...", Math.round(value * 100)); @@ -418,8 +440,15 @@ class CollapsedCodeBlock extends Disposable { labelDetail.textContent = ''; } - if (!isStreaming && isComplete && diffBetweenStops) { - renderDiff(diffBetweenStops.read(r)); + if (!diffBetweenStops) { + diffBetweenStops = modifiedEntry && editSession + ? editSession.getEntryDiffBetweenStops(modifiedEntry.modifiedURI, this.requestId, this.inUndoStop) + : undefined; + diffValue = diffBetweenStops?.read(r); + } + + if (!isStreaming && isComplete) { + renderDiff(diffValue); } })); } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatProgressContentPart.ts index e3f08938b2d..6c5c13e045e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatProgressContentPart.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, append } from '../../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventType } from '../../../../../base/browser/dom.js'; import { alert } from '../../../../../base/browser/ui/aria/aria.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; @@ -53,10 +53,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP alert(progress.content.value); } const codicon = icon ? icon : this.showSpinner ? ThemeIcon.modify(Codicon.loading, 'spin') : Codicon.check; - const markdown = new MarkdownString(progress.content.value, { - supportThemeIcons: true - }); - const result = this._register(renderer.render(markdown)); + const result = this._register(renderer.render(progress.content)); result.element.classList.add('progress-step'); this.renderFileWidgets(result.element); @@ -111,9 +108,17 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl kind: 'progressMessage', content: workingProgress.isPaused ? new MarkdownString().appendText(localize('pausedMessage', "Paused")) : - new MarkdownString().appendText(localize('workingMessage', "Working")) + new MarkdownString().appendText(localize('workingMessage', "Working...")) }; super(progressMessage, renderer, context, undefined, undefined, workingProgress.isPaused ? Codicon.debugPause : undefined, instantiationService, chatMarkdownAnchorService); + + if (workingProgress.isPaused) { + this.domNode.style.cursor = 'pointer'; + this.domNode.title = localize('resume', "Click to resume"); + this._register(addDisposableListener(this.domNode, EventType.CLICK, () => { + workingProgress.setPaused(false); + })); + } } override hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts index 0e8dcd58fa0..e41a5813ab0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; @@ -14,6 +15,7 @@ import { assertType } from '../../../../../base/common/types.js'; import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { asCssVariable, textLinkForeground } from '../../../../../platform/theme/common/colorRegistry.js'; import { IChatResponseViewModel } from '../../common/chatViewModel.js'; @@ -42,7 +44,8 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar element: IChatResponseViewModel, renderer: MarkdownRenderer, @IChatWidgetService chatWidgetService: IChatWidgetService, - @ICommandService commandService: ICommandService + @ICommandService commandService: ICommandService, + @ITelemetryService telemetryService: ITelemetryService, ) { super(); @@ -99,7 +102,9 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar }; this._register(button1.onDidClick(async () => { - await commandService.executeCommand('workbench.action.chat.upgradePlan', 'chat-response'); + const commandId = 'workbench.action.chat.upgradePlan'; + telemetryService.publicLog2('workbenchActionExecuted', { id: commandId, from: 'chat-response' }); + await commandService.executeCommand(commandId); shouldShowRetryButton = true; addRetryButtonIfNeeded(); diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts index 2d53ebef93a..291f81a0c8c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts @@ -4,13 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { ButtonWithIcon } from '../../../../../base/browser/ui/button/button.js'; import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; import { coalesce } from '../../../../../base/common/arrays.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { matchesSomeScheme, Schemas } from '../../../../../base/common/network.js'; import { basename } from '../../../../../base/common/path.js'; import { basenameOrAuthority, isEqualAuthority } from '../../../../../base/common/resources.js'; @@ -38,85 +37,45 @@ import { ResourceContextKey } from '../../../../common/contextkeys.js'; import { SETTINGS_AUTHORITY } from '../../../../services/preferences/common/preferences.js'; import { createFileIconThemableTreeContainerScope } from '../../../files/browser/views/explorerView.js'; import { ExplorerFolderContext } from '../../../files/common/files.js'; -import { chatEditingWidgetFileStateContextKey, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { ChatResponseReferencePartStatusKind, IChatContentReference, IChatWarningMessage } from '../../common/chatService.js'; -import { IChatVariablesService } from '../../common/chatVariables.js'; import { IChatRendererContent, IChatResponseViewModel } from '../../common/chatViewModel.js'; import { ChatTreeItem, IChatWidgetService } from '../chat.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { IDisposableReference, ResourcePool } from './chatCollections.js'; -import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { IChatContentPartRenderContext } from './chatContentParts.js'; -const $ = dom.$; +export const $ = dom.$; export interface IChatReferenceListItem extends IChatContentReference { title?: string; description?: string; - state?: WorkingSetEntryState; + state?: ModifiedFileEntryState; excluded?: boolean; } export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage; -export class ChatCollapsibleListContentPart extends Disposable implements IChatContentPart { - public domNode!: HTMLElement; - - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - - private hasFollowingContent!: boolean; +export class ChatCollapsibleListContentPart extends ChatCollapsibleContentPart { constructor( private readonly data: ReadonlyArray, - private readonly labelOverride: IMarkdownString | string | undefined, - protected readonly context: IChatContentPartRenderContext, + labelOverride: IMarkdownString | string | undefined, + context: IChatContentPartRenderContext, private readonly contentReferencesListPool: CollapsibleListPool, @IOpenerService private readonly openerService: IOpenerService, @IMenuService private readonly menuService: IMenuService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { - super(); - - this.init(); + super(labelOverride ?? (data.length > 1 ? + localize('usedReferencesPlural', "Used {0} references", data.length) : + localize('usedReferencesSingular', "Used {0} reference", 1)), context); } - protected init() { - this.hasFollowingContent = this.context.contentIndex + 1 < this.context.content.length; - const referencesLabel = this.labelOverride ?? (this.data.length > 1 ? - localize('usedReferencesPlural', "Used {0} references", this.data.length) : - localize('usedReferencesSingular', "Used {0} reference", 1)); - - const icon = () => { - return this.isExpanded() ? Codicon.chevronDown : Codicon.chevronRight; - }; - const buttonElement = $('.chat-used-context-label', undefined); - - const collapseButton = this._register(new ButtonWithIcon(buttonElement, { - buttonBackground: undefined, - buttonBorder: undefined, - buttonForeground: undefined, - buttonHoverBackground: undefined, - buttonSecondaryBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryHoverBackground: undefined, - buttonSeparator: undefined - })); - this.domNode = $('.chat-used-context', undefined, buttonElement); - collapseButton.label = referencesLabel; - collapseButton.icon = icon(); - this.updateAriaLabel(collapseButton.element, typeof referencesLabel === 'string' ? referencesLabel : referencesLabel.value, this.isExpanded()); - this.domNode.classList.toggle('chat-used-context-collapsed', !this.isExpanded()); - this._register(collapseButton.onDidClick(() => { - this.setExpanded(!this.isExpanded()); - collapseButton.icon = icon(); - this.domNode.classList.toggle('chat-used-context-collapsed', !this.isExpanded()); - this._onDidChangeHeight.fire(); - this.updateAriaLabel(collapseButton.element, typeof referencesLabel === 'string' ? referencesLabel : referencesLabel.value, this.isExpanded()); - })); - + protected override initContent(): HTMLElement { const ref = this._register(this.contentReferencesListPool.get()); const list = ref.object; - this.domNode.appendChild(list.getHTMLElement().parentElement!); this._register(list.onDidOpen((e) => { if (e.element && 'reference' in e.element && typeof e.element.reference === 'object') { @@ -170,28 +129,13 @@ export class ChatCollapsibleListContentPart extends Disposable implements IChatC list.layout(height); list.getHTMLElement().style.height = `${height}px`; list.splice(0, list.length, this.data); + + return list.getHTMLElement().parentElement!; } hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { return other.kind === 'references' && other.references.length === this.data.length && (!!followingContent.length === this.hasFollowingContent); } - - private updateAriaLabel(element: HTMLElement, label: string, expanded?: boolean): void { - element.ariaLabel = expanded ? localize('usedReferencesExpanded', "{0}, expanded", label) : localize('usedReferencesCollapsed', "{0}, collapsed", label); - } - - addDisposable(disposable: IDisposable): void { - this._register(disposable); - } - - private _isExpanded = false; - protected isExpanded(): boolean { - return this._isExpanded; - } - - protected setExpanded(value: boolean): void { - this._isExpanded = value; - } } export interface IChatUsedReferencesListOptions { @@ -211,11 +155,9 @@ export class ChatUsedReferencesListContentPart extends ChatCollapsibleListConten @IContextMenuService contextMenuService: IContextMenuService, ) { super(data, labelOverride, context, contentReferencesListPool, openerService, menuService, instantiationService, contextMenuService); - super.init(); - } - - protected override init(): void { - // prevent it from calling overridden methods until after the constructor runs and this.options is set + if (data.length === 0) { + dom.hide(this.domNode); + } } protected override isExpanded(): boolean { @@ -459,10 +401,10 @@ class CollapsibleListRenderer implements IListRenderer { const chatWidgetService = accessor.get(IChatWidgetService); - const variablesService = accessor.get(IChatVariablesService); - if (!resource) { return; } const widget = chatWidgetService.lastFocusedWidget; - if (!widget) { - return; + if (widget) { + widget.attachmentModel.addFile(resource); } - - variablesService.attachContext('file', resource, widget.location); } }); diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatTaskContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatTaskContentPart.ts index 65f1ab01e16..310130ec951 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatTaskContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatTaskContentPart.ts @@ -18,6 +18,8 @@ export class ChatTaskContentPart extends Disposable implements IChatContentPart public readonly domNode: HTMLElement; public readonly onDidChangeHeight: Event; + private isSettled: boolean; + constructor( private readonly task: IChatTask, contentReferencesListPool: CollapsibleListPool, @@ -28,6 +30,7 @@ export class ChatTaskContentPart extends Disposable implements IChatContentPart super(); if (task.progress.length) { + this.isSettled = true; const refsPart = this._register(instantiationService.createInstance(ChatCollapsibleListContentPart, task.progress, task.content.value, context, contentReferencesListPool)); this.domNode = dom.$('.chat-progress-task'); this.domNode.appendChild(refsPart.domNode); @@ -35,6 +38,7 @@ export class ChatTaskContentPart extends Disposable implements IChatContentPart } else { // #217645 const isSettled = task.isSettled?.() ?? true; + this.isSettled = isSettled; const showSpinner = !isSettled && !context.element.isComplete; const progressPart = this._register(instantiationService.createInstance(ChatProgressContentPart, task, renderer, context, showSpinner, true, undefined)); this.domNode = progressPart.domNode; @@ -45,7 +49,7 @@ export class ChatTaskContentPart extends Disposable implements IChatContentPart hasSameContent(other: IChatProgressRenderableResponseContent): boolean { return other.kind === 'progressTask' && other.progress.length === this.task.progress.length - && other.isSettled() === this.task.isSettled(); + && other.isSettled() === this.isSettled; } addDisposable(disposable: IDisposable): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatToolInputOutputContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatToolInputOutputContentPart.ts new file mode 100644 index 00000000000..a3929a2dfd9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatToolInputOutputContentPart.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Button } from '../../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatRendererContent } from '../../common/chatViewModel.js'; +import { ChatTreeItem, IChatCodeBlockInfo } from '../chat.js'; +import { CodeBlockPart, ICodeBlockData, ICodeBlockRenderOptions } from '../codeBlockPart.js'; +import { IDisposableReference } from './chatCollections.js'; +import { ChatQueryTitlePart } from './chatConfirmationWidget.js'; +import { IChatContentPartRenderContext } from './chatContentParts.js'; +import { EditorPool } from './chatMarkdownContentPart.js'; + +export interface IChatCollapsibleIOCodePart { + textModel: ITextModel; + languageId: string; + options: ICodeBlockRenderOptions; + codeBlockInfo: IChatCodeBlockInfo; +} + +export interface IChatCollapsibleInputData extends IChatCollapsibleIOCodePart { } +export interface IChatCollapsibleOutputData { + // todo: show images etc. here + parts: IChatCollapsibleIOCodePart[]; +} + +export class ChatCollapsibleInputOutputContentPart extends Disposable { + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + private _currentWidth: number = 0; + private readonly _editorReferences: IDisposableReference[] = []; + private readonly _titlePart: ChatQueryTitlePart; + public readonly domNode: HTMLElement; + + readonly codeblocks: IChatCodeBlockInfo[] = []; + + public set title(s: string | IMarkdownString) { + this._titlePart.title = s; + } + + public get title(): string | IMarkdownString { + return this._titlePart.title; + } + + private readonly _expanded: ISettableObservable; + + public get expanded(): boolean { + return this._expanded.get(); + } + + constructor( + title: IMarkdownString | string, + subtitle: string | IMarkdownString | undefined, + private readonly context: IChatContentPartRenderContext, + private readonly editorPool: EditorPool, + private readonly input: IChatCollapsibleInputData, + private readonly output: IChatCollapsibleOutputData | undefined, + isError: boolean, + initiallyExpanded: boolean, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const elements = dom.h('.chat-confirmation-widget@root', [ + dom.h('.chat-confirmation-widget-title.expandable@title', [ + dom.h('.chat-confirmation-widget-expando@expando'), + ]), + dom.h('.chat-confirmation-widget-message@message'), + ]); + this.domNode = elements.root; + + const titlePart = this._titlePart = this._register(instantiationService.createInstance( + ChatQueryTitlePart, + elements.title, + title, + subtitle, + instantiationService.createInstance(MarkdownRenderer, {}), + )); + + this._register(titlePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + const spacer = document.createElement('span'); + spacer.style.flexGrow = '1'; + elements.title.appendChild(spacer); + const check = dom.h(isError + ? ThemeIcon.asCSSSelector(Codicon.error) + : output + ? ThemeIcon.asCSSSelector(Codicon.check) + : ThemeIcon.asCSSSelector(ThemeIcon.modify(Codicon.loading, 'spin')) + ); + elements.title.appendChild(check.root); + + const expanded = this._expanded = observableValue(this, initiallyExpanded); + const btn = this._register(new Button(elements.expando, {})); + + this._register(autorun(r => { + const value = expanded.read(r); + btn.icon = value ? Codicon.chevronDown : Codicon.chevronRight; + elements.root.classList.toggle('collapsed', !value); + this._onDidChangeHeight.fire(); + })); + + this._register(dom.addDisposableGenericMouseDownListener(elements.title, () => { + const value = expanded.get(); + expanded.set(!value, undefined); + })); + + elements.message.appendChild(this.createMessageContents()); + } + + private createMessageContents() { + const contents = dom.h('div', [ + dom.h('h3@inputTitle'), + dom.h('div@input'), + dom.h('h3@outputTitle'), + dom.h('div@output'), + ]); + + const { input, output } = this; + + contents.inputTitle.textContent = localize('chat.input', "Input"); + this.addCodeBlock(input, contents.input); + + if (!output) { + contents.output.remove(); + contents.outputTitle.remove(); + } else { + contents.outputTitle.textContent = localize('chat.output', "Output"); + for (const part of output.parts) { + this.addCodeBlock(part, contents.output); + } + } + + return contents.root; + } + + private addCodeBlock(part: IChatCollapsibleIOCodePart, container: HTMLElement) { + const data: ICodeBlockData = { + languageId: part.languageId, + textModel: Promise.resolve(part.textModel), + codeBlockIndex: part.codeBlockInfo.codeBlockIndex, + codeBlockPartIndex: 0, + element: this.context.element, + parentContextKeyService: this.contextKeyService, + renderOptions: part.options, + chatSessionId: this.context.element.sessionId, + }; + const editorReference = this._register(this.editorPool.get()); + editorReference.object.render(data, this._currentWidth || 300); + this._register(editorReference.object.onDidChangeContentHeight(() => this._onDidChangeHeight.fire())); + container.appendChild(editorReference.object.element); + this._editorReferences.push(editorReference); + } + + hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { + // For now, we consider content different unless it's exactly the same instance + return false; + } + + layout(width: number): void { + this._currentWidth = width; + this._editorReferences.forEach(r => r.object.layout(width)); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatToolInvocationPart.ts index d9070aa1618..fa13aeeed7d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatToolInvocationPart.ts @@ -4,23 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, thenIfNotDisposed, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorunWithStore } from '../../../../../base/common/observable.js'; +import { count } from '../../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { Location } from '../../../../../editor/common/languages.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { localize } from '../../../../../nls.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../../platform/markers/common/markers.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { IChatMarkdownContent, IChatProgressMessage, IChatTerminalToolInvocationData, IChatToolInvocation, IChatToolInvocationSerialized } from '../../common/chatService.js'; import { IChatRendererContent } from '../../common/chatViewModel.js'; import { CodeBlockModelCollection } from '../../common/codeBlockModelCollection.js'; -import { IToolResult } from '../../common/languageModelToolsService.js'; +import { createToolInputUri, createToolSchemaUri, ILanguageModelToolsService, isToolResultInputOutputDetails } from '../../common/languageModelToolsService.js'; import { CancelChatActionId } from '../actions/chatExecuteActions.js'; import { AcceptToolConfirmationActionId } from '../actions/chatToolActions.js'; import { ChatTreeItem, IChatCodeBlockInfo } from '../chat.js'; @@ -30,6 +38,7 @@ import { IChatContentPart, IChatContentPartRenderContext } from './chatContentPa import { ChatMarkdownContentPart, EditorPool } from './chatMarkdownContentPart.js'; import { ChatCustomProgressPart, ChatProgressContentPart } from './chatProgressContentPart.js'; import { ChatCollapsibleListContentPart, CollapsibleListPool, IChatCollapsibleListItem } from './chatReferencesContentPart.js'; +import { ChatCollapsibleInputOutputContentPart, IChatCollapsibleIOCodePart } from './chatToolInputOutputContentPart.js'; export class ChatToolInvocationPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; @@ -48,7 +57,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa private subPart!: ChatToolInvocationSubPart; constructor( - toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + private readonly toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, context: IChatContentPartRenderContext, renderer: MarkdownRenderer, listPool: CollapsibleListPool, @@ -85,7 +94,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa } hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { - return other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized'; + return (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && this.toolInvocation.toolCallId === other.toolCallId; } addDisposable(disposable: IDisposable): void { @@ -95,6 +104,9 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa class ChatToolInvocationSubPart extends Disposable { private static idPool = 0; + /** Remembers expanded tool parts on re-render */ + private static readonly _expandedByDefault = new WeakMap(); + private readonly _codeblocksPartId = 'tool-' + (ChatToolInvocationSubPart.idPool++); public readonly domNode: HTMLElement; @@ -130,6 +142,9 @@ class ChatToolInvocationSubPart extends Disposable { @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, + @ICommandService private readonly commandService: ICommandService, + @IMarkerService private readonly markerService: IMarkerService, ) { super(); @@ -141,8 +156,12 @@ class ChatToolInvocationSubPart extends Disposable { } } else if (toolInvocation.toolSpecificData?.kind === 'terminal') { this.domNode = this.createTerminalMarkdownProgressPart(toolInvocation, toolInvocation.toolSpecificData); - } else if (toolInvocation.resultDetails?.length) { + } else if (Array.isArray(toolInvocation.resultDetails) && toolInvocation.resultDetails?.length) { this.domNode = this.createResultList(toolInvocation.pastTenseMessage ?? toolInvocation.invocationMessage, toolInvocation.resultDetails); + } else if (isToolResultInputOutputDetails(toolInvocation.resultDetails)) { + this.domNode = this.createInputOutputMarkdownProgressPart(toolInvocation.pastTenseMessage ?? toolInvocation.invocationMessage, toolInvocation.originMessage, toolInvocation.resultDetails.input, toolInvocation.resultDetails.output, !!toolInvocation.resultDetails.isError); + } else if (toolInvocation.kind === 'toolInvocation' && toolInvocation.toolSpecificData?.kind === 'input' && !toolInvocation.isComplete) { + this.domNode = this.createInputOutputMarkdownProgressPart(this.toolInvocation.invocationMessage, toolInvocation.originMessage, typeof toolInvocation.toolSpecificData.rawInput === 'string' ? toolInvocation.toolSpecificData.rawInput : JSON.stringify(toolInvocation.toolSpecificData.rawInput, null, 2), undefined, false); } else { this.domNode = this.createProgressPart(); } @@ -156,8 +175,7 @@ class ChatToolInvocationSubPart extends Disposable { if (!toolInvocation.confirmationMessages) { throw new Error('Confirmation messages are missing'); } - const title = toolInvocation.confirmationMessages.title; - const message = toolInvocation.confirmationMessages.message; + const { title, message, allowAutoConfirm } = toolInvocation.confirmationMessages; const continueLabel = localize('continue', "Continue"); const continueKeybinding = this.keybindingService.lookupKeybinding(AcceptToolConfirmationActionId)?.getLabel(); const continueTooltip = continueKeybinding ? `${continueLabel} (${continueKeybinding})` : continueLabel; @@ -165,15 +183,28 @@ class ChatToolInvocationSubPart extends Disposable { const cancelKeybinding = this.keybindingService.lookupKeybinding(CancelChatActionId)?.getLabel(); const cancelTooltip = cancelKeybinding ? `${cancelLabel} (${cancelKeybinding})` : cancelLabel; + const enum ConfirmationOutcome { + Allow, + Disallow, + AllowWorkspace, + AllowGlobally, + AllowSession, + } + const buttons: IChatConfirmationButton[] = [ { label: continueLabel, - data: true, - tooltip: continueTooltip + data: ConfirmationOutcome.Allow, + tooltip: continueTooltip, + moreActions: !allowAutoConfirm ? undefined : [ + { label: localize('allowSession', 'Allow in this Session'), data: ConfirmationOutcome.AllowSession, tooltip: localize('allowSesssionTooltip', 'Allow this tool to run in this session without confirmation.') }, + { label: localize('allowWorkspace', 'Allow in this Workspace'), data: ConfirmationOutcome.AllowWorkspace, tooltip: localize('allowWorkspaceTooltip', 'Allow this tool to run in this workspace without confirmation.') }, + { label: localize('allowGlobally', 'Always Allow'), data: ConfirmationOutcome.AllowGlobally, tooltip: localize('allowGloballTooltip', 'Always allow this tool to run without confirmation.') }, + ], }, { - label: cancelLabel, - data: false, + label: localize('cancel', "Cancel"), + data: ConfirmationOutcome.Disallow, isSecondary: true, tooltip: cancelTooltip }]; @@ -182,6 +213,7 @@ class ChatToolInvocationSubPart extends Disposable { confirmWidget = this._register(this.instantiationService.createInstance( ChatConfirmationWidget, title, + toolInvocation.originMessage, message, buttons )); @@ -198,21 +230,159 @@ class ChatToolInvocationSubPart extends Disposable { wordWrap: 'on' } }; + + const elements = dom.h('div', [ + dom.h('.message@message'), + dom.h('.editor@editor'), + ]); + + if (toolInvocation.toolSpecificData?.kind === 'input') { + + const inputData = toolInvocation.toolSpecificData; + + const codeBlockRenderOptions: ICodeBlockRenderOptions = { + hideToolbar: true, + reserveWidth: 19, + maxHeightInLines: 13, + verticalPadding: 5, + editorOptions: { + wordWrap: 'on', + readOnly: false + } + }; + + const langId = this.languageService.getLanguageIdByLanguageName('json'); + const rawJsonInput = JSON.stringify(inputData.rawInput ?? {}, null, 1); + const canSeeMore = count(rawJsonInput, '\n') > 2; // if more than one key:value + const model = this._register(this.modelService.createModel( + // View a single JSON line by default until they 'see more' + rawJsonInput.replace(/\n */g, ' '), + this.languageService.createById(langId), + createToolInputUri(toolInvocation.toolId) + )); + + const markerOwner = generateUuid(); + const schemaUri = createToolSchemaUri(toolInvocation.toolId); + const validator = new RunOnceScheduler(async () => { + + const newMarker: IMarkerData[] = []; + + const result = await this.commandService.executeCommand('json.validate', schemaUri, model.getValue()); + for (const item of result) { + if (item.range && item.message) { + newMarker.push({ + severity: item.severity === 'Error' ? MarkerSeverity.Error : MarkerSeverity.Warning, + message: item.message, + startLineNumber: item.range[0].line + 1, + startColumn: item.range[0].character + 1, + endLineNumber: item.range[1].line + 1, + endColumn: item.range[1].character + 1, + code: item.code ? String(item.code) : undefined + }); + } + } + + this.markerService.changeOne(markerOwner, model.uri, newMarker); + }, 500); + + validator.schedule(); + this._register(model.onDidChangeContent(() => validator.schedule())); + this._register(toDisposable(() => this.markerService.remove(markerOwner, [model.uri]))); + this._register(validator); + + const editor = this._register(this.editorPool.get()); + editor.object.render({ + codeBlockIndex: this.codeBlockStartIndex, + codeBlockPartIndex: 0, + element: this.context.element, + languageId: langId ?? 'json', + renderOptions: codeBlockRenderOptions, + textModel: Promise.resolve(model), + chatSessionId: this.context.element.sessionId + }, this.currentWidthDelegate()); + this._codeblocks.push({ + codeBlockIndex: this.codeBlockStartIndex, + codemapperUri: undefined, + elementId: this.context.element.id, + focus: () => editor.object.focus(), + isStreaming: false, + ownerMarkdownPartId: this.codeblocksPartId, + uri: model.uri, + uriPromise: Promise.resolve(model.uri), + chatSessionId: this.context.element.sessionId + }); + this._register(editor.object.onDidChangeContentHeight(() => { + editor.object.layout(this.currentWidthDelegate()); + this._onDidChangeHeight.fire(); + })); + this._register(model.onDidChangeContent(e => { + try { + inputData.rawInput = JSON.parse(model.getValue()); + } catch { + // ignore + } + })); + + elements.editor.append(editor.object.element); + + if (canSeeMore) { + const seeMore = dom.h('div.see-more', [dom.h('a@link')]); + seeMore.link.textContent = localize('seeMore', "See more"); + this._register(dom.addDisposableGenericMouseDownListener(seeMore.link, () => { + try { + const parsed = JSON.parse(model.getValue()); + model.setValue(JSON.stringify(parsed, null, 2)); + } catch { + // ignored + } + seeMore.root.remove(); + })); + elements.editor.append(seeMore.root); + } + } + this.markdownPart = this._register(this.instantiationService.createInstance(ChatMarkdownContentPart, chatMarkdownContent, this.context, this.editorPool, false, this.codeBlockStartIndex, this.renderer, this.currentWidthDelegate(), this.codeBlockModelCollection, { codeBlockRenderOptions })); + elements.message.append(this.markdownPart.domNode); + this._register(this.markdownPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); confirmWidget = this._register(this.instantiationService.createInstance( ChatCustomConfirmationWidget, title, - this.markdownPart.domNode, + toolInvocation.originMessage, + elements.root, buttons )); } + const hasToolConfirmation = ChatContextKeys.Editing.hasToolConfirmation.bindTo(this.contextKeyService); + hasToolConfirmation.set(true); + this._register(confirmWidget.onDidClick(button => { - toolInvocation.confirmed.complete(button.data); + switch (button.data as ConfirmationOutcome) { + case ConfirmationOutcome.AllowGlobally: + this.languageModelToolsService.setToolAutoConfirmation(toolInvocation.toolId, 'profile', true); + toolInvocation.confirmed.complete(true); + break; + case ConfirmationOutcome.AllowWorkspace: + this.languageModelToolsService.setToolAutoConfirmation(toolInvocation.toolId, 'workspace', true); + toolInvocation.confirmed.complete(true); + break; + case ConfirmationOutcome.AllowSession: + this.languageModelToolsService.setToolAutoConfirmation(toolInvocation.toolId, 'memory', true); + toolInvocation.confirmed.complete(true); + break; + case ConfirmationOutcome.Allow: + toolInvocation.confirmed.complete(true); + break; + case ConfirmationOutcome.Disallow: + toolInvocation.confirmed.complete(false); + break; + } })); this._register(confirmWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + this._register(toDisposable(() => hasToolConfirmation.reset())); toolInvocation.confirmed.p.then(() => { + hasToolConfirmation.reset(); this._onNeedsRerender.fire(); }); return confirmWidget.domNode; @@ -259,14 +429,16 @@ class ChatToolInvocationSubPart extends Disposable { const langId = this.languageService.getLanguageIdByLanguageName(terminalData.language ?? 'sh') ?? 'shellscript'; const model = this.modelService.createModel(terminalData.command, this.languageService.createById(langId)); const editor = this._register(this.editorPool.get()); - editor.object.render({ + const renderPromise = editor.object.render({ codeBlockIndex: this.codeBlockStartIndex, codeBlockPartIndex: 0, element: this.context.element, languageId: langId, renderOptions: codeBlockRenderOptions, - textModel: Promise.resolve(model) + textModel: Promise.resolve(model), + chatSessionId: this.context.element.sessionId }, this.currentWidthDelegate()); + this._register(thenIfNotDisposed(renderPromise, () => this._onDidChangeHeight.fire())); this._codeblocks.push({ codeBlockIndex: this.codeBlockStartIndex, codemapperUri: undefined, @@ -275,7 +447,8 @@ class ChatToolInvocationSubPart extends Disposable { isStreaming: false, ownerMarkdownPartId: this.codeblocksPartId, uri: model.uri, - uriPromise: Promise.resolve(model.uri) + uriPromise: Promise.resolve(model.uri), + chatSessionId: this.context.element.sessionId }); this._register(editor.object.onDidChangeContentHeight(() => { editor.object.layout(this.currentWidthDelegate()); @@ -290,6 +463,7 @@ class ChatToolInvocationSubPart extends Disposable { const confirmWidget = this._register(this.instantiationService.createInstance( ChatCustomConfirmationWidget, title, + undefined, element, buttons )); @@ -307,27 +481,37 @@ class ChatToolInvocationSubPart extends Disposable { } private createProgressPart(): HTMLElement { - let content: IMarkdownString; if (this.toolInvocation.isComplete && this.toolInvocation.isConfirmed !== false && this.toolInvocation.pastTenseMessage) { - content = typeof this.toolInvocation.pastTenseMessage === 'string' ? - new MarkdownString().appendText(this.toolInvocation.pastTenseMessage) : - this.toolInvocation.pastTenseMessage; + const part = this.renderProgressContent(this.toolInvocation.pastTenseMessage); + this._register(part); + return part.domNode; } else { - content = typeof this.toolInvocation.invocationMessage === 'string' ? - new MarkdownString().appendText(this.toolInvocation.invocationMessage + '…') : - MarkdownString.lift(this.toolInvocation.invocationMessage).appendText('…'); + const container = document.createElement('div'); + const progressObservable = this.toolInvocation.kind === 'toolInvocation' ? this.toolInvocation.progress : undefined; + this._register(autorunWithStore((reader, store) => { + const progress = progressObservable?.read(reader); + const part = store.add(this.renderProgressContent(progress?.message || this.toolInvocation.invocationMessage)); + dom.reset(container, part.domNode); + })); + return container; + } + } + + private renderProgressContent(content: IMarkdownString | string) { + if (typeof content === 'string') { + content = new MarkdownString().appendText(content); } const progressMessage: IChatProgressMessage = { kind: 'progressMessage', content }; + const iconOverride = !this.toolInvocation.isConfirmed ? Codicon.error : this.toolInvocation.isComplete ? Codicon.check : undefined; - const progressPart = this._register(this.instantiationService.createInstance(ChatProgressContentPart, progressMessage, this.renderer, this.context, undefined, true, iconOverride)); - return progressPart.domNode; + return this.instantiationService.createInstance(ChatProgressContentPart, progressMessage, this.renderer, this.context, undefined, true, iconOverride); } private createTerminalMarkdownProgressPart(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, terminalData: IChatTerminalToolInvocationData): HTMLElement { @@ -355,9 +539,71 @@ class ChatToolInvocationSubPart extends Disposable { return progressPart.domNode; } + private createInputOutputMarkdownProgressPart(message: string | IMarkdownString, subtitle: string | IMarkdownString | undefined, input: string, output: string | undefined, isError: boolean): HTMLElement { + let codeBlockIndex = this.codeBlockStartIndex; + const toCodePart = (data: string): IChatCollapsibleIOCodePart => { + const model = this._register(this.modelService.createModel( + data, + this.languageService.createById('json') + )); + + return { + textModel: model, + languageId: model.getLanguageId(), + options: { + hideToolbar: true, + reserveWidth: 19, + maxHeightInLines: 13, + verticalPadding: 5, + editorOptions: { + wordWrap: 'on' + } + }, + codeBlockInfo: { + codeBlockIndex: codeBlockIndex++, + codemapperUri: undefined, + elementId: this.context.element.id, + focus: () => { }, + isStreaming: false, + ownerMarkdownPartId: this.codeblocksPartId, + uri: model.uri, + chatSessionId: this.context.element.sessionId, + uriPromise: Promise.resolve(model.uri) + } + }; + }; + + const collapsibleListPart = this._register(this.instantiationService.createInstance( + ChatCollapsibleInputOutputContentPart, + message, + subtitle, + this.context, + this.editorPool, + toCodePart(input), + output ? { parts: [toCodePart(output)] } : undefined, + isError, + ChatToolInvocationSubPart._expandedByDefault.get(this.toolInvocation) ?? false, + )); + this._codeblocks.push(...collapsibleListPart.codeblocks); + this._register(collapsibleListPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + this._register(toDisposable(() => ChatToolInvocationSubPart._expandedByDefault.set(this.toolInvocation, collapsibleListPart.expanded))); + + const progressObservable = this.toolInvocation.kind === 'toolInvocation' ? this.toolInvocation.progress : undefined; + if (progressObservable) { + this._register(autorunWithStore((reader, store) => { + const progress = progressObservable?.read(reader); + if (progress.message) { + collapsibleListPart.title = progress.message; + } + })); + } + + return collapsibleListPart.domNode; + } + private createResultList( message: string | IMarkdownString, - toolDetails: NonNullable, + toolDetails: Array, ): HTMLElement { const collapsibleListPart = this._register(this.instantiationService.createInstance( ChatCollapsibleListContentPart, diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatConfirmationWidget.css b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatConfirmationWidget.css index 4d711ebf783..5672cea19b7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatConfirmationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatConfirmationWidget.css @@ -6,19 +6,88 @@ .chat-confirmation-widget { border: 1px solid var(--vscode-chat-requestBorder); border-radius: 4px; - padding: 8px 12px 12px; + padding: 3px; + display: flex; + flex-wrap: wrap; + align-items: center; } .chat-confirmation-widget:not(:last-child) { margin-bottom: 16px; } -.chat-confirmation-widget .chat-confirmation-widget-title { - font-weight: 600; +.chat-confirmation-widget .chat-confirmation-widget-title { + display: flex; + align-items: center; + flex-wrap: wrap; + width: 100%; + border-radius: 3px; + padding: 3px 8px; + user-select: none; + gap: 4px; +} + +.chat-confirmation-widget .chat-confirmation-widget-title.expandable { + cursor: pointer; + margin-left: 0; +} + +.chat-confirmation-widget .chat-confirmation-widget-title.expandable:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.chat-confirmation-widget .chat-confirmation-widget-title p, +.chat-confirmation-widget .chat-confirmation-widget-title .rendered-markdown { + display: inline; } .chat-confirmation-widget .chat-confirmation-widget-title p { - margin: 0 0 4px 0; + margin: 0 !important; +} +.chat-confirmation-widget .chat-confirmation-widget-title .codicon-check { + color: var(--vscode-debugIcon-startForeground) !important; +} +.chat-confirmation-widget .chat-confirmation-widget-title .codicon-error { + color: var(--vscode-errorForeground) !important; +} + +.chat-confirmation-widget .chat-confirmation-widget-title .chat-confirmation-widget-expando { + display: flex; + align-items: center; +} + +.chat-confirmation-widget-message h3 { + font-weight: 600; + margin: 4px 0 8px; + font-size: 14px; +} + +.chat-confirmation-widget .chat-confirmation-widget-title .rendered-markdown p a { + color: inherit; +} + +.chat-confirmation-widget-title small { + font-size: 1em; + opacity: 0.85; + + &::before { + content: ' \2013 '; + } +} + +.chat-confirmation-widget .chat-confirmation-buttons-container, +.chat-confirmation-widget .chat-confirmation-widget-message { + flex-basis: 100%; + padding: 0 8px; + margin: 8px 0; + + &:last-child { + margin-bottom: 0; + } +} + +.chat-confirmation-widget.collapsed .chat-confirmation-widget-message { + display: none; } .chat-confirmation-widget .chat-confirmation-widget-message .rendered-markdown p { @@ -29,11 +98,26 @@ margin-bottom: 0px; } +.chat-confirmation-widget .chat-confirmation-widget-message .see-more { + margin-top: -4px; + + a { + color: var(--vscode-textLink-foreground); + text-decoration: underline; + display: block; + cursor: pointer; + } +} + .chat-confirmation-widget .chat-confirmation-buttons-container { display: flex; gap: 8px; - margin-top: 13px; + margin-top: 0px; flex-wrap: wrap; + + &:last-child { + margin-bottom: 8px; + } } .chat-confirmation-widget.hideButtons .chat-confirmation-buttons-container { diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatExtensionsContent.css b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatExtensionsContent.css new file mode 100644 index 00000000000..f9be266abd3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatExtensionsContent.css @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-extensions-content-part { + border: 1px solid var(--vscode-chat-requestBorder); + border-bottom: none; + border-radius: 4px; +} + +.chat-extensions-content-part .extension-list-item { + border-bottom: 1px solid var(--vscode-chat-requestBorder); +} + +.chat-extensions-content-part .loading-extensions-element { + line-height: 18px; + padding: 4px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + user-select: none; + border-bottom: 1px solid var(--vscode-chat-requestBorder); +} + +.chat-extensions-content-part .loading-extensions-element .loading-message { + padding-left: 4px; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDragAndDrop.ts b/src/vs/workbench/contrib/chat/browser/chatDragAndDrop.ts index 0de0ecc7d07..26ee81a355a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDragAndDrop.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDragAndDrop.ts @@ -7,29 +7,28 @@ import { DataTransfers } from '../../../../base/browser/dnd.js'; import { $, DragAndDropObserver } from '../../../../base/browser/dom.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { coalesce } from '../../../../base/common/arrays.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { UriList } from '../../../../base/common/dataTransfer.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { Mimes } from '../../../../base/common/mime.js'; -import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { IRange } from '../../../../editor/common/core/range.js'; -import { SymbolKinds } from '../../../../editor/common/languages.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../nls.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { CodeDataTransfers, containsDragType, DocumentSymbolTransferData, extractEditorsDropData, extractMarkerDropData, extractSymbolDropData, IDraggedResourceEditorInput, MarkerTransferData } from '../../../../platform/dnd/browser/dnd.js'; +import { CodeDataTransfers, containsDragType, extractEditorsDropData, extractMarkerDropData, extractNotebookCellOutputDropData, extractSymbolDropData } from '../../../../platform/dnd/browser/dnd.js'; import { IFileService } from '../../../../platform/files/common/files.js'; -import { MarkerSeverity } from '../../../../platform/markers/common/markers.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js'; -import { isUntitledResourceEditorInput } from '../../../common/editor.js'; -import { EditorInput } from '../../../common/editor/editorInput.js'; +import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js'; -import { UntitledTextEditorInput } from '../../../services/untitled/common/untitledTextEditorInput.js'; -import { IChatRequestVariableEntry, IDiagnosticVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry } from '../common/chatModel.js'; +import { IChatRequestVariableEntry } from '../common/chatModel.js'; +import { IChatWidgetService } from './chat.js'; +import { ImageTransferData, resolveEditorAttachContext, resolveImageAttachContext, resolveMarkerAttachContext, resolveNotebookOutputAttachContext, resolveSymbolsAttachContext } from './chatAttachmentResolve.js'; import { ChatAttachmentModel } from './chatAttachmentModel.js'; import { IChatInputStyles } from './chatInputPart.js'; -import { resizeImage } from './imageUtils.js'; +import { convertStringToUInt8Array } from './imageUtils.js'; enum ChatDragAndDropType { FILE_INTERNAL, @@ -39,8 +38,12 @@ enum ChatDragAndDropType { SYMBOL, HTML, MARKER, + NOTEBOOK_CELL_OUTPUT } +const IMAGE_DATA_REGEX = /^data:image\/[a-z]+;base64,/; +const URL_REGEX = /^https?:\/\/.+/; + export class ChatDragAndDrop extends Themable { private readonly overlays: Map = new Map(); @@ -55,7 +58,10 @@ export class ChatDragAndDrop extends Themable { @IFileService private readonly fileService: IFileService, @IEditorService private readonly editorService: IEditorService, @IDialogService private readonly dialogService: IDialogService, - @ITextModelService private readonly textModelService: ITextModelService + @ITextModelService private readonly textModelService: ITextModelService, + @ISharedWebContentExtractorService private readonly webContentExtractorService: ISharedWebContentExtractorService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @ILogService private readonly logService: ILogService, ) { super(themeService); @@ -145,7 +151,7 @@ export class ChatDragAndDrop extends Themable { } private async drop(e: DragEvent): Promise { - const contexts = await this.getAttachContext(e); + const contexts = await this.resolveAttachmentsFromDragEvent(e); if (contexts.length === 0) { return; } @@ -163,11 +169,13 @@ export class ChatDragAndDrop extends Themable { } private guessDropType(e: DragEvent): ChatDragAndDropType | undefined { - // This is an esstimation based on the datatransfer types/items - if (this.isImageDnd(e)) { + // This is an estimation based on the datatransfer types/items + if (containsDragType(e, CodeDataTransfers.NOTEBOOK_CELL_OUTPUT)) { + return ChatDragAndDropType.NOTEBOOK_CELL_OUTPUT; + } else if (containsImageDragType(e)) { return this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? ChatDragAndDropType.IMAGE : undefined; - // } else if (containsDragType(e, 'text/html')) { - // return ChatDragAndDropType.HTML; + } else if (containsDragType(e, 'text/html')) { + return ChatDragAndDropType.HTML; } else if (containsDragType(e, CodeDataTransfers.SYMBOLS)) { return ChatDragAndDropType.SYMBOL; } else if (containsDragType(e, CodeDataTransfers.MARKERS)) { @@ -198,189 +206,126 @@ export class ChatDragAndDrop extends Themable { case ChatDragAndDropType.SYMBOL: return localize('symbol', 'Symbol'); case ChatDragAndDropType.MARKER: return localize('problem', 'Problem'); case ChatDragAndDropType.HTML: return localize('url', 'URL'); + case ChatDragAndDropType.NOTEBOOK_CELL_OUTPUT: return localize('notebookOutput', 'Output'); } } - private isImageDnd(e: DragEvent): boolean { - // Image detection should not have false positives, only false negatives are allowed - if (containsDragType(e, 'image')) { - return true; - } - - if (containsDragType(e, DataTransfers.FILES)) { - const files = e.dataTransfer?.files; - if (files && files.length > 0) { - const file = files[0]; - return file.type.startsWith('image/'); - } - - const items = e.dataTransfer?.items; - if (items && items.length > 0) { - const item = items[0]; - return item.type.startsWith('image/'); - } - } - - return false; - } - - private async getAttachContext(e: DragEvent): Promise { + private async resolveAttachmentsFromDragEvent(e: DragEvent): Promise { if (!this.isDragEventSupported(e)) { return []; } + if (containsDragType(e, CodeDataTransfers.NOTEBOOK_CELL_OUTPUT)) { + const notebookOutputData = extractNotebookCellOutputDropData(e); + if (notebookOutputData) { + return resolveNotebookOutputAttachContext(notebookOutputData, this.editorService); + } + } + const markerData = extractMarkerDropData(e); if (markerData) { - return this.resolveMarkerAttachContext(markerData); + return resolveMarkerAttachContext(markerData); } if (containsDragType(e, CodeDataTransfers.SYMBOLS)) { - const data = extractSymbolDropData(e); - return this.resolveSymbolsAttachContext(data); + const symbolsData = extractSymbolDropData(e); + return resolveSymbolsAttachContext(symbolsData); } - // Removing HTML support for now - // if (containsDragType(e, 'text/html')) { - // const data = e.dataTransfer?.getData('text/html'); - // return data ? this.resolveHTMLAttachContext(data) : []; - // } + const editorDragData = extractEditorsDropData(e); + if (editorDragData.length > 0) { + return coalesce(await Promise.all(editorDragData.map(editorInput => { + return resolveEditorAttachContext(editorInput, this.fileService, this.editorService, this.textModelService, this.extensionService, this.dialogService); + }))); + } - const data = extractEditorsDropData(e); - return coalesce(await Promise.all(data.map(editorInput => { - return this.resolveAttachContext(editorInput); - }))); + if (!containsDragType(e, DataTransfers.INTERNAL_URI_LIST) && containsDragType(e, Mimes.uriList) && ((containsDragType(e, Mimes.html) || containsDragType(e, Mimes.text) /* Text mime needed for safari support */))) { + return this.resolveHTMLAttachContext(e); + } + + return []; } - private async resolveAttachContext(editorInput: IDraggedResourceEditorInput): Promise { - // Image - const imageContext = await getImageAttachContext(editorInput, this.fileService, this.dialogService); - if (imageContext) { - return this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? imageContext : undefined; - } - - // File - return await this.getEditorAttachContext(editorInput); - } - - private async getEditorAttachContext(editor: EditorInput | IDraggedResourceEditorInput): Promise { - - // untitled editor - if (isUntitledResourceEditorInput(editor)) { - return await this.resolveUntitledAttachContext(editor); - } - - if (!editor.resource) { - return undefined; - } - - let stat; + private async downloadImageAsUint8Array(url: string): Promise { try { - stat = await this.fileService.stat(editor.resource); - } catch { - return undefined; - } - - if (!stat.isDirectory && !stat.isFile) { - return undefined; - } - - return await getResourceAttachContext(editor.resource, stat.isDirectory, this.textModelService); - } - - private async resolveUntitledAttachContext(editor: IDraggedResourceEditorInput): Promise { - // If the resource is known, we can use it directly - if (editor.resource) { - return await getResourceAttachContext(editor.resource, false, this.textModelService); - } - - // Otherwise, we need to check if the contents are already open in another editor - const openUntitledEditors = this.editorService.editors.filter(editor => editor instanceof UntitledTextEditorInput) as UntitledTextEditorInput[]; - for (const canidate of openUntitledEditors) { - const model = await canidate.resolve(); - const contents = model.textEditorModel?.getValue(); - if (contents === editor.contents) { - return await getResourceAttachContext(canidate.resource, false, this.textModelService); + const extractedImages = await this.webContentExtractorService.readImage(URI.parse(url), CancellationToken.None); + if (extractedImages) { + return extractedImages.buffer; } + } catch (error) { + this.logService.warn('Fetch failed:', error); } + // TODO: use dnd provider to insert text @justschen + const selection = this.chatWidgetService.lastFocusedWidget?.inputEditor.getSelection(); + if (selection && this.chatWidgetService.lastFocusedWidget) { + this.chatWidgetService.lastFocusedWidget.inputEditor.executeEdits('chatInsertUrl', [{ range: selection, text: url }]); + } + + this.logService.warn(`Image URLs must end in .jpg, .png, .gif, .webp, or .bmp. Failed to fetch image from this URL: ${url}`); return undefined; } - private resolveSymbolsAttachContext(symbols: DocumentSymbolTransferData[]): ISymbolVariableEntry[] { - return symbols.map(symbol => { - const resource = URI.file(symbol.fsPath); - return { - kind: 'symbol', - id: symbolId(resource, symbol.range), - value: { uri: resource, range: symbol.range }, - symbolKind: symbol.kind, - fullName: `$(${SymbolKinds.toIcon(symbol.kind).id}) ${symbol.name}`, - name: symbol.name, - }; - }); - } + private async resolveHTMLAttachContext(e: DragEvent): Promise { + const existingAttachmentNames = new Set(this.attachmentModel.attachments.map(attachment => attachment.name)); + const createDisplayName = (): string => { + const baseName = localize('dragAndDroppedImageName', 'Image from URL'); + let uniqueName = baseName; + let baseNameInstance = 1; - // private async resolveHTMLAttachContext(data: string): Promise { - // const displayName = localize('dragAndDroppedImageName', 'Image from URL'); - // let finalDisplayName = displayName; - - // for (let appendValue = 2; this.attachmentModel.attachments.some(attachment => attachment.name === finalDisplayName); appendValue++) { - // finalDisplayName = `${displayName} ${appendValue}`; - // } - - // const { src, alt } = extractImageAttributes(data); - // finalDisplayName = alt ?? finalDisplayName; - - // if (/^data:image\/[a-z]+;base64,/.test(src)) { - // const resizedImage = await resizeImage(src); - // return [{ - // id: await imageToHash(resizedImage), - // name: finalDisplayName, - // value: resizedImage, - // isImage: true, - // isFile: false, - // isDirectory: false - // }]; - // } else if (/^https?:\/\/.+/.test(src)) { - // const url = new URL(src); - // const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(url.pathname); - // if (isImage) { - // const buffer = convertStringToUInt8Array(src); - // return [{ - // kind: 'image', - // id: url.toString(), - // name: finalDisplayName, - // value: buffer, - // isImage, - // isFile: false, - // isDirectory: false, - // isURL: true, - // }]; - // } else { - // return [{ - // kind: 'link', - // id: url.toString(), - // name: finalDisplayName, - // value: URI.parse(url.toString()), - // isFile: false, - // isDirectory: false, - // }]; - // } - // } - // return []; - // } - - private resolveMarkerAttachContext(markers: MarkerTransferData[]): IDiagnosticVariableEntry[] { - return markers.map((marker): IDiagnosticVariableEntry => { - let filter: IDiagnosticVariableEntryFilterData; - if (!('severity' in marker)) { - filter = { filterUri: URI.revive(marker.uri), filterSeverity: MarkerSeverity.Warning }; - } else { - filter = IDiagnosticVariableEntryFilterData.fromMarker(marker); + while (existingAttachmentNames.has(uniqueName)) { + uniqueName = `${baseName} ${++baseNameInstance}`; } - return IDiagnosticVariableEntryFilterData.toEntry(filter); - }); + existingAttachmentNames.add(uniqueName); + return uniqueName; + }; + + const getImageTransferDataFromUrl = async (url: string): Promise => { + const resource = URI.parse(url); + + if (IMAGE_DATA_REGEX.test(url)) { + return { data: convertStringToUInt8Array(url), name: createDisplayName(), resource }; + } + + if (URL_REGEX.test(url)) { + const data = await this.downloadImageAsUint8Array(url); + if (data) { + return { data, name: createDisplayName(), resource, id: url }; + } + } + + return undefined; + }; + + const getImageTransferDataFromFile = async (file: File): Promise => { + try { + const buffer = await file.arrayBuffer(); + return { data: new Uint8Array(buffer), name: createDisplayName() }; + } catch (error) { + this.logService.error('Error reading file:', error); + } + + return undefined; + }; + + const imageTransferData: ImageTransferData[] = []; + + // Image Web File Drag and Drop + const imageFiles = extractImageFilesFromDragEvent(e); + if (imageFiles.length) { + const imageTransferDataFromFiles = await Promise.all(imageFiles.map(file => getImageTransferDataFromFile(file))); + imageTransferData.push(...imageTransferDataFromFiles.filter(data => !!data)); + } + + // Image Web URL Drag and Drop + const imageUrls = extractUrlsFromDragEvent(e); + if (imageUrls.length) { + const imageTransferDataFromUrl = await Promise.all(imageUrls.map(getImageTransferDataFromUrl)); + imageTransferData.push(...imageTransferDataFromUrl.filter(data => !!data)); + } + + return await resolveImageAttachContext(imageTransferData); } private setOverlay(target: HTMLElement, type: ChatDragAndDropType | undefined): void { @@ -424,81 +369,49 @@ export class ChatDragAndDrop extends Themable { } } -async function getResourceAttachContext(resource: URI, isDirectory: boolean, textModelService: ITextModelService): Promise { - let isOmitted = false; +function containsImageDragType(e: DragEvent): boolean { + // Image detection should not have false positives, only false negatives are allowed + if (containsDragType(e, 'image')) { + return true; + } - if (!isDirectory) { + if (containsDragType(e, DataTransfers.FILES)) { + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + return Array.from(files).some(file => file.type.startsWith('image/')); + } + + const items = e.dataTransfer?.items; + if (items && items.length > 0) { + return Array.from(items).some(item => item.type.startsWith('image/')); + } + } + + return false; +} + +function extractUrlsFromDragEvent(e: DragEvent, logService?: ILogService): string[] { + const textUrl = e.dataTransfer?.getData('text/uri-list'); + if (textUrl) { try { - const createdModel = await textModelService.createModelReference(resource); - createdModel.dispose(); - } catch { - isOmitted = true; - } - - if (/\.(svg)$/i.test(resource.path)) { - isOmitted = true; + const urls = UriList.parse(textUrl); + if (urls.length > 0) { + return urls; + } + } catch (error) { + logService?.error('Error parsing URI list:', error); + return []; } } - return { - value: resource, - id: resource.toString(), - name: basename(resource), - isFile: !isDirectory, - isDirectory, - isOmitted - }; + return []; } -async function getImageAttachContext(editor: EditorInput | IDraggedResourceEditorInput, fileService: IFileService, dialogService: IDialogService): Promise { - if (!editor.resource) { - return undefined; +function extractImageFilesFromDragEvent(e: DragEvent): File[] { + const files = e.dataTransfer?.files; + if (!files) { + return []; } - if (/\.(png|jpg|jpeg|gif|webp)$/i.test(editor.resource.path)) { - const fileName = basename(editor.resource); - const readFile = await fileService.readFile(editor.resource); - if (readFile.size > 30 * 1024 * 1024) { // 30 MB - dialogService.error(localize('imageTooLarge', 'Image is too large'), localize('imageTooLargeMessage', 'The image {0} is too large to be attached.', fileName)); - throw new Error('Image is too large'); - } - const resizedImage = await resizeImage(readFile.value.buffer); - return { - id: editor.resource.toString(), - name: fileName, - fullName: editor.resource.path, - value: resizedImage, - icon: Codicon.fileMedia, - isImage: true, - isFile: false, - references: [{ reference: editor.resource, kind: 'reference' }] - }; - } - - return undefined; + return Array.from(files).filter(file => file.type.startsWith('image/')); } - -function symbolId(resource: URI, range?: IRange): string { - let rangePart = ''; - if (range) { - rangePart = `:${range.startLineNumber}`; - if (range.startLineNumber !== range.endLineNumber) { - rangePart += `-${range.endLineNumber}`; - } - } - return resource.fsPath + rangePart; -} - -// function extractImageAttributes(html: string): { src: string; alt?: string } { -// const imgTagRegex = /]+src=["']([^"']+)["'][^>]*>/; -// const altRegex = /alt=["']([^"']+)["']/; - -// const match = imgTagRegex.exec(html); -// if (match) { -// const src = match[1]; -// const altMatch = match[0].match(altRegex); -// return { src, alt: altMatch ? altMatch[1] : undefined }; -// } - -// return { src: '', alt: undefined }; -// } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 246c250124d..bc7590c5bce 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -23,12 +23,11 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { IListService } from '../../../../../platform/list/browser/listService.js'; -import { GroupsOrder, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { IEditorPane } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { isChatViewTitleActionContext } from '../../common/chatActions.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; -import { applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingResourceContextKey, chatEditingWidgetFileStateContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, WorkingSetEntryRemovalReason, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingResourceContextKey, chatEditingWidgetFileStateContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { IChatService } from '../../common/chatService.js'; import { isRequestVM, isResponseVM } from '../../common/chatViewModel.js'; import { ChatAgentLocation, ChatMode } from '../../common/constants.js'; @@ -60,17 +59,11 @@ export function getEditingSessionContext(accessor: ServicesAccessor, args: any[] const arg0 = args.at(0); const context = isChatViewTitleActionContext(arg0) ? arg0 : undefined; - const chatService = accessor.get(IChatService); const chatWidgetService = accessor.get(IChatWidgetService); const chatEditingService = accessor.get(IChatEditingService); let chatWidget = context ? chatWidgetService.getWidgetBySessionId(context.sessionId) : undefined; if (!chatWidget) { - if (chatService.unifiedViewEnabled) { - // TODO ugly - chatWidget = chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel).find(w => w.isUnifiedPanelWidget); - } else { - chatWidget = chatWidgetService.getWidgetsByLocations(ChatAgentLocation.EditingSession).at(0); - } + chatWidget = chatWidgetService.lastFocusedWidget ?? chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel).find(w => w.supportsChangingModes); } if (!chatWidget?.viewModel) { @@ -127,7 +120,7 @@ registerAction2(class RemoveFileFromWorkingSet extends WorkingSetAction { async runWorkingSetAction(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, chatWidget: IChatWidget, ...uris: URI[]): Promise { const dialogService = accessor.get(IDialogService); - const pendingEntries = currentEditingSession.entries.get().filter((entry) => uris.includes(entry.modifiedURI) && entry.state.get() === WorkingSetEntryState.Modified); + const pendingEntries = currentEditingSession.entries.get().filter((entry) => uris.includes(entry.modifiedURI) && entry.state.get() === ModifiedFileEntryState.Modified); if (pendingEntries.length > 0) { // Ask for confirmation if there are any pending edits const file = pendingEntries.length > 1 @@ -146,7 +139,7 @@ registerAction2(class RemoveFileFromWorkingSet extends WorkingSetAction { // Remove from working set await currentEditingSession.reject(...uris); - currentEditingSession.remove(WorkingSetEntryRemovalReason.User, ...uris); + currentEditingSession.remove(...uris); // Remove from chat input part for (const uri of uris) { @@ -168,7 +161,7 @@ registerAction2(class OpenFileInDiffAction extends WorkingSetAction { icon: Codicon.diffSingle, menu: [{ id: MenuId.ChatEditingWidgetModifiedFilesToolbar, - when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Modified), + when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, ModifiedFileEntryState.Modified), order: 2, group: 'navigation' }], @@ -177,16 +170,21 @@ registerAction2(class OpenFileInDiffAction extends WorkingSetAction { async runWorkingSetAction(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, _chatWidget: IChatWidget, ...uris: URI[]): Promise { const editorService = accessor.get(IEditorService); + + for (const uri of uris) { - const editedFile = currentEditingSession.getEntry(uri); - if (editedFile?.state.get() === WorkingSetEntryState.Modified) { - await editorService.openEditor({ - original: { resource: URI.from(editedFile.originalURI, true) }, - modified: { resource: URI.from(editedFile.modifiedURI, true) }, - }); - } else { - await editorService.openEditor({ resource: uri }); + + let pane: IEditorPane | undefined = editorService.activeEditorPane; + if (!pane) { + pane = await editorService.openEditor({ resource: uri }); } + + if (!pane) { + return; + } + + const editedFile = currentEditingSession.getEntry(uri); + editedFile?.getEditorIntegration(pane).toggleDiff(undefined, true); } } }); @@ -205,7 +203,7 @@ registerAction2(class AcceptAction extends WorkingSetAction { group: 'navigation', }, { id: MenuId.ChatEditingWidgetModifiedFilesToolbar, - when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Modified), + when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, ModifiedFileEntryState.Modified), order: 0, group: 'navigation' }], @@ -231,7 +229,7 @@ registerAction2(class DiscardAction extends WorkingSetAction { group: 'navigation', }, { id: MenuId.ChatEditingWidgetModifiedFilesToolbar, - when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Modified), + when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, ModifiedFileEntryState.Modified), order: 1, group: 'navigation' }], @@ -330,6 +328,7 @@ export async function discardAllEditsWithConfirmation(accessor: ServicesAccessor return true; } +// TODO@roblourens this may be obsolete? export class ChatEditingRemoveAllFilesAction extends EditingSessionAction { static readonly ID = 'chatEditing.clearWorkingSet'; @@ -354,7 +353,7 @@ export class ChatEditingRemoveAllFilesAction extends EditingSessionAction { override async runEditingSessionAction(accessor: ServicesAccessor, editingSession: IChatEditingSession, chatWidget: IChatWidget, ...args: any[]): Promise { // Remove all files from working set const uris = [...editingSession.entries.get()].map((e) => e.modifiedURI); - editingSession.remove(WorkingSetEntryRemovalReason.User, ...uris); + editingSession.remove(...uris); // Remove all file attachments const fileAttachments = chatWidget.attachmentModel ? chatWidget.attachmentModel.fileAttachments : []; @@ -393,53 +392,11 @@ export class ChatEditingShowChangesAction extends EditingSessionAction { } registerAction2(ChatEditingShowChangesAction); -registerAction2(class AddFilesToWorkingSetAction extends EditingSessionAction { - constructor() { - super({ - id: 'workbench.action.chat.addSelectedFilesToWorkingSet', - title: localize2('workbench.action.chat.addSelectedFilesToWorkingSet.label', "Add Selected Files to Working Set"), - icon: Codicon.attach, - precondition: ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), - f1: true - }); - } - - override async runEditingSessionAction(accessor: ServicesAccessor, editingSession: IChatEditingSession, chatWidget: IChatWidget, ...args: any[]): Promise { - const listService = accessor.get(IListService); - const editorGroupService = accessor.get(IEditorGroupsService); - - const uris: URI[] = []; - - for (const group of editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) { - for (const selection of group.selectedEditors) { - if (selection.resource) { - uris.push(selection.resource); - } - } - } - - if (uris.length === 0) { - const selection = listService.lastFocusedList?.getSelection(); - if (selection?.length) { - for (const file of selection) { - if (!!file && typeof file === 'object' && 'resource' in file && URI.isUri(file.resource)) { - uris.push(file.resource); - } - } - } - } - - for (const file of uris) { - chatWidget.attachmentModel.addFile(file); - } - } -}); - registerAction2(class RemoveAction extends Action2 { constructor() { super({ id: 'workbench.action.chat.undoEdits', - title: localize2('chat.undoEdits.label', "Undo Edits"), + title: localize2('chat.undoEdits.label', "Undo Requests"), f1: false, category: CHAT_CATEGORY, icon: Codicon.x, @@ -448,7 +405,7 @@ registerAction2(class RemoveAction extends Action2 { mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace, }, - when: ContextKeyExpr.and(ChatContextKeys.chatMode.notEqualsTo(ChatMode.Ask), ChatContextKeys.inChatSession, ChatContextKeys.inChatInput.negate()), + when: ContextKeyExpr.and(ChatContextKeys.inChatSession, EditorContextKeys.textInputFocus.negate()), weight: KeybindingWeight.WorkbenchContrib, }, menu: [ @@ -456,7 +413,7 @@ registerAction2(class RemoveAction extends Action2 { id: MenuId.ChatMessageTitle, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.chatMode.notEqualsTo(ChatMode.Ask), ChatContextKeys.isRequest) + when: ChatContextKeys.isRequest } ] }); @@ -476,14 +433,13 @@ registerAction2(class RemoveAction extends Action2 { const configurationService = accessor.get(IConfigurationService); const dialogService = accessor.get(IDialogService); - const chatEditingService = accessor.get(IChatEditingService); const chatService = accessor.get(IChatService); const chatModel = chatService.getSession(item.sessionId); - if (chatModel?.initialLocation !== ChatAgentLocation.EditingSession) { + if (!chatModel) { return; } - const session = chatEditingService.getEditingSession(chatModel.sessionId); + const session = chatModel.editingSession; if (!session) { return; } @@ -688,3 +644,32 @@ registerAction2(class ResolveSymbolsContextAction extends EditingSessionAction { return implementations.flat(); } }); + +export class ViewPreviousEditsAction extends EditingSessionAction { + static readonly Id = 'chatEditing.viewPreviousEdits'; + static readonly Label = localize('chatEditing.viewPreviousEdits', 'View Previous Edits'); + + constructor() { + super({ + id: ViewPreviousEditsAction.Id, + title: ViewPreviousEditsAction.Label, + tooltip: ViewPreviousEditsAction.Label, + f1: false, + icon: Codicon.diffMultiple, + precondition: hasUndecidedChatEditingResourceContextKey.negate(), + menu: [ + { + id: MenuId.ChatEditingWidgetToolbar, + group: 'navigation', + order: 4, + when: ContextKeyExpr.and(applyingChatEditsFailedContextKey.negate(), ContextKeyExpr.and(hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey.negate())) + } + ], + }); + } + + override async runEditingSessionAction(accessor: ServicesAccessor, editingSession: IChatEditingSession, chatWidget: IChatWidget, ...args: any[]): Promise { + await editingSession.show(true); + } +} +registerAction2(ViewPreviousEditsAction); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts index ba68a8321b5..b91ea9bde5e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts @@ -36,7 +36,7 @@ import { EditorsOrder, IEditorIdentifier, isDiffEditorInput } from '../../../../ import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { overviewRulerModifiedForeground, minimapGutterModifiedBackground, overviewRulerAddedForeground, minimapGutterAddedBackground, overviewRulerDeletedForeground, minimapGutterDeletedBackground } from '../../../scm/common/quickDiff.js'; import { IChatAgentService } from '../../common/chatAgents.js'; -import { IChatEditingService, IModifiedFileEntry, IModifiedFileEntryChangeHunk, IModifiedFileEntryEditorIntegration, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { IModifiedFileEntry, IModifiedFileEntryChangeHunk, IModifiedFileEntryEditorIntegration, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { isTextDiffEditorForEntry } from './chatEditing.js'; import { IEditorDecorationsCollection } from '../../../../../editor/common/editorCommon.js'; import { ChatAgentLocation } from '../../common/constants.js'; @@ -70,7 +70,7 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito private readonly _entry: IModifiedFileEntry, private readonly _editor: ICodeEditor, documentDiffInfo: IObservable, - @IChatEditingService chatEditingService: IChatEditingService, + renderDiffImmediately: boolean, @IChatAgentService private readonly _chatAgentService: IChatAgentService, @IEditorService private readonly _editorService: IEditorService, @IAccessibilitySignalService private readonly _accessibilitySignalsService: IAccessibilitySignalService, @@ -148,20 +148,15 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito } // done: render diff - if (!_entry.isCurrentlyBeingModifiedBy.read(r)) { + if (!_entry.isCurrentlyBeingModifiedBy.read(r) || renderDiffImmediately) { + const isDiffEditor = this._editor.getOption(EditorOption.inDiffEditor); - // Add diff decoration to the UI (unless in diff editor) - if (!this._editor.getOption(EditorOption.inDiffEditor)) { - codeEditorObs.getOption(EditorOption.fontInfo).read(r); - codeEditorObs.getOption(EditorOption.lineHeight).read(r); + codeEditorObs.getOption(EditorOption.fontInfo).read(r); + codeEditorObs.getOption(EditorOption.lineHeight).read(r); - const reviewMode = _entry.reviewMode.read(r); - const diff = documentDiffInfo.read(r); - this._updateDiffRendering(diff, reviewMode); - - } else { - this._clearDiffRendering(); - } + const reviewMode = _entry.reviewMode.read(r); + const diff = documentDiffInfo.read(r); + this._updateDiffRendering(diff, reviewMode, isDiffEditor); } })); @@ -224,22 +219,26 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito this._store.add(toDisposable(restoreActualOptions)); - const shouldBeReadOnly = derived(this, r => { + const renderAsBeingModified = derived(this, r => { return enabledObs.read(r) && Boolean(_entry.isCurrentlyBeingModifiedBy.read(r)); }); this._store.add(autorun(r => { - const value = shouldBeReadOnly.read(r); + const value = renderAsBeingModified.read(r); if (value) { actualOptions ??= { readOnly: this._editor.getOption(EditorOption.readOnly), - stickyScroll: this._editor.getOption(EditorOption.stickyScroll) + stickyScroll: this._editor.getOption(EditorOption.stickyScroll), + codeLens: this._editor.getOption(EditorOption.codeLens), + guides: this._editor.getOption(EditorOption.guides) }; this._editor.updateOptions({ readOnly: true, - stickyScroll: { enabled: false } + stickyScroll: { enabled: false }, + codeLens: false, + guides: { indentation: false, bracketPairs: false } }); } else { restoreActualOptions(); @@ -271,7 +270,7 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito this._diffVisualDecorations.clear(); } - private _updateDiffRendering(diff: IDocumentDiff2, reviewMode: boolean): void { + private _updateDiffRendering(diff: IDocumentDiff2, reviewMode: boolean, diffMode: boolean): void { const chatDiffAddDecoration = ModelDecorationOptions.createDynamic({ ...diffAddDecoration, @@ -367,11 +366,12 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito }); } - if (reviewMode) { + let extraLines = 0; + if (reviewMode && !diffMode) { const domNode = document.createElement('div'); domNode.className = 'chat-editing-original-zone view-lines line-delete monaco-mouse-cursor-text'; const result = renderLines(source, renderOptions, decorations, domNode); - + extraLines = result.heightInLines; if (!isCreatedContent) { const viewZoneData: IViewZone = { @@ -383,12 +383,14 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito this._viewZones.push(viewZoneChangeAccessor.addZone(viewZoneData)); } + } + if (reviewMode || diffMode) { // Add content widget for each diff change const widget = this._editor.invokeWithinContext(accessor => { const instaService = accessor.get(IInstantiationService); - return instaService.createInstance(DiffHunkWidget, diff, diffEntry, this._editor.getModel()!.getVersionId(), this._editor, isCreatedContent ? 0 : result.heightInLines); + return instaService.createInstance(DiffHunkWidget, diff, diffEntry, this._editor.getModel()!.getVersionId(), this._editor, isCreatedContent ? 0 : extraLines); }); widget.layout(diffEntry.modified.startLineNumber); @@ -404,7 +406,7 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito } } - this._diffVisualDecorations.set(modifiedVisualDecorations); + this._diffVisualDecorations.set(!diffMode ? modifiedVisualDecorations : []); }); const diffHunkDecoCollection = this._editor.createDecorationsCollection(diffHunkDecorations); @@ -483,7 +485,7 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito // ---- navigation logic - reveal(firstOrLast: boolean): void { + reveal(firstOrLast: boolean, preserveFocus?: boolean): void { const decorations = this._diffLineDecorations .getRanges() @@ -494,7 +496,9 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito if (range) { this._editor.setPosition(range.getStartPosition()); this._editor.revealRange(range); - this._editor.focus(); + if (!preserveFocus) { + this._editor.focus(); + } this._currentIndex.set(index, undefined); } } @@ -578,23 +582,23 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito return closestWidget; } - rejectNearestChange(closestWidget: IModifiedFileEntryChangeHunk | undefined): void { + async rejectNearestChange(closestWidget?: IModifiedFileEntryChangeHunk): Promise { closestWidget = closestWidget ?? this._findClosestWidget(); if (closestWidget instanceof DiffHunkWidget) { - closestWidget.reject(); + await closestWidget.reject(); this.next(true); } } - acceptNearestChange(closestWidget: IModifiedFileEntryChangeHunk | undefined): void { + async acceptNearestChange(closestWidget?: IModifiedFileEntryChangeHunk): Promise { closestWidget = closestWidget ?? this._findClosestWidget(); if (closestWidget instanceof DiffHunkWidget) { - closestWidget.accept(); + await closestWidget.accept(); this.next(true); } } - async toggleDiff(widget: IModifiedFileEntryChangeHunk | undefined): Promise { + async toggleDiff(widget: IModifiedFileEntryChangeHunk | undefined, show?: boolean): Promise { if (!this._editor.hasModel()) { return; } @@ -610,38 +614,25 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito const isDiffEditor = this._editor.getOption(EditorOption.inDiffEditor); - if (isDiffEditor) { - // normal EDITOR - await this._editorService.openEditor({ - resource: this._entry.modifiedURI, - options: { - selection, - selectionRevealType: TextEditorSelectionRevealType.NearTopIfOutsideViewport - } - }); - - } else { - // DIFF editor - const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.EditingSession)?.fullName; + // Use the 'show' argument to control the diff state if provided + if (show !== undefined ? show : !isDiffEditor) { + // Open DIFF editor + const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)?.fullName; const diffEditor = await this._editorService.openEditor({ - original: { resource: this._entry.originalURI, options: { selection: undefined } }, - modified: { resource: this._entry.modifiedURI, options: { selection } }, + original: { resource: this._entry.originalURI }, + modified: { resource: this._entry.modifiedURI }, + options: { selection }, label: defaultAgentName ? localize('diff.agent', '{0} (changes from {1})', basename(this._entry.modifiedURI), defaultAgentName) : localize('diff.generic', '{0} (changes from chat)', basename(this._entry.modifiedURI)) }); if (diffEditor && diffEditor.input) { - - // this is needed, passing the selection doesn't seem to work diffEditor.getControl()?.setSelection(selection); - - // close diff editor when entry is decided const d = autorun(r => { const state = this._entry.state.read(r); - if (state === WorkingSetEntryState.Accepted || state === WorkingSetEntryState.Rejected) { + if (state === ModifiedFileEntryState.Accepted || state === ModifiedFileEntryState.Rejected) { d.dispose(); - const editorIdents: IEditorIdentifier[] = []; for (const candidate of this._editorService.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE)) { if (isDiffEditorInput(candidate.editor) @@ -656,6 +647,15 @@ export class ChatEditingCodeEditorIntegration implements IModifiedFileEntryEdito } }); } + } else { + // Open normal editor + await this._editorService.openEditor({ + resource: this._entry.modifiedURI, + options: { + selection, + selectionRevealType: TextEditorSelectionRevealType.NearTopIfOutsideViewport + } + }); } } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts index 29299765e87..991e8f05f27 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts @@ -9,11 +9,11 @@ import { Action2, IAction2Options, MenuId, MenuRegistry, registerAction2 } from import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; -import { ctxHasEditorModification, ctxHasRequestInProgress, ctxReviewModeEnabled } from './chatEditingEditorContextKeys.js'; +import { ctxHasEditorModification, ctxHasRequestInProgress, ctxIsGlobalEditingSession, ctxReviewModeEnabled } from './chatEditingEditorContextKeys.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; import { ACTIVE_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; -import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, IChatEditingService, IChatEditingSession, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, IChatEditingService, IChatEditingSession, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { resolveCommandsContext } from '../../../../browser/parts/editor/editorCommandsContext.js'; import { IListService } from '../../../../../platform/list/browser/listService.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; @@ -22,6 +22,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { EditorResourceAccessor, SideBySideEditor, TEXT_DIFF_EDITOR_ID } from '../../../../common/editor.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; +import { NOTEBOOK_CELL_LIST_FOCUSED } from '../../../notebook/common/notebookContextKeys.js'; abstract class ChatEditingEditorAction extends Action2 { @@ -72,7 +73,7 @@ abstract class NavigateAction extends ChatEditingEditorAction { ? localize2('next', 'Go to Next Chat Edit') : localize2('prev', 'Go to Previous Chat Edit'), icon: next ? Codicon.arrowDown : Codicon.arrowUp, - precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ctxHasRequestInProgress.negate()), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ctxHasEditorModification), keybinding: { primary: next ? KeyMod.Alt | KeyCode.F5 @@ -80,7 +81,7 @@ abstract class NavigateAction extends ChatEditingEditorAction { weight: KeybindingWeight.WorkbenchContrib, when: ContextKeyExpr.and( ctxHasEditorModification, - EditorContextKeys.focus + ContextKeyExpr.or(EditorContextKeys.focus, NOTEBOOK_CELL_LIST_FOCUSED) ), }, f1: true, @@ -88,7 +89,7 @@ abstract class NavigateAction extends ChatEditingEditorAction { id: MenuId.ChatEditingEditorContent, group: 'navigate', order: !next ? 2 : 3, - when: ContextKeyExpr.and(ctxReviewModeEnabled, ctxHasRequestInProgress.negate()) + when: ContextKeyExpr.and(ctxReviewModeEnabled, ctxHasEditorModification) } }); } @@ -128,7 +129,7 @@ async function openNextOrPreviousChange(accessor: ServicesAccessor, session: ICh while (true) { idx = (idx + (next ? 1 : -1) + entries.length) % entries.length; newEntry = entries[idx]; - if (newEntry.state.get() === WorkingSetEntryState.Modified) { + if (newEntry.state.get() === ModifiedFileEntryState.Modified) { break; } else if (newEntry === entry) { return false; @@ -155,37 +156,40 @@ async function openNextOrPreviousChange(accessor: ServicesAccessor, session: ICh return true; } -abstract class AcceptDiscardAction extends ChatEditingEditorAction { +abstract class KeepOrUndoAction extends ChatEditingEditorAction { - constructor(id: string, readonly accept: boolean) { + constructor(id: string, private _keep: boolean) { super({ id, - title: accept + title: _keep ? localize2('accept', 'Keep Chat Edits') : localize2('discard', 'Undo Chat Edits'), - shortTitle: accept + shortTitle: _keep ? localize2('accept2', 'Keep') : localize2('discard2', 'Undo'), - tooltip: accept + tooltip: _keep ? localize2('accept3', 'Keep Chat Edits in this File') : localize2('discard3', 'Undo Chat Edits in this File'), precondition: ContextKeyExpr.and(ctxHasEditorModification, ctxHasRequestInProgress.negate()), - icon: accept + icon: _keep ? Codicon.check : Codicon.discard, f1: true, keybinding: { when: EditorContextKeys.focus, weight: KeybindingWeight.WorkbenchContrib, - primary: accept + primary: _keep ? KeyMod.CtrlCmd | KeyCode.Enter : KeyMod.CtrlCmd | KeyCode.Backspace }, menu: { id: MenuId.ChatEditingEditorContent, group: 'a_resolve', - order: accept ? 0 : 1, - when: ContextKeyExpr.and(!accept ? ctxReviewModeEnabled : undefined, ctxHasRequestInProgress.negate()) + order: _keep ? 0 : 1, + when: ContextKeyExpr.or( + ContextKeyExpr.and(ctxIsGlobalEditingSession.negate(), ctxHasRequestInProgress.negate()), // Inline chat + ContextKeyExpr.and(ctxIsGlobalEditingSession, !_keep ? ctxReviewModeEnabled : undefined), // Panel chat + ) } }); } @@ -194,7 +198,7 @@ abstract class AcceptDiscardAction extends ChatEditingEditorAction { const instaService = accessor.get(IInstantiationService); - if (this.accept) { + if (this._keep) { session.accept(entry.modifiedURI); } else { session.reject(entry.modifiedURI); @@ -204,7 +208,7 @@ abstract class AcceptDiscardAction extends ChatEditingEditorAction { } } -export class AcceptAction extends AcceptDiscardAction { +export class AcceptAction extends KeepOrUndoAction { static readonly ID = 'chatEditor.action.accept'; @@ -213,7 +217,7 @@ export class AcceptAction extends AcceptDiscardAction { } } -export class RejectAction extends AcceptDiscardAction { +export class RejectAction extends KeepOrUndoAction { static readonly ID = 'chatEditor.action.reject'; @@ -233,8 +237,8 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { icon: _accept ? Codicon.check : Codicon.discard, f1: true, keybinding: { - when: EditorContextKeys.focus, - weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.or(EditorContextKeys.focus, NOTEBOOK_CELL_LIST_FOCUSED), + weight: KeybindingWeight.WorkbenchContrib + 1, primary: _accept ? KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Enter : KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Backspace @@ -247,11 +251,11 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { ); } - override runChatEditingCommand(_accessor: ServicesAccessor, _session: IChatEditingSession, _entry: IModifiedFileEntry, ctrl: IModifiedFileEntryEditorIntegration, ...args: any[]): Promise | void { + override async runChatEditingCommand(_accessor: ServicesAccessor, _session: IChatEditingSession, _entry: IModifiedFileEntry, ctrl: IModifiedFileEntryEditorIntegration, ...args: any[]): Promise { if (this._accept) { - ctrl.acceptNearestChange(args[0]); + await ctrl.acceptNearestChange(args[0]); } else { - ctrl.rejectNearestChange(args[0]); + await ctrl.rejectNearestChange(args[0]); } } } @@ -260,13 +264,13 @@ class ToggleDiffAction extends ChatEditingEditorAction { constructor() { super({ id: 'chatEditor.action.toggleDiff', - title: localize2('diff', 'Toggle Diff Editor'), + title: localize2('diff', 'Toggle Diff Editor for Chat Edits'), category: CHAT_CATEGORY, toggled: { condition: ContextKeyExpr.or(EditorContextKeys.inDiffEditor, ActiveEditorContext.isEqualTo(TEXT_DIFF_EDITOR_ID))!, icon: Codicon.goToFile, }, - precondition: ContextKeyExpr.and(ctxHasEditorModification, ctxHasRequestInProgress.negate()), + precondition: ContextKeyExpr.and(ctxHasEditorModification), icon: Codicon.diffSingle, keybinding: { when: EditorContextKeys.focus, @@ -280,7 +284,7 @@ class ToggleDiffAction extends ChatEditingEditorAction { id: MenuId.ChatEditingEditorContent, group: 'a_resolve', order: 2, - when: ContextKeyExpr.and(ctxReviewModeEnabled, ctxHasRequestInProgress.negate()) + when: ContextKeyExpr.and(ctxReviewModeEnabled) }] }); } @@ -294,7 +298,7 @@ class ToggleAccessibleDiffViewAction extends ChatEditingEditorAction { constructor() { super({ id: 'chatEditor.action.showAccessibleDiffView', - title: localize2('accessibleDiff', 'Show Accessible Diff View'), + title: localize2('accessibleDiff', 'Show Accessible Diff View for Chat Edits'), f1: true, precondition: ContextKeyExpr.and(ctxHasEditorModification, ctxHasRequestInProgress.negate()), keybinding: { @@ -385,7 +389,7 @@ export function registerChatEditorActions() { registerAction2(AcceptAction); registerAction2(RejectAction); registerAction2(class AcceptHunkAction extends AcceptRejectHunkAction { constructor() { super(true); } }); - registerAction2(class AcceptHunkAction extends AcceptRejectHunkAction { constructor() { super(false); } }); + registerAction2(class RejectHunkAction extends AcceptRejectHunkAction { constructor() { super(false); } }); registerAction2(ToggleDiffAction); registerAction2(ToggleAccessibleDiffViewAction); @@ -400,7 +404,7 @@ export function registerChatEditorActions() { }, group: 'navigate', order: -1, - when: ContextKeyExpr.and(ctxReviewModeEnabled, ctxHasRequestInProgress.negate()), + when: ContextKeyExpr.and(ctxReviewModeEnabled, ctxHasEditorModification), }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorContextKeys.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorContextKeys.ts index d86957b3ca8..361997d3bcc 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorContextKeys.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorContextKeys.ts @@ -13,7 +13,7 @@ import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IInlineChatSessionService } from '../../../inlineChat/browser/inlineChatSessionService.js'; -import { IChatEditingService, IChatEditingSession, IModifiedFileEntry, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { IChatService } from '../../common/chatService.js'; export const ctxIsGlobalEditingSession = new RawContextKey('chatEdits.isGlobalEditingSession', undefined, localize('chat.ctxEditSessionIsGlobal', "The current editor is part of the global edit session")); @@ -109,21 +109,22 @@ class ContextKeyGroup { return; } - const { session, entry, isInlineChat } = tuple; + const { session, entry } = tuple; const chatModel = chatService.getSession(session.chatSessionId); - const isRequestInProgress = chatModel - ? observableFromEvent(this, chatModel.onDidChange, () => chatModel.requestInProgress) + const lastResponse = chatModel + ? observableFromEvent(this, chatModel.onDidChange, () => chatModel.getRequests().at(-1)?.response).read(r) + : undefined; + + const isRequestInProgress = lastResponse + ? observableFromEvent(this, lastResponse.onDidChange, () => !lastResponse.isPendingConfirmation && !lastResponse.isComplete) : constObservable(false); - this._ctxHasEditorModification.set(isInlineChat || entry?.state.read(r) === WorkingSetEntryState.Modified); + this._ctxHasEditorModification.set(entry?.state.read(r) === ModifiedFileEntryState.Modified); this._ctxIsGlobalEditingSession.set(session.isGlobalEditingSession); this._ctxReviewModeEnabled.set(entry ? entry.reviewMode.read(r) : false); - this._ctxHasRequestInProgress.set( - Boolean(entry?.isCurrentlyBeingModifiedBy.read(r)) // any entry changing - || (isInlineChat && isRequestInProgress.read(r)) // inline chat request - ); + this._ctxHasRequestInProgress.set(isRequestInProgress.read(r)); // number of requests const requestCount = chatModel diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts index 13643c63bf9..ac5568ba310 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts @@ -4,18 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import '../media/chatEditingEditorOverlay.css'; -import { combinedDisposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable, observableFromEvent, observableSignalFromEvent, observableValue, transaction } from '../../../../../base/common/observable.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar, WorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { combinedDisposable, Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, derivedOpts, IObservable, observableFromEvent, observableFromEventOpts, observableSignalFromEvent, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IChatEditingService, IChatEditingSession, IModifiedFileEntry, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { ActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IActionRunner } from '../../../../../base/common/actions.js'; -import { addDisposableGenericMouseMoveListener, append, reset } from '../../../../../base/browser/dom.js'; -import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; +import { $, addDisposableGenericMouseMoveListener, append } from '../../../../../base/browser/dom.js'; import { assertType } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; import { AcceptAction, navigationBearingFakeActionId, RejectAction } from './chatEditingEditorActions.js'; @@ -30,39 +27,145 @@ import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/edi import { IInlineChatSessionService } from '../../../inlineChat/browser/inlineChatSessionService.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { ObservableEditorSession } from './chatEditingEditorContextKeys.js'; -import { rcut } from '../../../../../base/common/strings.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import * as arrays from '../../../../../base/common/arrays.js'; +import { renderStringAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; -class ChatEditorOverlayWidget { +class ChatEditorOverlayWidget extends Disposable { private readonly _domNode: HTMLElement; - // private readonly _progressNode: HTMLElement; - private readonly _toolbar: WorkbenchToolBar; + private readonly _toolbarNode: HTMLElement; - private readonly _showStore = new DisposableStore(); + private readonly _showStore = this._store.add(new DisposableStore()); private readonly _session = observableValue(this, undefined); private readonly _entry = observableValue(this, undefined); + private readonly _isBusy: IObservable; private readonly _navigationBearings = observableValue<{ changeCount: number; activeIdx: number; entriesCount: number }>(this, { changeCount: -1, activeIdx: -1, entriesCount: -1 }); constructor( private readonly _editor: { focus(): void }, @IChatService private readonly _chatService: IChatService, - @IInstantiationService instaService: IInstantiationService, + @IInstantiationService private readonly _instaService: IInstantiationService, ) { + super(); this._domNode = document.createElement('div'); this._domNode.classList.add('chat-editor-overlay-widget'); + this._isBusy = derived(r => { + const session = this._session.read(r); + const chatModel = session && _chatService.getSession(session?.chatSessionId); + return chatModel && observableFromEvent(this, chatModel.onDidChange, () => chatModel.requestInProgress).read(r); + }); + + const requestMessage = derived(r => { + + const session = this._session.read(r); + const chatModel = this._chatService.getSession(session?.chatSessionId ?? ''); + if (!session || !chatModel) { + return undefined; + } + + const response = this._entry.read(r)?.lastModifyingResponse.read(r); + if (!response) { + return { message: localize('working', "Working...") }; + } + + if (response.isPaused.read(r)) { + return { message: localize('paused', "Paused"), paused: true }; + } + + const lastPart = observableFromEventOpts({ equalsFn: arrays.equals }, response.onDidChange, () => response.response.value) + .read(r) + .filter(part => part.kind === 'progressMessage' || part.kind === 'toolInvocation') + .at(-1); + + if (lastPart?.kind === 'toolInvocation') { + return { message: lastPart.invocationMessage }; + + } else if (lastPart?.kind === 'progressMessage') { + return { message: lastPart.content }; + + } else { + return { message: localize('working', "Working...") }; + } + }); + + const progressNode = document.createElement('div'); progressNode.classList.add('chat-editor-overlay-progress'); append(progressNode, renderIcon(ThemeIcon.modify(Codicon.loading, 'spin'))); + const textProgress = append(progressNode, $('span.progress-message')); this._domNode.appendChild(progressNode); - const toolbarNode = document.createElement('div'); - toolbarNode.classList.add('chat-editor-overlay-toolbar'); - this._domNode.appendChild(toolbarNode); + this._store.add(autorun(r => { + const value = requestMessage.read(r); + const busy = this._isBusy.read(r) && !value?.paused; - this._toolbar = instaService.createInstance(MenuWorkbenchToolBar, toolbarNode, MenuId.ChatEditingEditorContent, { + this._domNode.classList.toggle('busy', busy); + + if (!busy || !value || this._session.read(r)?.isGlobalEditingSession) { + textProgress.innerText = ''; + } else if (value) { + textProgress.innerText = renderStringAsPlaintext(value.message); + } + })); + + this._toolbarNode = document.createElement('div'); + this._toolbarNode.classList.add('chat-editor-overlay-toolbar'); + + } + + override dispose() { + this.hide(); + super.dispose(); + } + + getDomNode(): HTMLElement { + return this._domNode; + } + + show(session: IChatEditingSession, entry: IModifiedFileEntry | undefined, indicies: { entryIndex: IObservable; changeIndex: IObservable }) { + + this._showStore.clear(); + + transaction(tx => { + this._session.set(session, tx); + this._entry.set(entry, tx); + }); + + this._showStore.add(autorun(r => { + + const entryIndex = indicies.entryIndex.read(r); + const changeIndex = indicies.changeIndex.read(r); + + const entries = session.entries.read(r); + + let activeIdx = entryIndex !== undefined && changeIndex !== undefined + ? changeIndex + : -1; + + let totalChangesCount = 0; + for (let i = 0; i < entries.length; i++) { + const changesCount = entries[i].changesCount.read(r); + totalChangesCount += changesCount; + + if (entryIndex !== undefined && i < entryIndex) { + activeIdx += changesCount; + } + } + + this._navigationBearings.set({ changeCount: totalChangesCount, activeIdx, entriesCount: entries.length }, undefined); + })); + + + this._domNode.appendChild(this._toolbarNode); + this._showStore.add(toDisposable(() => this._toolbarNode.remove())); + + this._showStore.add(this._instaService.createInstance(MenuWorkbenchToolBar, this._toolbarNode, MenuId.ChatEditingEditorContent, { telemetrySource: 'chatEditor.overlayToolbar', hiddenItemStrategy: HiddenItemStrategy.Ignore, toolbarOptions: { @@ -94,7 +197,8 @@ class ChatEditorOverlayWidget { const n = activeIdx === -1 ? '1' : `${activeIdx + 1}`; this.label.innerText = localize('nOfM', "{0} of {1}", n, changeCount); } else { - this.label.innerText = localize('0Of0', "0 of 0"); + // allow-any-unicode-next-line + this.label.innerText = localize('0Of0', "—"); } this.updateTooltip(); @@ -105,15 +209,21 @@ class ChatEditorOverlayWidget { const { changeCount, entriesCount } = that._navigationBearings.get(); if (changeCount === -1 || entriesCount === -1) { return undefined; - } else if (changeCount === 1 && entriesCount === 1) { - return localize('tooltip_11', "1 change in 1 file"); - } else if (changeCount === 1) { - return localize('tooltip_1n', "1 change in {0} files", entriesCount); - } else if (entriesCount === 1) { - return localize('tooltip_n1', "{0} changes in 1 file", changeCount); - } else { - return localize('tooltip_nm', "{0} changes in {1} files", changeCount, entriesCount); } + let result: string | undefined; + if (changeCount === 1 && entriesCount === 1) { + result = localize('tooltip_11', "1 change in 1 file"); + } else if (changeCount === 1) { + result = localize('tooltip_1n', "1 change in {0} files", entriesCount); + } else if (entriesCount === 1) { + result = localize('tooltip_n1', "{0} changes in 1 file", changeCount); + } else { + result = localize('tooltip_nm', "{0} changes in {1} files", changeCount, entriesCount); + } + if (!that._isBusy.get()) { + return result; + } + return localize('tooltip_busy', "{0} - Working...", result); } }; } @@ -168,138 +278,8 @@ class ChatEditorOverlayWidget { }; } - if (action.id === 'inlineChat2.reveal' || action.id === 'workbench.action.chat.openEditSession') { - return new class extends ActionViewItem { - - private _requestMessage: IObservable<{ message: string; paused?: boolean } | undefined>; - - constructor() { - super(undefined, action, options); - - this._requestMessage = derived(r => { - const session = that._session.read(r); - const chatModel = that._chatService.getSession(session?.chatSessionId ?? ''); - if (!session || !chatModel) { - return undefined; - } - - const response = that._entry.read(r)?.isCurrentlyBeingModifiedBy.read(r); - - if (response) { - - if (response?.isPaused.read(r)) { - return { message: localize('paused', "Edits Paused"), paused: true }; - } - - const entry = that._entry.read(r); - if (entry) { - const progress = entry?.rewriteRatio.read(r); - const message = progress === 0 - ? localize('generating', "Generating Edits") - : localize('applyingPercentage', "{0}% Applying Edits", Math.round(progress * 100)); - - return { message }; - } - } - - if (session.isGlobalEditingSession) { - return undefined; - } - - const request = observableFromEvent(this, chatModel.onDidChange, () => chatModel.getRequests().at(-1)).read(r); - if (!request || request.response?.isComplete) { - return undefined; - } - return { message: request.message.text }; - }); - } - - override render(container: HTMLElement) { - super.render(container); - - container.classList.add('label-item'); - - this._store.add(autorun(r => { - assertType(this.label); - - const value = this._requestMessage.read(r); - if (!value) { - // normal rendering - this.options.icon = true; - this.options.label = false; - reset(this.label); - this.updateClass(); - this.updateLabel(); - this.updateTooltip(); - - } else { - this.options.icon = false; - this.options.label = true; - this.updateClass(); - this.updateTooltip(); - - const message = rcut(value.message, 47); - reset(this.label, message); - } - - const busy = Boolean(value && !value.paused); - that._domNode.classList.toggle('busy', busy); - this.label.classList.toggle('busy', busy); - - })); - } - - protected override getTooltip(): string | undefined { - return this._requestMessage.get()?.message || super.getTooltip(); - } - }; - } return undefined; } - }); - } - - dispose() { - this.hide(); - this._showStore.dispose(); - this._toolbar.dispose(); - } - - getDomNode(): HTMLElement { - return this._domNode; - } - - show(session: IChatEditingSession, entry: IModifiedFileEntry | undefined, indicies: { entryIndex: IObservable; changeIndex: IObservable }) { - - this._showStore.clear(); - - transaction(tx => { - this._session.set(session, tx); - this._entry.set(entry, tx); - }); - - this._showStore.add(autorun(r => { - - const entryIndex = indicies.entryIndex.read(r); - const changeIndex = indicies.changeIndex.read(r); - - const entries = session.entries.read(r); - - let activeIdx = entryIndex !== undefined && changeIndex !== undefined - ? changeIndex - : -1; - - let totalChangesCount = 0; - for (let i = 0; i < entries.length; i++) { - const changesCount = entries[i].changesCount.read(r); - totalChangesCount += changesCount; - - if (entryIndex !== undefined && i < entryIndex) { - activeIdx += changesCount; - } - } - - this._navigationBearings.set({ changeCount: totalChangesCount, activeIdx, entriesCount: entries.length }, undefined); })); } @@ -405,8 +385,14 @@ class ChatEditingOverlayController { const { session, entry } = data; + if (!session.isGlobalEditingSession && !inlineChatService.hideOnRequest.read(r)) { + // inline chat - no chat overlay unless hideOnRequest is on + hide(); + return; + } + if ( - entry?.state.read(r) === WorkingSetEntryState.Modified // any entry changing + entry?.state.read(r) === ModifiedFileEntryState.Modified // any entry changing || (!session.isGlobalEditingSession && isInProgress.read(r)) // inline chat request ) { // any session with changes diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts index cd89a9c4a70..dd22484f9c3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts @@ -6,7 +6,7 @@ import { assert } from '../../../../../base/common/assert.js'; import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { observableValue, IObservable, ITransaction, autorun, transaction } from '../../../../../base/common/observable.js'; +import { observableValue, ITransaction, autorun, transaction } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { themeColorFromId } from '../../../../../base/common/themables.js'; import { assertType } from '../../../../../base/common/types.js'; @@ -27,19 +27,20 @@ import { IEditorWorkerService } from '../../../../../editor/common/services/edit import { IModelService } from '../../../../../editor/common/services/model.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { IModelContentChangedEvent } from '../../../../../editor/common/textModelEvents.js'; +import { TextModelChangeRecorder } from '../../../../../editor/contrib/inlineCompletions/browser/model/changeRecorder.js'; import { localize } from '../../../../../nls.js'; +import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IMarkerService } from '../../../../../platform/markers/common/markers.js'; -import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { editorSelectionBackground } from '../../../../../platform/theme/common/colorRegistry.js'; import { IUndoRedoElement, IUndoRedoService } from '../../../../../platform/undoRedo/common/undoRedo.js'; import { SaveReason, IEditorPane } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; -import { IResolvedTextFileEditorModel, stringToSnapshot } from '../../../../services/textfile/common/textfiles.js'; +import { isTextFileEditorModel, ITextFileService, stringToSnapshot } from '../../../../services/textfile/common/textfiles.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; -import { IModifiedFileEntry, ChatEditKind, WorkingSetEntryState, IModifiedFileEntryEditorIntegration } from '../../common/chatEditingService.js'; +import { IModifiedFileEntry, ChatEditKind, ModifiedFileEntryState, IModifiedFileEntryEditorIntegration } from '../../common/chatEditingService.js'; import { IChatResponseModel } from '../../common/chatModel.js'; import { IChatService } from '../../common/chatService.js'; import { ChatEditingCodeEditorIntegration, IDocumentDiff2 } from './chatEditingCodeEditorIntegration.js'; @@ -75,7 +76,7 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie private readonly originalModel: ITextModel; private readonly modifiedModel: ITextModel; - readonly docFileEditorModel: IResolvedTextFileEditorModel; + private readonly _docFileEditorModel: IResolvedTextEditorModel; private _edit: OffsetEdit = OffsetEdit.empty; private _isEditFromUs: boolean = false; @@ -90,9 +91,6 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie private readonly _editDecorationClear = this._register(new RunOnceScheduler(() => { this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, []); }, 500)); private _editDecorations: string[] = []; - - private readonly _diffTrimWhitespace: IObservable; - readonly originalURI: URI; constructor( @@ -109,9 +107,11 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie @IFilesConfigurationService fileConfigService: IFilesConfigurationService, @IChatService chatService: IChatService, @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + @ITextFileService private readonly _textFileService: ITextFileService, @IFileService fileService: IFileService, @IUndoRedoService undoRedoService: IUndoRedoService, - @IInstantiationService instantiationService: IInstantiationService + @IInstantiationService instantiationService: IInstantiationService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, ) { super( resourceRef.object.textEditorModel.uri, @@ -125,7 +125,7 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie instantiationService ); - this.docFileEditorModel = this._register(resourceRef).object as IResolvedTextFileEditorModel; + this._docFileEditorModel = this._register(resourceRef).object; this.modifiedModel = resourceRef.object.textEditorModel; this.originalURI = ChatEditingTextModelContentProvider.getFileURI(telemetryInfo.sessionId, this.entryId, this.modifiedURI.path); @@ -156,17 +156,12 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie this._clearCurrentEditLineDecoration(); })); - this._diffTrimWhitespace = observableConfigValue('diffEditor.ignoreTrimWhitespace', true, configService); - this._register(autorun(r => { - this._diffTrimWhitespace.read(r); - this._updateDiffInfoSeq(); - })); - const resourceFilter = this._register(new MutableDisposable()); this._register(autorun(r => { - const res = this.isCurrentlyBeingModifiedBy.read(r); - if (res) { - const req = res.session.getRequests().find(value => value.id === res.requestId); + const inProgress = this._lastModifyingResponseInProgressObs.read(r); + if (inProgress) { + const res = this._lastModifyingResponseObs.read(r); + const req = res && res.session.getRequests().find(value => value.id === res.requestId); resourceFilter.value = markerService.installResourceFilter(this.modifiedURI, req?.message.text || localize('default', "Chat Edits")); } else { resourceFilter.clear(); @@ -201,10 +196,12 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie }; } - restoreFromSnapshot(snapshot: ISnapshotEntry) { + restoreFromSnapshot(snapshot: ISnapshotEntry, restoreToDisk = true) { this._stateObs.set(snapshot.state, undefined); this.originalModel.setValue(snapshot.original); - this._setDocValue(snapshot.current); + if (restoreToDisk) { + this._setDocValue(snapshot.current); + } this._edit = snapshot.originalToCurrentEdit; this._updateDiffInfoSeq(); } @@ -230,7 +227,6 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie const e_sum = this._edit; const e_ai = edit; this._edit = e_sum.compose(e_ai); - } else { // e_ai @@ -263,14 +259,15 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie } this._allEditsAreFromUs = false; + this._userEditScheduler.schedule(); this._updateDiffInfoSeq(); const didResetToOriginalContent = this.modifiedModel.getValue() === this.initialContent; const currentState = this._stateObs.get(); switch (currentState) { - case WorkingSetEntryState.Modified: + case ModifiedFileEntryState.Modified: if (didResetToOriginalContent) { - this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + this._stateObs.set(ModifiedFileEntryState.Rejected, undefined); break; } } @@ -314,7 +311,7 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie transaction((tx) => { if (!isLastEdits) { - this._stateObs.set(WorkingSetEntryState.Modified, tx); + this._stateObs.set(ModifiedFileEntryState.Modified, tx); this._isCurrentlyBeingModifiedByObs.set(responseModel, tx); const lineCount = this.modifiedModel.getLineCount(); this._rewriteRatioObs.set(Math.min(1, maxLineNumber / lineCount), tx); @@ -341,8 +338,10 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie this.originalModel.pushEditOperations(null, edits, _ => null); await this._updateDiffInfoSeq(); if (this._diffInfo.get().identical) { - this._stateObs.set(WorkingSetEntryState.Accepted, undefined); + this._stateObs.set(ModifiedFileEntryState.Accepted, undefined); + this._notifyAction('accepted'); } + this._accessibilitySignalService.playSignal(AccessibilitySignal.editsKept, { allowManyInParallel: true }); return true; } @@ -358,8 +357,10 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie this.modifiedModel.pushEditOperations(null, edits, _ => null); await this._updateDiffInfoSeq(); if (this._diffInfo.get().identical) { - this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + this._stateObs.set(ModifiedFileEntryState.Rejected, undefined); + this._notifyAction('rejected'); } + this._accessibilitySignalService.playSignal(AccessibilitySignal.editsUndone, { allowManyInParallel: true }); return true; } @@ -368,9 +369,11 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie this._isEditFromUs = true; try { let result: ISingleEditOperation[] = []; - this.modifiedModel.pushEditOperations(null, edits, (undoEdits) => { - result = undoEdits; - return null; + TextModelChangeRecorder.editWithMetadata({ source: 'Chat.applyEdits' }, () => { + this.modifiedModel.pushEditOperations(null, edits, (undoEdits) => { + result = undoEdits; + return null; + }); }); return result; } finally { @@ -394,15 +397,22 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie return undefined; } + if (this.state.get() !== ModifiedFileEntryState.Modified) { + this._diffInfo.set(nullDocumentDiff, undefined); + return nullDocumentDiff; + } + const docVersionNow = this.modifiedModel.getVersionId(); const snapshotVersionNow = this.originalModel.getVersionId(); - const ignoreTrimWhitespace = this._diffTrimWhitespace.get(); - const diff = await this._editorWorkerService.computeDiff( this.originalModel.uri, this.modifiedModel.uri, - { ignoreTrimWhitespace, computeMoves: false, maxComputationTimeMs: 3000 }, + { + ignoreTrimWhitespace: false, // NEVER ignore whitespace so that undo/accept edits are correct and so that all changes (1 of 2) are spelled out + computeMoves: false, + maxComputationTimeMs: 3000 + }, 'advanced' ); @@ -425,19 +435,36 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie this._diffInfo.set(nullDocumentDiff, tx); this._edit = OffsetEdit.empty; await this._collapse(tx); + + const config = this._fileConfigService.getAutoSaveConfiguration(this.modifiedURI); + if (!config.autoSave || !this._textFileService.isDirty(this.modifiedURI)) { + // SAVE after accept for manual-savers, for auto-savers + // trigger explict save to get save participants going + try { + await this._textFileService.save(this.modifiedURI, { + reason: SaveReason.EXPLICIT, + force: true, + ignoreErrorHandler: true + }); + } catch { + // ignored + } + } } protected override async _doReject(tx: ITransaction | undefined): Promise { if (this.createdInRequestId === this._telemetryInfo.requestId) { - await this.docFileEditorModel.revert({ soft: true }); - await this._fileService.del(this.modifiedURI); + if (isTextFileEditorModel(this._docFileEditorModel)) { + await this._docFileEditorModel.revert({ soft: true }); + await this._fileService.del(this.modifiedURI); + } this._onDidDelete.fire(); } else { this._setDocValue(this.originalModel.getValue()); - if (this._allEditsAreFromUs) { + if (this._allEditsAreFromUs && isTextFileEditorModel(this._docFileEditorModel)) { // save the file after discarding so that the dirty indicator goes away // and so that an intermediate saved state gets reverted - await this.docFileEditorModel.save({ reason: SaveReason.EXPLICIT, skipSaveParticipants: true }); + await this._docFileEditorModel.save({ reason: SaveReason.EXPLICIT, skipSaveParticipants: true }); } await this._collapse(tx); } @@ -473,6 +500,6 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie } satisfies IDocumentDiff2; }); - return this._instantiationService.createInstance(ChatEditingCodeEditorIntegration, this, codeEditor, diffInfo); + return this._instantiationService.createInstance(ChatEditingCodeEditorIntegration, this, codeEditor, diffInfo, false); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts index 73bd3f49695..e0b5ebe19ed 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { Disposable, DisposableMap, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { clamp } from '../../../../../base/common/numbers.js'; -import { autorun, derived, IObservable, ITransaction, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, ITransaction, observableFromEvent, observableValue, observableValueOpts } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { OffsetEdit } from '../../../../../editor/common/core/offsetEdit.js'; import { TextEdit } from '../../../../../editor/common/languages.js'; @@ -22,7 +23,7 @@ import { IEditorPane } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { IChatAgentResult } from '../../common/chatAgents.js'; -import { ChatEditKind, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { ChatEditKind, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { IChatResponseModel } from '../../common/chatModel.js'; import { IChatService } from '../../common/chatService.js'; @@ -50,12 +51,19 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im protected readonly _onDidDelete = this._register(new Emitter()); readonly onDidDelete = this._onDidDelete.event; - protected readonly _stateObs = observableValue(this, WorkingSetEntryState.Attached); - readonly state: IObservable = this._stateObs; + protected readonly _stateObs = observableValue(this, ModifiedFileEntryState.Modified); + readonly state: IObservable = this._stateObs; protected readonly _isCurrentlyBeingModifiedByObs = observableValue(this, undefined); readonly isCurrentlyBeingModifiedBy: IObservable = this._isCurrentlyBeingModifiedByObs; + protected readonly _lastModifyingResponseObs = observableValueOpts({ equalsFn: (a, b) => a?.requestId === b?.requestId }, undefined); + readonly lastModifyingResponse: IObservable = this._lastModifyingResponseObs; + + protected readonly _lastModifyingResponseInProgressObs = this._lastModifyingResponseObs.map((value, r) => { + return value && observableFromEvent(this, value.onDidChange, () => !value.isComplete && !value.isPendingConfirmation).read(r); + }); + protected readonly _rewriteRatioObs = observableValue(this, 0); readonly rewriteRatio: IObservable = this._rewriteRatioObs; @@ -81,12 +89,14 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im readonly abstract originalURI: URI; + protected readonly _userEditScheduler = this._register(new RunOnceScheduler(() => this._notifyAction('userModified'), 1000)); + constructor( readonly modifiedURI: URI, protected _telemetryInfo: IModifiedEntryTelemetryInfo, kind: ChatEditKind, @IConfigurationService configService: IConfigurationService, - @IFilesConfigurationService fileConfigService: IFilesConfigurationService, + @IFilesConfigurationService protected _fileConfigService: IFilesConfigurationService, @IChatService protected readonly _chatService: IChatService, @IFileService protected readonly _fileService: IFileService, @IUndoRedoService private readonly _undoRedoService: IUndoRedoService, @@ -119,14 +129,47 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im return tempValue ?? configuredValue === 0; }); + this._store.add(toDisposable(() => this._lastModifyingResponseObs.set(undefined, undefined))); + const autoSaveOff = this._store.add(new MutableDisposable()); this._store.add(autorun(r => { - if (this.isCurrentlyBeingModifiedBy.read(r)) { - autoSaveOff.value = fileConfigService.disableAutoSave(this.modifiedURI); + if (this._lastModifyingResponseInProgressObs.read(r)) { + autoSaveOff.value = _fileConfigService.disableAutoSave(this.modifiedURI); } else { autoSaveOff.clear(); } })); + + this._store.add(autorun(r => { + const inProgress = this._lastModifyingResponseInProgressObs.read(r); + if (inProgress === false && !this.reviewMode.read(r)) { + // AUTO accept mode (when request is done) + + const acceptTimeout = this._autoAcceptTimeout.get() * 1000; + const future = Date.now() + acceptTimeout; + const update = () => { + + const reviewMode = this.reviewMode.get(); + if (reviewMode) { + // switched back to review mode + this._autoAcceptCtrl.set(undefined, undefined); + return; + } + + const remain = Math.round(future - Date.now()); + if (remain <= 0) { + this.accept(undefined); + } else { + const handle = setTimeout(update, 100); + this._autoAcceptCtrl.set(new AutoAcceptControl(acceptTimeout, remain, () => { + clearTimeout(handle); + this._autoAcceptCtrl.set(undefined, undefined); + }), undefined); + } + }; + update(); + } + })); } override dispose(): void { @@ -146,7 +189,7 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im const cleanup = autorun(r => { // reset config when settled - const resetConfig = this.state.read(r) !== WorkingSetEntryState.Modified; + const resetConfig = this.state.read(r) !== ModifiedFileEntryState.Modified; if (resetConfig) { this._store.delete(cleanup); this._reviewModeTempObs.set(undefined, undefined); @@ -161,13 +204,13 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im } async accept(tx: ITransaction | undefined): Promise { - if (this._stateObs.get() !== WorkingSetEntryState.Modified) { + if (this._stateObs.get() !== ModifiedFileEntryState.Modified) { // already accepted or rejected return; } await this._doAccept(tx); - this._stateObs.set(WorkingSetEntryState.Accepted, tx); + this._stateObs.set(ModifiedFileEntryState.Accepted, tx); this._autoAcceptCtrl.set(undefined, tx); this._notifyAction('accepted'); @@ -176,20 +219,20 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im protected abstract _doAccept(tx: ITransaction | undefined): Promise; async reject(tx: ITransaction | undefined): Promise { - if (this._stateObs.get() !== WorkingSetEntryState.Modified) { + if (this._stateObs.get() !== ModifiedFileEntryState.Modified) { // already accepted or rejected return; } - await this._doReject(tx); - this._stateObs.set(WorkingSetEntryState.Rejected, tx); - this._autoAcceptCtrl.set(undefined, tx); this._notifyAction('rejected'); + await this._doReject(tx); + this._stateObs.set(ModifiedFileEntryState.Rejected, tx); + this._autoAcceptCtrl.set(undefined, tx); } protected abstract _doReject(tx: ITransaction | undefined): Promise; - private _notifyAction(outcome: 'accepted' | 'rejected') { + protected _notifyAction(outcome: 'accepted' | 'rejected' | 'userModified') { this._chatService.notifyUserAction({ action: { kind: 'chatEditingSessionAction', uri: this.modifiedURI, hasRemainingEdits: false, outcome }, agentId: this._telemetryInfo.agentId, @@ -224,6 +267,7 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im acceptStreamingEditsStart(responseModel: IChatResponseModel, tx: ITransaction) { this._resetEditsState(tx); this._isCurrentlyBeingModifiedByObs.set(responseModel, tx); + this._lastModifyingResponseObs.set(responseModel, tx); this._autoAcceptCtrl.get()?.cancel(); const undoRedoElement = this._createUndoRedoElement(responseModel); @@ -242,33 +286,6 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im if (await this._areOriginalAndModifiedIdentical()) { // ACCEPT if identical this.accept(tx); - - } else if (!this.reviewMode.get() && !this._autoAcceptCtrl.get()) { - // AUTO accept mode - - const acceptTimeout = this._autoAcceptTimeout.get() * 1000; - const future = Date.now() + acceptTimeout; - const update = () => { - - const reviewMode = this.reviewMode.get(); - if (reviewMode) { - // switched back to review mode - this._autoAcceptCtrl.set(undefined, undefined); - return; - } - - const remain = Math.round(future - Date.now()); - if (remain <= 0) { - this.accept(undefined); - } else { - const handle = setTimeout(update, 100); - this._autoAcceptCtrl.set(new AutoAcceptControl(acceptTimeout, remain, () => { - clearTimeout(handle); - this._autoAcceptCtrl.set(undefined, undefined); - }), undefined); - } - }; - update(); } } @@ -285,7 +302,7 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im abstract equalsSnapshot(snapshot: ISnapshotEntry | undefined): boolean; - abstract restoreFromSnapshot(snapshot: ISnapshotEntry): void; + abstract restoreFromSnapshot(snapshot: ISnapshotEntry, restoreToDisk?: boolean): void; // --- inital content @@ -309,6 +326,6 @@ export interface ISnapshotEntry { readonly original: string; readonly current: string; readonly originalToCurrentEdit: OffsetEdit; - readonly state: WorkingSetEntryState; + readonly state: ModifiedFileEntryState; telemetryInfo: IModifiedEntryTelemetryInfo; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index db997adc606..2df60310898 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -5,6 +5,7 @@ import { streamToBuffer } from '../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { StringSHA1 } from '../../../../../base/common/hash.js'; import { DisposableStore, IReference } from '../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -22,10 +23,11 @@ import { TextEdit } from '../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IUndoRedoElement, IUndoRedoService } from '../../../../../platform/undoRedo/common/undoRedo.js'; +import { IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from '../../../../../platform/undoRedo/common/undoRedo.js'; import { IEditorPane, SaveReason } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { SnapshotContext } from '../../../../services/workingCopy/common/fileWorkingCopy.js'; @@ -35,13 +37,13 @@ import { CellDiffInfo } from '../../../notebook/browser/diff/notebookDiffViewMod import { getNotebookEditorFromEditorPane } from '../../../notebook/browser/notebookBrowser.js'; import { NotebookCellTextModel } from '../../../notebook/common/model/notebookCellTextModel.js'; import { NotebookTextModel } from '../../../notebook/common/model/notebookTextModel.js'; -import { CellEditType, ICellDto2, ICellEditOperation, ICellReplaceEdit, IResolvedNotebookEditorModel, NotebookCellsChangeType, NotebookTextModelChangedEvent, TransientOptions } from '../../../notebook/common/notebookCommon.js'; +import { CellEditType, ICellDto2, ICellEditOperation, ICellReplaceEdit, IResolvedNotebookEditorModel, NotebookCellsChangeType, NotebookSetting, NotebookTextModelChangedEvent, TransientOptions } from '../../../notebook/common/notebookCommon.js'; import { computeDiff } from '../../../notebook/common/notebookDiff.js'; import { INotebookEditorModelResolverService } from '../../../notebook/common/notebookEditorModelResolverService.js'; import { INotebookLoggingService } from '../../../notebook/common/notebookLoggingService.js'; import { INotebookService } from '../../../notebook/common/notebookService.js'; import { INotebookEditorWorkerService } from '../../../notebook/common/services/notebookWorkerService.js'; -import { ChatEditKind, IModifiedFileEntryEditorIntegration, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { ChatEditKind, IModifiedFileEntryEditorIntegration, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { IChatResponseModel } from '../../common/chatModel.js'; import { IChatService } from '../../common/chatService.js'; import { AbstractChatEditingModifiedFileEntry, IModifiedEntryTelemetryInfo, ISnapshotEntry } from './chatEditingModifiedFileEntry.js'; @@ -113,7 +115,12 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie disposables.add(ChatEditingNotebookFileSystemProvider.registerFile(originalUri, buffer)); const originalRef = await resolver.resolve(originalUri, notebook.viewType); if (initialContent) { - restoreSnapshot(originalRef.object.notebook, initialContent); + try { + restoreSnapshot(originalRef.object.notebook, initialContent); + } catch (ex) { + console.error(`Error restoring snapshot: ${initialContent}`, ex); + initialContent = createSnapshot(notebook, options.serializer.options, configurationServie); + } } else { initialContent = createSnapshot(notebook, options.serializer.options, configurationServie); // Both models are the same, ensure the cell ids are the same, this way we get a perfect diffing. @@ -124,11 +131,10 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie restoreSnapshot(originalRef.object.notebook, initialContent); const edits: ICellEditOperation[] = []; notebook.cells.forEach((cell, index) => { - const cellId = cell.internalMetadata?.cellId; - if (cellId) { - edits.push({ editType: CellEditType.PartialInternalMetadata, index, internalMetadata: { cellId } }); - } + const internalId = generateCellHash(cell.uri); + edits.push({ editType: CellEditType.PartialInternalMetadata, index, internalMetadata: { internalId } }); }); + resourceRef.object.notebook.applyEdits(edits, true, undefined, () => undefined, undefined, false); originalRef.object.notebook.applyEdits(edits, true, undefined, () => undefined, undefined, false); } const instance = instantiationService.createInstance(ChatEditingModifiedNotebookEntry, resourceRef, originalRef, _multiDiffEntryDelegate, options.serializer.options, telemetryInfo, chatKind, initialContent); @@ -178,6 +184,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie @IUndoRedoService undoRedoService: IUndoRedoService, @INotebookEditorWorkerService private readonly notebookEditorWorkerService: INotebookEditorWorkerService, @INotebookLoggingService private readonly loggingService: INotebookLoggingService, + @INotebookEditorModelResolverService private readonly notebookResolver: INotebookEditorModelResolverService, ) { super(modifiedResourceRef.object.notebook.uri, telemetryInfo, kind, configurationService, fileConfigService, chatService, fileService, undoRedoService, instantiationService); this.initialContentComparer = new SnapshotComparer(initialContent); @@ -251,23 +258,28 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie // const didResetToOriginalContent = createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService) === this.initialContent; let didResetToOriginalContent = this.initialContentComparer.isEqual(this.modifiedModel); const currentState = this._stateObs.get(); - if (currentState === WorkingSetEntryState.Rejected) { - return; - } - if (currentState === WorkingSetEntryState.Modified && didResetToOriginalContent) { - this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + if (currentState === ModifiedFileEntryState.Modified && didResetToOriginalContent) { + this._stateObs.set(ModifiedFileEntryState.Rejected, undefined); this.updateCellDiffInfo([], undefined); + this.initializeModelsFromDiff(); + this._notifyAction('rejected'); return; } if (!e.rawEvents.length) { return; } + + if (currentState === ModifiedFileEntryState.Rejected) { + return; + } + if (isTransientIPyNbExtensionEvent(this.modifiedModel.notebookType, e)) { return; } this._allEditsAreFromUs = false; + this._userEditScheduler.schedule(); // Changes to cell text is sync'ed and handled separately. // See ChatEditingNotebookCellEntry._mirrorEdits @@ -278,11 +290,27 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie editType: CellEditType.DocumentMetadata, metadata: this.modifiedModel.metadata }; - this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, true); + this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false); break; } case NotebookCellsChangeType.ModelChange: { let cellDiffs = sortCellChanges(this._cellsDiffInfo.get()); + // Ensure the new notebook cells have internalIds + this._applyEditsSync(() => { + event.changes.forEach(change => { + change[2].forEach((cell, i) => { + if (cell.internalMetadata.internalId) { + return; + } + const index = change[0] + i; + const internalId = generateCellHash(cell.uri); + const edits: ICellEditOperation[] = [{ editType: CellEditType.PartialInternalMetadata, index, internalMetadata: { internalId } }]; + this.modifiedModel.applyEdits(edits, true, undefined, () => undefined, undefined, false); + cell.internalMetadata ??= {}; + cell.internalMetadata.internalId = internalId; + }); + }); + }); event.changes.forEach(change => { cellDiffs = adjustCellDiffAndOriginalModelBasedOnCellAddDelete(change, cellDiffs, @@ -303,7 +331,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie index, language: event.language }; - this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, true); + this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false); } break; } @@ -316,7 +344,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie index, metadata: event.metadata }; - this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, true); + this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false); } break; } @@ -330,7 +358,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie index, internalMetadata: event.internalMetadata }; - this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, true); + this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false); } break; } @@ -344,7 +372,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie append: event.append, outputs: event.outputs }; - this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, true); + this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false); } break; } @@ -357,14 +385,14 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie append: event.append, items: event.outputItems }; - this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, true); + this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false); } break; } case NotebookCellsChangeType.Move: { const result = adjustCellDiffAndOriginalModelBasedOnCellMovements(event, this._cellsDiffInfo.get().slice()); if (result) { - this.originalModel.applyEdits(result[1], true, undefined, () => undefined, undefined, true); + this.originalModel.applyEdits(result[1], true, undefined, () => undefined, undefined, false); this._cellsDiffInfo.set(result[0], undefined); } break; @@ -376,9 +404,10 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } didResetToOriginalContent = this.initialContentComparer.isEqual(this.modifiedModel); - if (currentState === WorkingSetEntryState.Modified && didResetToOriginalContent) { - this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + if (currentState === ModifiedFileEntryState.Modified && didResetToOriginalContent) { + this._stateObs.set(ModifiedFileEntryState.Rejected, undefined); this.updateCellDiffInfo([], undefined); + this.initializeModelsFromDiff(); return; } } @@ -389,6 +418,22 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie restoreSnapshot(this.originalModel, snapshot); this.initializeModelsFromDiff(); await this._collapse(tx); + + const config = this._fileConfigService.getAutoSaveConfiguration(this.modifiedURI); + if (this.modifiedModel.uri.scheme !== Schemas.untitled && (!config.autoSave || !this.notebookResolver.isDirty(this.modifiedURI))) { + // SAVE after accept for manual-savers, for auto-savers + // trigger explict save to get save participants going + await this._applyEdits(async () => { + try { + await this.modifiedResourceRef.object.save({ + reason: SaveReason.EXPLICIT, + force: true, + }); + } catch { + // ignored + } + }); + } } protected override async _doReject(tx: ITransaction | undefined): Promise { @@ -433,9 +478,53 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie this.cellEntryMap.forEach(entry => !entry.disposed && entry.clearCurrentEditLineDecoration()); } - protected override _createUndoRedoElement(_response: IChatResponseModel): IUndoRedoElement | undefined { - // TODO@amunger - return undefined; + protected override _createUndoRedoElement(response: IChatResponseModel): IUndoRedoElement | undefined { + const request = response.session.getRequests().find(req => req.id === response.requestId); + const label = request?.message.text ? localize('chatNotebookEdit1', "Chat Edit: '{0}'", request.message.text) : localize('chatNotebookEdit2', "Chat Edit"); + const transientOptions = this.transientOptions; + const outputSizeLimit = this.configurationService.getValue(NotebookSetting.outputBackupSizeLimit) * 1024; + + // create a snapshot of the current state of the model, before the next set of edits + let initial = createSnapshot(this.modifiedModel, transientOptions, outputSizeLimit); + let last = ''; + let redoState = ModifiedFileEntryState.Rejected; + + return { + type: UndoRedoElementType.Resource, + resource: this.modifiedURI, + label, + code: 'chat.edit', + confirmBeforeUndo: false, + undo: async () => { + last = createSnapshot(this.modifiedModel, transientOptions, outputSizeLimit); + this._isEditFromUs = true; + try { + restoreSnapshot(this.modifiedModel, initial); + restoreSnapshot(this.originalModel, initial); + } finally { + this._isEditFromUs = false; + } + redoState = this._stateObs.get() === ModifiedFileEntryState.Accepted ? ModifiedFileEntryState.Accepted : ModifiedFileEntryState.Rejected; + this._stateObs.set(ModifiedFileEntryState.Rejected, undefined); + this.updateCellDiffInfo([], undefined); + this.initializeModelsFromDiff(); + this._notifyAction('userModified'); + }, + redo: async () => { + initial = createSnapshot(this.modifiedModel, transientOptions, outputSizeLimit); + this._isEditFromUs = true; + try { + restoreSnapshot(this.modifiedModel, last); + restoreSnapshot(this.originalModel, last); + } finally { + this._isEditFromUs = false; + } + this._stateObs.set(redoState, undefined); + this.updateCellDiffInfo([], undefined); + this.initializeModelsFromDiff(); + this._notifyAction('userModified'); + } + }; } protected override async _areOriginalAndModifiedIdentical(): Promise { @@ -517,7 +606,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie transaction((tx) => { if (!isLastEdits) { - this._stateObs.set(WorkingSetEntryState.Modified, tx); + this._stateObs.set(ModifiedFileEntryState.Modified, tx); this._isCurrentlyBeingModifiedByObs.set(responseModel, tx); const newRewriteRation = Math.max(this._rewriteRatioObs.get(), calculateNotebookRewriteRatio(this._cellsDiffInfo.get(), this.originalModel, this.modifiedModel)); this._rewriteRatioObs.set(Math.min(1, newRewriteRation), tx); @@ -544,15 +633,29 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie acceptNotebookEdit(edit: ICellEditOperation): void { // make the actual edit - this.modifiedModel.applyEdits([edit], true, undefined, () => undefined, undefined, true); + this.modifiedModel.applyEdits([edit], true, undefined, () => undefined, undefined, false); this.disposeDeletedCellEntries(); + if (edit.editType !== CellEditType.Replace) { return; } + // Ensure cells have internal Ids. + edit.cells.forEach((_, i) => { + const index = edit.index + i; + const cell = this.modifiedModel.cells[index]; + if (cell.internalMetadata.internalId) { + return; + } + const internalId = generateCellHash(cell.uri); + const edits: ICellEditOperation[] = [{ editType: CellEditType.PartialInternalMetadata, index, internalMetadata: { internalId } }]; + this.modifiedModel.applyEdits(edits, true, undefined, () => undefined, undefined, false); + }); + + let diff: ICellDiffInfo[] = []; if (edit.count === 0) { // All existing indexes are shifted by number of cells added. - const diff = sortCellChanges(this._cellsDiffInfo.get()); + diff = sortCellChanges(this._cellsDiffInfo.get()); diff.forEach(d => { if (d.type !== 'delete' && d.modifiedCellIndex >= edit.index) { d.modifiedCellIndex += edit.cells.length; @@ -560,11 +663,10 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie }); const diffInsert = edit.cells.map((_, i) => this.createInsertedCellDiffInfo(edit.index + i)); diff.splice(edit.index, 0, ...diffInsert); - this.updateCellDiffInfo(diff, undefined); } else { // All existing indexes are shifted by number of cells removed. // And unchanged cells should be converted to deleted cells. - const diff = sortCellChanges(this._cellsDiffInfo.get()).map((d) => { + diff = sortCellChanges(this._cellsDiffInfo.get()).map((d) => { if (d.type === 'unchanged' && d.modifiedCellIndex >= edit.index && d.modifiedCellIndex <= (edit.index + edit.count - 1)) { return this.createDeleteCellDiffInfo(d.originalCellIndex); } @@ -574,15 +676,16 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } return d; }); - this.updateCellDiffInfo(diff, undefined); } + this.updateCellDiffInfo(diff, undefined); } private computeStateAfterAcceptingRejectingChanges(accepted: boolean) { const currentSnapshot = createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); if (new SnapshotComparer(currentSnapshot).isEqual(this.originalModel)) { - const state = accepted ? WorkingSetEntryState.Accepted : WorkingSetEntryState.Rejected; + const state = accepted ? ModifiedFileEntryState.Accepted : ModifiedFileEntryState.Rejected; this._stateObs.set(state, undefined); + this._notifyAction(accepted ? 'accepted' : 'rejected'); } } @@ -704,10 +807,13 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } private undoPreviouslyInsertedCell(cell: NotebookCellTextModel) { - const index = this.modifiedModel.cells.indexOf(cell); - const diffs = adjustCellDiffForRevertingAnInsertedCell(index, - this._cellsDiffInfo.get(), - this.modifiedModel.applyEdits.bind(this.modifiedModel)); + let diffs: ICellDiffInfo[] = []; + this._applyEditsSync(() => { + const index = this.modifiedModel.cells.indexOf(cell); + diffs = adjustCellDiffForRevertingAnInsertedCell(index, + this._cellsDiffInfo.get(), + this.modifiedModel.applyEdits.bind(this.modifiedModel)); + }); this.disposeDeletedCellEntries(); this.updateCellDiffInfo(diffs, undefined); } @@ -726,7 +832,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie source: cell.getValue(), mime: cell.mime, internalMetadata: { - cellId: cell.internalMetadata.cellId + internalId: cell.internalMetadata.internalId } }; this.cellEntryMap.get(cell.uri)?.dispose(); @@ -750,16 +856,19 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie source: originalCell.getValue(), mime: originalCell.mime, internalMetadata: { - cellId: originalCell.internalMetadata.cellId + internalId: originalCell.internalMetadata.internalId } }; - const cellDiffs = adjustCellDiffForRevertingADeletedCell( - deletedOriginalIndex, - this._cellsDiffInfo.get(), - cellToInsert, - this.modifiedModel.applyEdits.bind(this.modifiedModel), - this.createModifiedCellDiffInfo.bind(this) - ); + let cellDiffs: ICellDiffInfo[] = []; + this._applyEditsSync(() => { + cellDiffs = adjustCellDiffForRevertingADeletedCell( + deletedOriginalIndex, + this._cellsDiffInfo.get(), + cellToInsert, + this.modifiedModel.applyEdits.bind(this.modifiedModel), + this.createModifiedCellDiffInfo.bind(this) + ); + }); this.updateCellDiffInfo(cellDiffs, undefined); } @@ -767,7 +876,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie private keepPreviouslyDeletedCell(deletedOriginalIndex: number) { // Delete this cell from original as well. const edit: ICellReplaceEdit = { cells: [], count: 1, editType: CellEditType.Replace, index: deletedOriginalIndex, }; - this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, true); + this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false); const diffs = sortCellChanges(this._cellsDiffInfo.get()) .filter(d => !(d.type === 'delete' && d.originalCellIndex === deletedOriginalIndex)) .map(diff => { @@ -824,11 +933,13 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } - override restoreFromSnapshot(snapshot: ISnapshotEntry): void { + override restoreFromSnapshot(snapshot: ISnapshotEntry, restoreToDisk = true): void { this.updateCellDiffInfo([], undefined); this._stateObs.set(snapshot.state, undefined); restoreSnapshot(this.originalModel, snapshot.original); - this.restoreSnapshotInModifiedModel(snapshot.current); + if (restoreToDisk) { + this.restoreSnapshotInModifiedModel(snapshot.current); + } this.initializeModelsFromDiff(); } @@ -910,9 +1021,9 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } const cellState = cellEntry.state.read(r); - if (cellState === WorkingSetEntryState.Accepted) { + if (cellState === ModifiedFileEntryState.Accepted) { this.computeStateAfterAcceptingRejectingChanges(true); - } else if (cellState === WorkingSetEntryState.Rejected) { + } else if (cellState === ModifiedFileEntryState.Rejected) { this.computeStateAfterAcceptingRejectingChanges(false); } })); @@ -920,3 +1031,10 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie return cellEntry; } } + + +function generateCellHash(cellUri: URI) { + const hash = new StringSHA1(); + hash.update(cellUri.toString()); + return hash.digest().substring(0, 8); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts index a1d9606a616..029acf012b2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts @@ -16,7 +16,7 @@ import { derived, IObservable, observableValueOpts, runOnChange, ValueWithChange import { isEqual } from '../../../../../base/common/resources.js'; import { compare } from '../../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { assertType, isString } from '../../../../../base/common/types.js'; +import { assertType } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { TextEdit } from '../../../../../editor/common/languages.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; @@ -26,25 +26,24 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; import { CellUri } from '../../../notebook/common/notebookCommon.js'; +import { INotebookService } from '../../../notebook/common/notebookService.js'; import { IChatAgentService } from '../../common/chatAgents.js'; -import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, chatEditingSnapshotScheme, IChatEditingService, IChatEditingSession, IChatRelatedFile, IChatRelatedFilesProvider, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, WorkingSetEntryState } from '../../common/chatEditingService.js'; -import { IChatResponseModel, isCellTextEditOperation } from '../../common/chatModel.js'; +import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, chatEditingSnapshotScheme, IChatEditingService, IChatEditingSession, IChatRelatedFile, IChatRelatedFilesProvider, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, ModifiedFileEntryState, parseChatMultiDiffUri } from '../../common/chatEditingService.js'; +import { ChatModel, IChatResponseModel, isCellTextEditOperation } from '../../common/chatModel.js'; import { IChatService } from '../../common/chatService.js'; import { ChatAgentLocation } from '../../common/constants.js'; +import { ChatEditorInput } from '../chatEditorInput.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingSession } from './chatEditingSession.js'; import { ChatEditingSnapshotTextModelContentProvider, ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; - -const STORAGE_KEY_EDITING_SESSION = 'chat.editingSession'; - export class ChatEditingService extends Disposable implements IChatEditingService { _serviceBrand: undefined; @@ -75,6 +74,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic @ILogService logService: ILogService, @IExtensionService extensionService: IExtensionService, @IProductService productService: IProductService, + @INotebookService private readonly notebookService: INotebookService ) { super(); this._register(decorationsService.registerDecorationsProvider(_instantiationService.createInstance(ChatDecorationsProvider, this.editingSessionsObs))); @@ -105,21 +105,15 @@ export class ChatEditingService extends Disposable implements IChatEditingServic let storageTask: Promise | undefined; this._register(storageService.onWillSaveState(() => { - const sessionIds: string[] = []; const tasks: Promise[] = []; for (const session of this.editingSessionsObs.get()) { if (!session.isGlobalEditingSession) { continue; } - sessionIds.push(session.chatSessionId); tasks.push((session as ChatEditingSession).storeState()); } - if (sessionIds.length) { - storageService.store(STORAGE_KEY_EDITING_SESSION, sessionIds.join(), StorageScope.WORKSPACE, StorageTarget.MACHINE); - } - storageTask = Promise.resolve(storageTask) .then(() => Promise.all(tasks)) .finally(() => storageTask = undefined); @@ -134,27 +128,6 @@ export class ChatEditingService extends Disposable implements IChatEditingServic label: localize('join.chatEditingSession', "Saving chat edits history") }); })); - - const rawSessionsToRestore = storageService.get(STORAGE_KEY_EDITING_SESSION, StorageScope.WORKSPACE); - if (isString(rawSessionsToRestore)) { - - const sessionIds = rawSessionsToRestore.split(','); - - const tasks = sessionIds.map(async sessionId => { - const chatModel = await _chatService.getOrRestoreSession(sessionId); - if (!chatModel) { - logService.error(`Edit session session to restore is a non-existing chat session: ${rawSessionsToRestore}`); - return; - } - await this.startOrContinueGlobalEditingSession(chatModel.sessionId); - }); - - this._restoringEditingSession = Promise.all(tasks).finally(() => { - this._restoringEditingSession = undefined; - }); - - storageService.remove(STORAGE_KEY_EDITING_SESSION, StorageScope.WORKSPACE); - } } override dispose(): void { @@ -162,14 +135,17 @@ export class ChatEditingService extends Disposable implements IChatEditingServic super.dispose(); } - async startOrContinueGlobalEditingSession(chatSessionId: string): Promise { - await this._restoringEditingSession; + async startOrContinueGlobalEditingSession(chatModel: ChatModel, waitForRestore = true): Promise { + if (waitForRestore) { + await this._restoringEditingSession; + } - const session = this.getEditingSession(chatSessionId); + const session = this.getEditingSession(chatModel.sessionId); if (session) { return session; } - return this.createEditingSession(chatSessionId, true); + const result = await this.createEditingSession(chatModel, true); + return result; } @@ -190,11 +166,11 @@ export class ChatEditingService extends Disposable implements IChatEditingServic .find(candidate => candidate.chatSessionId === chatSessionId); } - async createEditingSession(chatSessionId: string, global: boolean = false): Promise { + async createEditingSession(chatModel: ChatModel, global: boolean = false): Promise { - assertType(this.getEditingSession(chatSessionId) === undefined, 'CANNOT have more than one editing session per chat session'); + assertType(this.getEditingSession(chatModel.sessionId) === undefined, 'CANNOT have more than one editing session per chat session'); - const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, global, this._lookupEntry.bind(this)); + const session = this._instantiationService.createInstance(ChatEditingSession, chatModel.sessionId, global, this._lookupEntry.bind(this)); await session.init(); const list = this._sessionsObs.get(); @@ -203,7 +179,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic const store = new DisposableStore(); this._store.add(store); - store.add(await this.installAutoApplyObserver(session)); + store.add(this.installAutoApplyObserver(session, chatModel)); store.add(session.onDidDispose(e => { removeSession(); @@ -216,8 +192,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic return session; } - private async installAutoApplyObserver(session: ChatEditingSession): Promise { - const chatModel = await this._chatService.getOrRestoreSession(session.chatSessionId); + private installAutoApplyObserver(session: ChatEditingSession, chatModel: ChatModel): IDisposable { if (!chatModel) { throw new ErrorNoTelemetry(`Edit session was created for a non-existing chat session: ${session.chatSessionId}`); } @@ -245,6 +220,11 @@ export class ChatEditingService extends Disposable implements IChatEditingServic // multiple times during the process of response streaming. const editsSeen: ({ seen: number; streaming: IStreamingEdits } | undefined)[] = []; + let editorDidChange = false; + const editorListener = Event.once(this._editorService.onDidActiveEditorChange)(() => { + editorDidChange = true; + }); + const editedFilesExist = new ResourceMap>(); const ensureEditorOpen = (partUri: URI) => { const uri = CellUri.parse(partUri)?.notebook ?? partUri; @@ -252,12 +232,15 @@ export class ChatEditingService extends Disposable implements IChatEditingServic return; } - editedFilesExist.set(uri, this._fileService.exists(uri).then((e) => { + const fileExists = this.notebookService.getNotebookTextModel(uri) ? Promise.resolve(true) : this._fileService.exists(uri); + editedFilesExist.set(uri, fileExists.then((e) => { if (!e) { return; } const activeUri = this._editorService.activeEditorPane?.input.resource; - const inactive = Boolean(activeUri && session.entries.get().find(entry => isEqual(activeUri, entry.modifiedURI))); + const inactive = editorDidChange + || this._editorService.activeEditorPane?.input instanceof ChatEditorInput && this._editorService.activeEditorPane.input.sessionId === session.chatSessionId + || Boolean(activeUri && session.entries.get().find(entry => isEqual(activeUri, entry.modifiedURI))); this._editorService.openEditor({ resource: uri, options: { inactive, preserveFocus: true, pinned: true } }); })); }; @@ -273,6 +256,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic editsSeen.length = 0; editedFilesExist.clear(); + editorListener.dispose(); }; const handleResponseParts = async () => { @@ -417,7 +401,7 @@ class ChatDecorationsProvider extends Disposable implements IDecorationsProvider private readonly _modifiedUris = derived(this, (r) => { const uri = this._currentEntries.read(r); - return uri.filter(entry => !entry.isCurrentlyBeingModifiedBy.read(r) && entry.state.read(r) === WorkingSetEntryState.Modified).map(entry => entry.modifiedURI); + return uri.filter(entry => !entry.isCurrentlyBeingModifiedBy.read(r) && entry.state.read(r) === ModifiedFileEntryState.Modified).map(entry => entry.modifiedURI); }); public readonly onDidChange = Event.any( @@ -443,7 +427,7 @@ class ChatDecorationsProvider extends Disposable implements IDecorationsProvider } const isModified = this._modifiedUris.get().some(e => e.toString() === uri.toString()); if (isModified) { - const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.EditingSession)?.fullName; + const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)?.fullName; return { weight: 1000, letter: Codicon.diffModified, @@ -468,11 +452,12 @@ export class ChatEditingMultiDiffSourceResolver implements IMultiDiffSourceResol async resolveDiffSource(uri: URI): Promise { + const parsed = parseChatMultiDiffUri(uri); const thisSession = derived(this, r => { - return this._editingSessionsObs.read(r).find(candidate => candidate.chatSessionId === uri.authority); + return this._editingSessionsObs.read(r).find(candidate => candidate.chatSessionId === parsed.chatSessionId); }); - return this._instantiationService.createInstance(ChatEditingMultiDiffSource, thisSession); + return this._instantiationService.createInstance(ChatEditingMultiDiffSource, thisSession, parsed.showPreviousChanges); } } @@ -484,6 +469,21 @@ class ChatEditingMultiDiffSource implements IResolvedMultiDiffSource { } const entries = currentSession.entries.read(reader); return entries.map((entry) => { + if (this._showPreviousChanges) { + const entryDiffObs = currentSession.getEntryDiffBetweenStops(entry.modifiedURI, undefined, undefined); + const entryDiff = entryDiffObs?.read(reader); + if (entryDiff) { + return new MultiDiffEditorItem( + entryDiff.originalURI, + entryDiff.modifiedURI, + undefined, + { + [chatEditingResourceContextKey.key]: entry.entryId, + }, + ); + } + } + return new MultiDiffEditorItem( entry.originalURI, entry.modifiedURI, @@ -502,6 +502,7 @@ class ChatEditingMultiDiffSource implements IResolvedMultiDiffSource { }; constructor( - private readonly _currentSession: IObservable + private readonly _currentSession: IObservable, + private readonly _showPreviousChanges: boolean ) { } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 714f256b7be..d07f8f070bc 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -4,20 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import { equals as arraysEqual, binarySearch2 } from '../../../../../base/common/arrays.js'; +import { findLast } from '../../../../../base/common/arraysFind.js'; import { DeferredPromise, ITask, Sequencer, SequencerByKey, timeout } from '../../../../../base/common/async.js'; -import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { StringSHA1 } from '../../../../../base/common/hash.js'; import { Iterable } from '../../../../../base/common/iterator.js'; -import { Disposable, DisposableMap, dispose } from '../../../../../base/common/lifecycle.js'; +import { Disposable, dispose } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { asyncTransaction, autorun, derived, derivedOpts, derivedWithStore, IObservable, IReader, ITransaction, ObservablePromise, observableValue, transaction } from '../../../../../base/common/observable.js'; -import { autorunDelta, autorunIterableDelta } from '../../../../../base/common/observableInternal/autorun.js'; -import { isEqual, joinPath } from '../../../../../base/common/resources.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { IBulkEditService } from '../../../../../editor/browser/services/bulkEditService.js'; -import { IOffsetEdit, ISingleOffsetEdit, OffsetEdit } from '../../../../../editor/common/core/offsetEdit.js'; import { TextEdit } from '../../../../../editor/common/languages.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { ITextModel } from '../../../../../editor/common/model.js'; @@ -25,35 +23,28 @@ import { IEditorWorkerService } from '../../../../../editor/common/services/edit import { IModelService } from '../../../../../editor/common/services/model.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../../nls.js'; +import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; -import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { SaveReason } from '../../../../common/editor.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; -import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEditor.js'; import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; +import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { INotebookService } from '../../../notebook/common/notebookService.js'; -import { ChatEditingSessionChangeType, ChatEditingSessionState, ChatEditKind, getMultiDiffSourceUri, IChatEditingSession, IEditSessionEntryDiff, IModifiedFileEntry, IStreamingEdits, WorkingSetDisplayMetadata, WorkingSetEntryRemovalReason, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { ChatEditingSessionState, ChatEditKind, getMultiDiffSourceUri, IChatEditingSession, IEditSessionEntryDiff, IModifiedFileEntry, IStreamingEdits, ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { IChatRequestDisablement, IChatResponseModel } from '../../common/chatModel.js'; import { IChatService } from '../../common/chatService.js'; -import { AbstractChatEditingModifiedFileEntry, IModifiedEntryTelemetryInfo, ISnapshotEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js'; -import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; -import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; +import { AbstractChatEditingModifiedFileEntry, IModifiedEntryTelemetryInfo, ISnapshotEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { ChatEditingSessionStorage, IChatEditingSessionSnapshot, IChatEditingSessionStop, StoredSessionState } from './chatEditingSessionStorage.js'; +import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; import { ChatEditingModifiedNotebookDiff } from './notebook/chatEditingModifiedNotebookDiff.js'; -const STORAGE_CONTENTS_FOLDER = 'contents'; -const STORAGE_STATE_FILE = 'state.json'; const POST_EDIT_STOP_ID = 'd19944f6-f46c-4e17-911b-79a8e843c7c0'; // randomly generated class ThrottledSequencer extends Sequencer { @@ -123,6 +114,38 @@ function getCurrentAndNextStop(requestId: string, stopId: string | undefined, hi return { current, next }; } +function getFirstAndLastStop(uri: URI, history: readonly IChatEditingSessionSnapshot[]): { current: ResourceMap; next: ResourceMap } | undefined { + let firstStopWithUri: IChatEditingSessionStop | undefined; + for (const snapshot of history) { + const stop = snapshot.stops.find(s => s.entries.has(uri)); + if (stop) { + firstStopWithUri = stop; + break; + } + } + + let lastStopWithUri: ResourceMap | undefined; + for (let i = history.length - 1; i >= 0; i--) { + const snapshot = history[i]; + if (snapshot.postEdit?.has(uri)) { + lastStopWithUri = snapshot.postEdit; + break; + } + + const stop = findLast(snapshot.stops, s => s.entries.has(uri)); + if (stop) { + lastStopWithUri = stop.entries; + break; + } + } + + if (!firstStopWithUri || !lastStopWithUri) { + return undefined; + } + + return { current: firstStopWithUri.entries, next: lastStopWithUri }; +} + export class ChatEditingSession extends Disposable implements IChatEditingSession { private readonly _state = observableValue(this, ChatEditingSessionState.Initial); @@ -140,8 +163,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return this._entriesObs; } - private _workingSet = new ResourceMap(); - private _editorPane: MultiDiffEditor | undefined; get state(): IObservable { @@ -170,12 +191,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio // return linearHistory.slice(linearHistoryIndex).map(s => s.requestId).filter((r): r is string => !!r); // }); - private readonly _onDidChange = this._register(new Emitter()); - get onDidChange() { - this._assertNotDisposed(); - return this._onDidChange.event; - } - private readonly _onDidDispose = new Emitter(); get onDidDispose() { this._assertNotDisposed(); @@ -195,11 +210,12 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio @IEditorService private readonly _editorService: IEditorService, @IChatService private readonly _chatService: IChatService, @INotebookService private readonly _notebookService: INotebookService, - @ITextFileService private readonly _textFileService: ITextFileService, @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, ) { super(); + this._ignoreTrimWhitespaceObservable = observableConfigValue('diffEditor.ignoreTrimWhitespace', true, this._configurationService); } public async init(): Promise { @@ -210,20 +226,20 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } await asyncTransaction(async tx => { this._pendingSnapshot = restoredSessionState.pendingSnapshot; - await this._restoreSnapshot(restoredSessionState.recentSnapshot, tx); + await this._restoreSnapshot(restoredSessionState.recentSnapshot, tx, false); this._linearHistory.set(restoredSessionState.linearHistory, tx); this._linearHistoryIndex.set(restoredSessionState.linearHistoryIndex, tx); this._state.set(ChatEditingSessionState.Idle, tx); }); + } else { + this._state.set(ChatEditingSessionState.Idle, undefined); } - this._triggerSaveParticipantsOnAccept(); this._register(autorun(reader => { const entries = this.entries.read(reader); entries.forEach(entry => { entry.state.read(reader); }); - this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); })); } @@ -253,39 +269,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return storage.storeState(state); } - private _triggerSaveParticipantsOnAccept() { - const im = this._register(new DisposableMap()); - const attachToEntry = (entry: IModifiedFileEntry) => { - return autorunDelta(entry.state, ({ lastValue, newValue }) => { - if (newValue === WorkingSetEntryState.Accepted && lastValue === WorkingSetEntryState.Modified) { - // Don't save a file if there's still pending changes. If there's not (e.g. - // the agentic flow with autosave) then save again to trigger participants. - if (!this._textFileService.isDirty(entry.modifiedURI)) { - this._textFileService.save(entry.modifiedURI, { - reason: SaveReason.EXPLICIT, - force: true, - ignoreErrorHandler: true, - }).catch(() => { - // ignored - }); - } - } - }); - }; - - this._register(autorunIterableDelta( - reader => this._entriesObs.read(reader), - ({ addedValues, removedValues }) => { - for (const entry of addedValues) { - im.set(entry, attachToEntry(entry)); - } - for (const entry of removedValues) { - im.deleteAndDispose(entry); - } - } - )); - } - private _findSnapshot(requestId: string): IChatEditingSessionSnapshot | undefined { return this._linearHistory.get().find(s => s.requestId === requestId); } @@ -304,8 +287,9 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } private _diffsBetweenStops = new Map>(); + private _fullDiffs = new Map>(); - private readonly _ignoreTrimWhitespaceObservable = observableConfigValue('diffEditor.ignoreTrimWhitespace', true, this._configurationService); + private readonly _ignoreTrimWhitespaceObservable: IObservable; /** * Gets diff for text entries between stops. @@ -334,7 +318,8 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }); return derived((reader): ObservablePromise | undefined => { - const refs = modelRefsPromise.read(reader)?.promiseResult.read(reader)?.data; + const refs2 = modelRefsPromise.read(reader)?.promiseResult.read(reader); + const refs = refs2?.data; if (!refs) { return; } @@ -375,13 +360,15 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }); } - private _createDiffBetweenStopsObservable(uri: URI, requestId: string, stopId: string | undefined): IObservable { + private _createDiffBetweenStopsObservable(uri: URI, requestId: string | undefined, stopId: string | undefined): IObservable { const entries = derivedOpts( { equalsFn: (a, b) => snapshotsEqualForDiff(a?.before, b?.before) && snapshotsEqualForDiff(a?.after, b?.after), }, reader => { - const stops = getCurrentAndNextStop(requestId, stopId, this._linearHistory.read(reader)); + const stops = requestId ? + getCurrentAndNextStop(requestId, stopId, this._linearHistory.read(reader)) : + getFirstAndLastStop(uri, this._linearHistory.read(reader)); if (!stops) { return undefined; } const before = stops.current.get(uri); const after = stops.next.get(uri); @@ -404,22 +391,30 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }); } - public getEntryDiffBetweenStops(uri: URI, requestId: string, stopId: string | undefined) { - const key = `${uri}\0${requestId}\0${stopId}`; - let observable = this._diffsBetweenStops.get(key); - if (!observable) { - observable = this._createDiffBetweenStopsObservable(uri, requestId, stopId); - this._diffsBetweenStops.set(key, observable); - } + public getEntryDiffBetweenStops(uri: URI, requestId: string | undefined, stopId: string | undefined) { + if (requestId) { + const key = `${uri}\0${requestId}\0${stopId}`; + let observable = this._diffsBetweenStops.get(key); + if (!observable) { + observable = this._createDiffBetweenStopsObservable(uri, requestId, stopId); + this._diffsBetweenStops.set(key, observable); + } - return observable; + return observable; + } else { + const key = uri.toString(); + let observable = this._fullDiffs.get(key); + if (!observable) { + observable = this._createDiffBetweenStopsObservable(uri, requestId, stopId); + this._fullDiffs.set(key, observable); + } + + return observable; + } } - public createSnapshot(requestId: string, undoStop: string | undefined): void { - const snapshot = this._createSnapshot(requestId, undoStop); - for (const [uri, _] of this._workingSet) { - this._workingSet.set(uri, { state: WorkingSetEntryState.Sent }); - } + public createSnapshot(requestId: string, undoStop: string | undefined, makeEmpty = undoStop !== undefined): void { + const snapshot = makeEmpty ? this._createEmptySnapshot(undoStop) : this._createSnapshot(requestId, undoStop); const linearHistoryPtr = this._linearHistoryIndex.get(); const newLinearHistory: IChatEditingSessionSnapshot[] = []; @@ -445,8 +440,15 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }); } + private _createEmptySnapshot(undoStop: string | undefined): IChatEditingSessionStop { + return { + stopId: undoStop, + entries: new ResourceMap(), + }; + } + private _createSnapshot(requestId: string | undefined, undoStop: string | undefined): IChatEditingSessionStop { - const workingSet = new ResourceMap(this._workingSet); + const entries = new ResourceMap(); for (const entry of this._entriesObs.get()) { entries.set(entry.modifiedURI, entry.createSnapshot(requestId, undoStop)); @@ -454,7 +456,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return { stopId: undoStop, - workingSet, entries, }; } @@ -505,8 +506,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } - private async _restoreSnapshot({ workingSet, entries }: IChatEditingSessionStop, tx: ITransaction | undefined): Promise { - this._workingSet = new ResourceMap(workingSet); + private async _restoreSnapshot({ entries }: IChatEditingSessionStop, tx: ITransaction | undefined, restoreResolvedToDisk = true): Promise { // Reset all the files which are modified in this session state // but which are not found in the snapshot @@ -522,14 +522,15 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio // Restore all entries from the snapshot for (const snapshotEntry of entries.values()) { const entry = await this._getOrCreateModifiedFileEntry(snapshotEntry.resource, snapshotEntry.telemetryInfo); - entry.restoreFromSnapshot(snapshotEntry); + const restoreToDisk = snapshotEntry.state === ModifiedFileEntryState.Modified || restoreResolvedToDisk; + entry.restoreFromSnapshot(snapshotEntry, restoreToDisk); entriesArr.push(entry); } this._entriesObs.set(entriesArr, tx); } - remove(reason: WorkingSetEntryRemovalReason, ...uris: URI[]): void { + remove(...uris: URI[]): void { this._assertNotDisposed(); let didRemoveUris = false; @@ -543,17 +544,12 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio didRemoveUris = true; } - const state = this._workingSet.get(uri); - if (state !== undefined) { - didRemoveUris = this._workingSet.delete(uri) || didRemoveUris; - } } if (!didRemoveUris) { return; // noop } - this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); } private _assertNotDisposed(): void { @@ -578,8 +574,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } }); - - this._onDidChange.fire(ChatEditingSessionChangeType.Other); + this._accessibilitySignalService.playSignal(AccessibilitySignal.editsKept, { allowManyInParallel: true }); } async reject(...uris: URI[]): Promise { @@ -597,11 +592,10 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } }); - - this._onDidChange.fire(ChatEditingSessionChangeType.Other); + this._accessibilitySignalService.playSignal(AccessibilitySignal.editsUndone, { allowManyInParallel: true }); } - async show(): Promise { + async show(previousChanges?: boolean): Promise { this._assertNotDisposed(); if (this._editorPane) { if (this._editorPane.isVisible()) { @@ -612,7 +606,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } const input = MultiDiffEditorInput.fromResourceMultiDiffEditorInput({ - multiDiffSource: getMultiDiffSourceUri(this), + multiDiffSource: getMultiDiffSourceUri(this, previousChanges), label: localize('multiDiffEditorInput.name', "Suggested Edits") }, this._instantiationService); @@ -622,7 +616,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio private _stopPromise: Promise | undefined; async stop(clearState = false): Promise { - this._stopPromise ??= this._performStop(); + this._stopPromise ??= Promise.allSettled([this._performStop(), this.storeState()]).then(() => { }); await this._stopPromise; if (clearState) { await this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionId).clearState(); @@ -640,11 +634,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } }); })); - - if (this._state.get() !== ChatEditingSessionState.Disposed) { - // session got disposed while we were closing editors and clearing state - this.dispose(); - } } override dispose() { @@ -835,7 +824,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio // special case: put the last change in the pendingSnapshot as needed if (next) { if (stopIndex === snap.stops.length - 1) { - const postEdit = new ResourceMap(snap.postEdit || this._createSnapshot(undefined, undefined).entries); + const postEdit = new ResourceMap(snap.postEdit || this._createEmptySnapshot(undefined).entries); if (!snap.postEdit || !entry.equalsSnapshot(postEdit.get(entry.modifiedURI))) { postEdit.set(entry.modifiedURI, entry.createSnapshot(requestId, POST_EDIT_STOP_ID)); const newHistory = history.slice(); @@ -864,6 +853,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } private async _acceptEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise { + this._fullDiffs.delete(resource.toString()); const entry = await this._getOrCreateModifiedFileEntry(resource, this._getTelemetryInfoForModel(responseModel)); await entry.acceptAgentEdits(resource, textEdits, isLastEdits, responseModel); } @@ -895,7 +885,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return entry.acceptStreamingEditsEnd(tx); }); - this._onDidChange.fire(ChatEditingSessionChangeType.Other); } /** @@ -934,7 +923,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio const listener = entry.onDidDelete(() => { const newEntries = this._entriesObs.get().filter(e => !isEqual(e.modifiedURI, entry.modifiedURI)); this._entriesObs.set(newEntries, undefined); - this._workingSet.delete(entry.modifiedURI); this._editorService.closeEditors(this._editorService.findEditors(entry.modifiedURI)); if (!existingExternalEntry) { @@ -943,13 +931,11 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } this._store.delete(listener); - this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); }); this._store.add(listener); const entriesArr = [...this._entriesObs.get(), entry]; this._entriesObs.set(entriesArr, undefined); - this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); return entry; } @@ -959,8 +945,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio const chatKind = mustExist ? ChatEditKind.Created : ChatEditKind.Modified; const notebookUri = CellUri.parse(resource)?.notebook || resource; try { - // If a notebook isn't open, then use the old synchronization approach. - if (this._notebookService.hasSupportedNotebooks(notebookUri) && (this._notebookService.getNotebookTextModel(notebookUri) || ChatEditingModifiedNotebookEntry.canHandleSnapshotContent(initialContent))) { + if (this._notebookService.hasSupportedNotebooks(notebookUri)) { return await ChatEditingModifiedNotebookEntry.create(notebookUri, multiDiffEntryDelegate, telemetryInfo, chatKind, initialContent, this._instantiationService); } else { const ref = await this._textModelService.createModelReference(resource); @@ -991,291 +976,3 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } } - -interface StoredSessionState { - readonly initialFileContents: ResourceMap; - readonly pendingSnapshot?: IChatEditingSessionStop; - readonly recentSnapshot: IChatEditingSessionStop; - readonly linearHistoryIndex: number; - readonly linearHistory: readonly IChatEditingSessionSnapshot[]; -} - -class ChatEditingSessionStorage { - constructor( - private readonly chatSessionId: string, - @IFileService private readonly _fileService: IFileService, - @IEnvironmentService private readonly _environmentService: IEnvironmentService, - @ILogService private readonly _logService: ILogService, - @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - ) { } - - private _getStorageLocation(): URI { - const workspaceId = this._workspaceContextService.getWorkspace().id; - return joinPath(this._environmentService.workspaceStorageHome, workspaceId, 'chatEditingSessions', this.chatSessionId); - } - - public async restoreState(): Promise { - const storageLocation = this._getStorageLocation(); - const getFileContent = (hash: string) => { - return this._fileService.readFile(joinPath(storageLocation, STORAGE_CONTENTS_FOLDER, hash)).then(content => content.value.toString()); - }; - const deserializeResourceMap = (resourceMap: ResourceMapDTO, deserialize: (value: any) => T, result: ResourceMap): ResourceMap => { - resourceMap.forEach(([resourceURI, value]) => { - result.set(URI.parse(resourceURI), deserialize(value)); - }); - return result; - }; - const deserializeSnapshotEntriesDTO = async (dtoEntries: ISnapshotEntryDTO[]): Promise> => { - const entries = new ResourceMap(); - for (const entryDTO of dtoEntries) { - const entry = await deserializeSnapshotEntry(entryDTO); - entries.set(entry.resource, entry); - } - return entries; - }; - const deserializeChatEditingStopDTO = async (stopDTO: IChatEditingSessionStopDTO | IChatEditingSessionSnapshotDTO): Promise => { - const entries = await deserializeSnapshotEntriesDTO(stopDTO.entries); - const workingSet = deserializeResourceMap(stopDTO.workingSet, (value) => value, new ResourceMap()); - return { stopId: 'stopId' in stopDTO ? stopDTO.stopId : undefined, workingSet, entries }; - }; - const normalizeSnapshotDtos = (snapshot: IChatEditingSessionSnapshotDTO | IChatEditingSessionSnapshotDTO2): IChatEditingSessionSnapshotDTO2 => { - if ('stops' in snapshot) { - return snapshot; - } - return { requestId: snapshot.requestId, stops: [{ stopId: undefined, entries: snapshot.entries, workingSet: snapshot.workingSet }], postEdit: undefined }; - }; - const deserializeChatEditingSessionSnapshot = async (startIndex: number, snapshot: IChatEditingSessionSnapshotDTO2): Promise => { - const stops = await Promise.all(snapshot.stops.map(deserializeChatEditingStopDTO)); - return { startIndex, requestId: snapshot.requestId, stops, postEdit: snapshot.postEdit && await deserializeSnapshotEntriesDTO(snapshot.postEdit) }; - }; - const deserializeSnapshotEntry = async (entry: ISnapshotEntryDTO) => { - return { - resource: URI.parse(entry.resource), - languageId: entry.languageId, - original: await getFileContent(entry.originalHash), - current: await getFileContent(entry.currentHash), - originalToCurrentEdit: OffsetEdit.fromJson(entry.originalToCurrentEdit), - state: entry.state, - snapshotUri: URI.parse(entry.snapshotUri), - telemetryInfo: { requestId: entry.telemetryInfo.requestId, agentId: entry.telemetryInfo.agentId, command: entry.telemetryInfo.command, sessionId: this.chatSessionId, result: undefined } - } satisfies ISnapshotEntry; - }; - try { - const stateFilePath = joinPath(storageLocation, STORAGE_STATE_FILE); - if (! await this._fileService.exists(stateFilePath)) { - this._logService.debug(`chatEditingSession: No editing session state found at ${stateFilePath.toString()}`); - return undefined; - } - this._logService.debug(`chatEditingSession: Restoring editing session at ${stateFilePath.toString()}`); - const stateFileContent = await this._fileService.readFile(stateFilePath); - const data = JSON.parse(stateFileContent.value.toString()) as IChatEditingSessionDTO; - if (!COMPATIBLE_STORAGE_VERSIONS.includes(data.version)) { - return undefined; - } - - let linearHistoryIndex = 0; - const linearHistory = await Promise.all(data.linearHistory.map(snapshot => { - const norm = normalizeSnapshotDtos(snapshot); - const result = deserializeChatEditingSessionSnapshot(linearHistoryIndex, norm); - linearHistoryIndex += norm.stops.length; - return result; - })); - - const initialFileContents = new ResourceMap(); - for (const fileContentDTO of data.initialFileContents) { - initialFileContents.set(URI.parse(fileContentDTO[0]), await getFileContent(fileContentDTO[1])); - } - const pendingSnapshot = data.pendingSnapshot ? await deserializeChatEditingStopDTO(data.pendingSnapshot) : undefined; - const recentSnapshot = await deserializeChatEditingStopDTO(data.recentSnapshot); - - return { - initialFileContents, - pendingSnapshot, - recentSnapshot, - linearHistoryIndex: data.linearHistoryIndex, - linearHistory - }; - } catch (e) { - this._logService.error(`Error restoring chat editing session from ${storageLocation.toString()}`, e); - } - return undefined; - } - - public async storeState(state: StoredSessionState): Promise { - const storageFolder = this._getStorageLocation(); - const contentsFolder = URI.joinPath(storageFolder, STORAGE_CONTENTS_FOLDER); - - // prepare the content folder - const existingContents = new Set(); - try { - const stat = await this._fileService.resolve(contentsFolder); - stat.children?.forEach(child => { - if (child.isDirectory) { - existingContents.add(child.name); - } - }); - } catch (e) { - try { - // does not exist, create - await this._fileService.createFolder(contentsFolder); - } catch (e) { - this._logService.error(`Error creating chat editing session content folder ${contentsFolder.toString()}`, e); - return; - } - } - - const fileContents = new Map(); - const addFileContent = (content: string): string => { - const shaComputer = new StringSHA1(); - shaComputer.update(content); - const sha = shaComputer.digest().substring(0, 7); - if (!existingContents.has(sha)) { - fileContents.set(sha, content); - } - return sha; - }; - const serializeResourceMap = (resourceMap: ResourceMap, serialize: (value: T) => any): ResourceMapDTO => { - return Array.from(resourceMap.entries()).map(([resourceURI, value]) => [resourceURI.toString(), serialize(value)]); - }; - const serializeChatEditingSessionStop = (stop: IChatEditingSessionStop): IChatEditingSessionStopDTO => { - return { - stopId: stop.stopId, - workingSet: serializeResourceMap(stop.workingSet, value => value), - entries: Array.from(stop.entries.values()).map(serializeSnapshotEntry) - }; - }; - const serializeChatEditingSessionSnapshot = (snapshot: IChatEditingSessionSnapshot): IChatEditingSessionSnapshotDTO2 => { - return { - requestId: snapshot.requestId, - stops: snapshot.stops.map(serializeChatEditingSessionStop), - postEdit: snapshot.postEdit ? Array.from(snapshot.postEdit.values()).map(serializeSnapshotEntry) : undefined - }; - }; - const serializeSnapshotEntry = (entry: ISnapshotEntry): ISnapshotEntryDTO => { - return { - resource: entry.resource.toString(), - languageId: entry.languageId, - originalHash: addFileContent(entry.original), - currentHash: addFileContent(entry.current), - originalToCurrentEdit: entry.originalToCurrentEdit.edits.map(edit => ({ pos: edit.replaceRange.start, len: edit.replaceRange.length, txt: edit.newText } satisfies ISingleOffsetEdit)), - state: entry.state, - snapshotUri: entry.snapshotUri.toString(), - telemetryInfo: { requestId: entry.telemetryInfo.requestId, agentId: entry.telemetryInfo.agentId, command: entry.telemetryInfo.command } - }; - }; - - try { - const data: IChatEditingSessionDTO = { - version: STORAGE_VERSION, - sessionId: this.chatSessionId, - linearHistory: state.linearHistory.map(serializeChatEditingSessionSnapshot), - linearHistoryIndex: state.linearHistoryIndex, - initialFileContents: serializeResourceMap(state.initialFileContents, value => addFileContent(value)), - pendingSnapshot: state.pendingSnapshot ? serializeChatEditingSessionStop(state.pendingSnapshot) : undefined, - recentSnapshot: serializeChatEditingSessionStop(state.recentSnapshot), - }; - - this._logService.debug(`chatEditingSession: Storing editing session at ${storageFolder.toString()}: ${fileContents.size} files`); - - for (const [hash, content] of fileContents) { - await this._fileService.writeFile(joinPath(contentsFolder, hash), VSBuffer.fromString(content)); - } - - await this._fileService.writeFile(joinPath(storageFolder, STORAGE_STATE_FILE), VSBuffer.fromString(JSON.stringify(data, undefined, 2))); - } catch (e) { - this._logService.debug(`Error storing chat editing session to ${storageFolder.toString()}`, e); - } - } - - public async clearState(): Promise { - const storageFolder = this._getStorageLocation(); - if (await this._fileService.exists(storageFolder)) { - this._logService.debug(`chatEditingSession: Clearing editing session at ${storageFolder.toString()}`); - try { - await this._fileService.del(storageFolder, { recursive: true }); - } catch (e) { - this._logService.debug(`Error clearing chat editing session from ${storageFolder.toString()}`, e); - } - } - } -} - -export interface IChatEditingSessionSnapshot { - /** - * Index of this session in the linear history. It's the sum of the lengths - * of all {@link stops} prior this one. - */ - readonly startIndex: number; - - readonly requestId: string | undefined; - /** - * Edit stops in the request. Always initially populatd with stopId: undefind - * for th request's initial state. - * - * Invariant: never empty. - */ - readonly stops: IChatEditingSessionStop[]; - - /** Stop that represents changes after the last undo stop, kept for diffing purposes. */ - readonly postEdit: ResourceMap | undefined; -} - -interface IChatEditingSessionStop { - /** Edit stop ID, first for a request is always undefined. */ - stopId: string | undefined; - - readonly workingSet: ResourceMap; - readonly entries: ResourceMap; -} - -interface IChatEditingSessionStopDTO { - readonly stopId: string | undefined; - readonly workingSet: ResourceMapDTO; - readonly entries: ISnapshotEntryDTO[]; -} - - -interface IChatEditingSessionSnapshotDTO { - readonly requestId: string | undefined; - readonly workingSet: ResourceMapDTO; - readonly entries: ISnapshotEntryDTO[]; -} - -interface IChatEditingSessionSnapshotDTO2 { - readonly requestId: string | undefined; - readonly stops: IChatEditingSessionStopDTO[]; - readonly postEdit: ISnapshotEntryDTO[] | undefined; -} - -interface ISnapshotEntryDTO { - readonly resource: string; - readonly languageId: string; - readonly originalHash: string; - readonly currentHash: string; - readonly originalToCurrentEdit: IOffsetEdit; - readonly state: WorkingSetEntryState; - readonly snapshotUri: string; - readonly telemetryInfo: IModifiedEntryTelemetryInfoDTO; -} - -interface IModifiedEntryTelemetryInfoDTO { - readonly requestId: string; - readonly agentId?: string; - readonly command?: string; -} - -type ResourceMapDTO = [string, T][]; - -const COMPATIBLE_STORAGE_VERSIONS = [1, 2]; -const STORAGE_VERSION = 2; - -/** Old history uses IChatEditingSessionSnapshotDTO, new history uses IChatEditingSessionSnapshotDTO. */ -interface IChatEditingSessionDTO { - readonly version: number; - readonly sessionId: string; - readonly recentSnapshot: (IChatEditingSessionStopDTO | IChatEditingSessionSnapshotDTO); - readonly linearHistory: (IChatEditingSessionSnapshotDTO2 | IChatEditingSessionSnapshotDTO)[]; - readonly linearHistoryIndex: number; - readonly pendingSnapshot: (IChatEditingSessionStopDTO | IChatEditingSessionSnapshotDTO) | undefined; - readonly initialFileContents: ResourceMapDTO; -} diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts new file mode 100644 index 00000000000..7eba4f40c87 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts @@ -0,0 +1,304 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { StringSHA1 } from '../../../../../base/common/hash.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { OffsetEdit, ISingleOffsetEdit, IOffsetEdit } from '../../../../../editor/common/core/offsetEdit.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { ISnapshotEntry } from './chatEditingModifiedFileEntry.js'; +import { WorkingSetDisplayMetadata, ModifiedFileEntryState } from '../../common/chatEditingService.js'; + +const STORAGE_CONTENTS_FOLDER = 'contents'; +const STORAGE_STATE_FILE = 'state.json'; + +export interface StoredSessionState { + readonly initialFileContents: ResourceMap; + readonly pendingSnapshot?: IChatEditingSessionStop; + readonly recentSnapshot: IChatEditingSessionStop; + readonly linearHistoryIndex: number; + readonly linearHistory: readonly IChatEditingSessionSnapshot[]; +} + +export class ChatEditingSessionStorage { + constructor( + private readonly chatSessionId: string, + @IFileService private readonly _fileService: IFileService, + @IEnvironmentService private readonly _environmentService: IEnvironmentService, + @ILogService private readonly _logService: ILogService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + ) { } + + protected _getStorageLocation(): URI { + const workspaceId = this._workspaceContextService.getWorkspace().id; + return joinPath(this._environmentService.workspaceStorageHome, workspaceId, 'chatEditingSessions', this.chatSessionId); + } + + public async restoreState(): Promise { + const storageLocation = this._getStorageLocation(); + const fileContents = new Map>(); + const getFileContent = (hash: string) => { + let readPromise = fileContents.get(hash); + if (!readPromise) { + readPromise = this._fileService.readFile(joinPath(storageLocation, STORAGE_CONTENTS_FOLDER, hash)).then(content => content.value.toString()); + fileContents.set(hash, readPromise); + } + return readPromise; + }; + const deserializeSnapshotEntriesDTO = async (dtoEntries: ISnapshotEntryDTO[]): Promise> => { + const entries = new ResourceMap(); + for (const entryDTO of dtoEntries) { + const entry = await deserializeSnapshotEntry(entryDTO); + entries.set(entry.resource, entry); + } + return entries; + }; + const deserializeChatEditingStopDTO = async (stopDTO: IChatEditingSessionStopDTO | IChatEditingSessionSnapshotDTO): Promise => { + const entries = await deserializeSnapshotEntriesDTO(stopDTO.entries); + return { stopId: 'stopId' in stopDTO ? stopDTO.stopId : undefined, entries }; + }; + const normalizeSnapshotDtos = (snapshot: IChatEditingSessionSnapshotDTO | IChatEditingSessionSnapshotDTO2): IChatEditingSessionSnapshotDTO2 => { + if ('stops' in snapshot) { + return snapshot; + } + return { requestId: snapshot.requestId, stops: [{ stopId: undefined, entries: snapshot.entries }], postEdit: undefined }; + }; + const deserializeChatEditingSessionSnapshot = async (startIndex: number, snapshot: IChatEditingSessionSnapshotDTO2): Promise => { + const stops = await Promise.all(snapshot.stops.map(deserializeChatEditingStopDTO)); + return { startIndex, requestId: snapshot.requestId, stops, postEdit: snapshot.postEdit && await deserializeSnapshotEntriesDTO(snapshot.postEdit) }; + }; + const deserializeSnapshotEntry = async (entry: ISnapshotEntryDTO) => { + return { + resource: URI.parse(entry.resource), + languageId: entry.languageId, + original: await getFileContent(entry.originalHash), + current: await getFileContent(entry.currentHash), + originalToCurrentEdit: OffsetEdit.fromJson(entry.originalToCurrentEdit), + state: entry.state, + snapshotUri: URI.parse(entry.snapshotUri), + telemetryInfo: { requestId: entry.telemetryInfo.requestId, agentId: entry.telemetryInfo.agentId, command: entry.telemetryInfo.command, sessionId: this.chatSessionId, result: undefined } + } satisfies ISnapshotEntry; + }; + try { + const stateFilePath = joinPath(storageLocation, STORAGE_STATE_FILE); + if (! await this._fileService.exists(stateFilePath)) { + this._logService.debug(`chatEditingSession: No editing session state found at ${stateFilePath.toString()}`); + return undefined; + } + this._logService.debug(`chatEditingSession: Restoring editing session at ${stateFilePath.toString()}`); + const stateFileContent = await this._fileService.readFile(stateFilePath); + const data = JSON.parse(stateFileContent.value.toString()) as IChatEditingSessionDTO; + if (!COMPATIBLE_STORAGE_VERSIONS.includes(data.version)) { + return undefined; + } + + let linearHistoryIndex = 0; + const linearHistory = await Promise.all(data.linearHistory.map(snapshot => { + const norm = normalizeSnapshotDtos(snapshot); + const result = deserializeChatEditingSessionSnapshot(linearHistoryIndex, norm); + linearHistoryIndex += norm.stops.length; + return result; + })); + + const initialFileContents = new ResourceMap(); + for (const fileContentDTO of data.initialFileContents) { + initialFileContents.set(URI.parse(fileContentDTO[0]), await getFileContent(fileContentDTO[1])); + } + const pendingSnapshot = data.pendingSnapshot ? await deserializeChatEditingStopDTO(data.pendingSnapshot) : undefined; + const recentSnapshot = await deserializeChatEditingStopDTO(data.recentSnapshot); + + return { + initialFileContents, + pendingSnapshot, + recentSnapshot, + linearHistoryIndex: data.linearHistoryIndex, + linearHistory + }; + } catch (e) { + this._logService.error(`Error restoring chat editing session from ${storageLocation.toString()}`, e); + } + return undefined; + } + + public async storeState(state: StoredSessionState): Promise { + const storageFolder = this._getStorageLocation(); + const contentsFolder = URI.joinPath(storageFolder, STORAGE_CONTENTS_FOLDER); + + // prepare the content folder + const existingContents = new Set(); + try { + const stat = await this._fileService.resolve(contentsFolder); + stat.children?.forEach(child => { + if (child.isFile) { + existingContents.add(child.name); + } + }); + } catch (e) { + try { + // does not exist, create + await this._fileService.createFolder(contentsFolder); + } catch (e) { + this._logService.error(`Error creating chat editing session content folder ${contentsFolder.toString()}`, e); + return; + } + } + + const fileContents = new Map(); + const addFileContent = (content: string): string => { + const shaComputer = new StringSHA1(); + shaComputer.update(content); + const sha = shaComputer.digest().substring(0, 7); + fileContents.set(sha, content); + return sha; + }; + const serializeResourceMap = (resourceMap: ResourceMap, serialize: (value: T) => any): ResourceMapDTO => { + return Array.from(resourceMap.entries()).map(([resourceURI, value]) => [resourceURI.toString(), serialize(value)]); + }; + const serializeChatEditingSessionStop = (stop: IChatEditingSessionStop): IChatEditingSessionStopDTO => { + return { + stopId: stop.stopId, + entries: Array.from(stop.entries.values()).map(serializeSnapshotEntry) + }; + }; + const serializeChatEditingSessionSnapshot = (snapshot: IChatEditingSessionSnapshot): IChatEditingSessionSnapshotDTO2 => { + return { + requestId: snapshot.requestId, + stops: snapshot.stops.map(serializeChatEditingSessionStop), + postEdit: snapshot.postEdit ? Array.from(snapshot.postEdit.values()).map(serializeSnapshotEntry) : undefined + }; + }; + const serializeSnapshotEntry = (entry: ISnapshotEntry): ISnapshotEntryDTO => { + return { + resource: entry.resource.toString(), + languageId: entry.languageId, + originalHash: addFileContent(entry.original), + currentHash: addFileContent(entry.current), + originalToCurrentEdit: entry.originalToCurrentEdit.edits.map(edit => ({ pos: edit.replaceRange.start, len: edit.replaceRange.length, txt: edit.newText } satisfies ISingleOffsetEdit)), + state: entry.state, + snapshotUri: entry.snapshotUri.toString(), + telemetryInfo: { requestId: entry.telemetryInfo.requestId, agentId: entry.telemetryInfo.agentId, command: entry.telemetryInfo.command } + }; + }; + + try { + const data: IChatEditingSessionDTO = { + version: STORAGE_VERSION, + sessionId: this.chatSessionId, + linearHistory: state.linearHistory.map(serializeChatEditingSessionSnapshot), + linearHistoryIndex: state.linearHistoryIndex, + initialFileContents: serializeResourceMap(state.initialFileContents, value => addFileContent(value)), + pendingSnapshot: state.pendingSnapshot ? serializeChatEditingSessionStop(state.pendingSnapshot) : undefined, + recentSnapshot: serializeChatEditingSessionStop(state.recentSnapshot), + }; + + this._logService.debug(`chatEditingSession: Storing editing session at ${storageFolder.toString()}: ${fileContents.size} files`); + + for (const [hash, content] of fileContents) { + if (!existingContents.has(hash)) { + await this._fileService.writeFile(joinPath(contentsFolder, hash), VSBuffer.fromString(content)); + } + } + + await this._fileService.writeFile(joinPath(storageFolder, STORAGE_STATE_FILE), VSBuffer.fromString(JSON.stringify(data))); + } catch (e) { + this._logService.debug(`Error storing chat editing session to ${storageFolder.toString()}`, e); + } + } + + public async clearState(): Promise { + const storageFolder = this._getStorageLocation(); + if (await this._fileService.exists(storageFolder)) { + this._logService.debug(`chatEditingSession: Clearing editing session at ${storageFolder.toString()}`); + try { + await this._fileService.del(storageFolder, { recursive: true }); + } catch (e) { + this._logService.debug(`Error clearing chat editing session from ${storageFolder.toString()}`, e); + } + } + } +} + +export interface IChatEditingSessionSnapshot { + /** + * Index of this session in the linear history. It's the sum of the lengths + * of all {@link stops} prior this one. + */ + readonly startIndex: number; + + readonly requestId: string | undefined; + /** + * Edit stops in the request. Always initially populatd with stopId: undefind + * for th request's initial state. + * + * Invariant: never empty. + */ + readonly stops: IChatEditingSessionStop[]; + + /** Stop that represents changes after the last undo stop, kept for diffing purposes. */ + readonly postEdit: ResourceMap | undefined; +} + +export interface IChatEditingSessionStop { + /** Edit stop ID, first for a request is always undefined. */ + stopId: string | undefined; + + readonly entries: ResourceMap; +} + +interface IChatEditingSessionStopDTO { + readonly stopId: string | undefined; + readonly entries: ISnapshotEntryDTO[]; +} + + +interface IChatEditingSessionSnapshotDTO { + readonly requestId: string | undefined; + readonly workingSet: ResourceMapDTO; + readonly entries: ISnapshotEntryDTO[]; +} + +interface IChatEditingSessionSnapshotDTO2 { + readonly requestId: string | undefined; + readonly stops: IChatEditingSessionStopDTO[]; + readonly postEdit: ISnapshotEntryDTO[] | undefined; +} + +interface ISnapshotEntryDTO { + readonly resource: string; + readonly languageId: string; + readonly originalHash: string; + readonly currentHash: string; + readonly originalToCurrentEdit: IOffsetEdit; + readonly state: ModifiedFileEntryState; + readonly snapshotUri: string; + readonly telemetryInfo: IModifiedEntryTelemetryInfoDTO; +} + +interface IModifiedEntryTelemetryInfoDTO { + readonly requestId: string; + readonly agentId?: string; + readonly command?: string; +} + +type ResourceMapDTO = [string, T][]; + +const COMPATIBLE_STORAGE_VERSIONS = [1, 2]; +const STORAGE_VERSION = 2; + +/** Old history uses IChatEditingSessionSnapshotDTO, new history uses IChatEditingSessionSnapshotDTO. */ +interface IChatEditingSessionDTO { + readonly version: number; + readonly sessionId: string; + readonly recentSnapshot: (IChatEditingSessionStopDTO | IChatEditingSessionSnapshotDTO); + readonly linearHistory: (IChatEditingSessionSnapshotDTO2 | IChatEditingSessionSnapshotDTO)[]; + readonly linearHistoryIndex: number; + readonly pendingSnapshot: (IChatEditingSessionStopDTO | IChatEditingSessionSnapshotDTO) | undefined; + readonly initialFileContents: ResourceMapDTO; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts index 5066d63b7c1..3d70ef8377b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts @@ -30,8 +30,8 @@ export function parseNotebookSnapshotFileURI(resource: URI): ChatEditingSnapshot return { sessionId: data.sessionId ?? '', requestId: data.requestId ?? '', undoStop: data.undoStop ?? '', viewType: data.viewType }; } -export function createSnapshot(notebook: NotebookTextModel, transientOptions: TransientOptions | undefined, configurationService: IConfigurationService): string { - const outputSizeLimit = configurationService.getValue(NotebookSetting.outputBackupSizeLimit) * 1024; +export function createSnapshot(notebook: NotebookTextModel, transientOptions: TransientOptions | undefined, outputSizeConfig: IConfigurationService | number): string { + const outputSizeLimit = (typeof outputSizeConfig === 'number' ? outputSizeConfig : outputSizeConfig.getValue(NotebookSetting.outputBackupSizeLimit)) * 1024; return serializeSnapshot(notebook.createSnapshot({ context: SnapshotContext.Backup, outputSizeLimit, transientOptions }), transientOptions); } @@ -41,9 +41,9 @@ export function restoreSnapshot(notebook: NotebookTextModel, snapshot: string): notebook.restoreSnapshot(data, transientOptions); const edits: ICellEditOperation[] = []; data.cells.forEach((cell, index) => { - const cellId = cell.internalMetadata?.cellId; - if (cellId) { - edits.push({ editType: CellEditType.PartialInternalMetadata, index, internalMetadata: { cellId } }); + const internalId = cell.internalMetadata?.internalId; + if (internalId) { + edits.push({ editType: CellEditType.PartialInternalMetadata, index, internalMetadata: { internalId } }); } }); notebook.applyEdits(edits, true, undefined, () => undefined, undefined, false); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookCellEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookCellEntry.ts index 9418dc7c6bd..1697e227fe6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookCellEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookCellEntry.ts @@ -26,7 +26,8 @@ import { editorSelectionBackground } from '../../../../../../platform/theme/comm import { CellEditState } from '../../../../notebook/browser/notebookBrowser.js'; import { INotebookEditorService } from '../../../../notebook/browser/services/notebookEditorService.js'; import { NotebookCellTextModel } from '../../../../notebook/common/model/notebookCellTextModel.js'; -import { WorkingSetEntryState } from '../../../common/chatEditingService.js'; +import { CellKind } from '../../../../notebook/common/notebookCommon.js'; +import { ModifiedFileEntryState } from '../../../common/chatEditingService.js'; import { IChatResponseModel } from '../../../common/chatModel.js'; import { pendingRewriteMinimap } from '../chatEditingModifiedFileEntry.js'; @@ -83,8 +84,8 @@ export class ChatEditingNotebookCellEntry extends ObservableDisposable { private _editDecorations: string[] = []; private readonly _diffTrimWhitespace: IObservable; - protected readonly _stateObs = observableValue(this, WorkingSetEntryState.Modified); - readonly state: IObservable = this._stateObs; + protected readonly _stateObs = observableValue(this, ModifiedFileEntryState.Modified); + readonly state: IObservable = this._stateObs; protected readonly _isCurrentlyBeingModifiedByObs = observableValue(this, undefined); readonly isCurrentlyBeingModifiedBy: IObservable = this._isCurrentlyBeingModifiedByObs; private readonly initialContent: string; @@ -169,9 +170,9 @@ export class ChatEditingNotebookCellEntry extends ObservableDisposable { const didResetToOriginalContent = this.modifiedModel.getValue() === this.initialContent; const currentState = this._stateObs.get(); switch (currentState) { - case WorkingSetEntryState.Modified: + case ModifiedFileEntryState.Modified: if (didResetToOriginalContent) { - this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + this._stateObs.set(ModifiedFileEntryState.Rejected, undefined); break; } } @@ -212,7 +213,7 @@ export class ChatEditingNotebookCellEntry extends ObservableDisposable { transaction((tx) => { if (!isLastEdits) { - this._stateObs.set(WorkingSetEntryState.Modified, tx); + this._stateObs.set(ModifiedFileEntryState.Modified, tx); this._isCurrentlyBeingModifiedByObs.set(responseModel, tx); this._maxModifiedLineNumber.set(maxLineNumber, tx); @@ -229,6 +230,21 @@ export class ChatEditingNotebookCellEntry extends ObservableDisposable { this._editDecorationClear.schedule(); } + revertMarkdownPreviewState(): void { + if (this.cell.cellKind !== CellKind.Markup) { + return; + } + + const notebookEditor = this.notebookEditorService.retrieveExistingWidgetFromURI(this.notebookUri)?.value; + if (notebookEditor) { + const vm = notebookEditor.getCellByHandle(this.cell.handle); + if (vm?.getEditState() === CellEditState.Editing && + (vm.editStateSource === 'chatEdit' || vm.editStateSource === 'chatEditNavigation')) { + vm?.updateEditState(CellEditState.Preview, 'chatEdit'); + } + } + } + protected _resetEditsState(tx: ITransaction): void { this._isCurrentlyBeingModifiedByObs.set(undefined, tx); this._maxModifiedLineNumber.set(0, tx); @@ -241,7 +257,7 @@ export class ChatEditingNotebookCellEntry extends ObservableDisposable { private async _acceptHunk(change: DetailedLineRangeMapping): Promise { this._isEditFromUs = true; try { - if (!this._diffInfo.get().changes.includes(change)) { + if (!this._diffInfo.get().changes.filter(c => c.modified.equals(change.modified) && c.original.equals(change.original)).length) { // diffInfo should have model version ids and check them (instead of the caller doing that) return false; } @@ -257,7 +273,8 @@ export class ChatEditingNotebookCellEntry extends ObservableDisposable { } await this._updateDiffInfoSeq(); if (this._diffInfo.get().identical) { - this._stateObs.set(WorkingSetEntryState.Accepted, undefined); + this.revertMarkdownPreviewState(); + this._stateObs.set(ModifiedFileEntryState.Accepted, undefined); } return true; } @@ -283,7 +300,8 @@ export class ChatEditingNotebookCellEntry extends ObservableDisposable { } await this._updateDiffInfoSeq(); if (this._diffInfo.get().identical) { - this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + this.revertMarkdownPreviewState(); + this._stateObs.set(ModifiedFileEntryState.Rejected, undefined); } return true; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts index 819d1329779..ff79d9d3ea4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingNotebookEditorIntegration.ts @@ -3,32 +3,37 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { autorun, derivedWithStore, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; -import { debouncedObservable } from '../../../../../../base/common/observableInternal/utils.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, debouncedObservable, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { basename } from '../../../../../../base/common/resources.js'; import { assertType } from '../../../../../../base/common/types.js'; import { LineRange } from '../../../../../../editor/common/core/lineRange.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { nullDocumentDiff } from '../../../../../../editor/common/diff/documentDiffProvider.js'; +import { PrefixSumComputer } from '../../../../../../editor/common/model/prefixSumComputer.js'; import { localize } from '../../../../../../nls.js'; +import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { MenuId } from '../../../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IEditorPane, IResourceDiffEditorInput } from '../../../../../common/editor.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { NotebookDeletedCellDecorator } from '../../../../notebook/browser/diff/inlineDiff/notebookDeletedCellDecorator.js'; import { NotebookInsertedCellDecorator } from '../../../../notebook/browser/diff/inlineDiff/notebookInsertedCellDecorator.js'; +import { NotebookModifiedCellDecorator } from '../../../../notebook/browser/diff/inlineDiff/notebookModifiedCellDecorator.js'; import { INotebookTextDiffEditor } from '../../../../notebook/browser/diff/notebookDiffEditorBrowser.js'; -import { getNotebookEditorFromEditorPane, ICellViewModel, INotebookEditor } from '../../../../notebook/browser/notebookBrowser.js'; +import { CellEditState, getNotebookEditorFromEditorPane, ICellViewModel, INotebookEditor } from '../../../../notebook/browser/notebookBrowser.js'; import { INotebookEditorService } from '../../../../notebook/browser/services/notebookEditorService.js'; import { NotebookCellTextModel } from '../../../../notebook/common/model/notebookCellTextModel.js'; import { NotebookTextModel } from '../../../../notebook/common/model/notebookTextModel.js'; +import { CellKind } from '../../../../notebook/common/notebookCommon.js'; import { IChatAgentService } from '../../../common/chatAgents.js'; import { IModifiedFileEntryChangeHunk, IModifiedFileEntryEditorIntegration } from '../../../common/chatEditingService.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { ChatEditingCodeEditorIntegration, IDocumentDiff2 } from '../chatEditingCodeEditorIntegration.js'; import { ChatEditingModifiedNotebookEntry } from '../chatEditingModifiedNotebookEntry.js'; import { countChanges, ICellDiffInfo, sortCellChanges } from './notebookCellChanges.js'; +import { OverlayToolbarDecorator } from './overlayToolbarDecorator.js'; export class ChatEditingNotebookEditorIntegration extends Disposable implements IModifiedFileEntryEditorIntegration { private integration: ChatEditingNotebookEditorWidgetIntegration; @@ -71,14 +76,19 @@ export class ChatEditingNotebookEditorIntegration extends Disposable implements enableAccessibleDiffView(): void { this.integration.enableAccessibleDiffView(); } - acceptNearestChange(change: IModifiedFileEntryChangeHunk): void { - this.integration.acceptNearestChange(change); + acceptNearestChange(change: IModifiedFileEntryChangeHunk | undefined): Promise { + return this.integration.acceptNearestChange(change); } - rejectNearestChange(change: IModifiedFileEntryChangeHunk): void { - this.integration.rejectNearestChange(change); + rejectNearestChange(change: IModifiedFileEntryChangeHunk | undefined): Promise { + return this.integration.rejectNearestChange(change); } - toggleDiff(change: IModifiedFileEntryChangeHunk | undefined): Promise { - return this.integration.toggleDiff(change); + toggleDiff(change: IModifiedFileEntryChangeHunk | undefined, show?: boolean): Promise { + return this.integration.toggleDiff(change, show); + } + + public override dispose(): void { + this.integration.dispose(); + super.dispose(); } } @@ -86,12 +96,19 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I private readonly _currentIndex = observableValue(this, -1); readonly currentIndex: IObservable = this._currentIndex; - private readonly _currentChange = observableValue<{ change: ICellDiffInfo; index: number } | undefined>(this, undefined); - readonly currentChange: IObservable<{ change: ICellDiffInfo; index: number } | undefined> = this._currentChange; + private deletedCellDecorator: NotebookDeletedCellDecorator | undefined; + private insertedCellDecorator: NotebookInsertedCellDecorator | undefined; + private modifiedCellDecorator: NotebookModifiedCellDecorator | undefined; + private overlayToolbarDecorator: OverlayToolbarDecorator | undefined; private readonly cellEditorIntegrations = new Map }>(); - private readonly insertDeleteDecorators: IObservable<{ insertedCellDecorator: NotebookInsertedCellDecorator; deletedCellDecorator: NotebookDeletedCellDecorator } | undefined>; + private readonly markdownEditState = observableValue(this, ''); + + private markupCellListeners = new Map(); + + private sortedCellChanges: ICellDiffInfo[] = []; + private changeIndexComputer: PrefixSumComputer = new PrefixSumComputer(new Uint32Array(0)); constructor( private readonly _entry: ChatEditingModifiedNotebookEntry, @@ -103,11 +120,16 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I @IEditorService private readonly _editorService: IEditorService, @IChatAgentService private readonly _chatAgentService: IChatAgentService, @INotebookEditorService notebookEditorService: INotebookEditorService, + @IAccessibilitySignalService private readonly accessibilitySignalService: IAccessibilitySignalService, + @ILogService private readonly logService: ILogService, ) { super(); const onDidChangeVisibleRanges = debouncedObservable(observableFromEvent(notebookEditor.onDidChangeVisibleRanges, () => notebookEditor.visibleRanges), 50); - const notebookEdotirViewModelAttached = observableFromEvent(notebookEditor.onDidAttachViewModel, () => notebookEditor.getViewModel()); + + this._register(toDisposable(() => { + this.markupCellListeners.forEach((v) => v.dispose()); + })); let originalReadonly: boolean | undefined = undefined; const shouldBeReadonly = _entry.isCurrentlyBeingModifiedBy.map(value => !!value); @@ -119,13 +141,26 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I } originalReadonly ??= notebookEditor.isReadOnly; if (isReadOnly) { - if (!notebookEditor.isReadOnly) { + notebookEditor.setOptions({ isReadOnly: true }); + } else if (originalReadonly === false) { + notebookEditor.setOptions({ isReadOnly: false }); + // Ensure all cells area editable. + // We make use of chatEditingCodeEditorIntegration to handle cell diffing and navigation. + // However that also makes the cell read-only. We need to ensure that the cell is editable. + // E.g. first we make notebook readonly (in here), then cells end up being readonly because notebook is readonly. + // Then chatEditingCodeEditorIntegration makes cells readonly and keeps track of the original readonly state. + // However the cell is already readonly because the notebook is readonly. + // So when we restore the notebook to editable (in here), the cell is made editable again. + // But when chatEditingCodeEditorIntegration attempts to restore, it will restore the original readonly state. + // & from the perpspective of chatEditingCodeEditorIntegration, the cell was readonly & should continue to be readonly. + // To get around this, we wait for a few ms before restoring the original readonly state for each cell. + const timeout = setTimeout(() => { notebookEditor.setOptions({ isReadOnly: true }); - } - } else { - if (notebookEditor.isReadOnly && originalReadonly === false) { notebookEditor.setOptions({ isReadOnly: false }); - } + disposable.dispose(); + }, 100); + const disposable = toDisposable(() => clearTimeout(timeout)); + this._register(disposable); } })); @@ -138,12 +173,31 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I && lastModifyingRequestId !== _entry.lastModifyingRequestId && cellChanges.read(r).some(c => c.type !== 'unchanged' && !c.diff.read(r).identical) ) { + lastModifyingRequestId = _entry.lastModifyingRequestId; this.reveal(true); } })); + this._register(autorun(r => { + this.sortedCellChanges = sortCellChanges(cellChanges.read(r)); + const indexes: number[] = []; + for (const change of this.sortedCellChanges) { + indexes.push(change.type === 'insert' || change.type === 'delete' ? 1 + : change.type === 'modified' ? change.diff.read(r).changes.length + : 0); + } + + this.changeIndexComputer = new PrefixSumComputer(new Uint32Array(indexes)); + if (this.changeIndexComputer.getTotalSum() === 0) { + this.revertMarkupCellState(); + } + })); + // Build cell integrations (responsible for navigating changes within a cell and decorating cell text changes) this._register(autorun(r => { + if (this.notebookEditor.textModel !== this.notebookModel) { + return; + } const sortedCellChanges = sortCellChanges(cellChanges.read(r)); const changes = sortedCellChanges.filter(c => c.type !== 'delete'); @@ -154,6 +208,7 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I }); return; } + this.markdownEditState.read(r); const validCells = new Set(); changes.forEach((change) => { @@ -164,7 +219,21 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I const editor = notebookEditor.codeEditors.find(([vm,]) => vm.handle === notebookModel.cells[change.modifiedCellIndex].handle)?.[1]; const modifiedModel = change.modifiedModel.promiseResult.read(r)?.data; const originalModel = change.originalModel.promiseResult.read(r)?.data; - if (!editor || !cell || !originalModel || !modifiedModel) { + if (!cell || !originalModel || !modifiedModel) { + return; + } + if (cell.cellKind === CellKind.Markup && !this.markupCellListeners.has(cell.handle)) { + const cellModel = this.notebookEditor.getViewModel()?.viewCells.find(c => c.handle === cell.handle); + if (cellModel) { + const listener = cellModel.onDidChangeState((e) => { + if (e.editStateChanged) { + setTimeout(() => this.markdownEditState.set(cellModel.handle + '-' + cellModel.getEditState(), undefined), 0); + } + }); + this.markupCellListeners.set(cell.handle, listener); + } + } + if (!editor) { return; } const diff = { @@ -183,7 +252,7 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I } } else { const diff2 = observableValue(`diff${cell.handle}`, diff); - const integration = this.instantiationService.createInstance(ChatEditingCodeEditorIntegration, _entry, editor, diff2); + const integration = this.instantiationService.createInstance(ChatEditingCodeEditorIntegration, _entry, editor, diff2, true); this.cellEditorIntegrations.set(cell, { integration, diff: diff2 }); this._register(integration); this._register(editor.onDidDispose(() => { @@ -208,79 +277,82 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I }); })); - this._register(autorun(r => { - const currentChange = this.currentChange.read(r); - if (!currentChange) { - this._currentIndex.set(-1, undefined); - return; - } - - let index = 0; - const sortedCellChanges = sortCellChanges(cellChanges.read(r)); - for (const change of sortedCellChanges) { - if (currentChange && currentChange.change === change) { - if (change.type === 'modified') { - index += currentChange.index; - } - break; - } - if (change.type === 'insert' || change.type === 'delete') { - index++; - } else if (change.type === 'modified') { - index += change.diff.read(r).changes.length; - } - } - - this._currentIndex.set(index, undefined); - })); - - this.insertDeleteDecorators = derivedWithStore((r, store) => { - if (!notebookEdotirViewModelAttached.read(r)) { - return; - } - - const insertedCellDecorator = store.add(this.instantiationService.createInstance(NotebookInsertedCellDecorator, this.notebookEditor)); - const deletedCellDecorator = store.add(this.instantiationService.createInstance(NotebookDeletedCellDecorator, this.notebookEditor, { - className: 'chat-diff-change-content-widget', - telemetrySource: 'chatEditingNotebookHunk', - menuId: MenuId.ChatEditingEditorHunk, - argFactory: (deletedCellIndex: number) => { - return { - accept() { - const entry = cellChanges.get().find(c => c.type === 'delete' && c.originalCellIndex === deletedCellIndex); - if (entry) { - return entry.keep(entry.diff.get().changes[0]); - } - return Promise.resolve(true); - }, - reject() { - const entry = cellChanges.get().find(c => c.type === 'delete' && c.originalCellIndex === deletedCellIndex); - if (entry) { - return entry.undo(entry.diff.get().changes[0]); - } - return Promise.resolve(true); - }, - } satisfies IModifiedFileEntryChangeHunk; - } - })); - - return { - insertedCellDecorator, - deletedCellDecorator - }; - }); - const cellsAreVisible = onDidChangeVisibleRanges.map(v => v.length > 0); + const debouncedChanges = debouncedObservable(cellChanges, 10); this._register(autorun(r => { - if (!cellsAreVisible.read(r)) { + if (this.notebookEditor.textModel !== this.notebookModel || !cellsAreVisible.read(r) || !this.notebookEditor.getViewModel()) { return; } - // We can have inserted cells that have been accepted, in those cases we do not wany any decorators on them. - const changes = debouncedObservable(cellChanges, 10).read(r).filter(c => c.type === 'insert' ? !c.diff.read(r).identical : true); - const decorators = debouncedObservable(this.insertDeleteDecorators, 10).read(r); - if (decorators) { - decorators.insertedCellDecorator.apply(changes); - decorators.deletedCellDecorator.apply(changes, originalModel); + // We can have inserted cells that have been accepted, in those cases we do not want any decorators on them. + const changes = debouncedChanges.read(r).filter(c => c.type === 'insert' ? !c.diff.read(r).identical : true); + const modifiedChanges = changes.filter(c => c.type === 'modified'); + + this.createDecorators(); + // If all cells are just inserts, then no need to show any decorations. + if (changes.every(c => c.type === 'insert')) { + this.insertedCellDecorator?.apply([]); + this.modifiedCellDecorator?.apply([]); + this.deletedCellDecorator?.apply([], originalModel); + this.overlayToolbarDecorator?.decorate([]); + } else { + this.insertedCellDecorator?.apply(changes); + this.modifiedCellDecorator?.apply(modifiedChanges); + this.deletedCellDecorator?.apply(changes, originalModel); + this.overlayToolbarDecorator?.decorate(changes.filter(c => c.type === 'insert' || c.type === 'modified')); + } + })); + } + + private getCurrentChange() { + const currentIndex = Math.min(this._currentIndex.get(), this.changeIndexComputer.getTotalSum() - 1); + const index = this.changeIndexComputer.getIndexOf(currentIndex); + const change = this.sortedCellChanges[index.index]; + + return change ? { change, index: index.remainder } : undefined; + } + + private updateCurrentIndex(change: ICellDiffInfo, indexInCell: number = 0) { + const index = this.sortedCellChanges.indexOf(change); + const changeIndex = this.changeIndexComputer.getPrefixSum(index - 1); + const currentIndex = Math.min(changeIndex + indexInCell, this.changeIndexComputer.getTotalSum() - 1); + this._currentIndex.set(currentIndex, undefined); + } + + private createDecorators() { + const cellChanges = this.cellChanges.get(); + const accessibilitySignalService = this.accessibilitySignalService; + + this.insertedCellDecorator ??= this._register(this.instantiationService.createInstance(NotebookInsertedCellDecorator, this.notebookEditor)); + this.modifiedCellDecorator ??= this._register(this.instantiationService.createInstance(NotebookModifiedCellDecorator, this.notebookEditor)); + this.overlayToolbarDecorator ??= this._register(this.instantiationService.createInstance(OverlayToolbarDecorator, this.notebookEditor, this.notebookModel)); + + if (this.deletedCellDecorator) { + this._store.delete(this.deletedCellDecorator); + this.deletedCellDecorator.dispose(); + } + this.deletedCellDecorator = this._register(this.instantiationService.createInstance(NotebookDeletedCellDecorator, this.notebookEditor, { + className: 'chat-diff-change-content-widget', + telemetrySource: 'chatEditingNotebookHunk', + menuId: MenuId.ChatEditingEditorHunk, + argFactory: (deletedCellIndex: number) => { + return { + accept() { + const entry = cellChanges.find(c => c.type === 'delete' && c.originalCellIndex === deletedCellIndex); + if (entry) { + return entry.keep(entry.diff.get().changes[0]); + } + accessibilitySignalService.playSignal(AccessibilitySignal.editsKept, { allowManyInParallel: true }); + return Promise.resolve(true); + }, + reject() { + const entry = cellChanges.find(c => c.type === 'delete' && c.originalCellIndex === deletedCellIndex); + if (entry) { + return entry.undo(entry.diff.get().changes[0]); + } + accessibilitySignalService.playSignal(AccessibilitySignal.editsUndone, { allowManyInParallel: true }); + return Promise.resolve(true); + }, + } satisfies IModifiedFileEntryChangeHunk; } })); } @@ -292,9 +364,9 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I } reveal(firstOrLast: boolean): void { - const changes = sortCellChanges(this.cellChanges.get().filter(c => c.type !== 'unchanged')); + const changes = this.sortedCellChanges.filter(c => c.type !== 'unchanged'); if (!changes.length) { - return undefined; + return; } const change = firstOrLast ? changes[0] : changes[changes.length - 1]; this._revealFirstOrLast(change, firstOrLast); @@ -305,20 +377,15 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I case 'insert': case 'modified': { + this.blur(this.getCurrentChange()?.change); const index = firstOrLast || change.type === 'insert' ? 0 : change.diff.get().changes.length - 1; - const cellIntegration = this.getCell(change.modifiedCellIndex); - if (cellIntegration) { - cellIntegration.reveal(firstOrLast); - this._currentChange.set({ change: change, index }, undefined); - return true; - } else { - return this._revealChange(change, index); - } + return this._revealChange(change, index); } case 'delete': + this.blur(this.getCurrentChange()?.change); // reveal the deleted cell decorator - this.insertDeleteDecorators.get()?.deletedCellDecorator.reveal(change.originalCellIndex); - this._currentChange.set({ change: change, index: 0 }, undefined); + this.deletedCellDecorator?.reveal(change.originalCellIndex); + this.updateCurrentIndex(change); return true; default: break; @@ -335,16 +402,17 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I const textChange = change.diff.get().changes[indexInCell]; const cellViewModel = this.getCellViewModel(change); if (cellViewModel) { - this.revealChangeInView(cellViewModel, textChange.modified); - this._currentChange.set({ change: change, index: indexInCell }, undefined); + this.updateCurrentIndex(change, indexInCell); + this.revealChangeInView(cellViewModel, textChange?.modified, change) + .catch(err => { this.logService.warn(`Error revealing change in view: ${err}`); }); + return true; } - - return true; + break; } case 'delete': + this.updateCurrentIndex(change); // reveal the deleted cell decorator - this.insertDeleteDecorators.get()?.deletedCellDecorator.reveal(change.originalCellIndex); - this._currentChange.set({ change: change, index: 0 }, undefined); + this.deletedCellDecorator?.reveal(change.originalCellIndex); return true; default: break; @@ -354,7 +422,7 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I } private getCellViewModel(change: ICellDiffInfo) { - if (change.type === 'delete' || change.modifiedCellIndex === undefined) { + if (change.type === 'delete' || change.modifiedCellIndex === undefined || change.modifiedCellIndex >= this.notebookModel.cells.length) { return undefined; } const cell = this.notebookModel.cells[change.modifiedCellIndex]; @@ -362,14 +430,40 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I return cellViewModel; } - private async revealChangeInView(cell: ICellViewModel, lines: LineRange): Promise { - await this.notebookEditor.focusNotebookCell(cell, 'editor', { focusEditorLine: lines.startLineNumber }); - await this.notebookEditor.revealRangeInCenterAsync(cell, new Range(lines.startLineNumber, 0, lines.endLineNumberExclusive, 0)); + private async revealChangeInView(cell: ICellViewModel, lines: LineRange | undefined, change: ICellDiffInfo): Promise { + const targetLines = lines ?? new LineRange(0, 0); + if (change.type === 'modified' && cell.cellKind === CellKind.Markup && cell.getEditState() === CellEditState.Preview) { + cell.updateEditState(CellEditState.Editing, 'chatEditNavigation'); + } + + const focusTarget = cell.cellKind === CellKind.Code || change.type === 'modified' ? 'editor' : 'container'; + await this.notebookEditor.focusNotebookCell(cell, focusTarget, { focusEditorLine: targetLines.startLineNumber }); + await this.notebookEditor.revealRangeInCenterAsync(cell, new Range(targetLines.startLineNumber, 0, targetLines.endLineNumberExclusive, 0)); + } + + private revertMarkupCellState() { + for (const change of this.sortedCellChanges) { + const cellViewModel = this.getCellViewModel(change); + if (cellViewModel?.cellKind === CellKind.Markup && cellViewModel.getEditState() === CellEditState.Editing && + (cellViewModel.editStateSource === 'chatEditNavigation' || cellViewModel.editStateSource === 'chatEdit')) { + cellViewModel.updateEditState(CellEditState.Preview, 'chatEdit'); + } + } + } + + private blur(change: ICellDiffInfo | undefined) { + if (!change) { + return; + } + const cellViewModel = this.getCellViewModel(change); + if (cellViewModel?.cellKind === CellKind.Markup && cellViewModel.getEditState() === CellEditState.Editing && cellViewModel.editStateSource === 'chatEditNavigation') { + cellViewModel.updateEditState(CellEditState.Preview, 'chatEditNavigation'); + } } next(wrap: boolean): boolean { - const changes = sortCellChanges(this.cellChanges.get().filter(c => c.type !== 'unchanged')); - const currentChange = this.currentChange.get(); + const changes = this.sortedCellChanges.filter(c => c.type !== 'unchanged'); + const currentChange = this.getCurrentChange(); if (!currentChange) { const firstChange = changes[0]; @@ -388,27 +482,34 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I const cellIntegration = this.getCell(currentChange.change.modifiedCellIndex); if (cellIntegration) { if (cellIntegration.next(false)) { - this._currentChange.set({ change: currentChange.change, index: cellIntegration.currentIndex.get() }, undefined); + this.updateCurrentIndex(currentChange.change, cellIntegration.currentIndex.get()); return true; } } - const isLastChangeInCell = currentChange.index === lastChangeIndex(currentChange.change); + const isLastChangeInCell = currentChange.index >= lastChangeIndex(currentChange.change); const index = isLastChangeInCell ? 0 : currentChange.index + 1; const change = isLastChangeInCell ? changes[changes.indexOf(currentChange.change) + 1] : currentChange.change; if (change) { - return this._revealChange(change, index); + if (isLastChangeInCell) { + this.blur(currentChange.change); + } + + if (this._revealChange(change, index)) { + return true; + } } } break; case 'insert': case 'delete': { + this.blur(currentChange.change); // go to next change directly const nextChange = changes[changes.indexOf(currentChange.change) + 1]; - if (nextChange) { - return this._revealFirstOrLast(nextChange, true); + if (nextChange && this._revealFirstOrLast(nextChange, true)) { + return true; } } break; @@ -417,15 +518,18 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I } if (wrap) { - return this.next(false); + const firstChange = changes[0]; + if (firstChange) { + return this._revealFirstOrLast(firstChange, true); + } } return false; } previous(wrap: boolean): boolean { - const changes = sortCellChanges(this.cellChanges.get().filter(c => c.type !== 'unchanged')); - const currentChange = this.currentChange.get(); + const changes = this.sortedCellChanges.filter(c => c.type !== 'unchanged'); + const currentChange = this.getCurrentChange(); if (!currentChange) { const lastChange = changes[changes.length - 1]; if (lastChange) { @@ -443,27 +547,33 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I const cellIntegration = this.getCell(currentChange.change.modifiedCellIndex); if (cellIntegration) { if (cellIntegration.previous(false)) { - this._currentChange.set({ change: currentChange.change, index: cellIntegration.currentIndex.get() }, undefined); + this.updateCurrentIndex(currentChange.change, cellIntegration.currentIndex.get()); return true; } } - const isFirstChangeInCell = currentChange.index === 0; - const index = isFirstChangeInCell ? 0 : currentChange.index - 1; + const isFirstChangeInCell = currentChange.index <= 0; const change = isFirstChangeInCell ? changes[changes.indexOf(currentChange.change) - 1] : currentChange.change; if (change) { - return this._revealChange(change, index); + const index = isFirstChangeInCell ? lastChangeIndex(change) : currentChange.index - 1; + if (isFirstChangeInCell) { + this.blur(currentChange.change); + } + if (this._revealChange(change, index)) { + return true; + } } } break; case 'insert': case 'delete': { + this.blur(currentChange.change); // go to previous change directly const prevChange = changes[changes.indexOf(currentChange.change) - 1]; - if (prevChange) { - return this._revealFirstOrLast(prevChange, false); + if (prevChange && this._revealFirstOrLast(prevChange, false)) { + return true; } } break; @@ -488,23 +598,60 @@ class ChatEditingNotebookEditorWidgetIntegration extends Disposable implements I integration?.enableAccessibleDiffView(); } } - acceptNearestChange(change: IModifiedFileEntryChangeHunk): void { - change.accept(); - this.next(true); + + private getfocusedIntegration(): ChatEditingCodeEditorIntegration | undefined { + const first = this.notebookEditor.getSelectionViewModels()[0]; + if (first) { + return this.cellEditorIntegrations.get(first.model)?.integration; + } + return undefined; } - rejectNearestChange(change: IModifiedFileEntryChangeHunk): void { - change.reject(); - this.next(true); + + async acceptNearestChange(hunk: IModifiedFileEntryChangeHunk | undefined): Promise { + if (hunk) { + await hunk.accept(); + } else { + const current = this.getCurrentChange(); + const focused = this.getfocusedIntegration(); + // delete changes can't be focused + if (current && !focused || current?.change.type === 'delete') { + current.change.keep(current?.change.diff.get().changes[current.index]); + } else if (focused) { + await focused.acceptNearestChange(); + } + + this._currentIndex.set(this._currentIndex.get() - 1, undefined); + this.next(true); + } } - async toggleDiff(_change: IModifiedFileEntryChangeHunk | undefined): Promise { - const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.EditingSession)?.fullName; - const diffInput = { - original: { resource: this._entry.originalURI, options: { selection: undefined } }, - modified: { resource: this._entry.modifiedURI, options: { selection: undefined } }, + + async rejectNearestChange(hunk: IModifiedFileEntryChangeHunk | undefined): Promise { + if (hunk) { + await hunk.reject(); + } else { + const current = this.getCurrentChange(); + const focused = this.getfocusedIntegration(); + // delete changes can't be focused + if (current && !focused || current?.change.type === 'delete') { + current.change.undo(current.change.diff.get().changes[current.index]); + } else if (focused) { + await focused.rejectNearestChange(); + } + + this._currentIndex.set(this._currentIndex.get() - 1, undefined); + this.next(true); + } + + } + async toggleDiff(_change: IModifiedFileEntryChangeHunk | undefined, _show?: boolean): Promise { + const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)?.fullName; + const diffInput: IResourceDiffEditorInput = { + original: { resource: this._entry.originalURI }, + modified: { resource: this._entry.modifiedURI }, label: defaultAgentName ? localize('diff.agent', '{0} (changes from {1})', basename(this._entry.modifiedURI), defaultAgentName) : localize('diff.generic', '{0} (changes from chat)', basename(this._entry.modifiedURI)) - } satisfies IResourceDiffEditorInput; + }; await this._editorService.openEditor(diffInput); } @@ -568,15 +715,15 @@ export class ChatEditingNotebookDiffEditorIntegration extends Disposable impleme enableAccessibleDiffView(): void { // } - acceptNearestChange(change: IModifiedFileEntryChangeHunk): void { - change.accept(); + async acceptNearestChange(change: IModifiedFileEntryChangeHunk): Promise { + await change.accept(); this.next(true); } - rejectNearestChange(change: IModifiedFileEntryChangeHunk): void { - change.reject(); + async rejectNearestChange(change: IModifiedFileEntryChangeHunk): Promise { + await change.reject(); this.next(true); } - async toggleDiff(_change: IModifiedFileEntryChangeHunk | undefined): Promise { + async toggleDiff(_change: IModifiedFileEntryChangeHunk | undefined, _show?: boolean): Promise { // } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/helpers.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/helpers.ts index 23a647019cb..e2b9aed2975 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/helpers.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/helpers.ts @@ -153,15 +153,28 @@ export function adjustCellDiffAndOriginalModelBasedOnCellAddDelete(change: Noteb internalMetadata: cell.internalMetadata } satisfies ICellDto2; }); - const wasInsertedAsFirstCell = change[0] === 0; - const wasInsertedAsLastCell = change[0] === modifiedModelCellCount - 1; - const diffEntryIndex = wasInsertedAsFirstCell ? 0 : (wasInsertedAsLastCell ? cellDiffInfo.length - 1 : (cellDiffInfo.findIndex(d => d.modifiedCellIndex === change[0]))); - const indexToInsertInOriginalModel = (wasInsertedAsFirstCell || diffEntryIndex === -1) ? 0 : (wasInsertedAsLastCell ? originalModelCellCount : (((cellDiffInfo.slice(0, diffEntryIndex).reverse().find(c => typeof c.originalCellIndex === 'number')?.originalCellIndex ?? -1) + 1))); + let diffEntryIndex = -1; + let indexToInsertInOriginalModel: number | undefined = undefined; if (cells.length) { + for (let i = 0; i < cellDiffInfo.length; i++) { + const diff = cellDiffInfo[i]; + if (typeof diff.modifiedCellIndex === 'number' && diff.modifiedCellIndex === change[0]) { + diffEntryIndex = i; + + if (typeof diff.originalCellIndex === 'number') { + indexToInsertInOriginalModel = diff.originalCellIndex; + } + break; + } + if (typeof diff.originalCellIndex === 'number') { + indexToInsertInOriginalModel = diff.originalCellIndex + 1; + } + } + const edit: ICellEditOperation = { editType: CellEditType.Replace, cells, - index: indexToInsertInOriginalModel, + index: indexToInsertInOriginalModel ?? 0, count: change[1] }; applyEdits([edit], true, undefined, () => undefined, undefined, true); @@ -220,7 +233,7 @@ export function adjustCellDiffAndOriginalModelBasedOnCellAddDelete(change: Noteb cellDiffInfo = cellDiffInfo.filter(d => !itemsToRemove.has(d)); } - if (numberOfCellsInserted) { + if (numberOfCellsInserted && diffEntryIndex >= 0) { for (let i = 0; i < cellDiffInfo.length; i++) { const diff = cellDiffInfo[i]; if (i < diffEntryIndex) { @@ -244,10 +257,10 @@ export function adjustCellDiffAndOriginalModelBasedOnCellAddDelete(change: Noteb // For inserted cells, we need to ensure that we create a corresponding CellEntry. // So that any edits to the inserted cell is handled and mirrored over to the corresponding cell in original model. cells.forEach((_, i) => { - const originalCellIndex = i + indexToInsertInOriginalModel; + const originalCellIndex = i + (indexToInsertInOriginalModel ?? 0); const modifiedCellIndex = change[0] + i; const unchangedCell = createModifiedCellDiffInfo(modifiedCellIndex, originalCellIndex); - cellDiffInfo.splice((diffEntryIndex === -1 ? 0 : diffEntryIndex) + i, 0, unchangedCell); + cellDiffInfo.splice((diffEntryIndex === -1 ? cellDiffInfo.length : diffEntryIndex) + i, 0, unchangedCell); }); return cellDiffInfo; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/overlayToolbarDecorator.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/overlayToolbarDecorator.ts new file mode 100644 index 00000000000..48f600848af --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/overlayToolbarDecorator.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { MenuWorkbenchToolBar, HiddenItemStrategy } from '../../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; +import { CellEditState, INotebookEditor } from '../../../../notebook/browser/notebookBrowser.js'; +import { NotebookTextModel } from '../../../../notebook/common/model/notebookTextModel.js'; +import { CellKind } from '../../../../notebook/common/notebookCommon.js'; +import { IModifiedFileEntryChangeHunk } from '../../../common/chatEditingService.js'; +import { ICellDiffInfo } from './notebookCellChanges.js'; + + +export class OverlayToolbarDecorator extends Disposable { + + private _timeout: any | undefined = undefined; + private readonly overlayDisposables = this._register(new DisposableStore()); + + constructor( + private readonly notebookEditor: INotebookEditor, + private readonly notebookModel: NotebookTextModel, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IAccessibilitySignalService private readonly accessibilitySignalService: IAccessibilitySignalService, + ) { + super(); + } + + decorate(changes: ICellDiffInfo[]) { + if (this._timeout !== undefined) { + clearTimeout(this._timeout); + } + this._timeout = setTimeout(() => { + this._timeout = undefined; + this.createMarkdownPreviewToolbars(changes); + }, 100); + } + + private createMarkdownPreviewToolbars(changes: ICellDiffInfo[]) { + this.overlayDisposables.clear(); + + const accessibilitySignalService = this.accessibilitySignalService; + const editor = this.notebookEditor; + for (const change of changes) { + const cellViewModel = this.getCellViewModel(change); + + if (!cellViewModel || cellViewModel.cellKind !== CellKind.Markup) { + continue; + } + const toolbarContainer = document.createElement('div'); + + let overlayId: string | undefined = undefined; + editor.changeCellOverlays((accessor) => { + toolbarContainer.style.right = '44px'; + overlayId = accessor.addOverlay({ + cell: cellViewModel, + domNode: toolbarContainer, + }); + }); + + const removeOverlay = () => { + editor.changeCellOverlays(accessor => { + if (overlayId) { + accessor.removeOverlay(overlayId); + } + }); + }; + + this.overlayDisposables.add({ dispose: removeOverlay }); + + const toolbar = document.createElement('div'); + toolbarContainer.appendChild(toolbar); + toolbar.className = 'chat-diff-change-content-widget'; + toolbar.classList.add('hover'); // Show by default + toolbar.style.position = 'relative'; + toolbar.style.top = '18px'; + toolbar.style.zIndex = '10'; + toolbar.style.display = cellViewModel.getEditState() === CellEditState.Editing ? 'none' : 'block'; + + this.overlayDisposables.add(cellViewModel.onDidChangeState((e) => { + if (e.editStateChanged) { + if (cellViewModel.getEditState() === CellEditState.Editing) { + toolbar.style.display = 'none'; + } else { + toolbar.style.display = 'block'; + } + } + })); + + const scopedInstaService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.notebookEditor.scopedContextKeyService]))); + const toolbarWidget = scopedInstaService.createInstance(MenuWorkbenchToolBar, toolbar, MenuId.ChatEditingEditorHunk, { + telemetrySource: 'chatEditingNotebookHunk', + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + menuOptions: { + renderShortTitle: true, + arg: { + async accept() { + accessibilitySignalService.playSignal(AccessibilitySignal.editsKept, { allowManyInParallel: true }); + removeOverlay(); + toolbarWidget.dispose(); + for (const singleChange of change.diff.get().changes) { + await change.keep(singleChange); + } + return true; + }, + async reject() { + accessibilitySignalService.playSignal(AccessibilitySignal.editsUndone, { allowManyInParallel: true }); + removeOverlay(); + toolbarWidget.dispose(); + for (const singleChange of change.diff.get().changes) { + await change.undo(singleChange); + } + return true; + } + } satisfies IModifiedFileEntryChangeHunk, + }, + }); + + this.overlayDisposables.add(toolbarWidget); + } + } + + private getCellViewModel(change: ICellDiffInfo) { + if (change.type === 'delete' || change.modifiedCellIndex === undefined) { + return undefined; + } + const cell = this.notebookModel.cells[change.modifiedCellIndex]; + const cellViewModel = this.notebookEditor.getViewModel()?.viewCells.find(c => c.handle === cell.handle); + return cellViewModel; + } + + override dispose(): void { + super.dispose(); + if (this._timeout !== undefined) { + clearTimeout(this._timeout); + } + } + +} diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/simpleBrowserEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/simpleBrowserEditorOverlay.ts new file mode 100644 index 00000000000..50c3c1d2ae4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/simpleBrowserEditorOverlay.ts @@ -0,0 +1,352 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../media/simpleBrowserOverlay.css'; +import { combinedDisposable, DisposableMap, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derivedOpts, observableFromEvent, observableSignalFromEvent } from '../../../../../base/common/observable.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { EditorGroupView } from '../../../../browser/parts/editor/editorGroupView.js'; +import { Event } from '../../../../../base/common/event.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; +import { isEqual, joinPath } from '../../../../../base/common/resources.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; +import { IChatWidgetService, showChatView } from '../chat.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { addDisposableListener } from '../../../../../base/browser/dom.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { cleanupOldImages, createFileForMedia } from '../imageUtils.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +class SimpleBrowserOverlayWidget { + + private readonly _domNode: HTMLElement; + + private readonly imagesFolder: URI; + + private readonly _showStore = new DisposableStore(); + + private _timeout: any | undefined = undefined; + + constructor( + private readonly _editor: IEditorGroup, + private readonly _container: HTMLElement, + @IHostService private readonly _hostService: IHostService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IViewsService private readonly _viewService: IViewsService, + @IFileService private readonly fileService: IFileService, + @IEnvironmentService private readonly environmentService: IEnvironmentService, + @ILogService private readonly logService: ILogService, + ) { + + this.imagesFolder = joinPath(this.environmentService.workspaceStorageHome, 'vscode-chat-images'); + cleanupOldImages(this.fileService, this.logService, this.imagesFolder); + + this._domNode = document.createElement('div'); + this._domNode.className = 'element-selection-message'; + + const message = document.createElement('span'); + const startSelectionMessage = localize('elementSelectionMessage', 'Add element to chat'); + message.textContent = startSelectionMessage; + this._domNode.appendChild(message); + + let cts: CancellationTokenSource; + const selectButton = this._showStore.add(new Button(this._domNode, { ...defaultButtonStyles, supportIcons: true, title: localize('selectAnElement', 'Click to select an element.') })); + selectButton.element.className = 'element-selection-start'; + selectButton.label = localize('startSelection', 'Start Selection'); + + const cancelButton = this._showStore.add(new Button(this._domNode, { ...defaultButtonStyles, supportIcons: true, title: localize('cancelSelection', 'Click to cancel selection.') })); + cancelButton.element.className = 'element-selection-cancel hidden'; + cancelButton.label = localize('cancel', 'Cancel'); + + const collapseOverlay = this._showStore.add(new Button(this._domNode, { supportIcons: true, title: localize('chat.hideOverlay', "Collapse Overlay") })); + collapseOverlay.icon = Codicon.chevronRight; + + const nextSelection = this._showStore.add(new Button(this._domNode, { supportIcons: true, title: localize('chat.nextSelection', "Select Again") })); + nextSelection.icon = Codicon.close; + nextSelection.element.classList.add('hidden'); + + // shown if the overlay is collapsed + const expandOverlay = this._showStore.add(new Button(this._domNode, { supportIcons: true, title: localize('chat.expandOverlay', "Expand Overlay") })); + expandOverlay.icon = Codicon.layout; + const expandContainer = document.createElement('div'); + expandContainer.className = 'element-expand-container hidden'; + expandContainer.appendChild(expandOverlay.element); + this._container.appendChild(expandContainer); + + const resetButtons = () => { + this.hideElement(nextSelection.element); + this.showElement(selectButton.element); + this.showElement(collapseOverlay.element); + }; + + const finishedSelecting = () => { + // stop selection + this.hideElement(cancelButton.element); + this.hideElement(collapseOverlay.element); + this.showElement(nextSelection.element); + + // wait 3 seconds before showing the start button again unless cancelled out. + this._timeout = setTimeout(() => { + message.textContent = startSelectionMessage; + resetButtons(); + }, 3000); + }; + + this._showStore.add(addDisposableListener(selectButton.element, 'click', async () => { + cts = new CancellationTokenSource(); + this._editor.focus(); + + // start selection + message.textContent = localize('elementSelectionInProgress', 'Selection in progress...'); + this.hideElement(selectButton.element); + this.showElement(cancelButton.element); + await this.addElementToChat(cts); + + // stop selection + message.textContent = localize('elementSelectionComplete', 'Element added to chat'); + finishedSelecting(); + })); + + this._showStore.add(addDisposableListener(cancelButton.element, 'click', () => { + cts.cancel(); + message.textContent = localize('elementCancelMessage', 'Selection canceled'); + finishedSelecting(); + })); + + this._showStore.add(addDisposableListener(collapseOverlay.element, 'click', () => { + this.hideElement(this._domNode); + this.showElement(expandContainer); + })); + + this._showStore.add(addDisposableListener(expandOverlay.element, 'click', () => { + this.showElement(this._domNode); + this.hideElement(expandContainer); + })); + + this._showStore.add(addDisposableListener(nextSelection.element, 'click', () => { + clearTimeout(this._timeout); + message.textContent = startSelectionMessage; + resetButtons(); + })); + } + + hideElement(element: HTMLElement) { + if (element.classList.contains('hidden')) { + return; + } + element.classList.add('hidden'); + } + + showElement(element: HTMLElement) { + if (!element.classList.contains('hidden')) { + return; + } + element.classList.remove('hidden'); + } + + async addElementToChat(cts: CancellationTokenSource) { + const rect = this._container.getBoundingClientRect(); + const elementData = await this._hostService.getElementData(rect.x, rect.y, cts.token); + if (!elementData) { + throw new Error('Element data not found'); + } + const bounds = elementData.bounds; + + // remove container so we don't block anything on screenshot + this._domNode.style.display = 'none'; + + // Wait 1 extra frame to make sure overlay is gone + await new Promise(resolve => setTimeout(resolve, 100)); + + const screenshot = await this._hostService.getScreenshot(bounds); + if (!screenshot) { + throw new Error('Screenshot failed'); + } + this._domNode.style.display = ''; + const widget = this._chatWidgetService.lastFocusedWidget ?? await showChatView(this._viewService); + + const fileReference = await createFileForMedia(this.fileService, this.imagesFolder, screenshot.buffer, 'image/png'); + + widget?.attachmentModel?.addContext({ + id: 'element-' + Date.now(), + name: this.getDisplayNameFromOuterHTML(elementData.outerHTML), + fullName: this.getDisplayNameFromOuterHTML(elementData.outerHTML), + value: elementData.outerHTML + elementData.computedStyle, + kind: 'element', + icon: ThemeIcon.fromId(Codicon.layout.id), + }, { + id: 'element-screenshot-' + Date.now(), + name: 'Element Screenshot', + fullName: 'Element Screenshot', + kind: 'image', + value: screenshot.buffer, + references: fileReference ? [{ reference: fileReference, kind: 'reference' }] : [], + }); + } + + + getDisplayNameFromOuterHTML(outerHTML: string): string { + const firstElementMatch = outerHTML.match(/^<(\w+)([^>]*?)>/); + if (!firstElementMatch) { + throw new Error('No outer element found'); + } + + const tagName = firstElementMatch[1]; + const idMatch = firstElementMatch[2].match(/\s+id\s*=\s*["']([^"']+)["']/i); + const id = idMatch ? `#${idMatch[1]}` : ''; + const classMatch = firstElementMatch[2].match(/\s+class\s*=\s*["']([^"']+)["']/i); + const className = classMatch ? `.${classMatch[1].replace(/\s+/g, '.')}` : ''; + return `${tagName}${id}${className}`; + } + + dispose() { + this._showStore.dispose(); + } + + getDomNode(): HTMLElement { + return this._domNode; + } +} + +class SimpleBrowserOverlayController { + + private readonly _store = new DisposableStore(); + + private readonly _domNode = document.createElement('div'); + + constructor( + container: HTMLElement, + group: IEditorGroup, + @IInstantiationService instaService: IInstantiationService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + + this._domNode.classList.add('chat-simple-browser-overlay'); + this._domNode.style.position = 'absolute'; + this._domNode.style.bottom = `5px`; + this._domNode.style.right = `5px`; + this._domNode.style.zIndex = `100`; + + const widget = instaService.createInstance(SimpleBrowserOverlayWidget, group, container); + this._domNode.appendChild(widget.getDomNode()); + this._store.add(toDisposable(() => this._domNode.remove())); + this._store.add(widget); + + const show = () => { + if (!container.contains(this._domNode)) { + container.appendChild(this._domNode); + } + }; + + const hide = () => { + if (container.contains(this._domNode)) { + this._domNode.remove(); + } + }; + + const activeEditorSignal = observableSignalFromEvent(this, Event.any(group.onDidActiveEditorChange, group.onDidModelChange)); + + const activeUriObs = derivedOpts({ equalsFn: isEqual }, r => { + + activeEditorSignal.read(r); // signal + + const editor = group.activeEditorPane; + if (editor?.input.editorId === 'mainThreadWebview-simpleBrowser.view') { + if (!this.configurationService.getValue('chat.sendElementsToChat.enabled')) { + return undefined; + } + const uri = EditorResourceAccessor.getOriginalUri(editor?.input, { supportSideBySide: SideBySideEditor.PRIMARY }); + return uri; + } + return undefined; + }); + + this._store.add(autorun(r => { + + const data = activeUriObs.read(r); + + if (!data) { + hide(); + return; + } + + show(); + })); + } + + dispose(): void { + this._store.dispose(); + } +} + +export class SimpleBrowserOverlay implements IWorkbenchContribution { + + static readonly ID = 'chat.simpleBrowser.overlay'; + + private readonly _store = new DisposableStore(); + + constructor( + @IEditorGroupsService editorGroupsService: IEditorGroupsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + const editorGroups = observableFromEvent( + this, + Event.any(editorGroupsService.onDidAddGroup, editorGroupsService.onDidRemoveGroup), + () => editorGroupsService.groups + ); + + const overlayWidgets = new DisposableMap(); + + this._store.add(autorun(r => { + + const toDelete = new Set(overlayWidgets.keys()); + const groups = editorGroups.read(r); + + + for (const group of groups) { + + if (!(group instanceof EditorGroupView)) { + // TODO@jrieken better with https://github.com/microsoft/vscode/tree/ben/layout-group-container + continue; + } + + toDelete.delete(group); // we keep the widget for this group! + + if (!overlayWidgets.has(group)) { + + const scopedInstaService = instantiationService.createChild( + new ServiceCollection([IContextKeyService, group.scopedContextKeyService]) + ); + + const container = group.element; + + + const ctrl = scopedInstaService.createInstance(SimpleBrowserOverlayController, container, group); + overlayWidgets.set(group, combinedDisposable(ctrl, scopedInstaService)); + } + } + + for (const group of toDelete) { + overlayWidgets.deleteAndDispose(group); + } + })); + } + + dispose(): void { + this._store.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/chatEditor.ts index cccd397d87e..927e1a03f0f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditor.ts @@ -16,14 +16,14 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { Memento } from '../../../common/memento.js'; +import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../common/theme.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IChatModel, IExportableChatData, ISerializableChatData } from '../common/chatModel.js'; +import { CHAT_PROVIDER_ID } from '../common/chatParticipantContribTypes.js'; +import { ChatAgentLocation, ChatMode } from '../common/constants.js'; import { clearChatEditor } from './actions/chatClear.js'; import { ChatEditorInput } from './chatEditorInput.js'; import { ChatWidget, IChatViewState } from './chatWidget.js'; -import { IChatModel, IExportableChatData, ISerializableChatData } from '../common/chatModel.js'; -import { CHAT_PROVIDER_ID } from '../common/chatParticipantContribTypes.js'; -import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; -import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../common/theme.js'; -import { ChatAgentLocation } from '../common/constants.js'; export interface IChatEditorOptions extends IEditorOptions { target?: { sessionId: string } | { data: IExportableChatData | ISerializableChatData }; @@ -67,8 +67,19 @@ export class ChatEditor extends EditorPane { ChatAgentLocation.Panel, undefined, { + autoScroll: mode => mode !== ChatMode.Ask, + renderFollowups: true, supportsFileReferences: true, + rendererOptions: { + renderTextEditsAsSummary: (uri) => { + return true; + }, + referencesExpandedWhenEmptyResponse: false, + progressMessageAtBottomOfResponse: mode => mode !== ChatMode.Ask, + }, enableImplicitContext: true, + enableWorkingSet: 'explicit', + supportsChangingModes: true, }, { listForeground: editorForeground, @@ -128,6 +139,7 @@ export class ChatEditor extends EditorPane { // Need to set props individually on the memento this._viewState.inputValue = widgetViewState.inputValue; + this._viewState.inputState = widgetViewState.inputState; this._memento.saveMemento(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts index a2e7867472f..6dad621002c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts @@ -13,16 +13,18 @@ import { URI } from '../../../../base/common/uri.js'; import * as nls from '../../../../nls.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; -import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput } from '../../../common/editor.js'; -import { EditorInput } from '../../../common/editor/editorInput.js'; +import { EditorInputCapabilities, IEditorIdentifier, IEditorSerializer, IUntypedEditorInput } from '../../../common/editor.js'; +import { EditorInput, IEditorCloseHandler } from '../../../common/editor/editorInput.js'; import type { IChatEditorOptions } from './chatEditor.js'; import { IChatModel } from '../common/chatModel.js'; import { IChatService } from '../common/chatService.js'; import { ChatAgentLocation } from '../common/constants.js'; +import { ConfirmResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { shouldShowClearEditingSessionConfirmation, showClearEditingSessionConfirmation } from './actions/chatActions.js'; const ChatEditorIcon = registerIcon('chat-editor-label-icon', Codicon.commentDiscussion, nls.localize('chatEditorLabelIcon', 'Icon of the chat editor label.')); -export class ChatEditorInput extends EditorInput { +export class ChatEditorInput extends EditorInput implements IEditorCloseHandler { static readonly countsInUse = new Set(); static readonly TypeID: string = 'workbench.input.chatSession'; @@ -50,7 +52,8 @@ export class ChatEditorInput extends EditorInput { constructor( readonly resource: URI, readonly options: IChatEditorOptions, - @IChatService private readonly chatService: IChatService + @IChatService private readonly chatService: IChatService, + @IDialogService private readonly dialogService: IDialogService, ) { super(); @@ -67,12 +70,29 @@ export class ChatEditorInput extends EditorInput { this._register(toDisposable(() => ChatEditorInput.countsInUse.delete(this.inputCount))); } + override closeHandler = this; + + showConfirm(): boolean { + return this.model?.editingSession ? shouldShowClearEditingSessionConfirmation(this.model.editingSession) : false; + } + + async confirm(editors: ReadonlyArray): Promise { + if (!this.model?.editingSession) { + return ConfirmResult.SAVE; + } + + const titleOverride = nls.localize('chatEditorConfirmTitle', "Close Chat Editor"); + const messageOverride = nls.localize('chat.startEditing.confirmation.pending.message.default', "Closing the chat editor will end your current edit session."); + const result = await showClearEditingSessionConfirmation(this.model.editingSession, this.dialogService, { titleOverride, messageOverride }); + return result ? ConfirmResult.SAVE : ConfirmResult.CANCEL; + } + override get editorId(): string | undefined { return ChatEditorInput.EditorID; } override get capabilities(): EditorInputCapabilities { - return super.capabilities | EditorInputCapabilities.Singleton; + return super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.CanDropIntoEditor; } override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { @@ -93,7 +113,8 @@ export class ChatEditorInput extends EditorInput { override async resolve(): Promise { if (typeof this.sessionId === 'string') { - this.model = await this.chatService.getOrRestoreSession(this.sessionId); + this.model = await this.chatService.getOrRestoreSession(this.sessionId) + ?? this.chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None); } else if (!this.options.target) { this.model = this.chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None); } else if ('data' in this.options.target) { diff --git a/src/vs/workbench/contrib/chat/browser/chatInlineAnchorWidget.ts b/src/vs/workbench/contrib/chat/browser/chatInlineAnchorWidget.ts index 3752bff4c6b..402f5787097 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInlineAnchorWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInlineAnchorWidget.ts @@ -39,7 +39,6 @@ import { IEditorService, SIDE_GROUP } from '../../../services/editor/common/edit import { ExplorerFolderContext } from '../../files/common/files.js'; import { IWorkspaceSymbol } from '../../search/common/search.js'; import { IChatContentInlineReference } from '../common/chatService.js'; -import { IChatVariablesService } from '../common/chatVariables.js'; import { IChatWidgetService } from './chat.js'; import { chatAttachmentResourceContextKey, hookUpSymbolAttachmentDragAndContextMenu } from './chatContentParts/chatAttachmentsContentPart.js'; import { IChatMarkdownAnchorService } from './chatContentParts/chatMarkdownAnchorService.js'; @@ -228,14 +227,12 @@ registerAction2(class AddFileToChatAction extends Action2 { override async run(accessor: ServicesAccessor, resource: URI): Promise { const chatWidgetService = accessor.get(IChatWidgetService); - const variablesService = accessor.get(IChatVariablesService); const widget = chatWidgetService.lastFocusedWidget; - if (!widget) { - return; - } + if (widget) { + widget.attachmentModel.addFile(resource); - variablesService.attachContext('file', resource, widget.location); + } } }); @@ -365,11 +362,12 @@ registerAction2(class GoToDefinitionAction extends Action2 { override async run(accessor: ServicesAccessor, location: Location): Promise { const editorService = accessor.get(ICodeEditorService); + const instantiationService = accessor.get(IInstantiationService); await openEditorWithSelection(editorService, location); const action = new DefinitionAction({ openToSide: false, openInPeek: false, muteMessage: true }, { title: { value: '', original: '' }, id: '', precondition: undefined }); - return action.run(accessor); + return instantiationService.invokeFunction(accessor => action.run(accessor)); } }); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index d19a7606616..515b257dfa6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -7,21 +7,20 @@ import * as dom from '../../../../base/browser/dom.js'; import { addDisposableListener } from '../../../../base/browser/dom.js'; import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; -import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import * as aria from '../../../../base/browser/ui/aria/aria.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; -import { IActionProvider } from '../../../../base/browser/ui/dropdown/dropdown.js'; import { createInstantHoverDelegate, getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { IAction, Separator, toAction } from '../../../../base/common/actions.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; +import { Emitter } from '../../../../base/common/event.js'; import { HistoryNavigator2 } from '../../../../base/common/history.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../base/common/map.js'; import { isMacintosh } from '../../../../base/common/platform.js'; +import { assertType } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; @@ -42,12 +41,10 @@ import { SuggestController } from '../../../../editor/contrib/suggest/browser/su import { localize } from '../../../../nls.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; -import { DropdownMenuActionViewItemWithKeybinding } from '../../../../platform/actions/browser/dropdownActionViewItemWithKeybinding.js'; import { DropdownWithPrimaryActionViewItem, IDropdownWithPrimaryActionViewItemOptions } from '../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -62,7 +59,9 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; import { ResourceLabels } from '../../../browser/labels.js'; +import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; @@ -70,26 +69,25 @@ import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEd import { IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { IChatEditingSession } from '../common/chatEditingService.js'; -import { ChatEntitlement, IChatEntitlementService } from '../common/chatEntitlementService.js'; -import { IChatRequestVariableEntry, isImageVariableEntry, isLinkVariableEntry, isPasteVariableEntry } from '../common/chatModel.js'; -import { IChatFollowup, IChatService } from '../common/chatService.js'; +import { IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry } from '../common/chatModel.js'; +import { IChatFollowup } from '../common/chatService.js'; import { IChatVariablesService } from '../common/chatVariables.js'; import { IChatResponseViewModel } from '../common/chatViewModel.js'; import { ChatInputHistoryMaxEntries, IChatHistoryEntry, IChatInputState, IChatWidgetHistoryService } from '../common/chatWidgetHistoryService.js'; -import { ChatAgentLocation, ChatMode } from '../common/constants.js'; -import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js'; -import { CancelAction, ChatSubmitAction, ChatSubmitSecondaryAgentAction, ChatSwitchToNextModelActionId, IChatExecuteActionContext, IToggleChatModeArgs, ToggleAgentModeActionId } from './actions/chatExecuteActions.js'; +import { ChatAgentLocation, ChatConfiguration, ChatMode, validateChatMode } from '../common/constants.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js'; +import { CancelAction, ChatEditingSessionSubmitAction, ChatOpenModelPickerActionId, ChatSubmitAction, IChatExecuteActionContext, ToggleAgentModeActionId } from './actions/chatExecuteActions.js'; import { AttachToolsAction } from './actions/chatToolActions.js'; import { ImplicitContextAttachmentWidget } from './attachments/implicitContextAttachment.js'; -import { PromptAttachmentsCollectionWidget } from './attachments/promptAttachments/promptAttachmentsCollectionWidget.js'; +import { PromptInstructionsAttachmentsCollectionWidget } from './attachments/promptInstructions/promptInstructionsCollectionWidget.js'; import { IChatWidget } from './chat.js'; import { ChatAttachmentModel } from './chatAttachmentModel.js'; import { toChatVariable } from './chatAttachmentModel/chatPromptAttachmentsCollection.js'; -import { DefaultChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, LinkAttachmentWidget, PasteAttachmentWidget } from './chatAttachmentWidgets.js'; +import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget } from './chatAttachmentWidgets.js'; import { IDisposableReference } from './chatContentParts/chatCollections.js'; import { CollapsibleListPool, IChatCollapsibleListItem } from './chatContentParts/chatReferencesContentPart.js'; import { ChatDragAndDrop } from './chatDragAndDrop.js'; -import { ChatEditingRemoveAllFilesAction, ChatEditingShowChangesAction } from './chatEditing/chatEditingActions.js'; +import { ChatEditingRemoveAllFilesAction, ChatEditingShowChangesAction, ViewPreviousEditsAction } from './chatEditing/chatEditingActions.js'; import { ChatFollowups } from './chatFollowups.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; import { IChatViewState } from './chatWidget.js'; @@ -97,6 +95,8 @@ import { ChatFileReference } from './contrib/chatDynamicVariables/chatFileRefere import { ChatImplicitContext } from './contrib/chatImplicitContext.js'; import { ChatRelatedFiles } from './contrib/chatInputRelatedFilesContrib.js'; import { resizeImage } from './imageUtils.js'; +import { IModelPickerDelegate, ModelPickerActionItem } from './modelPicker/modelPickerActionItem.js'; +import { IModePickerDelegate, ModePickerActionItem } from './modelPicker/modePickerActionItem.js'; const $ = dom.$; @@ -120,6 +120,8 @@ interface IChatInputPartOptions { renderWorkingSet?: boolean; enableImplicitContext?: boolean; supportsChangingModes?: boolean; + dndContainer?: HTMLElement; + widgetViewKindTag: string; } export interface IWorkingSetEntry { @@ -155,30 +157,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge readonly selectedToolsModel: ChatSelectedTools; - public getAttachedAndImplicitContext(sessionId: string): IChatRequestVariableEntry[] { + public async getAttachedAndImplicitContext(sessionId: string): Promise { const contextArr = [...this.attachmentModel.attachments]; if (this.implicitContext?.enabled && this.implicitContext.value) { - contextArr.push(this.implicitContext.toBaseEntry()); - } - // retrieve links from the input editor - const linkOccurrences = this.inputEditor.getContribution(LinkDetector.ID)?.getAllLinkOccurrences() ?? []; - const linksSeen = new Set(); - for (const linkOccurrence of linkOccurrences) { - const link = linkOccurrence.link; - const uri = URI.isUri(link.url) ? link.url : link.url ? URI.parse(link.url) : undefined; - if (!uri || linksSeen.has(uri.toString())) { - continue; - } - - linksSeen.add(uri.toString()); - contextArr.push({ - kind: 'link', - id: uri.toString(), - name: uri.fsPath, - value: uri, - isFile: false, - }); + const implicitChatVariables = await this.implicitContext.toBaseEntries(); + contextArr.push(...implicitChatVariables); } // factor in nested file links of a prompt into the implicit context @@ -197,17 +181,38 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ); } + // prompt files may have nested child references to other prompt + // files that are resolved asynchronously, hence we need to wait + // for the entire prompt instruction tree to be processed + const instructionsStarted = performance.now(); + + // wait for all prompt files resolve precesses to settle + await this.promptInstructionsAttachmentsPart.allSettled(); + + // allow-any-unicode-next-line + this.logService.trace(`[⏱] instructions tree resolved in ${performance.now() - instructionsStarted}ms`); + contextArr - .push(...this.instructionAttachmentsPart.chatAttachments); + .push(...this.promptInstructionsAttachmentsPart.chatAttachments); return contextArr; } /** - * Check if the chat input part has any prompt instruction attachments. + * Check if the chat input part has any prompt file attachments. */ - public get hasInstructionAttachments(): boolean { - return !this.instructionAttachmentsPart.empty; + get hasPromptFileAttachments(): boolean { + // if prompt attached explicitly as a "prompt" attachment + if (this.promptInstructionsAttachmentsPart.hasInstructions) { + return true; + } + + if (this.implicitContext === undefined) { + return false; + } + + // if prompt attached as an implicit "current file" context + return (this.implicitContext.isPromptFile && this.implicitContext.enabled); } private _indexOfLastAttachedContextDeletedWithKeyboard: number = -1; @@ -260,6 +265,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._editSessionWidgetHeight; } + get attachmentsHeight() { + return this.attachmentsContainer.offsetHeight + (this.attachmentsContainer.checkVisibility() ? 6 : 0); + } + private _inputEditor!: CodeEditorWidget; private _inputEditorElement!: HTMLElement; @@ -284,9 +293,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** * Context key is set when prompt instructions are attached. */ - private promptInstructionsAttached: IContextKey; + private promptFileAttached: IContextKey; private chatMode: IContextKey; + private modelWidget: ModelPickerActionItem | undefined; private readonly _waitForPersistedLanguageModel = this._register(new MutableDisposable()); private _onDidChangeCurrentLanguageModel = this._register(new Emitter()); private _currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined; @@ -299,10 +309,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _currentMode: ChatMode = ChatMode.Ask; public get currentMode(): ChatMode { - if (this.location === ChatAgentLocation.Panel && !this.chatService.unifiedViewEnabled) { - return ChatMode.Ask; - } - return this._currentMode === ChatMode.Agent && !this.agentService.hasToolsAgent ? ChatMode.Edit : this._currentMode; @@ -343,9 +349,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** * Child widget of prompt instruction attachments. - * See {@linkcode PromptAttachmentsCollectionWidget}. + * See {@linkcode PromptInstructionsAttachmentsCollectionWidget}. */ - private instructionAttachmentsPart: PromptAttachmentsCollectionWidget; + private promptInstructionsAttachmentsPart: PromptInstructionsAttachmentsCollectionWidget; constructor( // private readonly editorOptions: ChatEditorOptions, // TODO this should be used @@ -370,7 +376,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @ILabelService private readonly labelService: ILabelService, @IChatVariablesService private readonly variableService: IChatVariablesService, @IChatAgentService private readonly agentService: IChatAgentService, - @IChatService private readonly chatService: IChatService, + @ISharedWebContentExtractorService private readonly sharedWebExtracterService: ISharedWebContentExtractorService, + @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, ) { super(); @@ -390,11 +397,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.inputEditorHasText = ChatContextKeys.inputHasText.bindTo(contextKeyService); this.chatCursorAtTop = ChatContextKeys.inputCursorAtTop.bindTo(contextKeyService); this.inputEditorHasFocus = ChatContextKeys.inputHasFocus.bindTo(contextKeyService); - this.promptInstructionsAttached = ChatContextKeys.instructionsAttached.bindTo(contextKeyService); + this.promptFileAttached = ChatContextKeys.hasPromptFile.bindTo(contextKeyService); this.chatMode = ChatContextKeys.chatMode.bindTo(contextKeyService); this.history = this.loadHistory(); - this._register(this.historyService.onDidClearHistory(() => this.history = new HistoryNavigator2([{ text: '' }], ChatInputHistoryMaxEntries, historyKeyFn))); + this._register(this.historyService.onDidClearHistory(() => this.history = new HistoryNavigator2([{ text: '', state: this.getInputState() }], ChatInputHistoryMaxEntries, historyKeyFn))); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AccessibilityVerbositySettingId.Chat)) { @@ -406,42 +413,62 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService); - this.instructionAttachmentsPart = this._register( + this.promptInstructionsAttachmentsPart = this._register( instantiationService.createInstance( - PromptAttachmentsCollectionWidget, + PromptInstructionsAttachmentsCollectionWidget, this.attachmentModel.promptInstructions, this._contextResourceLabels, ), ); // trigger re-layout of chat input when number of instruction attachment changes - this.instructionAttachmentsPart.onAttachmentsCountChange(() => { + this.promptInstructionsAttachmentsPart.onAttachmentsChange(() => { + this._handleAttachedContextChange(); this._onDidChangeHeight.fire(); }); this.initSelectedModel(); + + this._register(this.onDidChangeCurrentChatMode(() => this.accessibilityService.alert(this._currentMode))); + this._register(this._onDidChangeCurrentLanguageModel.event(() => { + if (this._currentLanguageModel?.metadata.name) { + this.accessibilityService.alert(this._currentLanguageModel.metadata.name); + } + })); } private getSelectedModelStorageKey(): string { return `chat.currentLanguageModel.${this.location}`; } + private getSelectedModelIsDefaultStorageKey(): string { + return `chat.currentLanguageModel.${this.location}.isDefault`; + } + private initSelectedModel() { const persistedSelection = this.storageService.get(this.getSelectedModelStorageKey(), StorageScope.APPLICATION); + const persistedAsDefault = this.storageService.getBoolean(this.getSelectedModelIsDefaultStorageKey(), StorageScope.APPLICATION, persistedSelection === 'github.copilot-chat/gpt-4o'); + if (persistedSelection) { const model = this.languageModelsService.lookupLanguageModel(persistedSelection); if (model) { - this.setCurrentLanguageModel({ metadata: model, identifier: persistedSelection }); - this.checkModelSupported(); + // Only restore the model if it wasn't the default at the time of storing or it is now the default + if (!persistedAsDefault || model.isDefault) { + this.setCurrentLanguageModel({ metadata: model, identifier: persistedSelection }); + this.checkModelSupported(); + } } else { this._waitForPersistedLanguageModel.value = this.languageModelsService.onDidChangeLanguageModels(e => { const persistedModel = e.added?.find(m => m.identifier === persistedSelection); if (persistedModel) { this._waitForPersistedLanguageModel.clear(); - if (persistedModel.metadata.isUserSelectable) { - this.setCurrentLanguageModel({ metadata: persistedModel.metadata, identifier: persistedSelection }); - this.checkModelSupported(); + // Only restore the model if it wasn't the default at the time of storing or it is now the default + if (!persistedAsDefault || persistedModel.metadata.isDefault) { + if (persistedModel.metadata.isUserSelectable) { + this.setCurrentLanguageModel({ metadata: persistedModel.metadata, identifier: persistedSelection }); + this.checkModelSupported(); + } } } }); @@ -451,6 +478,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(this._onDidChangeCurrentChatMode.event(() => { this.checkModelSupported(); })); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.Edits2Enabled)) { + this.checkModelSupported(); + } + })); + } + + public switchModel(modelMetadata: Pick) { + const models = this.getModels(); + const model = models.find(m => m.metadata.vendor === modelMetadata.vendor && m.metadata.id === modelMetadata.id && m.metadata.family === modelMetadata.family); + if (model) { + this.setCurrentLanguageModel(model); + } } public switchToNextModel(): void { @@ -462,6 +502,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + public openModelPicker(): void { + this.modelWidget?.show(); + } + private checkModelSupported(): void { if (this._currentLanguageModel && !this.modelSupportedForDefaultAgent(this._currentLanguageModel)) { this.setCurrentLanguageModelToDefault(); @@ -473,6 +517,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } + mode = validateChatMode(mode) ?? ChatMode.Ask; this._currentMode = mode; this.chatMode.set(mode); this._onDidChangeCurrentChatMode.fire(); @@ -480,7 +525,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private modelSupportedForDefaultAgent(model: ILanguageModelChatMetadataAndIdentifier): boolean { // Probably this logic could live in configuration on the agent, or somewhere else, if it gets more complex - if (this.currentMode === ChatMode.Agent || (this.currentMode === ChatMode.Edit && this.chatService.unifiedViewEnabled)) { + if (this.currentMode === ChatMode.Agent || (this.currentMode === ChatMode.Edit && this.configurationService.getValue(ChatConfiguration.Edits2Enabled))) { if (this.configurationService.getValue('chat.agent.allModels')) { return true; } @@ -526,6 +571,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } this.storageService.store(this.getSelectedModelStorageKey(), model.identifier, StorageScope.APPLICATION, StorageTarget.USER); + this.storageService.store(this.getSelectedModelIsDefaultStorageKey(), !!model.metadata.isDefault, StorageScope.APPLICATION, StorageTarget.USER); this._onDidChangeCurrentLanguageModel.fire(model); } @@ -533,7 +579,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private loadHistory(): HistoryNavigator2 { const history = this.historyService.getHistory(this.location); if (history.length === 0) { - history.push({ text: '' }); + history.push({ text: '', state: this.getInputState() }); } return new HistoryNavigator2(history, 50, historyKeyFn); @@ -548,7 +594,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return localize('chatInput', "Chat Input"); } - initForNewChatModel(state: IChatViewState): void { + initForNewChatModel(state: IChatViewState, modelIsEmpty: boolean): void { this.history = this.loadHistory(); this.history.add({ text: state.inputValue ?? this.history.current().text, @@ -563,11 +609,61 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (state.inputState?.chatMode) { this.setChatMode(state.inputState.chatMode); - } else if (this.location === ChatAgentLocation.EditingSession) { - this.setChatMode(ChatMode.Edit); } - this.selectedToolsModel.reset(); + // TODO@roblourens This is for an experiment which will be obsolete in a month or two and can then be removed. + if (modelIsEmpty) { + const storageKey = this.getDefaultModeExperimentStorageKey(); + const hasSetDefaultMode = this.storageService.getBoolean(storageKey, StorageScope.WORKSPACE, false); + if (!hasSetDefaultMode) { + Promise.all([ + this.experimentService.getTreatment('chat.defaultMode'), + this.experimentService.getTreatment('chat.defaultLanguageModel'), + ]).then(([defaultModeTreatment, defaultLanguageModelTreatment]) => { + if (typeof defaultModeTreatment === 'string') { + this.storageService.store(storageKey, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); + const defaultMode = validateChatMode(defaultModeTreatment); + if (defaultMode) { + this.logService.trace(`Applying default mode from experiment: ${defaultMode}`); + this.setChatMode(defaultMode); + this.checkModelSupported(); + } + } + + if (typeof defaultLanguageModelTreatment === 'string' && this._currentMode === ChatMode.Agent) { + this.storageService.store(storageKey, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); + this.logService.trace(`Applying default language model from experiment: ${defaultLanguageModelTreatment}`); + this.setExpModelOrWait(defaultLanguageModelTreatment); + } + }); + } + } + } + + private setExpModelOrWait(modelId: string) { + const model = this.languageModelsService.lookupLanguageModel(modelId); + if (model) { + this.setCurrentLanguageModel({ metadata: model, identifier: modelId }); + this.checkModelSupported(); + this._waitForPersistedLanguageModel.clear(); + } else { + this._waitForPersistedLanguageModel.value = this.languageModelsService.onDidChangeLanguageModels(e => { + const model = e.added?.find(m => m.identifier === modelId); + if (model) { + this._waitForPersistedLanguageModel.clear(); + + if (model.metadata.isUserSelectable) { + this.setCurrentLanguageModel({ metadata: model.metadata, identifier: modelId }); + this.checkModelSupported(); + } + } + }); + } + } + + private getDefaultModeExperimentStorageKey(): string { + const tag = this.options.widgetViewKindTag; + return `chat.${tag}.hasSetDefaultModeByExperiment`; } logInputHistory(): void { @@ -622,14 +718,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Check for images in history to restore the value. if (historyAttachments.length > 0) { historyAttachments = (await Promise.all(historyAttachments.map(async (attachment) => { - if (attachment.isImage && attachment.references?.length && URI.isUri(attachment.references[0].reference)) { + if (isImageVariableEntry(attachment) && attachment.references?.length && URI.isUri(attachment.references[0].reference)) { + const currReference = attachment.references[0].reference; try { - const buffer = await this.fileService.readFile(attachment.references[0].reference); + const imageBinary = currReference.toString(true).startsWith('http') ? await this.sharedWebExtracterService.readImage(currReference, CancellationToken.None) : (await this.fileService.readFile(currReference)).value; + if (!imageBinary) { + return undefined; + } const newAttachment = { ...attachment }; - newAttachment.value = (isImageVariableEntry(attachment) && attachment.isPasted) ? buffer.value.buffer : await resizeImage(buffer.value.buffer); // if pasted image, we do not need to resize. + newAttachment.value = (isImageVariableEntry(attachment) && attachment.isPasted) ? imageBinary.buffer : await resizeImage(imageBinary.buffer); // if pasted image, we do not need to resize. return newAttachment; } catch (err) { - this.logService.error('Failed to restore image from history', err); + this.logService.error('Failed to fetch and reference.', err); return undefined; } } @@ -712,10 +812,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - // A funtion that filters out specifically the `value` property of the attachment. + validateCurrentMode(): void { + if (!this.agentService.hasToolsAgent && this._currentMode === ChatMode.Agent) { + this.setChatMode(ChatMode.Edit); + } + } + + // A function that filters out specifically the `value` property of the attachment. private getFilteredEntry(query: string, inputState: IChatInputState): IChatHistoryEntry { const attachmentsWithoutImageValues = inputState.chatContextAttachments?.map(attachment => { - if (attachment.isImage && attachment.references?.length && attachment.value) { + if (isImageVariableEntry(attachment) && attachment.references?.length && attachment.value) { const newAttachment = { ...attachment }; newAttachment.value = undefined; return newAttachment; @@ -746,7 +852,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private _handleAttachedContextChange() { - this._hasFileAttachmentContextKey.set(Boolean(this._attachmentModel.attachments.find(a => a.isFile))); + this._hasFileAttachmentContextKey.set(Boolean(this._attachmentModel.attachments.find(a => a.kind === 'file'))); this.renderAttachedContext(); } @@ -801,12 +907,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const attachmentToolbarContainer = elements.attachmentToolbar; this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; if (this.options.enableImplicitContext) { - this._implicitContext = this._register(new ChatImplicitContext()); + this._implicitContext = this._register( + this.instantiationService.createInstance(ChatImplicitContext), + ); + this._register(this._implicitContext.onDidChangeValue(() => this._handleAttachedContextChange())); } this.renderAttachedContext(); - this._register(this._attachmentModel.onDidChangeContext(() => this._handleAttachedContextChange())); + this._register(this._attachmentModel.onDidChange(() => this._handleAttachedContextChange())); this.renderChatEditingSessionState(null); if (this.options.renderWorkingSet) { @@ -815,7 +924,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } this.renderChatRelatedFiles(); - this.dnd.addOverlay(container, container); + this.dnd.addOverlay(this.options.dndContainer ?? container, this.options.dndContainer ?? container); const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(inputContainer)); ChatContextKeys.inChatInput.bindTo(inputScopedContextKeyService).set(true); @@ -849,7 +958,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditorElement = dom.append(editorContainer!, $(chatInputEditorContainerSelector)); const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, CopyPasteController.ID, LinkDetector.ID])); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID])); this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); SuggestController.get(this._inputEditor)?.forceRenderingAbove(); @@ -914,19 +1023,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge hiddenItemStrategy: HiddenItemStrategy.Ignore, // keep it lean when hiding items and avoid a "..." overflow menu actionViewItemProvider: (action, options) => { if (this.location === ChatAgentLocation.Panel || this.location === ChatAgentLocation.Editor) { - if ((action.id === ChatSubmitAction.ID || action.id === CancelAction.ID) && action instanceof MenuItemAction) { + if ((action.id === ChatSubmitAction.ID || action.id === CancelAction.ID || action.id === ChatEditingSessionSubmitAction.ID) && action instanceof MenuItemAction) { const dropdownAction = this.instantiationService.createInstance(MenuItemAction, { id: 'chat.moreExecuteActions', title: localize('notebook.moreExecuteActionsLabel', "More..."), icon: Codicon.chevronDown }, undefined, undefined, undefined, undefined); return this.instantiationService.createInstance(ChatSubmitDropdownActionItem, action, dropdownAction, { ...options, menuAsChild: false }); } } - if (action.id === ChatSwitchToNextModelActionId && action instanceof MenuItemAction) { + if (action.id === ChatOpenModelPickerActionId && action instanceof MenuItemAction) { if (!this._currentLanguageModel) { this.setCurrentLanguageModelToDefault(); } if (this._currentLanguageModel) { - const itemDelegate: ModelPickerDelegate = { + const itemDelegate: IModelPickerDelegate = { + getCurrentModel: () => this._currentLanguageModel, onDidChangeModel: this._onDidChangeCurrentLanguageModel.event, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { // The user changed the language model, so we don't wait for the persisted option to be registered @@ -936,14 +1046,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }, getModels: () => this.getModels() }; - return this.instantiationService.createInstance(ModelPickerActionViewItem, action, this._currentLanguageModel, itemDelegate); + return this.modelWidget = this.instantiationService.createInstance(ModelPickerActionItem, action, this._currentLanguageModel, itemDelegate); } } else if (action.id === ToggleAgentModeActionId && action instanceof MenuItemAction) { const delegate: IModePickerDelegate = { getMode: () => this.currentMode, onDidChangeMode: this._onDidChangeCurrentChatMode.event }; - return this.instantiationService.createInstance(ToggleChatModeActionViewItem, action, delegate); + return this.instantiationService.createInstance(ModePickerActionItem, action, delegate); } return undefined; @@ -1023,11 +1133,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge hiddenItemStrategy: HiddenItemStrategy.Ignore, hoverDelegate, actionViewItemProvider: (action, options) => { - if (action.id === 'workbench.action.chat.editing.attachContext' || action.id === 'workbench.action.chat.attachContext') { + if (action.id === 'workbench.action.chat.attachContext') { const viewItem = this.instantiationService.createInstance(AddFilesButton, undefined, action, options); return viewItem; } if (action.id === AttachToolsAction.id) { + // TODO@jrieken let's remove this once the tools picker has its final place. return this.selectedToolsModel.toolsActionItemViewItemProvider(action, options); } return undefined; @@ -1036,14 +1147,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.addFilesToolbar.context = { widget, placeholder: localize('chatAttachFiles', 'Search for files and context to add to your request') }; this._register(this.addFilesToolbar.onDidChangeMenuItems(() => { if (this.cachedDimensions) { - this.layout(this.cachedDimensions.height, this.cachedDimensions.width); + this._onDidChangeHeight.fire(); } })); + + this._register(this.selectedToolsModel.toolsActionItemViewItemProvider.onDidRender(() => this._onDidChangeHeight.fire())); } private renderAttachedContext() { const container = this.attachedContextContainer; - const oldHeight = container.offsetHeight; + // Note- can't measure attachedContextContainer, because it has `display: contents`, so measure the parent to check for height changes + const oldHeight = this.attachmentsContainer.offsetHeight; const store = new DisposableStore(); this.attachedContextDisposables.value = store; @@ -1051,7 +1165,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const hoverDelegate = store.add(createInstantHoverDelegate()); const attachments = [...this.attachmentModel.attachments.entries()]; - const hasAttachments = Boolean(attachments.length) || Boolean(this.implicitContext?.value) || !this.instructionAttachmentsPart.empty; + const hasAttachments = Boolean(attachments.length) || Boolean(this.implicitContext?.value) || !this.promptInstructionsAttachmentsPart.empty; dom.setVisibility(Boolean(hasAttachments || (this.addFilesToolbar && !this.addFilesToolbar.isEmpty())), this.attachmentsContainer); dom.setVisibility(hasAttachments, this.attachedContextContainer); if (!attachments.length) { @@ -1063,8 +1177,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge container.appendChild(implicitPart.domNode); } - this.promptInstructionsAttached.set(!this.instructionAttachmentsPart.empty); - this.instructionAttachmentsPart.render(container); + this.promptFileAttached.set(this.hasPromptFileAttachments); + this.promptInstructionsAttachmentsPart.render(container); for (const [index, attachment] of attachments) { const resource = URI.isUri(attachment.value) ? attachment.value : attachment.value && typeof attachment.value === 'object' && 'uri' in attachment.value && URI.isUri(attachment.value.uri) ? attachment.value.uri : undefined; @@ -1072,39 +1186,38 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const shouldFocusClearButton = index === Math.min(this._indexOfLastAttachedContextDeletedWithKeyboard, this.attachmentModel.size - 1); let attachmentWidget; - if (resource && (attachment.isFile || attachment.isDirectory)) { + if (resource && isNotebookOutputVariableEntry(attachment)) { + attachmentWidget = this.instantiationService.createInstance(NotebookCellOutputChatAttachmentWidget, resource, attachment, this._currentLanguageModel, shouldFocusClearButton, container, this._contextResourceLabels, hoverDelegate); + } else if (resource && (attachment.kind === 'file' || attachment.kind === 'directory')) { attachmentWidget = this.instantiationService.createInstance(FileAttachmentWidget, resource, range, attachment, this._currentLanguageModel, shouldFocusClearButton, container, this._contextResourceLabels, hoverDelegate); - } else if (attachment.isImage) { + } else if (isImageVariableEntry(attachment)) { attachmentWidget = this.instantiationService.createInstance(ImageAttachmentWidget, resource, attachment, this._currentLanguageModel, shouldFocusClearButton, container, this._contextResourceLabels, hoverDelegate); + } else if (isElementVariableEntry(attachment)) { + attachmentWidget = this.instantiationService.createInstance(ElementChatAttachmentWidget, attachment, this._currentLanguageModel, shouldFocusClearButton, container, this._contextResourceLabels, hoverDelegate); } else if (isPasteVariableEntry(attachment)) { attachmentWidget = this.instantiationService.createInstance(PasteAttachmentWidget, attachment, this._currentLanguageModel, shouldFocusClearButton, container, this._contextResourceLabels, hoverDelegate); - } else if (isLinkVariableEntry(attachment)) { - attachmentWidget = this.instantiationService.createInstance(LinkAttachmentWidget, attachment, this._currentLanguageModel, shouldFocusClearButton, container, this._contextResourceLabels, hoverDelegate); } else { attachmentWidget = this.instantiationService.createInstance(DefaultChatAttachmentWidget, resource, range, attachment, this._currentLanguageModel, shouldFocusClearButton, container, this._contextResourceLabels, hoverDelegate); } store.add(attachmentWidget); - store.add(attachmentWidget.onDidDelete((e) => { + store.add(attachmentWidget.onDidDelete(e => { this.handleAttachmentDeletion(e, index, attachment); })); } - if (oldHeight !== container.offsetHeight) { + if (oldHeight !== this.attachmentsContainer.offsetHeight) { this._onDidChangeHeight.fire(); } } - private handleAttachmentDeletion(e: globalThis.Event, index: number, attachment: IChatRequestVariableEntry) { - this._attachmentModel.delete(attachment.id); - + private handleAttachmentDeletion(e: KeyboardEvent | unknown, index: number, attachment: IChatRequestVariableEntry) { // Set focus to the next attached context item if deletion was triggered by a keystroke (vs a mouse click) if (dom.isKeyboardEvent(e)) { - const event = new StandardKeyboardEvent(e); - if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { - this._indexOfLastAttachedContextDeletedWithKeyboard = index; - } + this._indexOfLastAttachedContextDeletedWithKeyboard = index; } + this._attachmentModel.delete(attachment.id); + if (this._attachmentModel.size === 0) { this.focus(); } @@ -1176,7 +1289,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge arg: { sessionId: chatEditingSession.chatSessionId }, }, buttonConfigProvider: (action) => { - if (action.id === ChatEditingShowChangesAction.ID || action.id === ChatEditingRemoveAllFilesAction.ID) { + if (action.id === ChatEditingShowChangesAction.ID || action.id === ChatEditingRemoveAllFilesAction.ID || action.id === ViewPreviousEditsAction.Id) { return { showIcon: true, showLabel: false, isSecondary: true }; } return undefined; @@ -1208,7 +1321,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); if (pane) { - entry?.getEditorIntegration(pane).reveal(true); + entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); } } })); @@ -1251,9 +1364,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge uriLabel.element.classList.add('monaco-icon-label'); uriLabel.element.title = localize('suggeste.title', "{0} - {1}", this.labelService.getUriLabel(uri, { relative: true }), description ?? ''); - this._chatEditsActionsDisposables.add(uriLabel.onDidClick(() => { + this._chatEditsActionsDisposables.add(uriLabel.onDidClick(async () => { group.remove(); // REMOVE asap - this._attachmentModel.addFile(uri); + await this._attachmentModel.addFile(uri); this.relatedFiles?.remove(uri); })); @@ -1265,9 +1378,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); addButton.icon = Codicon.add; addButton.setTitle(localize('chatEditingSession.addSuggested', 'Add suggestion')); - this._chatEditsActionsDisposables.add(addButton.onDidClick(() => { + this._chatEditsActionsDisposables.add(addButton.onDidClick(async () => { group.remove(); // REMOVE asap - this._attachmentModel.addFile(uri); + await this._attachmentModel.addFile(uri); this.relatedFiles?.remove(uri); })); @@ -1351,7 +1464,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge inputPartEditorHeight: Math.min(this._inputEditor.getContentHeight(), this.inputEditorMaxHeight), inputPartHorizontalPadding: this.options.renderStyle === 'compact' ? 16 : 32, inputPartVerticalPadding: this.options.renderStyle === 'compact' ? 12 : 28, - attachmentsHeight: this.attachmentsContainer.offsetHeight + (this.attachmentsContainer.checkVisibility() ? 6 : 0), + attachmentsHeight: this.attachmentsHeight, editorBorder: 2, inputPartHorizontalPaddingInside: 12, toolbarsWidth: this.options.renderStyle === 'compact' ? executeToolbarWidth + executeToolbarPadding + inputToolbarWidth + inputToolbarPadding : 0, @@ -1375,7 +1488,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } -const historyKeyFn = (entry: IChatHistoryEntry) => JSON.stringify(entry); +const historyKeyFn = (entry: IChatHistoryEntry) => JSON.stringify({ ...entry, state: { ...entry.state, chatMode: undefined } }); function getLastPosition(model: ITextModel): IPosition { return { lineNumber: model.getLineCount(), column: model.getLineLength(model.getLineCount()) + 1 }; @@ -1390,7 +1503,6 @@ class ChatSubmitDropdownActionItem extends DropdownWithPrimaryActionViewItem { options: IDropdownWithPrimaryActionViewItemOptions, @IMenuService menuService: IMenuService, @IContextMenuService contextMenuService: IContextMenuService, - @IChatAgentService chatAgentService: IChatAgentService, @IContextKeyService contextKeyService: IContextKeyService, @IKeybindingService keybindingService: IKeybindingService, @INotificationService notificationService: INotificationService, @@ -1415,17 +1527,6 @@ class ChatSubmitDropdownActionItem extends DropdownWithPrimaryActionViewItem { const menu = menuService.createMenu(MenuId.ChatExecuteSecondary, contextKeyService); const setActions = () => { const secondary = getFlatActionBarActions(menu.getActions({ shouldForwardArgs: true })); - const secondaryAgent = chatAgentService.getSecondaryAgent(); - if (secondaryAgent) { - secondary.forEach(a => { - if (a.id === ChatSubmitSecondaryAgentAction.ID) { - a.label = localize('chat.submitToSecondaryAgent', "Send to @{0}", secondaryAgent.name); - } - - return a; - }); - } - this.update(dropdownAction, secondary); }; setActions(); @@ -1433,165 +1534,28 @@ class ChatSubmitDropdownActionItem extends DropdownWithPrimaryActionViewItem { } } -interface ModelPickerDelegate { - onDidChangeModel: Event; - setModel(selectedModelId: ILanguageModelChatMetadataAndIdentifier): void; - getModels(): ILanguageModelChatMetadataAndIdentifier[]; -} - -class ModelPickerActionViewItem extends DropdownMenuActionViewItemWithKeybinding { - constructor( - action: MenuItemAction, - private currentLanguageModel: ILanguageModelChatMetadataAndIdentifier, - private readonly delegate: ModelPickerDelegate, - @IContextMenuService contextMenuService: IContextMenuService, - @IKeybindingService keybindingService: IKeybindingService, - @IContextKeyService contextKeyService: IContextKeyService, - @IChatEntitlementService chatEntitlementService: IChatEntitlementService, - @ICommandService commandService: ICommandService, - @IMenuService menuService: IMenuService, - ) { - const modelActionsProvider: IActionProvider = { - getActions: () => { - const setLanguageModelAction = (entry: ILanguageModelChatMetadataAndIdentifier): IAction => { - return { - id: entry.identifier, - label: entry.metadata.name, - tooltip: '', - class: undefined, - enabled: true, - checked: entry.identifier === this.currentLanguageModel.identifier, - run: () => { - this.currentLanguageModel = entry; - this.renderLabel(this.element!); - this.delegate.setModel(entry); - } - }; - }; - - const models: ILanguageModelChatMetadataAndIdentifier[] = this.delegate.getModels(); - const actions = models.map(entry => setLanguageModelAction(entry)); - - // Add menu contributions from extensions - const menuActions = menuService.getMenuActions(MenuId.ChatModelPicker, contextKeyService); - const menuContributions = getFlatActionBarActions(menuActions); - if (menuContributions.length > 0 || chatEntitlementService.entitlement === ChatEntitlement.Limited) { - actions.push(new Separator()); - } - actions.push(...menuContributions); - if (chatEntitlementService.entitlement === ChatEntitlement.Limited) { - actions.push(toAction({ id: 'moreModels', label: localize('chat.moreModels', "Add More Models..."), run: () => commandService.executeCommand('workbench.action.chat.upgradePlan', 'chat-models') })); - } - return actions; - } - }; - - const actionWithLabel: IAction = { - ...action, - tooltip: localize('chat.modelPicker.label', "Pick Model"), - run: () => { } - }; - super(actionWithLabel, modelActionsProvider, contextMenuService, undefined, keybindingService, contextKeyService); - this._register(delegate.onDidChangeModel(modelId => { - this.currentLanguageModel = modelId; - this.renderLabel(this.element!); - })); - } - - protected override renderLabel(element: HTMLElement): IDisposable | null { - this.setAriaLabelAttributes(element); - dom.reset(element, dom.$('span.chat-model-label', undefined, this.currentLanguageModel.metadata.name), ...renderLabelWithIcons(`$(chevron-down)`)); - return null; - } - - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('chat-modelPicker-item', 'chat-dropdown-item'); - } -} - const chatInputEditorContainerSelector = '.interactive-input-editor'; setupSimpleEditorSelectionStyling(chatInputEditorContainerSelector); -interface IModePickerDelegate { - onDidChangeMode: Event; - getMode(): ChatMode; -} - -class ToggleChatModeActionViewItem extends DropdownMenuActionViewItemWithKeybinding { - constructor( - action: MenuItemAction, - private readonly delegate: IModePickerDelegate, - @IContextMenuService contextMenuService: IContextMenuService, - @IKeybindingService keybindingService: IKeybindingService, - @IContextKeyService contextKeyService: IContextKeyService, - @IChatService chatService: IChatService, - ) { - const makeAction = (mode: ChatMode): IAction => ({ - ...action, - id: mode, - label: this.modeToString(mode), - class: undefined, - enabled: true, - checked: delegate.getMode() === mode, - run: async () => { - const result = await action.run({ mode } satisfies IToggleChatModeArgs); - this.renderLabel(this.element!); - return result; - } - }); - - const actionProvider: IActionProvider = { - getActions: () => { - const agentStateActions = [ - makeAction(ChatMode.Edit), - makeAction(ChatMode.Agent), - ]; - if (chatService.unifiedViewEnabled) { - agentStateActions.unshift(makeAction(ChatMode.Ask)); - } - - return agentStateActions; - } - }; - - super(action, actionProvider, contextMenuService, undefined, keybindingService, contextKeyService); - this._register(delegate.onDidChangeMode(() => this.renderLabel(this.element!))); - } - - private modeToString(mode: ChatMode) { - switch (mode) { - case ChatMode.Agent: - return localize('chat.agentMode', "Agent"); - case ChatMode.Edit: - return localize('chat.normalMode', "Edit"); - case ChatMode.Ask: - return localize('chat.askMode', "Ask"); - } - } - - protected override renderLabel(element: HTMLElement): IDisposable | null { - // Can't call super.renderLabel because it has a hack of forcing the 'codicon' class - this.setAriaLabelAttributes(element); - - const state = this.modeToString(this.delegate.getMode()); - dom.reset(element, dom.$('span.chat-model-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`)); - return null; - } - - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('chat-dropdown-item'); - } -} - class AddFilesButton extends ActionViewItem { + constructor(context: unknown, action: IAction, options: IActionViewItemOptions) { - super(context, action, options); + super(context, action, { + ...options, + icon: false, + label: true, + keybindingNotRenderedWithLabel: true, + }); } override render(container: HTMLElement): void { + container.classList.add('chat-attachment-button'); super.render(container); - container.classList.add('chat-attached-context-attachment', 'chat-add-files'); + } + + protected override updateLabel(): void { + assertType(this.label); + const message = `$(attach) ${this.action.label}`; + dom.reset(this.label, ...renderLabelWithIcons(message)); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts index 2ecff47e39b..b966a950be8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @@ -48,11 +48,11 @@ import { IChatAgentMetadata } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { IChatRequestVariableEntry, IChatTextEditGroup } from '../common/chatModel.js'; import { chatSubcommandLeader } from '../common/chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, IChatConfirmation, IChatContentReference, IChatFollowup, IChatMarkdownContent, IChatService, IChatTask, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop } from '../common/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, IChatConfirmation, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatTask, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop } from '../common/chatService.js'; import { IChatCodeCitations, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatWorkingProgress, isRequestVM, isResponseVM } from '../common/chatViewModel.js'; import { getNWords } from '../common/chatWordCounter.js'; import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'; -import { ChatMode } from '../common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../common/constants.js'; import { MarkUnhelpfulActionId } from './actions/chatTitleActions.js'; import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidgetService } from './chat.js'; import { ChatAgentHover, getChatAgentHoverOptions } from './chatAgentHover.js'; @@ -62,7 +62,8 @@ import { ChatCodeCitationContentPart } from './chatContentParts/chatCodeCitation import { ChatCommandButtonContentPart } from './chatContentParts/chatCommandContentPart.js'; import { ChatConfirmationContentPart } from './chatContentParts/chatConfirmationContentPart.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts/chatContentParts.js'; -import { ChatMarkdownContentPart, EditorPool, IChatMarkdownContentPartOptions } from './chatContentParts/chatMarkdownContentPart.js'; +import { ChatExtensionsContentPart } from './chatContentParts/chatExtensionsContentPart.js'; +import { ChatMarkdownContentPart, EditorPool } from './chatContentParts/chatMarkdownContentPart.js'; import { ChatProgressContentPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; import { ChatQuotaExceededPart } from './chatContentParts/chatQuotaExceededPart.js'; import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, CollapsibleListPool } from './chatContentParts/chatReferencesContentPart.js'; @@ -165,7 +166,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer templateData.value.classList.remove('inline-progress'))); value.push({ content: new MarkdownString('', { supportHtml: true }), kind: 'markdownContent' }); } else { templateData.value.classList.remove('inline-progress'); @@ -761,9 +764,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer element.model.setPaused(p) }); } return { content: partsToRender, moreContentAvailable }; @@ -831,7 +835,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !part.isComplete))) { + if ( + !lastPart || + lastPart.kind === 'references' || + (lastPart.kind === 'toolInvocation' && (lastPart.isComplete || lastPart.presentation === 'hidden')) || + ((lastPart.kind === 'textEditGroup' || lastPart.kind === 'notebookEditGroup') && lastPart.done && !partsToRender.some(part => part.kind === 'toolInvocation' && !part.isComplete)) || + (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) + ) { return true; } @@ -894,6 +904,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.updateItemHeight(templateData))); + return part; + } + private renderProgressTask(task: IChatTask, templateData: IChatListItemTemplate, context: IChatContentPartRenderContext): IChatContentPart | undefined { if (!isResponseVM(context.element)) { return; @@ -1054,10 +1072,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { markdownPart.layout(this._currentLayoutWidth); this.updateItemHeight(templateData); diff --git a/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts index 3b763727a52..d87cc9c4afa 100644 --- a/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts @@ -20,7 +20,7 @@ import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; import { contentRefUrl } from '../common/annotations.js'; import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentService } from '../common/chatAgents.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../common/chatColors.js'; -import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestToolPart, chatSubcommandLeader, IParsedChatRequest, IParsedChatRequestPart } from '../common/chatParserTypes.js'; +import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, chatSubcommandLeader, IParsedChatRequest, IParsedChatRequestPart } from '../common/chatParserTypes.js'; import { IChatMarkdownContent, IChatService } from '../common/chatService.js'; import { ILanguageModelToolsService } from '../common/languageModelToolsService.js'; import { IChatWidgetService } from './chat.js'; @@ -112,8 +112,9 @@ export class ChatMarkdownDecorationsRenderer { const title = uri ? this.labelService.getUriLabel(uri, { relative: true }) : part instanceof ChatRequestSlashCommandPart ? part.slashCommand.detail : part instanceof ChatRequestAgentSubcommandPart ? part.command.description : - part instanceof ChatRequestToolPart ? (this.toolsService.getTool(part.toolId)?.userDescription) : - ''; + part instanceof ChatRequestSlashPromptPart ? part.slashPromptCommand.command : + part instanceof ChatRequestToolPart ? (this.toolsService.getTool(part.toolId)?.userDescription) : + ''; const args: IDecorationWidgetArgs = { title }; const text = part.text; diff --git a/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts index 6e78f992eeb..f63a273ecdb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { $ } from '../../../../base/browser/dom.js'; import { MarkdownRenderOptions, MarkedOptions } from '../../../../base/browser/markdownRenderer.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; @@ -90,6 +91,14 @@ export class ChatMarkdownRenderer extends MarkdownRenderer { } : markdown; const result = super.render(mdWithBody, options, markedOptions); + + // In some cases, the renderer can return text that is not inside a

, + // but our CSS expects text to be in a

for margin to be applied properly. + // So just normalize it. + const lastChild = result.element.lastChild; + if (lastChild?.nodeType === Node.TEXT_NODE && lastChild.textContent?.trim()) { + lastChild.replaceWith($('p', undefined, lastChild.textContent)); + } return this.attachCustomHover(result); } diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts index 604883bf16b..ffcea82ee6a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts @@ -29,9 +29,9 @@ import { IExtension, IExtensionsWorkbenchService } from '../../extensions/common import { IChatAgentData, IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { IRawChatParticipantContribution } from '../common/chatParticipantContribTypes.js'; -import { ChatAgentLocation, ChatConfiguration } from '../common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../common/constants.js'; import { ChatViewId } from './chat.js'; -import { CHAT_EDITING_SIDEBAR_PANEL_ID, CHAT_SIDEBAR_PANEL_ID, ChatViewPane } from './chatViewPane.js'; +import { CHAT_SIDEBAR_PANEL_ID, ChatViewPane } from './chatViewPane.js'; // --- Chat Container & View Registration @@ -75,50 +75,6 @@ const chatViewDescriptor: IViewDescriptor[] = [{ }]; Registry.as(ViewExtensions.ViewsRegistry).registerViews(chatViewDescriptor, chatViewContainer); -// --- Edits Container & View Registration - -const editsViewContainer: ViewContainer = Registry.as(ViewExtensions.ViewContainersRegistry).registerViewContainer({ - id: CHAT_EDITING_SIDEBAR_PANEL_ID, - title: localize2('chatEditing.viewContainer.label', "Copilot Edits"), - icon: Codicon.editSession, - ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [CHAT_EDITING_SIDEBAR_PANEL_ID, { mergeViewWithContainerWhenSingleView: true }]), - storageId: CHAT_EDITING_SIDEBAR_PANEL_ID, - hideIfEmpty: true, - order: 101, -}, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true }); - -const editsViewDescriptor: IViewDescriptor[] = [{ - id: 'workbench.panel.chat.view.edits', - containerIcon: editsViewContainer.icon, - containerTitle: editsViewContainer.title.value, - singleViewPaneContainerTitle: editsViewContainer.title.value, - name: editsViewContainer.title, - canToggleVisibility: false, - canMoveView: true, - openCommandActionDescriptor: { - id: CHAT_EDITING_SIDEBAR_PANEL_ID, - title: editsViewContainer.title, - mnemonicTitle: localize({ key: 'miToggleEdits', comment: ['&& denotes a mnemonic'] }, "Copilot Ed&&its"), - keybindings: { - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyI, - linux: { - primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.KeyI - } - }, - order: 2 - }, - ctorDescriptor: new SyncDescriptor(ChatViewPane, [{ location: ChatAgentLocation.EditingSession }]), - when: ContextKeyExpr.and( - ContextKeyExpr.has(`config.${ChatConfiguration.UnifiedChatView}`).negate(), - ContextKeyExpr.or( - ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.installed, - ChatContextKeys.editingParticipantRegistered - ) - ) -}]; -Registry.as(ViewExtensions.ViewsRegistry).registerViews(editsViewDescriptor, editsViewContainer); - const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatParticipants', jsonSchema: { @@ -282,7 +238,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { continue; } - if ((providerDescriptor.isDefault || providerDescriptor.isAgent) && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) { + if ((providerDescriptor.isDefault || providerDescriptor.modes) && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) { this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: defaultChatParticipant.`); continue; } @@ -328,10 +284,10 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { name: providerDescriptor.name, fullName: providerDescriptor.fullName, isDefault: providerDescriptor.isDefault, - isToolsAgent: providerDescriptor.isAgent, locations: isNonEmptyArray(providerDescriptor.locations) ? providerDescriptor.locations.map(ChatAgentLocation.fromRaw) : [ChatAgentLocation.Panel], + modes: providerDescriptor.modes ?? [ChatMode.Ask], slashCommands: providerDescriptor.commands ?? [], disambiguation: coalesce(participantsDisambiguation.flat()), } satisfies IChatAgentData)); diff --git a/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts b/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts index 72911840c5f..37a86941f7c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { createStringDataTransferItem, IDataTransferItem, IReadonlyVSDataTransfer, VSDataTransfer } from '../../../../base/common/dataTransfer.js'; @@ -24,7 +23,7 @@ import { IExtensionService, isProposedApiEnabled } from '../../../services/exten import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry } from '../common/chatModel.js'; import { IChatWidgetService } from './chat.js'; import { ChatInputPart } from './chatInputPart.js'; -import { resizeImage } from './imageUtils.js'; +import { cleanupOldImages, createFileForMedia, resizeImage } from './imageUtils.js'; const COPY_MIME_TYPES = 'application/vnd.code.additional-editor-data'; @@ -50,7 +49,7 @@ export class PasteImageProvider implements DocumentPasteEditProvider { @ILogService private readonly logService: ILogService, ) { this.imagesFolder = joinPath(this.environmentService.workspaceStorageHome, 'vscode-chat-images'); - this.cleanupOldImages(); + cleanupOldImages(this.fileService, this.logService, this.imagesFolder,); } async provideDocumentPasteEdits(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, context: DocumentPasteContext, token: CancellationToken): Promise { @@ -100,7 +99,7 @@ export class PasteImageProvider implements DocumentPasteEditProvider { tempDisplayName = `${displayName} ${appendValue}`; } - const fileReference = await this.createFileForMedia(currClipboard, mimeType); + const fileReference = await createFileForMedia(this.fileService, this.imagesFolder, currClipboard, mimeType); if (token.isCancellationRequested || !fileReference) { return; } @@ -126,57 +125,6 @@ export class PasteImageProvider implements DocumentPasteEditProvider { const edit = createCustomPasteEdit(model, scaledImageContext, mimeType, this.kind, localize('pastedImageAttachment', 'Pasted Image Attachment'), this.chatWidgetService); return createEditSession(edit); } - - private async createFileForMedia( - dataTransfer: Uint8Array, - mimeType: string, - ): Promise { - const exists = await this.fileService.exists(this.imagesFolder); - if (!exists) { - await this.fileService.createFolder(this.imagesFolder); - } - - const ext = mimeType.split('/')[1] || 'png'; - const filename = `image-${Date.now()}.${ext}`; - const fileUri = joinPath(this.imagesFolder, filename); - - const buffer = VSBuffer.wrap(dataTransfer); - await this.fileService.writeFile(fileUri, buffer); - - return fileUri; - } - - private async cleanupOldImages(): Promise { - const exists = await this.fileService.exists(this.imagesFolder); - if (!exists) { - return; - } - - const duration = 7 * 24 * 60 * 60 * 1000; // 7 days - const files = await this.fileService.resolve(this.imagesFolder); - if (!files.children) { - return; - } - - await Promise.all(files.children.map(async (file) => { - try { - const timestamp = this.getTimestampFromFilename(file.name); - if (timestamp && (Date.now() - timestamp > duration)) { - await this.fileService.del(file.resource); - } - } catch (err) { - this.logService.error('Failed to clean up old images', err); - } - })); - } - - private getTimestampFromFilename(filename: string): number | undefined { - const match = filename.match(/image-(\d+)\./); - if (match) { - return parseInt(match[1], 10); - } - return undefined; - } } async function getImageAttachContext(data: Uint8Array, mimeType: string, token: CancellationToken, displayName: string, resource: URI): Promise { @@ -190,7 +138,6 @@ async function getImageAttachContext(data: Uint8Array, mimeType: string, token: value: data, id: imageHash, name: displayName, - isImage: true, icon: Codicon.fileMedia, mimeType, isPasted: true, diff --git a/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts index 5c96d139e1a..7ca30ffa337 100644 --- a/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts @@ -32,7 +32,6 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation const verifiedWidget: IChatWidget = widget; const focusedItem = verifiedWidget.getFocus(); - if (!focusedItem) { return; } @@ -65,6 +64,19 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi if (!responseContent && 'errorDetails' in item && item.errorDetails) { responseContent = item.errorDetails.message; } + if (isResponseVM(item)) { + const toolInvocation = item.response.value.find(item => item.kind === 'toolInvocation'); + if (toolInvocation?.confirmationMessages) { + const title = toolInvocation.confirmationMessages.title; + const message = typeof toolInvocation.confirmationMessages.message === 'string' ? toolInvocation.confirmationMessages.message : toolInvocation.confirmationMessages.message.value; + const terminalCommand = toolInvocation.toolSpecificData && 'command' in toolInvocation.toolSpecificData ? toolInvocation.toolSpecificData.command : undefined; + responseContent += `${title}`; + if (terminalCommand) { + responseContent += `: ${terminalCommand}`; + } + responseContent += `\n${message}`; + } + } return renderMarkdownAsPlaintext(new MarkdownString(responseContent), true); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts b/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts index e3e71ba0b69..922b417fe57 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts @@ -3,91 +3,149 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - import { reset } from '../../../../base/browser/dom.js'; import { IActionViewItemProvider } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; import { assertType } from '../../../../base/common/types.js'; import { localize } from '../../../../nls.js'; import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ILanguageModelToolsService, IToolData } from '../common/languageModelToolsService.js'; +import { ObservableMemento, observableMemento } from '../../../../platform/observable/common/observableMemento.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../common/languageModelToolsService.js'; + +/** + * New tools and new tool sources that come in should generally be enabled until + * the user disables them. To store things, we store only the buckets and + * individual tools that were disabled, so the new data sources that come in + * are enabled, and new tools that come in for data sources not disabled are + * also enabled. + */ +type StoredData = { disabledBuckets?: /* ToolDataSource.toKey */ readonly string[]; disabledTools?: readonly string[] }; + +const storedTools = observableMemento({ + defaultValue: {}, + key: 'chat/selectedTools', +}); export class ChatSelectedTools extends Disposable { - private readonly _selectedTools = observableValue(this, undefined); + private readonly _selectedTools: ObservableMemento; readonly tools: IObservable; - readonly toolsActionItemViewItemProvider: IActionViewItemProvider; + readonly toolsActionItemViewItemProvider: IActionViewItemProvider & { onDidRender: Event }; + + private readonly _allTools: IObservable[]>; constructor( @ILanguageModelToolsService toolsService: ILanguageModelToolsService, - @IInstantiationService instaService: IInstantiationService + @IInstantiationService instaService: IInstantiationService, + @IStorageService storageService: IStorageService, ) { super(); - const allTools = observableFromEvent( - toolsService.onDidChangeTools, - () => Array.from(toolsService.getTools()).filter(t => t.canBeReferencedInPrompt) - ); + this._selectedTools = this._register(storedTools(StorageScope.WORKSPACE, StorageTarget.MACHINE, storageService)); + + this._allTools = observableFromEvent(toolsService.onDidChangeTools, () => Array.from(toolsService.getTools())); + + const disabledData = this._selectedTools.map(data => { + return (data.disabledBuckets?.length || data.disabledTools?.length) && { + buckets: new Set(data.disabledBuckets), + toolIds: new Set(data.disabledTools), + }; + }); this.tools = derived(r => { - const custom = this._selectedTools.read(r); - return custom ?? allTools.read(r); + const disabled = disabledData.read(r); + const tools = this._allTools.read(r); + if (!disabled) { + return tools; + } + + return tools.filter(t => + !(disabled.toolIds.has(t.id) || disabled.buckets.has(ToolDataSource.toKey(t.source))) + ); }); const toolsCount = derived(r => { - const count = allTools.read(r).length; + const count = this._allTools.read(r).length; const enabled = this.tools.read(r).length; return { count, enabled }; }); - this.toolsActionItemViewItemProvider = (action, options) => { - if (!(action instanceof MenuItemAction)) { - return undefined; + const onDidRender = this._store.add(new Emitter()); + + this.toolsActionItemViewItemProvider = Object.assign( + (action: IAction, options: IActionViewItemOptions) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + + return instaService.createInstance(class extends MenuEntryActionViewItem { + + override render(container: HTMLElement): void { + this.options.icon = false; + this.options.label = true; + container.classList.add('chat-mcp', 'chat-attachment-button'); + super.render(container); + } + + protected override updateLabel(): void { + this._store.add(autorun(r => { + assertType(this.label); + + const { enabled, count } = toolsCount.read(r); + + const message = count === 0 + ? '$(tools)' + : enabled !== count + ? localize('tool.1', "{0} {1} of {2}", '$(tools)', enabled, count) + : localize('tool.0', "{0} {1}", '$(tools)', count); + + reset(this.label, ...renderLabelWithIcons(message)); + + if (this.element?.isConnected) { + onDidRender.fire(); + } + })); + } + + }, action, { ...options, keybindingNotRenderedWithLabel: true }); + }, + { onDidRender: onDidRender.event } + ); + } + + selectOnly(toolIds: readonly string[]): void { + const uniqueTools = new Set(toolIds); + + const disabledTools = this._allTools.get().filter(tool => !uniqueTools.has(tool.id)); + + this.update([], disabledTools); + } + + update(disableBuckets: readonly ToolDataSource[], disableTools: readonly IToolData[]): void { + this._selectedTools.set({ + disabledBuckets: disableBuckets.map(ToolDataSource.toKey), + disabledTools: disableTools.map(t => t.id) + }, undefined); + } + + asEnablementMap(): Map { + const result = new Map(); + const enabledTools = new Set(this.tools.get().map(t => t.id)); + for (const tool of this._allTools.get()) { + if (tool.supportsToolPicker) { + result.set(tool, enabledTools.has(tool.id)); } - - return instaService.createInstance(class extends MenuEntryActionViewItem { - - override render(container: HTMLElement): void { - this.options.icon = false; - this.options.label = true; - container.classList.add('chat-mcp'); - super.render(container); - } - - protected override updateLabel(): void { - this._store.add(autorun(r => { - assertType(this.label); - - const { enabled, count } = toolsCount.read(r); - - if (count === 0) { - super.updateLabel(); - return; - } - - const message = enabled !== count - ? localize('tool.1', "{0} {1} of {2}", '$(tools)', enabled, count) - : localize('tool.0', "{0} {1}", '$(tools)', count); - reset(this.label, ...renderLabelWithIcons(message)); - })); - } - - }, action, { ...options, keybindingNotRenderedWithLabel: true }); - - }; - } - - update(tools: IToolData[]): void { - this._selectedTools.set(tools, undefined); - } - - reset(): void { - this._selectedTools.set(undefined, undefined); + } + return result; } } diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup.ts b/src/vs/workbench/contrib/chat/browser/chatSetup.ts index 2b2b23d440c..e642487d347 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup.ts @@ -4,19 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatSetup.css'; -import { $, getActiveElement, setVisibility } from '../../../../base/browser/dom.js'; -import { ButtonWithDropdown } from '../../../../base/browser/ui/button/button.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { mainWindow } from '../../../../base/browser/window.js'; +import { $ } from '../../../../base/browser/dom.js'; +import { Dialog } from '../../../../base/browser/ui/dialog/dialog.js'; import { toAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../base/common/actions.js'; import { timeout } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../base/common/errorMessage.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { combinedDisposable, Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, markAsSingleton, MutableDisposable } from '../../../../base/common/lifecycle.js'; import Severity from '../../../../base/common/severity.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { equalsIgnoreCase } from '../../../../base/common/strings.js'; @@ -31,8 +30,11 @@ import { ConfigurationTarget, IConfigurationService } from '../../../../platform import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { createWorkbenchDialogOptions } from '../../../../platform/dialogs/browser/dialog.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import product from '../../../../platform/product/common/product.js'; @@ -41,7 +43,6 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { ITelemetryService, TelemetryLevel } from '../../../../platform/telemetry/common/telemetry.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../common/views.js'; @@ -52,24 +53,20 @@ import { nullExtensionDescription } from '../../../services/extensions/common/ex import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; -import { IStatusbarService } from '../../../services/statusbar/browser/statusbar.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; +import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolResult, ToolProgress } from '../../chat/common/languageModelToolsService.js'; import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; -import { IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService, IChatWelcomeMessageContent } from '../common/chatAgents.js'; +import { IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementRequests, ChatEntitlementService, IChatEntitlementService } from '../common/chatEntitlementService.js'; -import { IChatProgress, IChatProgressMessage, IChatService, IChatWarningMessage } from '../common/chatService.js'; -import { CHAT_CATEGORY, CHAT_SETUP_ACTION_ID } from './actions/chatActions.js'; -import { ChatViewId, EditsViewId, ensureSideBarChatViewSize, IChatWidgetService, preferCopilotEditsView, showCopilotView } from './chat.js'; -import { CHAT_EDITING_SIDEBAR_PANEL_ID, CHAT_SIDEBAR_PANEL_ID } from './chatViewPane.js'; -import { ChatViewsWelcomeExtensions, IChatViewsWelcomeContributionRegistry } from './viewsWelcome/chatViewsWelcome.js'; -import { ChatAgentLocation } from '../common/constants.js'; +import { ChatModel, ChatRequestModel, IChatRequestModel, IChatRequestToolEntry, IChatRequestVariableData } from '../common/chatModel.js'; +import { ChatRequestAgentPart, ChatRequestToolPart } from '../common/chatParserTypes.js'; +import { IChatProgress, IChatService } from '../common/chatService.js'; +import { ChatAgentLocation, ChatConfiguration, ChatMode, validateChatMode } from '../common/constants.js'; import { ILanguageModelsService } from '../common/languageModels.js'; -import { Dialog } from '../../../../base/browser/ui/dialog/dialog.js'; -import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { createWorkbenchDialogOptions } from '../../../../platform/dialogs/browser/dialog.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; +import { CHAT_CATEGORY, CHAT_OPEN_ACTION_ID, CHAT_SETUP_ACTION_ID } from './actions/chatActions.js'; +import { ChatViewId, IChatWidgetService, showCopilotView } from './chat.js'; +import { CHAT_SIDEBAR_PANEL_ID } from './chatViewPane.js'; const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', @@ -92,158 +89,472 @@ const defaultChat = { chatRefreshTokenCommand: product.defaultChatAgent?.chatRefreshTokenCommand ?? '', }; +const copilotSettingsMessage = localize({ key: 'settings', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "Copilot Free and Pro may show [public code]({0}) suggestions and we may use your data for product improvement. You can change these [settings]({1}) at any time.", defaultChat.publicCodeMatchesUrl, defaultChat.manageSettingsUrl); + //#region Contribution -class SetupChatAgentImplementation extends Disposable implements IChatAgentImplementation { +const ToolsAgentContextKey = ContextKeyExpr.and( + ContextKeyExpr.equals(`config.${ChatConfiguration.AgentEnabled}`, true), + ChatContextKeys.Editing.agentModeDisallowed.negate(), + ContextKeyExpr.not(`previewFeaturesDisabled`) // Set by extension +); - static register(instantiationService: IInstantiationService, location: ChatAgentLocation, isToolsAgent: boolean, context: ChatEntitlementContext, controller: Lazy): IDisposable { +class SetupAgent extends Disposable implements IChatAgentImplementation { + + static registerDefaultAgents(instantiationService: IInstantiationService, location: ChatAgentLocation, mode: ChatMode | undefined, context: ChatEntitlementContext, controller: Lazy): { agent: SetupAgent; disposable: IDisposable } { return instantiationService.invokeFunction(accessor => { const chatAgentService = accessor.get(IChatAgentService); - // TODO@bpasero: expand this to more cases (installed, not signed in / not signed up) - const setupChatAgentContext = ContextKeyExpr.and( - ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.installed.negate(), - ChatContextKeys.Setup.fromDialog - ); + let id: string; + let description = localize('chatDescription', "Ask Copilot"); + switch (location) { + case ChatAgentLocation.Panel: + if (mode === ChatMode.Ask) { + id = 'setup.chat'; + } else if (mode === ChatMode.Edit) { + id = 'setup.edits'; + description = localize('editsDescription', "Edit files in your workspace"); + } else { + id = 'setup.agent'; + description = localize('agentDescription', "Edit files in your workspace in agent mode"); + } + break; + case ChatAgentLocation.Terminal: + id = 'setup.terminal'; + break; + case ChatAgentLocation.Editor: + id = 'setup.editor'; + break; + case ChatAgentLocation.Notebook: + id = 'setup.notebook'; + break; + } - const id = location === ChatAgentLocation.Panel ? 'setup.chat' : isToolsAgent ? 'setup.agent' : 'setup.edits'; - - const welcomeMessageContent: IChatWelcomeMessageContent = location === ChatAgentLocation.Panel ? - { - title: localize('chatTitle', "Ask Copilot"), - message: new MarkdownString(localize('chatMessage', "Copilot is powered by AI, so mistakes are possible. Review output carefully before use.")), - icon: Codicon.copilotLarge - } : isToolsAgent ? - { - title: localize('editsTitle', "Edit with Copilot"), - message: new MarkdownString(localize('agentMessage', "Ask Copilot to edit your files in agent mode. Copilot will automatically use multiple requests to pick files to edit, run terminal commands, and iterate on errors.")), - icon: Codicon.copilotLarge - } : - { - title: localize('editsTitle', "Edit with Copilot"), - message: new MarkdownString(localize('editsMessage', "Start your editing session by defining a set of files that you want to work with. Then ask Copilot for the changes you want to make.")), - icon: Codicon.copilotLarge - }; - - const disposable = new DisposableStore(); - - disposable.add(chatAgentService.registerAgent(id, { - id, - name: `${defaultChat.providerName} Copilot`, - isDefault: true, - isToolsAgent, - when: setupChatAgentContext?.serialize(), - slashCommands: [], - disambiguation: [], - locations: [location], - metadata: { - welcomeMessageContent, - helpTextPrefix: SetupChatAgentImplementation.SETUP_WARNING - }, - description: location === ChatAgentLocation.Panel ? localize('chatDescription', "Ask Copilot") : isToolsAgent ? localize('agentDescription', "Edit files in your workspace in agent mode (Experimental)") : localize('editsDescription', "Edit files in your workspace"), - extensionId: nullExtensionDescription.identifier, - extensionDisplayName: nullExtensionDescription.name, - extensionPublisherId: nullExtensionDescription.publisher - })); - - disposable.add(chatAgentService.registerAgentImplementation(id, disposable.add(instantiationService.createInstance(SetupChatAgentImplementation, context, controller)))); - - return disposable; + return SetupAgent.doRegisterAgent(instantiationService, chatAgentService, id, `${defaultChat.providerName} Copilot`, true, description, location, mode, context, controller); }); } - private static readonly SETUP_WARNING = new MarkdownString(localize('settingUpCopilotWarning', "You need to [set up Copilot]({0} \"Set up Copilot\") to use Chat.", `command:${CHAT_SETUP_ACTION_ID}`), { isTrusted: true }); + static registerVSCodeAgent(instantiationService: IInstantiationService, context: ChatEntitlementContext, controller: Lazy): { agent: SetupAgent; disposable: IDisposable } { + return instantiationService.invokeFunction(accessor => { + const chatAgentService = accessor.get(IChatAgentService); + + const disposables = new DisposableStore(); + + const { agent, disposable } = SetupAgent.doRegisterAgent(instantiationService, chatAgentService, 'setup.vscode', 'vscode', false, localize2('vscodeAgentDescription', "Ask questions about VS Code").value, ChatAgentLocation.Panel, undefined, context, controller); + disposables.add(disposable); + + disposables.add(SetupTool.registerTool(instantiationService, { + id: 'setup.tools.createNewWorkspace', + source: { + type: 'internal', + }, + icon: Codicon.newFolder, + displayName: localize('setupToolDisplayName', "New Workspace"), + modelDescription: localize('setupToolsDescription', "Scaffold a new workspace in VS Code"), + userDescription: localize('setupToolsDescription', "Scaffold a new workspace in VS Code"), + canBeReferencedInPrompt: true, + toolReferenceName: 'new', + when: ContextKeyExpr.true(), + supportsToolPicker: true, + }).disposable); + + return { agent, disposable: disposables }; + }); + } + + private static doRegisterAgent(instantiationService: IInstantiationService, chatAgentService: IChatAgentService, id: string, name: string, isDefault: boolean, description: string, location: ChatAgentLocation, mode: ChatMode | undefined, context: ChatEntitlementContext, controller: Lazy): { agent: SetupAgent; disposable: IDisposable } { + const disposables = new DisposableStore(); + disposables.add(chatAgentService.registerAgent(id, { + id, + name, + isDefault, + isCore: true, + modes: mode ? [mode] : [ChatMode.Ask], + when: mode === ChatMode.Agent ? ToolsAgentContextKey?.serialize() : undefined, + slashCommands: [], + disambiguation: [], + locations: [location], + metadata: { helpTextPrefix: SetupAgent.SETUP_NEEDED_MESSAGE }, + description, + extensionId: nullExtensionDescription.identifier, + extensionDisplayName: nullExtensionDescription.name, + extensionPublisherId: nullExtensionDescription.publisher + })); + + const agent = disposables.add(instantiationService.createInstance(SetupAgent, context, controller, location)); + disposables.add(chatAgentService.registerAgentImplementation(id, agent)); + + return { agent, disposable: disposables }; + } + + private static readonly SETUP_NEEDED_MESSAGE = new MarkdownString(localize('settingUpCopilotNeeded', "You need to set up Copilot to use Chat.")); + + private readonly _onUnresolvableError = this._register(new Emitter()); + readonly onUnresolvableError = this._onUnresolvableError.event; + + private readonly pendingForwardedRequests = new Map>(); constructor( private readonly context: ChatEntitlementContext, private readonly controller: Lazy, + private readonly location: ChatAgentLocation, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @IConfigurationService private readonly configurationService: IConfigurationService, - @ITelemetryService private readonly telemetryService: ITelemetryService + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); } async invoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void): Promise { - return this.instantiationService.invokeFunction(async accessor => { - const chatService = accessor.get(IChatService); // use accessor for lazy loading - const languageModelsService = accessor.get(ILanguageModelsService); // of chat related services + return this.instantiationService.invokeFunction(async accessor /* using accessor for lazy loading */ => { + const chatService = accessor.get(IChatService); + const languageModelsService = accessor.get(ILanguageModelsService); const chatWidgetService = accessor.get(IChatWidgetService); + const chatAgentService = accessor.get(IChatAgentService); + const languageModelToolsService = accessor.get(ILanguageModelToolsService); - return this.doInvoke(request, progress, chatService, languageModelsService, chatWidgetService); + return this.doInvoke(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService); }); } - private async doInvoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService): Promise { + private async doInvoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService): Promise { + if (!this.context.state.installed || this.context.state.entitlement === ChatEntitlement.Available || this.context.state.entitlement === ChatEntitlement.Unknown) { + return this.doInvokeWithSetup(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService); + } + + return this.doInvokeWithoutSetup(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService); + } + + private async doInvokeWithoutSetup(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService): Promise { + const requestModel = chatWidgetService.getWidgetBySessionId(request.sessionId)?.viewModel?.model.getRequests().at(-1); + if (!requestModel) { + this.logService.error('[chat setup] Request model not found, cannot redispatch request.'); + return {}; // this should not happen + } + + progress({ + kind: 'progressMessage', + content: new MarkdownString(localize('waitingCopilot', "Getting Copilot ready.")), + }); + + await this.forwardRequestToCopilot(requestModel, progress, chatService, languageModelsService, chatAgentService, chatWidgetService, languageModelToolsService); + + return {}; + } + + private async forwardRequestToCopilot(requestModel: IChatRequestModel, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatAgentService: IChatAgentService, chatWidgetService: IChatWidgetService, languageModelToolsService: ILanguageModelToolsService): Promise { + try { + await this.doForwardRequestToCopilot(requestModel, progress, chatService, languageModelsService, chatAgentService, chatWidgetService, languageModelToolsService); + } catch (error) { + progress({ + kind: 'warning', + content: new MarkdownString(localize('copilotUnavailableWarning', "Copilot failed to get a response. Please try again.")) + }); + } + } + + private async doForwardRequestToCopilot(requestModel: IChatRequestModel, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatAgentService: IChatAgentService, chatWidgetService: IChatWidgetService, languageModelToolsService: ILanguageModelToolsService): Promise { + if (this.pendingForwardedRequests.has(requestModel.session.sessionId)) { + throw new Error('Request already in progress'); + } + + const forwardRequest = this.doForwardRequestToCopilotWhenReady(requestModel, progress, chatService, languageModelsService, chatAgentService, chatWidgetService, languageModelToolsService); + this.pendingForwardedRequests.set(requestModel.session.sessionId, forwardRequest); + + try { + await forwardRequest; + } finally { + this.pendingForwardedRequests.delete(requestModel.session.sessionId); + } + } + + private async doForwardRequestToCopilotWhenReady(requestModel: IChatRequestModel, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatAgentService: IChatAgentService, chatWidgetService: IChatWidgetService, languageModelToolsService: ILanguageModelToolsService): Promise { + const widget = chatWidgetService.getWidgetBySessionId(requestModel.session.sessionId); + const mode = widget?.input.currentMode; + const languageModel = widget?.input.currentLanguageModel; + + // We need a signal to know when we can resend the request to + // Copilot. Waiting for the registration of the agent is not + // enough, we also need a language/tools model to be available. + + const whenAgentReady = this.whenAgentReady(chatAgentService, mode); + const whenLanguageModelReady = this.whenLanguageModelReady(languageModelsService); + const whenToolsModelReady = this.whenToolsModelReady(languageModelToolsService, requestModel); + + if (whenLanguageModelReady instanceof Promise || whenAgentReady instanceof Promise || whenToolsModelReady instanceof Promise) { + const timeoutHandle = setTimeout(() => { + progress({ + kind: 'progressMessage', + content: new MarkdownString(localize('waitingCopilot2', "Copilot is almost ready.")), + }); + }, 10000); + + try { + const ready = await Promise.race([ + timeout(20000).then(() => 'timedout'), + this.whenDefaultAgentFailed(chatService).then(() => 'error'), + Promise.allSettled([whenLanguageModelReady, whenAgentReady, whenToolsModelReady]) + ]); + + if (ready === 'error' || ready === 'timedout') { + progress({ + kind: 'warning', + content: new MarkdownString(ready === 'timedout' ? + localize('copilotTookLongWarning', "Copilot took too long to get ready. Please review the guidance in the Chat view.") : + localize('copilotFailedWarning', "Copilot failed to get ready. Please review the guidance in the Chat view.") + ) + }); + + // This means Copilot is unhealthy and we cannot retry the + // request. Signal this to the outside via an event. + this._onUnresolvableError.fire(); + return; + } + } finally { + clearTimeout(timeoutHandle); + } + } + + await chatService.resendRequest(requestModel, { mode, userSelectedModelId: languageModel }); + } + + private whenLanguageModelReady(languageModelsService: ILanguageModelsService): Promise | void { + for (const id of languageModelsService.getLanguageModelIds()) { + const model = languageModelsService.lookupLanguageModel(id); + if (model && model.isDefault) { + return; // we have language models! + } + } + + return Event.toPromise(Event.filter(languageModelsService.onDidChangeLanguageModels, e => e.added?.some(added => added.metadata.isDefault) ?? false)); + } + + private whenToolsModelReady(languageModelToolsService: ILanguageModelToolsService, requestModel: IChatRequestModel): Promise | void { + const needsToolsModel = requestModel.message.parts.some(part => part instanceof ChatRequestToolPart); + if (!needsToolsModel) { + return; // No tools in this request, no need to check + } + + // check that tools other than setup. and internal tools are registered. + for (const tool of languageModelToolsService.getTools()) { + if (tool.source.type !== 'internal') { + return; // we have tools! + } + } + + return Event.toPromise(Event.filter(languageModelToolsService.onDidChangeTools, () => { + for (const tool of languageModelToolsService.getTools()) { + if (tool.source.type !== 'internal') { + return true; // we have tools! + } + } + + return false; // no external tools found + })); + } + + private whenAgentReady(chatAgentService: IChatAgentService, mode: ChatMode | undefined): Promise | void { + const defaultAgent = chatAgentService.getDefaultAgent(this.location, mode); + if (defaultAgent && !defaultAgent.isCore) { + return; // we have a default agent from an extension! + } + + return Event.toPromise(Event.filter(chatAgentService.onDidChangeAgents, () => { + const defaultAgent = chatAgentService.getDefaultAgent(this.location, mode); + return Boolean(defaultAgent && !defaultAgent.isCore); + })); + } + + private async whenDefaultAgentFailed(chatService: IChatService): Promise { + return new Promise(resolve => { + chatService.activateDefaultAgent(this.location).catch(() => resolve()); + }); + } + + private async doInvokeWithSetup(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService): Promise { this.telemetryService.publicLog2('workbenchActionExecuted', { id: CHAT_SETUP_ACTION_ID, from: 'chat' }); const requestModel = chatWidgetService.getWidgetBySessionId(request.sessionId)?.viewModel?.model.getRequests().at(-1); - const setup = this.instantiationService.createInstance(ChatSetup, this.context, this.controller); - - const setupListener = this.controller.value.onDidChange(() => { + const setupListener = Event.runAndSubscribe(this.controller.value.onDidChange, (() => { switch (this.controller.value.step) { case ChatSetupStep.SigningIn: progress({ kind: 'progressMessage', content: new MarkdownString(localize('setupChatSignIn2', "Signing in to {0}.", ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.enterpriseProviderId ? defaultChat.enterpriseProviderName : defaultChat.providerName)), - } satisfies IChatProgressMessage); + }); break; case ChatSetupStep.Installing: progress({ kind: 'progressMessage', content: new MarkdownString(localize('installingCopilot', "Getting Copilot ready.")), - } satisfies IChatProgressMessage); + }); break; } - }); + })); - const whenDefaultModel = Event.toPromise(Event.filter(languageModelsService.onDidChangeLanguageModels, e => e.added?.some(added => added.metadata.isDefault) ?? false)); - - let success = undefined; + let result: IChatSetupResult | undefined = undefined; try { - success = await setup.run(); + result = await ChatSetup.getInstance(this.instantiationService, this.context, this.controller).run(); } catch (error) { - this.logService.error(localize('setupError', "Error during setup: {0}", toErrorMessage(error))); + this.logService.error(`[chat setup] Error during setup: ${toErrorMessage(error)}`); } finally { setupListener.dispose(); } // User has agreed to run the setup - if (typeof success === 'boolean') { - if (success) { + if (typeof result?.success === 'boolean') { + if (result.success) { + if (result.dialogSkipped) { + progress({ + kind: 'markdownContent', + content: new MarkdownString([localize('copilotSetupSuccess', "Copilot setup finished successfully."), copilotSettingsMessage].join('\n\n')) + }); + } else if (requestModel) { + let newRequest = this.replaceAgentInRequestModel(requestModel, chatAgentService); // Replace agent part with the actual Copilot agent... + newRequest = this.replaceToolInRequestModel(newRequest); // ...then replace any tool parts with the actual Copilot tools - // Await a default model to be present before attempting - // to re-submit the request. Otherwise, the request will fail. - const hasDefaultModel = await Promise.race([ - timeout(5000), - whenDefaultModel - ]); - - // Resend the request now that the setup is complete - if (hasDefaultModel && requestModel) { - chatService.resendRequest(requestModel); + await this.forwardRequestToCopilot(newRequest, progress, chatService, languageModelsService, chatAgentService, chatWidgetService, languageModelToolsService); } } else { progress({ kind: 'warning', - content: new MarkdownString(localize('copilotSetupError', "Copilot setup failed. [Try again]({0} \"Retry\").", `command:${CHAT_SETUP_ACTION_ID}`), { isTrusted: true }), - } satisfies IChatWarningMessage); + content: new MarkdownString(localize('copilotSetupError', "Copilot setup failed.")) + }); } } // User has cancelled the setup else { progress({ - kind: 'warning', - content: SetupChatAgentImplementation.SETUP_WARNING, - } satisfies IChatWarningMessage); + kind: 'markdownContent', + content: SetupAgent.SETUP_NEEDED_MESSAGE, + }); } return {}; } + + private replaceAgentInRequestModel(requestModel: IChatRequestModel, chatAgentService: IChatAgentService): IChatRequestModel { + const agentPart = requestModel.message.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); + if (!agentPart) { + return requestModel; + } + + const agentId = agentPart.agent.id.replace(/setup\./, `${defaultChat.extensionId}.`.toLowerCase()); + const githubAgent = chatAgentService.getAgent(agentId); + if (!githubAgent) { + return requestModel; + } + + const newAgentPart = new ChatRequestAgentPart(agentPart.range, agentPart.editorRange, githubAgent); + + return new ChatRequestModel({ + session: requestModel.session as ChatModel, + message: { + parts: requestModel.message.parts.map(part => { + if (part instanceof ChatRequestAgentPart) { + return newAgentPart; + } + return part; + }), + text: requestModel.message.text + }, + variableData: requestModel.variableData, + timestamp: Date.now(), + attempt: requestModel.attempt, + confirmation: requestModel.confirmation, + locationData: requestModel.locationData, + attachedContext: requestModel.attachedContext, + isCompleteAddedRequest: requestModel.isCompleteAddedRequest, + }); + } + + private replaceToolInRequestModel(requestModel: IChatRequestModel): IChatRequestModel { + const toolPart = requestModel.message.parts.find((r): r is ChatRequestToolPart => r instanceof ChatRequestToolPart); + if (!toolPart) { + return requestModel; + } + + const toolId = toolPart.toolId.replace(/setup.tools\./, `copilot_`.toLowerCase()); + const newToolPart = new ChatRequestToolPart( + toolPart.range, + toolPart.editorRange, + toolPart.toolName, + toolId, + toolPart.displayName, + toolPart.icon + ); + + const chatRequestToolEntry: IChatRequestToolEntry = { + id: toolId, + name: 'new', + range: toolPart.range, + kind: 'tool', + value: undefined + }; + + const variableData: IChatRequestVariableData = { + variables: [chatRequestToolEntry] + }; + + return new ChatRequestModel({ + session: requestModel.session as ChatModel, + message: { + parts: requestModel.message.parts.map(part => { + if (part instanceof ChatRequestToolPart) { + return newToolPart; + } + return part; + }), + text: requestModel.message.text + }, + variableData: variableData, + timestamp: Date.now(), + attempt: requestModel.attempt, + confirmation: requestModel.confirmation, + locationData: requestModel.locationData, + attachedContext: [chatRequestToolEntry], + isCompleteAddedRequest: requestModel.isCompleteAddedRequest, + }); + } +} + + +class SetupTool extends Disposable implements IToolImpl { + + static registerTool(instantiationService: IInstantiationService, toolData: IToolData): { tool: SetupTool; disposable: IDisposable } { + return instantiationService.invokeFunction(accessor => { + const toolService = accessor.get(ILanguageModelToolsService); + + const disposables = new DisposableStore(); + + disposables.add(toolService.registerToolData(toolData)); + + const tool = instantiationService.createInstance(SetupTool); + disposables.add(toolService.registerToolImplementation(toolData.id, tool)); + + return { tool, disposable: disposables }; + }); + } + + async invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken): Promise { + const result: IToolResult = { + content: [ + { + kind: 'text', + value: '' + } + ] + }; + + return result; + } + + async prepareToolInvocation?(parameters: any, token: CancellationToken): Promise { + return undefined; + } } enum ChatSetupStrategy { @@ -253,9 +564,30 @@ enum ChatSetupStrategy { SetupWithEnterpriseProvider = 3 } +interface IChatSetupResult { + readonly success: boolean | undefined; + readonly dialogSkipped: boolean; +} + class ChatSetup { - constructor( + private static instance: ChatSetup | undefined = undefined; + static getInstance(instantiationService: IInstantiationService, context: ChatEntitlementContext, controller: Lazy): ChatSetup { + let instance = ChatSetup.instance; + if (!instance) { + instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { + return new ChatSetup(context, controller, instantiationService, accessor.get(ITelemetryService), accessor.get(IContextMenuService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService), accessor.get(ILogService), accessor.get(IConfigurationService)); + }); + } + + return instance; + } + + private pendingRun: Promise | undefined = undefined; + + private skipDialogOnce = false; + + private constructor( private readonly context: ChatEntitlementContext, private readonly controller: Lazy, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -265,52 +597,77 @@ class ChatSetup { @IKeybindingService private readonly keybindingService: IKeybindingService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @ILogService private readonly logService: ILogService, + @IConfigurationService private readonly configurationService: IConfigurationService ) { } - async run(): Promise { + skipDialog(): void { + this.skipDialogOnce = true; + } + + async run(): Promise { + if (this.pendingRun) { + return this.pendingRun; + } + + this.pendingRun = this.doRun(); + + try { + return await this.pendingRun; + } finally { + this.pendingRun = undefined; + } + } + + private async doRun(): Promise { + const dialogSkipped = this.skipDialogOnce; + this.skipDialogOnce = false; + let setupStrategy: ChatSetupStrategy; - if (this.chatEntitlementService.entitlement === ChatEntitlement.Pro || this.chatEntitlementService.entitlement === ChatEntitlement.Limited) { + if (dialogSkipped || this.chatEntitlementService.entitlement === ChatEntitlement.Pro || this.chatEntitlementService.entitlement === ChatEntitlement.Limited) { setupStrategy = ChatSetupStrategy.DefaultSetup; // existing pro/free users setup without a dialog } else { setupStrategy = await this.showDialog(); } + if (setupStrategy === ChatSetupStrategy.DefaultSetup && ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.enterpriseProviderId) { + setupStrategy = ChatSetupStrategy.SetupWithEnterpriseProvider; // users with a configured provider go through provider setup + } + let success = undefined; try { switch (setupStrategy) { case ChatSetupStrategy.SetupWithEnterpriseProvider: - success = await this.controller.value.setupWithProvider(true); + success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: true }); break; case ChatSetupStrategy.SetupWithoutEnterpriseProvider: - success = await this.controller.value.setupWithProvider(false); + success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: false }); break; case ChatSetupStrategy.DefaultSetup: success = await this.controller.value.setup(); break; } } catch (error) { - this.logService.error(localize('setupError', "Error during setup: {0}", toErrorMessage(error))); + this.logService.error(`[chat setup] Error during setup: ${toErrorMessage(error)}`); success = false; } - return success; + return { success, dialogSkipped }; } private async showDialog(): Promise { - const buttons = [ - this.getPrimaryButton(), - localize('maybeLater', "Maybe Later"), - ]; const disposables = new DisposableStore(); let result: ChatSetupStrategy | undefined = undefined; + const buttons = [this.getPrimaryButton(), localize('maybeLater', "Maybe Later")]; + const dialog = disposables.add(new Dialog( this.layoutService.activeContainer, - localize('copilotFree', "Set up Copilot Free"), + this.getDialogTitle(), buttons, createWorkbenchDialogOptions({ type: 'none', + icon: Codicon.copilotLarge, cancelId: buttons.length - 1, renderBody: body => body.appendChild(this.createDialog(disposables)), primaryButtonDropdown: { @@ -323,7 +680,6 @@ class ChatSetup { } }, this.keybindingService, this.layoutService) )); - dialog.element.classList.add('chat-setup-dialog'); const { button } = await dialog.show(); disposables.dispose(); @@ -332,58 +688,42 @@ class ChatSetup { } private getPrimaryButton(): string { - switch (this.context.state.entitlement) { - case ChatEntitlement.Unknown: - return this.context.state.registered ? localize('signUp', "Sign in to use Copilot") : localize('signUpFree', "Sign in to use Copilot Free"); - case ChatEntitlement.Unresolved: - return this.context.state.registered ? localize('startUp', "Use Copilot") : localize('startUpLimited', "Use Copilot Free"); - case ChatEntitlement.Available: - case ChatEntitlement.Limited: - return localize('startUpLimited', "Use Copilot Free"); - case ChatEntitlement.Pro: - case ChatEntitlement.Unavailable: - return localize('startUp', "Use Copilot"); + if (this.context.state.entitlement === ChatEntitlement.Unknown) { + return localize('signInButton', "Sign in"); } + + return localize('useCopilotButton', "Use Copilot"); + } + + private getDialogTitle(): string { + if (this.context.state.entitlement === ChatEntitlement.Unknown) { + return this.context.state.registered ? localize('signUp', "Sign in to use Copilot") : localize('signUpFree', "Sign in to use Copilot for free"); + } + + if (this.context.state.entitlement === ChatEntitlement.Pro) { + return localize('copilotProTitle', "Start using Copilot Pro"); + } + + return this.context.state.registered ? localize('copilotTitle', "Start using Copilot") : localize('copilotFreeTitle', "Start using Copilot for free"); } private createDialog(disposables: DisposableStore): HTMLElement { - const element = $('.chat-setup-view'); - - // Icon background - element.appendChild($('.chat-setup-dialog-icon-background')).classList.add(...ThemeIcon.asClassNameArray(Codicon.copilotLarge)); + const element = $('.chat-setup-dialog'); const markdown = this.instantiationService.createInstance(MarkdownRenderer, {}); // Header - const header = localize({ key: 'header', comment: ['{Locked="[Copilot]({0})"}'] }, "[Copilot]({0}) is your AI pair programmer.", defaultChat.documentationUrl); - element.appendChild($('p', undefined, disposables.add(markdown.render(new MarkdownString(header, { isTrusted: true }))).element)); - element.appendChild( - $('div.chat-features-container', undefined, - $('div', undefined, - $('div.chat-feature-container', undefined, - renderIcon(Codicon.code), - $('span', undefined, localize('featureChat', "Code faster with Completions")) - ), - $('div.chat-feature-container', undefined, - renderIcon(Codicon.editSession), - $('span', undefined, localize('featureEdits', "Build features with Copilot Edits")) - ), - $('div.chat-feature-container', undefined, - renderIcon(Codicon.commentDiscussion), - $('span', undefined, localize('featureExplore', "Explore your codebase with Chat")) - ) - ) - ) - ); + const header = localize({ key: 'headerDialog', comment: ['{Locked="[Copilot]({0})"}'] }, "[Copilot]({0}) is your AI pair programmer. Write code faster with completions, fix bugs and build new features across multiple files, and learn about your codebase through chat.", defaultChat.documentationUrl); + element.appendChild($('p.setup-header', undefined, disposables.add(markdown.render(new MarkdownString(header, { isTrusted: true }))).element)); // Terms const terms = localize({ key: 'terms', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "By continuing, you agree to the [Terms]({0}) and [Privacy Policy]({1}).", defaultChat.termsStatementUrl, defaultChat.privacyStatementUrl); - element.appendChild($('p.legal', undefined, disposables.add(markdown.render(new MarkdownString(terms, { isTrusted: true }))).element)); + element.appendChild($('p.setup-legal', undefined, disposables.add(markdown.render(new MarkdownString(terms, { isTrusted: true }))).element)); // SKU Settings if (this.telemetryService.telemetryLevel !== TelemetryLevel.NONE) { - const settings = localize({ key: 'settings', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "Copilot Free and Pro may show [public code]({0}) suggestions and we may use your data for product improvement. You can change these [settings]({1}) at any time.", defaultChat.publicCodeMatchesUrl, defaultChat.manageSettingsUrl); - element.appendChild($('p.legal', undefined, disposables.add(markdown.render(new MarkdownString(settings, { isTrusted: true }))).element)); + const settings = copilotSettingsMessage; + element.appendChild($('p.setup-settings', undefined, disposables.add(markdown.render(new MarkdownString(settings, { isTrusted: true }))).element)); } return element; @@ -392,7 +732,7 @@ class ChatSetup { export class ChatSetupContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.chat.setup'; + static readonly ID = 'workbench.contrib.chatSetup'; constructor( @IProductService private readonly productService: IProductService, @@ -400,7 +740,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr @ICommandService private readonly commandService: ICommandService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IChatEntitlementService chatEntitlementService: ChatEntitlementService, - @IConfigurationService private readonly configurationService: IConfigurationService, + @ILogService private readonly logService: ILogService, ) { super(); @@ -413,51 +753,70 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr const controller = new Lazy(() => this._register(this.instantiationService.createInstance(ChatSetupController, context, requests))); this.registerSetupAgents(context, controller); - this.registerChatWelcome(context, controller); this.registerActions(context, requests, controller); this.registerUrlLinkHandler(); } private registerSetupAgents(context: ChatEntitlementContext, controller: Lazy): void { - const registration = this._register(new MutableDisposable()); + const defaultAgentDisposables = markAsSingleton(new MutableDisposable()); // prevents flicker on window reload + const vscodeAgentDisposables = markAsSingleton(new MutableDisposable()); const updateRegistration = () => { - const disabled = context.state.installed || context.state.hidden || !this.configurationService.getValue('chat.experimental.setupFromDialog'); - if (!disabled && !registration.value) { - registration.value = combinedDisposable( - SetupChatAgentImplementation.register(this.instantiationService, ChatAgentLocation.Panel, false, context, controller), - SetupChatAgentImplementation.register(this.instantiationService, ChatAgentLocation.EditingSession, false, context, controller), - SetupChatAgentImplementation.register(this.instantiationService, ChatAgentLocation.EditingSession, true, context, controller) - ); - } else if (disabled && registration.value) { - registration.clear(); + const disabled = context.state.hidden; + if (!disabled) { + + // Default Agents (always, even if installed to allow for speedy requests right on startup) + if (!defaultAgentDisposables.value) { + const disposables = defaultAgentDisposables.value = new DisposableStore(); + + // Panel Agents + const panelAgentDisposables = disposables.add(new DisposableStore()); + for (const mode of [ChatMode.Ask, ChatMode.Edit, ChatMode.Agent]) { + const { agent, disposable } = SetupAgent.registerDefaultAgents(this.instantiationService, ChatAgentLocation.Panel, mode, context, controller); + panelAgentDisposables.add(disposable); + panelAgentDisposables.add(agent.onUnresolvableError(() => { + // An unresolvable error from our agent registrations means that + // Copilot is unhealthy for some reason. We clear our panel + // registration to give Copilot a chance to show a custom message + // to the user from the views and stop pretending as if there was + // a functional agent. + this.logService.error('[chat setup] Unresolvable error from Copilot agent registration, clearing registration.'); + panelAgentDisposables.dispose(); + })); + } + + // Inline Agents + disposables.add(SetupAgent.registerDefaultAgents(this.instantiationService, ChatAgentLocation.Terminal, undefined, context, controller).disposable); + disposables.add(SetupAgent.registerDefaultAgents(this.instantiationService, ChatAgentLocation.Notebook, undefined, context, controller).disposable); + disposables.add(SetupAgent.registerDefaultAgents(this.instantiationService, ChatAgentLocation.Editor, undefined, context, controller).disposable); + } + + // VSCode Agent + Tool (unless installed) + if (!context.state.installed && !vscodeAgentDisposables.value) { + const disposables = vscodeAgentDisposables.value = new DisposableStore(); + + disposables.add(SetupAgent.registerVSCodeAgent(this.instantiationService, context, controller).disposable); + } + } else { + defaultAgentDisposables.clear(); + vscodeAgentDisposables.clear(); + } + + if (context.state.installed) { + vscodeAgentDisposables.clear(); // we need to do this to prevent showing duplicate agent/tool entries in the list } }; - this._register(Event.runAndSubscribe(Event.any( - context.onDidChange, - Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('chat.experimental.setupFromDialog')) - ), () => updateRegistration())); - } - - private registerChatWelcome(context: ChatEntitlementContext, controller: Lazy): void { - Registry.as(ChatViewsWelcomeExtensions.ChatViewsWelcomeRegistry).register({ - title: localize('welcomeChat', "Welcome to Copilot"), - when: ChatContextKeys.SetupViewCondition, - icon: Codicon.copilotLarge, - content: disposables => disposables.add(this.instantiationService.createInstance(ChatSetupWelcomeContent, controller.value, context)).element, - }); + this._register(Event.runAndSubscribe(context.onDidChange, () => updateRegistration())); } private registerActions(context: ChatEntitlementContext, requests: ChatEntitlementRequests, controller: Lazy): void { - const chatSetupTriggerContext = ContextKeyExpr.and( - ChatContextKeys.Setup.fromDialog.negate(), // reduce noise when using the skeleton-view approach - ContextKeyExpr.or( - ChatContextKeys.Setup.installed.negate(), - ChatContextKeys.Entitlement.canSignUp - )); + const chatSetupTriggerContext = ContextKeyExpr.or( + ChatContextKeys.Setup.installed.negate(), + ChatContextKeys.Entitlement.canSignUp + ); - const CHAT_SETUP_ACTION_LABEL = localize2('triggerChatSetup', "Use AI Features with Copilot Free..."); + const CHAT_SETUP_ACTION_LABEL = localize2('triggerChatSetup', "Use AI Features with Copilot for free..."); class ChatSetupTriggerAction extends Action2 { @@ -467,52 +826,86 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr title: CHAT_SETUP_ACTION_LABEL, category: CHAT_CATEGORY, f1: true, - precondition: chatSetupTriggerContext, - menu: { - id: MenuId.ChatTitleBarMenu, - group: 'a_last', - order: 1, - when: chatSetupTriggerContext + precondition: chatSetupTriggerContext + }); + } + + override async run(accessor: ServicesAccessor, mode: ChatMode): Promise { + const viewsService = accessor.get(IViewsService); + const layoutService = accessor.get(IWorkbenchLayoutService); + const instantiationService = accessor.get(IInstantiationService); + const dialogService = accessor.get(IDialogService); + const commandService = accessor.get(ICommandService); + const lifecycleService = accessor.get(ILifecycleService); + + await context.update({ hidden: false }); + + const chatWidgetPromise = showCopilotView(viewsService, layoutService); + if (mode) { + const chatWidget = await chatWidgetPromise; + chatWidget?.input.setChatMode(mode); + } + + const setup = ChatSetup.getInstance(instantiationService, context, controller); + const { success } = await setup.run(); + if (success === false && !lifecycleService.willShutdown) { + const { confirmed } = await dialogService.confirm({ + type: Severity.Error, + message: localize('setupErrorDialog', "Copilot setup failed. Would you like to try again?"), + primaryButton: localize('retry', "Retry"), + }); + + if (confirmed) { + commandService.executeCommand(CHAT_SETUP_ACTION_ID); } + } + } + } + + class ChatSetupTriggerWithoutDialogAction extends Action2 { + + constructor() { + super({ + id: 'workbench.action.chat.triggerSetupWithoutDialog', + title: CHAT_SETUP_ACTION_LABEL, + precondition: chatSetupTriggerContext }); } override async run(accessor: ServicesAccessor): Promise { const viewsService = accessor.get(IViewsService); - const viewDescriptorService = accessor.get(IViewDescriptorService); - const configurationService = accessor.get(IConfigurationService); const layoutService = accessor.get(IWorkbenchLayoutService); - const statusbarService = accessor.get(IStatusbarService); const instantiationService = accessor.get(IInstantiationService); - const dialogService = accessor.get(IDialogService); - const commandService = accessor.get(ICommandService); await context.update({ hidden: false }); - const setupFromDialog = configurationService.getValue('chat.experimental.setupFromDialog'); - if (!setupFromDialog) { - showCopilotView(viewsService, layoutService); - ensureSideBarChatViewSize(viewDescriptorService, layoutService, viewsService); - } + const chatWidget = await showCopilotView(viewsService, layoutService); + ChatSetup.getInstance(instantiationService, context, controller).skipDialog(); + chatWidget?.acceptInput(localize('setupCopilot', "Set up Copilot.")); + } + } - statusbarService.updateEntryVisibility('chat.statusBarEntry', true); - configurationService.updateValue('chat.commandCenter.enabled', true); + class ChatSetupFromAccountsAction extends Action2 { - if (setupFromDialog) { - const setup = instantiationService.createInstance(ChatSetup, context, controller); - const result = await setup.run(); - if (result === false) { - const { confirmed } = await dialogService.confirm({ - type: Severity.Error, - message: localize('setupErrorDialog', "Copilot setup failed. Would you like to try again?"), - primaryButton: localize('retry', "Retry"), - }); - - if (confirmed) { - commandService.executeCommand(CHAT_SETUP_ACTION_ID); - } + constructor() { + super({ + id: 'workbench.action.chat.triggerSetupFromAccounts', + title: localize2('triggerChatSetupFromAccounts', "Sign in to use Copilot..."), + menu: { + id: MenuId.AccountsContext, + group: '2_copilot', + when: ContextKeyExpr.and( + ChatContextKeys.Setup.hidden.negate(), + ChatContextKeys.Setup.installed.negate(), + ChatContextKeys.Entitlement.signedOut + ) } - } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); + return commandService.executeCommand(CHAT_SETUP_ACTION_ID); } } @@ -540,9 +933,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr override async run(accessor: ServicesAccessor): Promise { const viewsDescriptorService = accessor.get(IViewDescriptorService); const layoutService = accessor.get(IWorkbenchLayoutService); - const configurationService = accessor.get(IConfigurationService); const dialogService = accessor.get(IDialogService); - const statusbarService = accessor.get(IStatusbarService); const { confirmed } = await dialogService.confirm({ message: localize('hideChatSetupConfirm', "Are you sure you want to hide Copilot?"), @@ -564,9 +955,6 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); // hide if there are no views in the secondary sidebar } } - - statusbarService.updateEntryVisibility('chat.statusBarEntry', false); - configurationService.updateValue('chat.commandCenter.enabled', false); } } @@ -586,9 +974,12 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr id: MenuId.ChatTitleBarMenu, group: 'a_first', order: 1, - when: ContextKeyExpr.or( - ChatContextKeys.chatQuotaExceeded, - ChatContextKeys.completionsQuotaExceeded + when: ContextKeyExpr.and( + ChatContextKeys.Entitlement.limited, + ContextKeyExpr.or( + ChatContextKeys.chatQuotaExceeded, + ChatContextKeys.completionsQuotaExceeded + ) ) } }); @@ -596,12 +987,9 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr override async run(accessor: ServicesAccessor, from?: string): Promise { const openerService = accessor.get(IOpenerService); - const telemetryService = accessor.get(ITelemetryService); const hostService = accessor.get(IHostService); const commandService = accessor.get(ICommandService); - telemetryService.publicLog2('workbenchActionExecuted', { id: this.desc.id, from: from ?? 'chat' }); - openerService.open(URI.parse(defaultChat.upgradePlanUrl)); const entitlement = context.state.entitlement; @@ -625,6 +1013,8 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr } registerAction2(ChatSetupTriggerAction); + registerAction2(ChatSetupFromAccountsAction); + registerAction2(ChatSetupTriggerWithoutDialogAction); registerAction2(ChatSetupHideAction); registerAction2(UpgradePlanAction); } @@ -638,7 +1028,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr const params = new URLSearchParams(url.query); this.telemetryService.publicLog2('workbenchActionExecuted', { id: CHAT_SETUP_ACTION_ID, from: 'url', detail: params.get('referrer') ?? undefined }); - await this.commandService.executeCommand(CHAT_SETUP_ACTION_ID); + await this.commandService.executeCommand(CHAT_SETUP_ACTION_ID, validateChatMode(params.get('mode'))); return true; } @@ -648,7 +1038,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr //#endregion -//#region Setup Rendering +//#region Setup Controller type InstallChatClassification = { owner: 'bpasero'; @@ -656,13 +1046,11 @@ type InstallChatClassification = { installResult: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the extension was installed successfully, cancelled or failed to install.' }; installDuration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The duration it took to install the extension.' }; signUpErrorCode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The error code in case of an error signing up.' }; - setupFromDialog: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the setup was triggered from the dialog or not.' }; }; type InstallChatEvent = { - installResult: 'installed' | 'cancelled' | 'failedInstall' | 'failedNotSignedIn' | 'failedSignUp' | 'failedNotTrusted' | 'failedNoSession'; + installResult: 'installed' | 'alreadyInstalled' | 'cancelled' | 'failedInstall' | 'failedNotSignedIn' | 'failedSignUp' | 'failedNotTrusted' | 'failedNoSession'; installDuration: number; signUpErrorCode: number | undefined; - setupFromDialog: boolean; }; enum ChatSetupStep { @@ -679,22 +1067,17 @@ class ChatSetupController extends Disposable { private _step = ChatSetupStep.Initial; get step(): ChatSetupStep { return this._step; } - private willShutdown = false; - constructor( private readonly context: ChatEntitlementContext, private readonly requests: ChatEntitlementRequests, @ITelemetryService private readonly telemetryService: ITelemetryService, @IAuthenticationService private readonly authenticationService: IAuthenticationService, - @IViewsService private readonly viewsService: IViewsService, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @IProductService private readonly productService: IProductService, @ILogService private readonly logService: ILogService, @IProgressService private readonly progressService: IProgressService, - @IChatAgentService private readonly chatAgentService: IChatAgentService, @IActivityService private readonly activityService: IActivityService, @ICommandService private readonly commandService: ICommandService, - @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IDialogService private readonly dialogService: IDialogService, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -708,7 +1091,6 @@ class ChatSetupController extends Disposable { private registerListeners(): void { this._register(this.context.onDidChange(() => this._onDidChange.fire())); - this._register(this.lifecycleService.onWillShutdown(() => this.willShutdown = true)); } private setStep(step: ChatSetupStep): void { @@ -720,41 +1102,39 @@ class ChatSetupController extends Disposable { this._onDidChange.fire(); } - async setup(options?: { forceSignIn?: boolean; notificationProgress?: boolean }): Promise { + async setup(options?: { forceSignIn?: boolean }): Promise { const watch = new StopWatch(false); const title = localize('setupChatProgress', "Getting Copilot ready..."); - const badge = this.activityService.showViewContainerActivity(preferCopilotEditsView(this.viewsService) ? CHAT_EDITING_SIDEBAR_PANEL_ID : CHAT_SIDEBAR_PANEL_ID, { + const badge = this.activityService.showViewContainerActivity(CHAT_SIDEBAR_PANEL_ID, { badge: new ProgressBadge(() => title), }); try { return await this.progressService.withProgress({ - location: options?.notificationProgress ? ProgressLocation.Notification : ProgressLocation.Window, - command: CHAT_SETUP_ACTION_ID, + location: ProgressLocation.Window, + command: CHAT_OPEN_ACTION_ID, title, - }, () => this.doSetup(options?.forceSignIn ?? false, watch)); + }, () => this.doSetup(options ?? {}, watch)); } finally { badge.dispose(); } } - private async doSetup(forceSignIn: boolean, watch: StopWatch): Promise { + private async doSetup(options: { forceSignIn?: boolean }, watch: StopWatch): Promise { this.context.suspend(); // reduces flicker - let focusChatInput = false; let success = false; try { - const setupFromDialog = Boolean(this.configurationService.getValue('chat.experimental.setupFromDialog')); const providerId = ChatEntitlementRequests.providerId(this.configurationService); let session: AuthenticationSession | undefined; let entitlement: ChatEntitlement | undefined; // Entitlement Unknown or `forceSignIn`: we need to sign-in user - if (this.context.state.entitlement === ChatEntitlement.Unknown || forceSignIn) { + if (this.context.state.entitlement === ChatEntitlement.Unknown || options.forceSignIn) { this.setStep(ChatSetupStep.SigningIn); const result = await this.signIn(providerId); if (!result.session) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', installDuration: watch.elapsed(), signUpErrorCode: undefined, setupFromDialog }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', installDuration: watch.elapsed(), signUpErrorCode: undefined }); return false; } @@ -766,27 +1146,18 @@ class ChatSetupController extends Disposable { message: localize('copilotWorkspaceTrust', "Copilot is currently only supported in trusted workspaces.") }); if (!trusted) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotTrusted', installDuration: watch.elapsed(), signUpErrorCode: undefined, setupFromDialog }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotTrusted', installDuration: watch.elapsed(), signUpErrorCode: undefined }); return false; } - const activeElement = getActiveElement(); - // Install this.setStep(ChatSetupStep.Installing); success = await this.install(session, entitlement ?? this.context.state.entitlement, providerId, watch); - - const currentActiveElement = getActiveElement(); - focusChatInput = activeElement === currentActiveElement || currentActiveElement === mainWindow.document.body; } finally { this.setStep(ChatSetupStep.Initial); this.context.resume(); } - if (focusChatInput) { - (await showCopilotView(this.viewsService, this.layoutService))?.focusInput(); - } - return success; } @@ -794,14 +1165,12 @@ class ChatSetupController extends Disposable { let session: AuthenticationSession | undefined; let entitlements; try { - showCopilotView(this.viewsService, this.layoutService); - ({ session, entitlements } = await this.requests.signIn()); } catch (e) { this.logService.error(`[chat setup] signIn: error ${e}`); } - if (!session && !this.willShutdown) { + if (!session && !this.lifecycleService.willShutdown) { const { confirmed } = await this.dialogService.confirm({ type: Severity.Error, message: localize('unknownSignInError', "Failed to sign in to {0}. Would you like to try again?", ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.enterpriseProviderId ? defaultChat.enterpriseProviderName : defaultChat.providerName), @@ -817,13 +1186,11 @@ class ChatSetupController extends Disposable { return { session, entitlement: entitlements?.entitlement }; } - private async install(session: AuthenticationSession | undefined, entitlement: ChatEntitlement, providerId: string, watch: StopWatch,): Promise { + private async install(session: AuthenticationSession | undefined, entitlement: ChatEntitlement, providerId: string, watch: StopWatch): Promise { const wasInstalled = this.context.state.installed; let signUpResult: boolean | { errorCode: number } | undefined = undefined; - const setupFromDialog = Boolean(this.configurationService.getValue('chat.experimental.setupFromDialog')); try { - showCopilotView(this.viewsService, this.layoutService); if ( entitlement !== ChatEntitlement.Limited && // User is not signed up to Copilot Free @@ -838,7 +1205,7 @@ class ChatSetupController extends Disposable { } if (!session) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNoSession', installDuration: watch.elapsed(), signUpErrorCode: undefined, setupFromDialog }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNoSession', installDuration: watch.elapsed(), signUpErrorCode: undefined }); return false; // unexpected } } @@ -846,28 +1213,25 @@ class ChatSetupController extends Disposable { signUpResult = await this.requests.signUpLimited(session); if (typeof signUpResult !== 'boolean' /* error */) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedSignUp', installDuration: watch.elapsed(), signUpErrorCode: signUpResult.errorCode, setupFromDialog }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedSignUp', installDuration: watch.elapsed(), signUpErrorCode: signUpResult.errorCode }); } } await this.doInstall(); } catch (error) { this.logService.error(`[chat setup] install: error ${error}`); - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: isCancellationError(error) ? 'cancelled' : 'failedInstall', installDuration: watch.elapsed(), signUpErrorCode: undefined, setupFromDialog }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: isCancellationError(error) ? 'cancelled' : 'failedInstall', installDuration: watch.elapsed(), signUpErrorCode: undefined }); return false; } - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'installed', installDuration: watch.elapsed(), signUpErrorCode: undefined, setupFromDialog }); + if (typeof signUpResult === 'boolean') { + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: wasInstalled && !signUpResult ? 'alreadyInstalled' : 'installed', installDuration: watch.elapsed(), signUpErrorCode: undefined }); + } if (wasInstalled && signUpResult === true) { refreshTokens(this.commandService); } - await Promise.race([ - timeout(5000), // helps prevent flicker with sign-in welcome view - Event.toPromise(this.chatAgentService.onDidChangeAgents) // https://github.com/microsoft/vscode-copilot/issues/9274 - ]); - return true; } @@ -880,14 +1244,14 @@ class ChatSetupController extends Disposable { isMachineScoped: false, // do not ask to sync installEverywhere: true, // install in local and remote installPreReleaseVersion: this.productService.quality !== 'stable' - }, preferCopilotEditsView(this.viewsService) ? EditsViewId : ChatViewId); + }, ChatViewId); } catch (e) { this.logService.error(`[chat setup] install: error ${error}`); error = e; } if (error) { - if (!this.willShutdown) { + if (!this.lifecycleService.willShutdown) { const { confirmed } = await this.dialogService.confirm({ type: Severity.Error, message: localize('unknownSetupError', "An error occurred while setting up Copilot. Would you like to try again?"), @@ -904,7 +1268,7 @@ class ChatSetupController extends Disposable { } } - async setupWithProvider(useEnterpriseProvider: boolean): Promise { + async setupWithProvider(options: { useEnterpriseProvider: boolean }): Promise { const registry = Registry.as(ConfigurationExtensions.Configuration); registry.registerConfiguration({ 'id': 'copilot.setup', @@ -924,7 +1288,7 @@ class ChatSetupController extends Disposable { } }); - if (useEnterpriseProvider) { + if (options.useEnterpriseProvider) { const success = await this.handleEnterpriseInstance(); if (!success) { return false; // not properly configured, abort @@ -936,7 +1300,7 @@ class ChatSetupController extends Disposable { existingAdvancedSetting = {}; } - if (useEnterpriseProvider) { + if (options.useEnterpriseProvider) { await this.configurationService.updateValue(`${defaultChat.completionsAdvancedSetting}`, { ...existingAdvancedSetting, 'authProvider': defaultChat.enterpriseProviderId @@ -949,7 +1313,7 @@ class ChatSetupController extends Disposable { await this.configurationService.updateValue(defaultChat.providerUriSetting, undefined, ConfigurationTarget.USER); } - return this.setup({ forceSignIn: true }); + return this.setup({ ...options, forceSignIn: true }); } private async handleEnterpriseInstance(): Promise { @@ -965,6 +1329,7 @@ class ChatSetupController extends Disposable { const result = await this.quickInputService.input({ prompt: localize('enterpriseInstance', "What is your {0} instance?", defaultChat.enterpriseProviderName), placeHolder: localize('enterpriseInstancePlaceholder', 'i.e. "octocat" or "https://octocat.ghe.com"...'), + ignoreFocusLost: true, value: uri, validateInput: async value => { isSingleWord = false; @@ -980,7 +1345,7 @@ class ChatSetupController extends Disposable { }; } if (!fullUriRegEx.test(value)) { return { - content: localize('invalidEnterpriseInstance', 'Please enter a valid {0} instance (i.e. "octocat" or "https://octocat.ghe.com")', defaultChat.enterpriseProviderName), + content: localize('invalidEnterpriseInstance', 'You must enter a valid {0} instance (i.e. "octocat" or "https://octocat.ghe.com")', defaultChat.enterpriseProviderName), severity: Severity.Error }; } @@ -1020,125 +1385,6 @@ class ChatSetupController extends Disposable { } } -class ChatSetupWelcomeContent extends Disposable { - - readonly element = $('.chat-setup-view'); - - constructor( - private readonly controller: ChatSetupController, - private readonly context: ChatEntitlementContext, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - ) { - super(); - - this.create(); - } - - private create(): void { - const markdown = this.instantiationService.createInstance(MarkdownRenderer, {}); - - // Header - { - const header = localize({ key: 'header', comment: ['{Locked="[Copilot]({0})"}'] }, "[Copilot]({0}) is your AI pair programmer.", this.context.state.installed ? `command:${defaultChat.walkthroughCommand}` : defaultChat.documentationUrl); - this.element.appendChild($('p', undefined, this._register(markdown.render(new MarkdownString(header, { isTrusted: true }))).element)); - - this.element.appendChild( - $('div.chat-features-container', undefined, - $('div', undefined, - $('div.chat-feature-container', undefined, - renderIcon(Codicon.code), - $('span', undefined, localize('featureChat', "Code faster with Completions")) - ), - $('div.chat-feature-container', undefined, - renderIcon(Codicon.editSession), - $('span', undefined, localize('featureEdits', "Build features with Copilot Edits")) - ), - $('div.chat-feature-container', undefined, - renderIcon(Codicon.commentDiscussion), - $('span', undefined, localize('featureExplore', "Explore your codebase with Chat")) - ) - ) - ) - ); - } - - // Limited SKU - const free = localize({ key: 'free', comment: ['{Locked="[]({0})"}'] }, "$(sparkle-filled) We now offer [Copilot Free]({0}).", defaultChat.skusDocumentationUrl); - const freeContainer = this.element.appendChild($('p', undefined, this._register(markdown.render(new MarkdownString(free, { isTrusted: true, supportThemeIcons: true }))).element)); - - // Setup Button - const buttonContainer = this.element.appendChild($('p')); - buttonContainer.classList.add('button-container'); - const button = this._register(new ButtonWithDropdown(buttonContainer, { - actions: [ - toAction({ id: 'chatSetup.setupWithProvider', label: localize('setupWithProvider', "Sign in with a {0} Account", defaultChat.providerName), run: () => this.controller.setupWithProvider(false) }), - toAction({ id: 'chatSetup.setupWithEnterpriseProvider', label: localize('setupWithEnterpriseProvider', "Sign in with a {0} Account", defaultChat.enterpriseProviderName), run: () => this.controller.setupWithProvider(true) }) - ], - addPrimaryActionToDropdown: false, - contextMenuProvider: this.contextMenuService, - supportIcons: true, - ...defaultButtonStyles - })); - this._register(button.onDidClick(() => this.controller.setup())); - - // Terms - const terms = localize({ key: 'terms', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "By continuing, you agree to the [Terms]({0}) and [Privacy Policy]({1}).", defaultChat.termsStatementUrl, defaultChat.privacyStatementUrl); - this.element.appendChild($('p', undefined, this._register(markdown.render(new MarkdownString(terms, { isTrusted: true }))).element)); - - // SKU Settings - const settings = localize({ key: 'settings', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "Copilot Free and Pro may show [public code]({0}) suggestions and we may use your data for product improvement. You can change these [settings]({1}) at any time.", defaultChat.publicCodeMatchesUrl, defaultChat.manageSettingsUrl); - const settingsContainer = this.element.appendChild($('p', undefined, this._register(markdown.render(new MarkdownString(settings, { isTrusted: true }))).element)); - - // Update based on model state - this._register(Event.runAndSubscribe(this.controller.onDidChange, () => this.update(freeContainer, settingsContainer, button))); - } - - private update(freeContainer: HTMLElement, settingsContainer: HTMLElement, button: ButtonWithDropdown): void { - const showSettings = this.telemetryService.telemetryLevel !== TelemetryLevel.NONE; - let showFree: boolean; - let buttonLabel: string; - - switch (this.context.state.entitlement) { - case ChatEntitlement.Unknown: - showFree = true; - buttonLabel = this.context.state.registered ? localize('signUp', "Sign in to use Copilot") : localize('signUpFree', "Sign in to use Copilot Free"); - break; - case ChatEntitlement.Unresolved: - showFree = true; - buttonLabel = this.context.state.registered ? localize('startUp', "Use Copilot") : localize('startUpLimited', "Use Copilot Free"); - break; - case ChatEntitlement.Available: - case ChatEntitlement.Limited: - showFree = true; - buttonLabel = localize('startUpLimited', "Use Copilot Free"); - break; - case ChatEntitlement.Pro: - case ChatEntitlement.Unavailable: - showFree = false; - buttonLabel = localize('startUp', "Use Copilot"); - break; - } - - switch (this.controller.step) { - case ChatSetupStep.SigningIn: - buttonLabel = localize('setupChatSignIn', "$(loading~spin) Signing in to {0}...", ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.enterpriseProviderId ? defaultChat.enterpriseProviderName : defaultChat.providerName); - break; - case ChatSetupStep.Installing: - buttonLabel = localize('setupChatInstalling', "$(loading~spin) Getting Copilot Ready..."); - break; - } - - setVisibility(showFree, freeContainer); - setVisibility(showSettings, settingsContainer); - - button.label = buttonLabel; - button.enabled = this.controller.step === ChatSetupStep.Initial; - } -} - //#endregion function refreshTokens(commandService: ICommandService): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus.ts b/src/vs/workbench/contrib/chat/browser/chatStatus.ts index aef9ef2cf0e..493de36903a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus.ts @@ -11,7 +11,7 @@ import { localize } from '../../../../nls.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, StatusbarEntryKind } from '../../../services/statusbar/browser/statusbar.js'; import { $, addDisposableListener, append, clearNode, EventHelper, EventType } from '../../../../base/browser/dom.js'; -import { ChatEntitlement, ChatEntitlementService, ChatSentiment, IChatEntitlementService } from '../common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementService, ChatSentiment, IChatEntitlementService, IQuotaSnapshot } from '../common/chatEntitlementService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { defaultButtonStyles, defaultCheckboxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; @@ -19,7 +19,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { contrastBorder, inputValidationErrorBorder, inputValidationInfoBorder, inputValidationWarningBorder, registerColor, transparent } from '../../../../platform/theme/common/colorRegistry.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { Color } from '../../../../base/common/color.js'; import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -28,8 +28,20 @@ import { isObject } from '../../../../base/common/types.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; - -//#region --- colors +import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification, IAction, toAction } from '../../../../base/common/actions.js'; +import { parseLinkedText } from '../../../../base/common/linkedText.js'; +import { Link } from '../../../../platform/opener/browser/link.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IChatStatusItemService, ChatStatusEntry } from './chatStatusItemService.js'; +import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; +import { EditorResourceAccessor, SideBySideEditor } from '../../../common/editor.js'; +import { getCodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; const gaugeBackground = registerColor('gauge.background', { dark: inputValidationInfoBorder, @@ -85,42 +97,41 @@ registerColor('gauge.errorForeground', { const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', completionsEnablementSetting: product.defaultChatAgent?.completionsEnablementSetting ?? '', - nextEditSuggestionsSetting: product.defaultChatAgent?.nextEditSuggestionsSetting ?? '' + nextEditSuggestionsSetting: product.defaultChatAgent?.nextEditSuggestionsSetting ?? '', + manageSettingsUrl: product.defaultChatAgent?.manageSettingsUrl ?? '', + manageOverageUrl: product.defaultChatAgent?.manageOverageUrl ?? '', }; export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribution { - static readonly ID = 'chat.statusBarEntry'; - - private static readonly SETTING = 'chat.experimental.statusIndicator.enabled'; + static readonly ID = 'workbench.contrib.chatStatusBarEntry'; private entry: IStatusbarEntryAccessor | undefined = undefined; private dashboard = new Lazy(() => this.instantiationService.createInstance(ChatStatusDashboard)); + private readonly activeCodeEditorListener = this._register(new MutableDisposable()); + constructor( - @IStatusbarService private readonly statusbarService: IStatusbarService, @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStatusbarService private readonly statusbarService: IStatusbarService, + @IEditorService private readonly editorService: IEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IInstantiationService private readonly instantiationService: IInstantiationService ) { super(); - this.create(); + this.update(); this.registerListeners(); } - private async create(): Promise { - const hidden = this.chatEntitlementService.sentiment === ChatSentiment.Disabled; - const disabled = this.configurationService.getValue(ChatStatusBarEntry.SETTING) === false; - - if (!hidden && !disabled) { - this.entry ||= this.statusbarService.addEntry(this.getEntryProps(), ChatStatusBarEntry.ID, StatusbarAlignment.RIGHT, { location: { id: 'status.editor.mode', priority: 100.1 }, alignment: StatusbarAlignment.RIGHT }); - - // TODO@bpasero: remove this eventually - const completionsStatusId = `${defaultChat.extensionId}.status`; - this.statusbarService.updateEntryVisibility(completionsStatusId, false); - this.statusbarService.overrideEntry(completionsStatusId, { name: localize('codeCompletionsStatus', "Copilot Code Completions"), text: localize('codeCompletionsStatusText', "$(copilot) Completions") }); + private update(): void { + if (this.chatEntitlementService.sentiment !== ChatSentiment.Disabled) { + if (!this.entry) { + this.entry = this.statusbarService.addEntry(this.getEntryProps(), 'chat.statusBarEntry', StatusbarAlignment.RIGHT, { location: { id: 'status.editor.mode', priority: 100.1 }, alignment: StatusbarAlignment.RIGHT }); + } else { + this.entry.update(this.getEntryProps()); + } } else { this.entry?.dispose(); this.entry = undefined; @@ -128,15 +139,31 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu } private registerListeners(): void { + this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.update())); + this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.update())); + this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.update())); + + this._register(this.editorService.onDidActiveEditorChange(() => this.onDidActiveEditorChange())); + this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatStatusBarEntry.SETTING)) { - this.create(); + if (e.affectsConfiguration(defaultChat.completionsEnablementSetting)) { + this.update(); } })); + } - this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.entry?.update(this.getEntryProps()))); - this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.entry?.update(this.getEntryProps()))); - this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.entry?.update(this.getEntryProps()))); + private onDidActiveEditorChange(): void { + this.update(); + + this.activeCodeEditorListener.clear(); + + // Listen to language changes in the active code editor + const activeCodeEditor = getCodeEditor(this.editorService.activeTextEditorControl); + if (activeCodeEditor) { + this.activeCodeEditorListener.value = activeCodeEditor.onDidChangeModelLanguage(() => { + this.update(); + }); + } } private getEntryProps(): IStatusbarEntry { @@ -145,7 +172,8 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu let kind: StatusbarEntryKind | undefined; if (!isNewUser(this.chatEntitlementService)) { - const { chatQuotaExceeded, completionsQuotaExceeded } = this.chatEntitlementService.quotas; + const chatQuotaExceeded = this.chatEntitlementService.quotas.chat?.percentRemaining === 0; + const completionsQuotaExceeded = this.chatEntitlementService.quotas.completions?.percentRemaining === 0; // Signed out if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown) { @@ -156,21 +184,27 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu kind = 'prominent'; } - // Quota Exceeded - else if (chatQuotaExceeded || completionsQuotaExceeded) { + // Free Quota Exceeded + else if (this.chatEntitlementService.entitlement === ChatEntitlement.Limited && (chatQuotaExceeded || completionsQuotaExceeded)) { let quotaWarning: string; if (chatQuotaExceeded && !completionsQuotaExceeded) { - quotaWarning = localize('chatQuotaExceededStatus', "Chat limit reached"); + quotaWarning = localize('chatQuotaExceededStatus', "Chat quota reached"); } else if (completionsQuotaExceeded && !chatQuotaExceeded) { - quotaWarning = localize('completionsQuotaExceededStatus', "Completions limit reached"); + quotaWarning = localize('completionsQuotaExceededStatus', "Completions quota reached"); } else { - quotaWarning = localize('chatAndCompletionsQuotaExceededStatus', "Limit reached"); + quotaWarning = localize('chatAndCompletionsQuotaExceededStatus', "Quota reached"); } text = `$(copilot-warning) ${quotaWarning}`; ariaLabel = quotaWarning; kind = 'prominent'; } + + // Completions Disabled + else if (this.editorService.activeTextEditorLanguageId && !isCompletionsEnabled(this.configurationService, this.editorService.activeTextEditorLanguageId)) { + text = `$(copilot-unavailable)`; + ariaLabel = localize('completionsDisabledStatus', "Code Completions Disabled"); + } } return { @@ -200,9 +234,23 @@ function isNewUser(chatEntitlementService: IChatEntitlementService): boolean { function canUseCopilot(chatEntitlementService: IChatEntitlementService): boolean { const newUser = isNewUser(chatEntitlementService); const signedOut = chatEntitlementService.entitlement === ChatEntitlement.Unknown; - const allQuotaReached = chatEntitlementService.quotas.chatQuotaExceeded && chatEntitlementService.quotas.completionsQuotaExceeded; + const limited = chatEntitlementService.entitlement === ChatEntitlement.Limited; + const allFreeQuotaReached = limited && chatEntitlementService.quotas.chat?.percentRemaining === 0 && chatEntitlementService.quotas.completions?.percentRemaining === 0; - return !newUser && !signedOut && !allQuotaReached; + return !newUser && !signedOut && !allFreeQuotaReached; +} + +function isCompletionsEnabled(configurationService: IConfigurationService, modeId: string = '*'): boolean { + const result = configurationService.getValue>(defaultChat.completionsEnablementSetting); + if (!isObject(result)) { + return false; + } + + if (typeof result[modeId] !== 'undefined') { + return Boolean(result[modeId]); // go with setting if explicitly defined + } + + return Boolean(result['*']); // fallback to global setting otherwise } interface ISettingsAccessor { @@ -219,11 +267,15 @@ class ChatStatusDashboard extends Disposable { constructor( @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @IChatStatusItemService private readonly chatStatusItemService: IChatStatusItemService, + @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IHoverService private readonly hoverService: IHoverService, @IEditorService private readonly editorService: IEditorService, + @IHoverService private readonly hoverService: IHoverService, @ILanguageService private readonly languageService: ILanguageService, - @ICommandService private readonly commandService: ICommandService + @IOpenerService private readonly openerService: IOpenerService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ITextResourceConfigurationService private readonly textResourceConfigurationService: ITextResourceConfigurationService, ) { super(); } @@ -235,34 +287,43 @@ class ChatStatusDashboard extends Disposable { disposables.add(token.onCancellationRequested(() => disposables.dispose())); let needsSeparator = false; - const addSeparator = (label: string | undefined) => { + const addSeparator = (label?: string, action?: IAction) => { if (needsSeparator) { this.element.appendChild($('hr')); - needsSeparator = false; } - if (label) { - this.element.appendChild($('div.header', undefined, label)); + if (label || action) { + this.renderHeader(this.element, disposables, label ?? '', action); } needsSeparator = true; }; // Quota Indicator - if (this.chatEntitlementService.entitlement === ChatEntitlement.Limited) { - const { chatTotal, chatRemaining, completionsTotal, completionsRemaining, quotaResetDate, chatQuotaExceeded, completionsQuotaExceeded } = this.chatEntitlementService.quotas; + const { chat: chatQuota, completions: completionsQuota, premiumChat: premiumChatQuota, resetDate } = this.chatEntitlementService.quotas; + if (chatQuota || completionsQuota || premiumChatQuota) { - addSeparator(localize('usageTitle', "Copilot Free Usage")); + addSeparator(localize('usageTitle', "Copilot Usage"), toAction({ + id: 'workbench.action.manageCopilot', + label: localize('quotaLabel', "Manage Copilot"), + tooltip: localize('quotaTooltip', "Manage Copilot"), + class: ThemeIcon.asClassName(Codicon.settings), + run: () => this.runCommandAndClose(() => this.openerService.open(URI.parse(defaultChat.manageSettingsUrl))), + })); - const chatQuotaIndicator = this.createQuotaIndicator(this.element, chatTotal, chatRemaining, localize('chatsLabel', "Chat messages")); - const completionsQuotaIndicator = this.createQuotaIndicator(this.element, completionsTotal, completionsRemaining, localize('completionsLabel', "Code completions")); + const completionsQuotaIndicator = completionsQuota ? this.createQuotaIndicator(this.element, completionsQuota, localize('completionsLabel', "Code completions"), false) : undefined; + const chatQuotaIndicator = chatQuota ? this.createQuotaIndicator(this.element, chatQuota, premiumChatQuota ? localize('basicChatsLabel', "Basic chat requests") : localize('chatsLabel', "Chat requests"), false) : undefined; + const premiumChatQuotaIndicator = premiumChatQuota ? this.createQuotaIndicator(this.element, premiumChatQuota, localize('premiumChatsLabel', "Premium chat requests"), true) : undefined; - this.element.appendChild($('div.description', undefined, localize('limitQuota', "Limits will reset on {0}.", this.dateFormatter.value.format(quotaResetDate)))); + if (resetDate) { + this.element.appendChild($('div.description', undefined, localize('limitQuota', "Allowance renews on {0}.", this.dateFormatter.value.format(new Date(resetDate))))); + } - if (chatQuotaExceeded || completionsQuotaExceeded) { - const upgradePlanButton = disposables.add(new Button(this.element, { ...defaultButtonStyles, secondary: canUseCopilot(this.chatEntitlementService) /* use secondary color when copilot can still be used */ })); - upgradePlanButton.label = localize('upgradeToCopilotPro', "Upgrade to Copilot Pro"); - disposables.add(upgradePlanButton.onDidClick(() => this.runCommandAndClose({ id: 'workbench.action.chat.upgradePlan', args: ['chat-status'] }))); + const limited = this.chatEntitlementService.entitlement === ChatEntitlement.Limited; + if ((limited && (chatQuota?.percentRemaining === 0 || completionsQuota?.percentRemaining === 0)) || (!limited && typeof premiumChatQuota?.percentRemaining === 'number' && premiumChatQuota.percentRemaining <= 25 && !premiumChatQuota.overageEnabled)) { + const button = disposables.add(new Button(this.element, { ...defaultButtonStyles, secondary: canUseCopilot(this.chatEntitlementService) /* use secondary color when copilot can still be used */ })); + button.label = limited ? localize('upgradeToCopilotPro', "Upgrade to Copilot Pro") : localize('enableAdditionalUsage', "Enable Additional Premium Requests"); + disposables.add(button.onDidClick(() => this.runCommandAndClose(limited ? 'workbench.action.chat.upgradePlan' : () => this.openerService.open(URI.parse(defaultChat.manageOverageUrl))))); } (async () => { @@ -271,16 +332,52 @@ class ChatStatusDashboard extends Disposable { return; } - const { chatTotal, chatRemaining, completionsTotal, completionsRemaining } = this.chatEntitlementService.quotas; - - chatQuotaIndicator(chatTotal, chatRemaining); - completionsQuotaIndicator(completionsTotal, completionsRemaining); + const { chat: chatQuota, completions: completionsQuota, premiumChat: premiumChatQuota } = this.chatEntitlementService.quotas; + if (completionsQuota) { + completionsQuotaIndicator?.(completionsQuota); + } + if (chatQuota) { + chatQuotaIndicator?.(chatQuota); + } + if (premiumChatQuota) { + premiumChatQuotaIndicator?.(premiumChatQuota); + } })(); } + // Contributions + { + for (const item of this.chatStatusItemService.getEntries()) { + addSeparator(); + + const itemDisposables = disposables.add(new MutableDisposable()); + + let rendered = this.renderContributedChatStatusItem(item); + itemDisposables.value = rendered.disposables; + this.element.appendChild(rendered.element); + + disposables.add(this.chatStatusItemService.onDidChange(e => { + if (e.entry.id === item.id) { + const previousElement = rendered.element; + + rendered = this.renderContributedChatStatusItem(e.entry); + itemDisposables.value = rendered.disposables; + + previousElement.replaceWith(rendered.element); + } + })); + } + } + // Settings { - addSeparator(localize('settingsTitle', "Settings")); + addSeparator(localize('settingsTitle', "Settings"), this.chatEntitlementService.sentiment === ChatSentiment.Installed ? toAction({ + id: 'workbench.action.openChatSettings', + label: localize('settingsLabel', "Settings"), + tooltip: localize('settingsTooltip', "Open Settings"), + class: ThemeIcon.asClassName(Codicon.settingsGear), + run: () => this.runCommandAndClose(() => this.commandService.executeCommand('workbench.action.openSettings', { query: `@id:${defaultChat.completionsEnablementSetting} @id:${defaultChat.nextEditSuggestionsSetting}` })), + }) : undefined); this.createSettings(this.element, disposables); } @@ -288,67 +385,134 @@ class ChatStatusDashboard extends Disposable { // New to Copilot / Signed out { const newUser = isNewUser(this.chatEntitlementService); - const proUser = this.chatEntitlementService.entitlement === ChatEntitlement.Pro; const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown; if (newUser || signedOut) { - addSeparator(undefined); + addSeparator(); this.element.appendChild($('div.description', undefined, newUser ? localize('activateDescription', "Set up Copilot to use AI features.") : localize('signInDescription', "Sign in to use Copilot AI features."))); const button = disposables.add(new Button(this.element, { ...defaultButtonStyles })); - button.label = newUser ? proUser ? localize('activateCopilotButton', "Set up Copilot") : localize('activateCopilotFreeButton', "Set up Copilot Free") : localize('signInToUseCopilotButton', "Sign in to use Copilot"); - disposables.add(button.onDidClick(() => this.runCommandAndClose(newUser ? { id: 'workbench.action.chat.triggerSetup' } : () => this.chatEntitlementService.requests?.value.signIn()))); + button.label = newUser ? localize('activateCopilotButton', "Set up Copilot") : localize('signInToUseCopilotButton', "Sign in to use Copilot"); + disposables.add(button.onDidClick(() => this.runCommandAndClose(newUser ? 'workbench.action.chat.triggerSetup' : () => this.chatEntitlementService.requests?.value.signIn()))); } } return this.element; } - private runCommandAndClose(command: { id: string; args?: unknown[] } | Function): void { - if (typeof command === 'function') { - command(); - } else { - this.commandService.executeCommand(command.id, ...(command.args ?? [])); + private renderHeader(container: HTMLElement, disposables: DisposableStore, label: string, action?: IAction): void { + const header = container.appendChild($('div.header', undefined, label ?? '')); + + if (action) { + const toolbar = disposables.add(new ActionBar(header, { hoverDelegate: nativeHoverDelegate })); + toolbar.push([action], { icon: true, label: false }); } + } + + private renderContributedChatStatusItem(item: ChatStatusEntry): { element: HTMLElement; disposables: DisposableStore } { + const disposables = new DisposableStore(); + + const itemElement = $('div.contribution'); + + const headerLabel = typeof item.label === 'string' ? item.label : item.label.label; + const headerLink = typeof item.label === 'string' ? undefined : item.label.link; + this.renderHeader(itemElement, disposables, headerLabel, headerLink ? toAction({ + id: 'workbench.action.openChatStatusItemLink', + label: localize('learnMore', "Learn More"), + tooltip: localize('learnMore', "Learn More"), + class: ThemeIcon.asClassName(Codicon.question), + run: () => this.runCommandAndClose(() => this.openerService.open(URI.parse(headerLink))), + }) : undefined); + + const itemBody = itemElement.appendChild($('div.body')); + + const description = itemBody.appendChild($('span.description')); + this.renderTextPlus(description, item.description, disposables); + + if (item.detail) { + const detail = itemBody.appendChild($('div.detail-item')); + this.renderTextPlus(detail, item.detail, disposables); + } + + return { element: itemElement, disposables }; + } + + private renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void { + for (const node of parseLinkedText(text).nodes) { + if (typeof node === 'string') { + const parts = renderLabelWithIcons(node); + target.append(...parts); + } else { + store.add(new Link(target, node, undefined, this.hoverService, this.openerService)); + } + } + } + + private runCommandAndClose(commandOrFn: string | Function): void { + if (typeof commandOrFn === 'function') { + commandOrFn(); + } else { + this.telemetryService.publicLog2('workbenchActionExecuted', { id: commandOrFn, from: 'chat-status' }); + this.commandService.executeCommand(commandOrFn); + } + this.hoverService.hideHover(true); } - private createQuotaIndicator(container: HTMLElement, total: number | undefined, remaining: number | undefined, label: string): (total: number | undefined, remaining: number | undefined) => void { - const quotaText = $('span'); + private createQuotaIndicator(container: HTMLElement, quota: IQuotaSnapshot, label: string, supportsOverage: boolean): (quota: IQuotaSnapshot) => void { + const quotaValue = $('span.quota-value'); const quotaBit = $('div.quota-bit'); + const overageLabel = $('span.overage-label'); const quotaIndicator = container.appendChild($('div.quota-indicator', undefined, $('div.quota-label', undefined, $('span', undefined, label), - quotaText + quotaValue ), $('div.quota-bar', undefined, quotaBit + ), + $('div.overage', undefined, + overageLabel ) )); - const update = (total: number | undefined, remaining: number | undefined) => { + const update = (quota: IQuotaSnapshot) => { quotaIndicator.classList.remove('error'); quotaIndicator.classList.remove('warning'); + quotaIndicator.classList.remove('unlimited'); - if (typeof total === 'number' && typeof remaining === 'number') { - let usedPercentage = Math.round(((total - remaining) / total) * 100); - if (total !== remaining && usedPercentage === 0) { + if (quota.unlimited) { + quotaIndicator.classList.add('unlimited'); + quotaValue.textContent = localize('quotaUnlimited', "Included"); + } else { + let usedPercentage = Math.max(0, 100 - quota.percentRemaining); + if (usedPercentage === 0) { usedPercentage = 1; // indicate minimal usage as 1% } - quotaText.textContent = localize('quotaDisplay', "{0}%", usedPercentage); + quotaValue.textContent = quota.overageCount ? localize('quotaDisplayWithOverage', "{0}% +{1}", usedPercentage, quota.overageCount) : localize('quotaDisplay', "{0}%", usedPercentage); quotaBit.style.width = `${usedPercentage}%`; - if (usedPercentage >= 90) { + if (usedPercentage >= 90 && !quota.overageEnabled) { quotaIndicator.classList.add('error'); } else if (usedPercentage >= 75) { quotaIndicator.classList.add('warning'); } } + + if (supportsOverage) { + if (quota.overageEnabled) { + overageLabel.textContent = localize('additionalUsageEnabled', "Additional paid premium requests are enabled."); + } else { + overageLabel.textContent = localize('additionalUsageDisabled', "Additional paid premium requests are disabled."); + } + } else { + overageLabel.textContent = ''; + } }; - update(total, remaining); + update(quota); return update; } @@ -371,13 +535,13 @@ class ChatStatusDashboard extends Disposable { // --- Next Edit Suggestions { const setting = append(settings, $('div.setting')); - this.createNextEditSuggestionsSetting(setting, localize('settings.nextEditSuggestions', "Next Edit Suggestions"), modeId, this.getCompletionsSettingAccessor(modeId), disposables); + this.createNextEditSuggestionsSetting(setting, localize('settings.nextEditSuggestions', "Next Edit Suggestions"), this.getCompletionsSettingAccessor(modeId), disposables); } return settings; } - private createSetting(container: HTMLElement, settingId: string, label: string, accessor: ISettingsAccessor, disposables: DisposableStore): Checkbox { + private createSetting(container: HTMLElement, settingIdsToReEvaluate: string[], label: string, accessor: ISettingsAccessor, disposables: DisposableStore): Checkbox { const checkbox = disposables.add(new Checkbox(label, Boolean(accessor.readSetting()), defaultCheckboxStyles)); container.appendChild(checkbox.domNode); @@ -400,7 +564,7 @@ class ChatStatusDashboard extends Disposable { })); disposables.add(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(settingId)) { + if (settingIdsToReEvaluate.some(id => e.affectsConfiguration(id))) { checkbox.checked = Boolean(accessor.readSetting()); } })); @@ -408,31 +572,21 @@ class ChatStatusDashboard extends Disposable { if (!canUseCopilot(this.chatEntitlementService)) { container.classList.add('disabled'); checkbox.disable(); + checkbox.checked = false; } return checkbox; } private createCodeCompletionsSetting(container: HTMLElement, label: string, modeId: string | undefined, disposables: DisposableStore): void { - this.createSetting(container, defaultChat.completionsEnablementSetting, label, this.getCompletionsSettingAccessor(modeId), disposables); + this.createSetting(container, [defaultChat.completionsEnablementSetting], label, this.getCompletionsSettingAccessor(modeId), disposables); } private getCompletionsSettingAccessor(modeId = '*'): ISettingsAccessor { const settingId = defaultChat.completionsEnablementSetting; return { - readSetting: () => { - const result = this.configurationService.getValue>(settingId); - if (!isObject(result)) { - return false; - } - - if (typeof result[modeId] !== 'undefined') { - return Boolean(result[modeId]); // go with setting if explicitly defined - } - - return Boolean(result['*']); // fallback to global setting otherwise - }, + readSetting: () => isCompletionsEnabled(this.configurationService, modeId), writeSetting: (value: boolean) => { let result = this.configurationService.getValue>(settingId); if (!isObject(result)) { @@ -444,20 +598,14 @@ class ChatStatusDashboard extends Disposable { }; } - private createNextEditSuggestionsSetting(container: HTMLElement, label: string, modeId: string | undefined, completionsSettingAccessor: ISettingsAccessor, disposables: DisposableStore): void { + private createNextEditSuggestionsSetting(container: HTMLElement, label: string, completionsSettingAccessor: ISettingsAccessor, disposables: DisposableStore): void { const nesSettingId = defaultChat.nextEditSuggestionsSetting; const completionsSettingId = defaultChat.completionsEnablementSetting; + const resource = EditorResourceAccessor.getOriginalUri(this.editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); - const checkbox = this.createSetting(container, nesSettingId, label, { - readSetting: () => this.configurationService.getValue(nesSettingId, { overrideIdentifier: modeId }), - writeSetting: (value: boolean) => { - const { overrideIdentifiers } = this.configurationService.inspect(nesSettingId, { overrideIdentifier: modeId }); - if (modeId && overrideIdentifiers?.includes(modeId)) { - return this.configurationService.updateValue(nesSettingId, value, { overrideIdentifier: modeId }); - } - - return this.configurationService.updateValue(nesSettingId, value); - } + const checkbox = this.createSetting(container, [nesSettingId, completionsSettingId], label, { + readSetting: () => completionsSettingAccessor.readSetting() && this.textResourceConfigurationService.getValue(resource, nesSettingId), + writeSetting: (value: boolean) => this.textResourceConfigurationService.updateValue(resource, nesSettingId, value) }, disposables); // enablement of NES depends on completions setting diff --git a/src/vs/workbench/contrib/chat/browser/chatStatusItemService.ts b/src/vs/workbench/contrib/chat/browser/chatStatusItemService.ts new file mode 100644 index 00000000000..91697c5cf83 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatStatusItemService.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IChatStatusItemService = createDecorator('IChatStatusItemService'); + +export interface IChatStatusItemService { + readonly _serviceBrand: undefined; + + readonly onDidChange: Event; + + setOrUpdateEntry(entry: ChatStatusEntry): void; + + deleteEntry(id: string): void; + + getEntries(): Iterable; +} + + +export interface IChatStatusItemChangeEvent { + readonly entry: ChatStatusEntry; +} + +export type ChatStatusEntry = { + id: string; + label: string | { label: string; link: string }; + description: string; + detail: string | undefined; +}; + + +class ChatStatusItemService implements IChatStatusItemService { + readonly _serviceBrand: undefined; + + private readonly _entries = new Map(); + + private readonly _onDidChange = new Emitter(); + readonly onDidChange = this._onDidChange.event; + + setOrUpdateEntry(entry: ChatStatusEntry): void { + const isUpdate = this._entries.has(entry.id); + this._entries.set(entry.id, entry); + if (isUpdate) { + this._onDidChange.fire({ entry }); + } + } + + deleteEntry(id: string): void { + this._entries.delete(id); + } + + getEntries(): Iterable { + return this._entries.values(); + } +} + +registerSingleton(IChatStatusItemService, ChatStatusItemService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatVariables.ts b/src/vs/workbench/contrib/chat/browser/chatVariables.ts index 81e667f8d37..573f54f0aad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/chatVariables.ts @@ -3,16 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { coalesce } from '../../../../base/common/arrays.js'; -import { URI } from '../../../../base/common/uri.js'; -import { Location } from '../../../../editor/common/languages.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IViewsService } from '../../../services/views/common/viewsService.js'; -import { IChatRequestVariableData, IChatRequestVariableEntry } from '../common/chatModel.js'; -import { ChatRequestDynamicVariablePart, ChatRequestToolPart, IParsedChatRequest } from '../common/chatParserTypes.js'; import { IChatVariablesService, IDynamicVariable } from '../common/chatVariables.js'; -import { ChatAgentLocation, ChatConfiguration } from '../common/constants.js'; -import { IChatWidgetService, showChatView, showEditsView } from './chat.js'; +import { IToolData } from '../common/languageModelToolsService.js'; +import { IChatWidgetService } from './chat.js'; import { ChatDynamicVariableModel } from './contrib/chatDynamicVariables.js'; export class ChatVariablesService implements IChatVariablesService { @@ -20,37 +13,7 @@ export class ChatVariablesService implements IChatVariablesService { constructor( @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IViewsService private readonly viewsService: IViewsService, - @IConfigurationService private readonly configurationService: IConfigurationService, - ) { - } - - resolveVariables(prompt: IParsedChatRequest, attachedContextVariables: IChatRequestVariableEntry[] | undefined): IChatRequestVariableData { - let resolvedVariables: IChatRequestVariableEntry[] = []; - - prompt.parts - .forEach((part, i) => { - if (part instanceof ChatRequestDynamicVariablePart || part instanceof ChatRequestToolPart) { - resolvedVariables[i] = part.toVariableEntry(); - } - }); - - // Make array not sparse - resolvedVariables = coalesce(resolvedVariables); - - // "reverse", high index first so that replacement is simple - resolvedVariables.sort((a, b) => b.range!.start - a.range!.start); - - if (attachedContextVariables) { - // attachments not in the prompt - resolvedVariables.push(...attachedContextVariables); - } - - - return { - variables: resolvedVariables, - }; - } + ) { } getDynamicVariables(sessionId: string): ReadonlyArray { // This is slightly wrong... the parser pulls dynamic references from the input widget, but there is no guarantee that message came from the input here. @@ -70,30 +33,12 @@ export class ChatVariablesService implements IChatVariablesService { return model.variables; } - async attachContext(name: string, value: string | URI | Location, location: ChatAgentLocation) { - if (location !== ChatAgentLocation.Panel && location !== ChatAgentLocation.EditingSession) { - return; - } - - const unifiedViewEnabled = !!this.configurationService.getValue(ChatConfiguration.UnifiedChatView); - const widget = location === ChatAgentLocation.EditingSession && !unifiedViewEnabled - ? await showEditsView(this.viewsService) - : (this.chatWidgetService.lastFocusedWidget ?? await showChatView(this.viewsService)); - if (!widget || !widget.viewModel) { - return; - } - - const key = name.toLowerCase(); - if (key === 'file' && typeof value !== 'string') { - const uri = URI.isUri(value) ? value : value.uri; - const range = 'range' in value ? value.range : undefined; - widget.attachmentModel.addFile(uri, range); - return; - } - - if (key === 'folder' && URI.isUri(value)) { - widget.attachmentModel.addFolder(value); - return; + getSelectedTools(sessionId: string): ReadonlyArray { + const widget = this.chatWidgetService.getWidgetBySessionId(sessionId); + if (!widget) { + return []; } + return widget.input.selectedToolsModel.tools.get(); } + } diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index 6a6f505190d..cf16001e255 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -26,7 +26,6 @@ import { SIDE_BAR_FOREGROUND } from '../../../common/theme.js'; import { IViewDescriptorService } from '../../../common/views.js'; import { IChatViewTitleActionContext } from '../common/chatActions.js'; import { IChatAgentService } from '../common/chatAgents.js'; -import { ChatContextKeys } from '../common/chatContextKeys.js'; import { ChatModelInitState, IChatModel } from '../common/chatModel.js'; import { CHAT_PROVIDER_ID } from '../common/chatParticipantContribTypes.js'; import { IChatService } from '../common/chatService.js'; @@ -36,11 +35,11 @@ import { ChatViewWelcomeController, IViewWelcomeDelegate } from './viewsWelcome/ interface IViewPaneState extends IChatViewState { sessionId?: string; + hasMigratedCurrentSession?: boolean; } export const CHAT_SIDEBAR_OLD_VIEW_PANEL_ID = 'workbench.panel.chatSidebar'; export const CHAT_SIDEBAR_PANEL_ID = 'workbench.panel.chat'; -export const CHAT_EDITING_SIDEBAR_PANEL_ID = 'workbench.panel.chatEditing'; export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private _widget!: ChatWidget; get widget(): ChatWidget { return this._widget; } @@ -54,7 +53,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private _restoringSession: Promise | undefined; constructor( - private readonly chatOptions: { location: ChatAgentLocation.Panel | ChatAgentLocation.EditingSession }, + private readonly chatOptions: { location: ChatAgentLocation.Panel }, options: IViewPaneOptions, @IKeybindingService keybindingService: IKeybindingService, @IContextMenuService contextMenuService: IContextMenuService, @@ -74,14 +73,38 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); // View state for the ViewPane is currently global per-provider basically, but some other strictly per-model state will require a separate memento. - this.memento = new Memento('interactive-session-view-' + CHAT_PROVIDER_ID + (this.chatOptions.location === ChatAgentLocation.EditingSession ? `-edits` : ''), this.storageService); + this.memento = new Memento('interactive-session-view-' + CHAT_PROVIDER_ID, this.storageService); this.viewState = this.memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE) as IViewPaneState; + + if (this.chatOptions.location === ChatAgentLocation.Panel && !this.viewState.hasMigratedCurrentSession) { + const editsMemento = new Memento('interactive-session-view-' + CHAT_PROVIDER_ID + `-edits`, this.storageService); + const lastEditsState = editsMemento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE) as IViewPaneState; + if (lastEditsState.sessionId) { + this.logService.trace(`ChatViewPane: last edits session was ${lastEditsState.sessionId}`); + if (!this.chatService.isPersistedSessionEmpty(lastEditsState.sessionId)) { + this.logService.info(`ChatViewPane: migrating ${lastEditsState.sessionId} to unified view`); + this.viewState.sessionId = lastEditsState.sessionId; + this.viewState.inputValue = lastEditsState.inputValue; + this.viewState.inputState = { + ...lastEditsState.inputState, + chatMode: lastEditsState.inputState?.chatMode ?? ChatMode.Edit + }; + this.viewState.hasMigratedCurrentSession = true; + } + } + } + this._register(this.chatAgentService.onDidChangeAgents(() => { if (this.chatAgentService.getDefaultAgent(this.chatOptions?.location)) { if (!this._widget?.viewModel && !this._restoringSession) { const info = this.getTransferredOrPersistedSessionInfo(); this._restoringSession = (info.sessionId ? this.chatService.getOrRestoreSession(info.sessionId) : Promise.resolve(undefined)).then(async model => { + if (!this._widget) { + // renderBody has not been called yet + return; + } + // The widget may be hidden at this point, because welcome views were allowed. Use setVisible to // avoid doing a render while the widget is hidden. This is changing the condition in `shouldShowWelcome` // so it should fire onDidChangeViewWelcomeState. @@ -105,12 +128,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._onDidChangeViewWelcomeState.fire(); })); - - this._register(this.contextKeyService.onDidChangeContext(e => { - if (e.affectsSome(ChatContextKeys.SetupViewKeys)) { - this._onDidChangeViewWelcomeState.fire(); - } - })); } override getActionsContext(): IChatViewTitleActionContext | undefined { @@ -142,10 +159,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } override shouldShowWelcome(): boolean { - const showSetup = this.contextKeyService.contextMatchesRules(ChatContextKeys.SetupViewCondition); const noPersistedSessions = !this.chatService.hasSessions(); - const shouldShow = this.didUnregisterProvider || !this._widget?.viewModel && noPersistedSessions || this.defaultParticipantRegistrationFailed || showSetup; - this.logService.trace(`ChatViewPane#shouldShowWelcome(${this.chatOptions.location}) = ${shouldShow}: didUnregister=${this.didUnregisterProvider} || noViewModel=${!this._widget?.viewModel} && noPersistedSessions=${noPersistedSessions} || defaultParticipantRegistrationFailed=${this.defaultParticipantRegistrationFailed} || showSetup=${showSetup}`); + const hasCoreAgent = this.chatAgentService.getAgents().some(agent => agent.isCore && agent.locations.includes(this.chatOptions.location)); + const shouldShow = !hasCoreAgent && (this.didUnregisterProvider || !this._widget?.viewModel && noPersistedSessions || this.defaultParticipantRegistrationFailed); + this.logService.trace(`ChatViewPane#shouldShowWelcome(${this.chatOptions.location}) = ${shouldShow}: hasCoreAgent=${hasCoreAgent} didUnregister=${this.didUnregisterProvider} || noViewModel=${!this._widget?.viewModel} && noPersistedSessions=${noPersistedSessions} || defaultParticipantRegistrationFailed=${this.defaultParticipantRegistrationFailed}`); return !!shouldShow; } @@ -181,19 +198,17 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { autoScroll: mode => mode !== ChatMode.Ask, renderFollowups: this.chatOptions.location === ChatAgentLocation.Panel, supportsFileReferences: true, - supportsAdditionalParticipants: this.chatOptions.location === ChatAgentLocation.Panel, rendererOptions: { - renderCodeBlockPills: mode => mode !== ChatMode.Ask, renderTextEditsAsSummary: (uri) => { - return this.chatService.isEditingLocation(this.chatOptions.location); + return true; }, - referencesExpandedWhenEmptyResponse: !this.chatService.isEditingLocation(this.chatOptions.location), - progressMessageAtBottomOfResponse: this.chatService.isEditingLocation(this.chatOptions.location), + referencesExpandedWhenEmptyResponse: false, + progressMessageAtBottomOfResponse: mode => mode !== ChatMode.Ask, }, editorOverflowWidgetsDomNode: editorOverflowNode, - enableImplicitContext: this.chatOptions.location === ChatAgentLocation.Panel || this.chatService.isEditingLocation(this.chatOptions.location), - enableWorkingSet: this.chatService.isEditingLocation(this.chatOptions.location) ? 'explicit' : undefined, - supportsChangingModes: this.chatService.isEditingLocation(this.chatOptions.location), + enableImplicitContext: this.chatOptions.location === ChatAgentLocation.Panel, + enableWorkingSet: 'explicit', + supportsChangingModes: true, }, { listForeground: SIDE_BAR_FOREGROUND, diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index 6622f4621f7..3efc62a14c8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -6,9 +6,10 @@ import * as dom from '../../../../base/browser/dom.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { ITreeContextMenuEvent, ITreeElement } from '../../../../base/browser/ui/tree/tree.js'; +import { pick } from '../../../../base/common/arrays.js'; +import { assert } from '../../../../base/common/assert.js'; import { disposableTimeout, timeout } from '../../../../base/common/async.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { memoize } from '../../../../base/common/decorators.js'; import { toErrorMessage } from '../../../../base/common/errorMessage.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { FuzzyScore } from '../../../../base/common/filters.js'; @@ -16,14 +17,16 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { combinedDisposable, Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; -import { autorunWithStore, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; -import { extUri, isEqual } from '../../../../base/common/resources.js'; +import { autorun, autorunWithStore, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { basename, extUri, isEqual } from '../../../../base/common/resources.js'; import { isDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { isLocation, Location } from '../../../../editor/common/languages.js'; import { localize } from '../../../../nls.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -33,27 +36,30 @@ import { ServiceCollection } from '../../../../platform/instantiation/common/ser import { WorkbenchObjectTree } from '../../../../platform/list/browser/listService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground } from '../../../../platform/theme/common/colorRegistry.js'; import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { checkModeOption } from '../common/chat.js'; -import { IChatAgentCommand, IChatAgentData, IChatAgentService, IChatWelcomeMessageContent, isChatWelcomeMessageContent } from '../common/chatAgents.js'; +import { IChatAgentCommand, IChatAgentData, IChatAgentService, IChatWelcomeMessageContent } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; -import { applyingChatEditsFailedContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, inChatEditingSessionContextKey, WorkingSetEntryState } from '../common/chatEditingService.js'; +import { applyingChatEditsFailedContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, inChatEditingSessionContextKey, ModifiedFileEntryState } from '../common/chatEditingService.js'; import { ChatPauseState, IChatModel, IChatRequestVariableEntry, IChatResponseModel } from '../common/chatModel.js'; -import { chatAgentLeader, ChatRequestAgentPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../common/chatParserTypes.js'; +import { chatAgentLeader, ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestSlashPromptPart, ChatRequestToolPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../common/chatParserTypes.js'; import { ChatRequestParser } from '../common/chatRequestParser.js'; import { IChatFollowup, IChatLocationData, IChatSendRequestOptions, IChatService } from '../common/chatService.js'; import { IChatSlashCommandService } from '../common/chatSlashCommands.js'; import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../common/chatViewModel.js'; import { IChatInputState } from '../common/chatWidgetHistoryService.js'; import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'; -import { ChatAgentLocation, ChatConfiguration, ChatMode } from '../common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../common/constants.js'; +import { ILanguageModelToolsService } from '../common/languageModelToolsService.js'; +import { IPromptsService } from '../common/promptSyntax/service/types.js'; +import { IToggleChatModeArgs, ToggleAgentModeActionId } from './actions/chatExecuteActions.js'; import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewOptions } from './chat.js'; import { ChatAccessibilityProvider } from './chatAccessibilityProvider.js'; import { ChatAttachmentModel } from './chatAttachmentModel.js'; +import { isPromptFileChatVariable, toChatVariable } from './chatAttachmentModel/chatPromptAttachmentsCollection.js'; import { ChatInputPart, IChatInputStyles } from './chatInputPart.js'; import { ChatListDelegate, ChatListItemRenderer, IChatRendererDelegate } from './chatListRenderer.js'; import { ChatEditorOptions } from './chatOptions.js'; @@ -97,8 +103,6 @@ export function isQuickChat(widget: IChatWidget): boolean { return 'viewContext' in widget && 'isQuickChat' in widget.viewContext && Boolean(widget.viewContext.isQuickChat); } -const PersistWelcomeMessageContentKey = 'chat.welcomeMessageContent'; - export class ChatWidget extends Disposable implements IChatWidget { public static readonly CONTRIBS: { new(...args: [IChatWidget, ...any]): IChatWidgetContrib }[] = []; @@ -150,8 +154,11 @@ export class ChatWidget extends Disposable implements IChatWidget { private listContainer!: HTMLElement; private container!: HTMLElement; + get domNode() { + return this.container; + } + private welcomeMessageContainer!: HTMLElement; - private persistedWelcomeMessage: IChatWelcomeMessageContent | undefined; private readonly welcomePart: MutableDisposable = this._register(new MutableDisposable()); private bodyDimension: dom.Dimension | undefined; @@ -175,6 +182,9 @@ export class ChatWidget extends Disposable implements IChatWidget { */ private scrollLock = true; + private _isReady = false; + private _onDidBecomeReady = this._register(new Emitter()); + private readonly viewModelDisposables = this._register(new DisposableStore()); private _viewModel: ChatViewModel | undefined; private set viewModel(viewModel: ChatViewModel | undefined) { @@ -187,6 +197,20 @@ export class ChatWidget extends Disposable implements IChatWidget { this._viewModel = viewModel; if (viewModel) { this.viewModelDisposables.add(viewModel); + this.logService.debug('ChatWidget#setViewModel: have viewModel'); + + if (viewModel.model.editingSessionObs) { + this.logService.debug('ChatWidget#setViewModel: waiting for editing session'); + viewModel.model.editingSessionObs?.promise.then(() => { + this._isReady = true; + this._onDidBecomeReady.fire(); + }); + } else { + this._isReady = true; + this._onDidBecomeReady.fire(); + } + } else { + this.logService.debug('ChatWidget#setViewModel: no viewModel'); } this._onDidChangeViewModel.fire(); @@ -206,6 +230,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel!.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentMode }); + this._onDidChangeParsedInput.fire(); } return this.parsedChatRequest; @@ -222,9 +247,8 @@ export class ChatWidget extends Disposable implements IChatWidget { readonly viewContext: IChatWidgetViewContext; - @memoize - get isUnifiedPanelWidget(): boolean { - return this._location.location === ChatAgentLocation.Panel && !!this.viewOptions.supportsChangingModes && this.configurationService.getValue(ChatConfiguration.UnifiedChatView); + get supportsChangingModes(): boolean { + return !!this.viewOptions.supportsChangingModes; } constructor( @@ -245,8 +269,10 @@ export class ChatWidget extends Disposable implements IChatWidget { @IThemeService private readonly themeService: IThemeService, @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, @IChatEditingService chatEditingService: IChatEditingService, - @IStorageService private readonly storageService: IStorageService, @ITelemetryService private readonly telemetryService: ITelemetryService, + @IPromptsService private readonly promptsService: IPromptsService, + @ICommandService private readonly commandService: ICommandService, + @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, ) { super(); @@ -263,8 +289,6 @@ export class ChatWidget extends Disposable implements IChatWidget { ChatContextKeys.inChatSession.bindTo(contextKeyService).set(true); ChatContextKeys.location.bindTo(contextKeyService).set(this._location.location); ChatContextKeys.inQuickChat.bindTo(contextKeyService).set(isQuickChat(this)); - ChatContextKeys.inUnifiedChat.bindTo(contextKeyService) - .set(this._location.location === ChatAgentLocation.Panel && !!this.viewOptions.supportsChangingModes && this.configurationService.getValue(ChatConfiguration.UnifiedChatView)); this.agentInInput = ChatContextKeys.inputHasAgent.bindTo(contextKeyService); this.requestInProgress = ChatContextKeys.requestInProgress.bindTo(contextKeyService); this.isRequestPaused = ChatContextKeys.isRequestPaused.bindTo(contextKeyService); @@ -276,13 +300,13 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } const entries = currentSession.entries.read(reader); - const decidedEntries = entries.filter(entry => entry.state.read(reader) !== WorkingSetEntryState.Modified); + const decidedEntries = entries.filter(entry => entry.state.read(reader) !== ModifiedFileEntryState.Modified); return decidedEntries.map(entry => entry.entryId); })); this._register(bindContextKey(hasUndecidedChatEditingResourceContextKey, contextKeyService, (reader) => { const currentSession = this._editingSession.read(reader); const entries = currentSession?.entries.read(reader) ?? []; // using currentSession here - const decidedEntries = entries.filter(entry => entry.state.read(reader) === WorkingSetEntryState.Modified); + const decidedEntries = entries.filter(entry => entry.state.read(reader) === ModifiedFileEntryState.Modified); return decidedEntries.length > 0; })); this._register(bindContextKey(hasAppliedChatEditsContextKey, contextKeyService, (reader) => { @@ -334,11 +358,13 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } + const entries = session.entries.read(r); + for (const entry of entries) { + entry.state.read(r); // SIGNAL + } + this._editingSession.set(session, undefined); - store.add(session.onDidChange(() => { - this.renderChatEditingSessionState(); - })); store.add(session.onDidDispose(() => { this._editingSession.set(undefined, undefined); this.renderChatEditingSessionState(); @@ -355,24 +381,6 @@ export class ChatWidget extends Disposable implements IChatWidget { this.renderChatEditingSessionState(); })); - if (this._location.location === ChatAgentLocation.EditingSession || this.chatService.unifiedViewEnabled) { - let currentEditSession: IChatEditingSession | undefined = undefined; - this._register(this.onDidChangeViewModel(async () => { - const sessionId = this._viewModel?.sessionId; - if (sessionId) { - if (sessionId !== currentEditSession?.chatSessionId) { - currentEditSession = await chatEditingService.startOrContinueGlobalEditingSession(sessionId); - } - } else { - if (currentEditSession) { - const session = currentEditSession; - currentEditSession = undefined; - await session.stop(); - } - } - })); - } - this._register(codeEditorService.registerCodeEditorOpenHandler(async (input: ITextResourceEditorInput, _source: ICodeEditor | null, _sideBySide?: boolean): Promise => { const resource = input.resource; if (resource.scheme !== Schemas.vscodeChatCodeBlock) { @@ -429,11 +437,6 @@ export class ChatWidget extends Disposable implements IChatWidget { return null; })); - const loadedWelcomeContent = storageService.getObject(`${PersistWelcomeMessageContentKey}.${this.location}`, StorageScope.APPLICATION); - if (isChatWelcomeMessageContent(loadedWelcomeContent)) { - this.persistedWelcomeMessage = loadedWelcomeContent; - } - this._register(this.onDidChangeParsedInput(() => this.updateChatInputContext())); } @@ -472,6 +475,22 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.inputPart.attachmentModel; } + async waitForReady(): Promise { + if (this._isReady) { + this.logService.debug('ChatWidget#waitForReady: already ready'); + return; + } + + this.logService.debug('ChatWidget#waitForReady: waiting for ready'); + await Event.toPromise(this._onDidBecomeReady.event); + + if (this.viewModel) { + this.logService.debug('ChatWidget#waitForReady: ready'); + } else { + this.logService.debug('ChatWidget#waitForReady: no viewModel'); + } + } + render(parent: HTMLElement): void { const viewId = 'viewId' in this.viewContext ? this.viewContext.viewId : undefined; this.editorOptions = this._register(this.instantiationService.createInstance(ChatEditorOptions, viewId, this.styles.listForeground, this.styles.inputEditorBackground, this.styles.resultEditorBackground)); @@ -525,6 +544,32 @@ export class ChatWidget extends Disposable implements IChatWidget { }).filter(isDefined); this._register((this.chatWidgetService as ChatWidgetService).register(this)); + + const parsedInput = observableFromEvent(this.onDidChangeParsedInput, () => this.parsedInput); + this._register(autorun(r => { + const input = parsedInput.read(r); + + const newPromptAttachments = new Map(); + const oldPromptAttachments = new Set(); + + // get all attachments, know those that are prompt-referenced + for (const attachment of this.attachmentModel.attachments) { + if (attachment.range) { + oldPromptAttachments.add(attachment.id); + } + } + + // update/insert prompt-referenced attachments + for (const part of input.parts) { + if (part instanceof ChatRequestToolPart || part instanceof ChatRequestDynamicVariablePart) { + const entry = part.toVariableEntry(); + newPromptAttachments.set(entry.id, entry); + oldPromptAttachments.delete(entry.id); + } + } + + this.attachmentModel.updateContent(oldPromptAttachments, newPromptAttachments.values()); + })); } private scrollToEnd() { @@ -540,6 +585,11 @@ export class ChatWidget extends Disposable implements IChatWidget { focusInput(): void { this.inputPart.focus(); + + // Sometimes focusing the input part is not possible, + // but we'd like to be the last focused chat widget, + // so we emit an optimistic onDidFocus event nonetheless. + this._onDidFocus.fire(); } hasInputFocus(): boolean { @@ -575,6 +625,8 @@ export class ChatWidget extends Disposable implements IChatWidget { } clear(): void { + this.logService.debug('ChatWidget#clear'); + this._isReady = false; if (this._dynamicMessageLayoutData) { this._dynamicMessageLayoutData.enabled = true; } @@ -639,16 +691,17 @@ export class ChatWidget extends Disposable implements IChatWidget { } const numItems = this.viewModel?.getItems().length ?? 0; - const defaultAgent = this.chatAgentService.getDefaultAgent(this.location, this.input.currentMode); - const welcomeContent = defaultAgent?.metadata.welcomeMessageContent ?? this.persistedWelcomeMessage; - if (welcomeContent && !numItems && (this.welcomeMessageContainer.children.length === 0 || this.chatService.unifiedViewEnabled)) { + if (!numItems) { + const welcomeContent = this.getWelcomeViewContent(); dom.clearNode(this.welcomeMessageContainer); - const tips = this.viewOptions.supportsAdditionalParticipants + const tips = this.input.currentMode === ChatMode.Ask ? new MarkdownString(localize('chatWidget.tips', "{0} or type {1} to attach context\n\n{2} to chat with extensions\n\nType {3} to use commands", '$(attach)', '#', '$(mention)', '/'), { supportThemeIcons: true }) : new MarkdownString(localize('chatWidget.tips.withoutParticipants', "{0} or type {1} to attach context", '$(attach)', '#'), { supportThemeIcons: true }); + const defaultAgent = this.chatAgentService.getDefaultAgent(this.location, this.input.currentMode); + const additionalMessage = defaultAgent?.metadata.additionalWelcomeMessage; this.welcomePart.value = this.instantiationService.createInstance( ChatViewWelcomePart, - { ...welcomeContent, tips, }, + { ...welcomeContent, tips, additionalMessage }, { location: this.location, isWidgetAgentWelcomeViewContent: this.input?.currentMode === ChatMode.Agent @@ -663,6 +716,29 @@ export class ChatWidget extends Disposable implements IChatWidget { } } + private getWelcomeViewContent(): IChatWelcomeMessageContent { + const baseMessage = localize('chatMessage', "Copilot is powered by AI, so mistakes are possible. Review output carefully before use."); + if (this.input.currentMode === ChatMode.Ask) { + return { + title: localize('chatDescription', "Ask Copilot"), + message: new MarkdownString(baseMessage), + icon: Codicon.copilotLarge + }; + } else if (this.input.currentMode === ChatMode.Edit) { + return { + title: localize('editsTitle', "Edit with Copilot"), + message: new MarkdownString(localize('editsMessage', "Start your editing session by defining a set of files that you want to work with. Then ask Copilot for the changes you want to make.") + `\n\n${baseMessage}`), + icon: Codicon.copilotLarge + }; + } else { + return { + title: localize('editsTitle', "Edit with Copilot"), + message: new MarkdownString(localize('agentMessage', "Ask Copilot to edit your files in [agent mode]({0}). Copilot will automatically use multiple requests to pick files to edit, run terminal commands, and iterate on errors.", 'https://aka.ms/vscode-copilot-agent') + `\n\n${baseMessage}`), + icon: Codicon.copilotLarge + }; + } + } + private async renderChatEditingSessionState() { if (!this.inputPart) { return; @@ -744,7 +820,7 @@ export class ChatWidget extends Disposable implements IChatWidget { attempt: request.attempt + 1, location: this.location, userSelectedModelId: this.input.currentLanguageModel, - hasInstructionAttachments: this.input.hasInstructionAttachments, + mode: this.input.currentMode, }; this.chatService.resendRequest(request, options).catch(e => this.logService.error('FAILED to rerun request', e)); } @@ -791,7 +867,9 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeTreeContentHeight(); })); this._register(this.renderer.onDidChangeItemHeight(e => { - this.tree.updateElementHeight(e.element, e.height); + if (this.tree.hasElement(e.element)) { + this.tree.updateElementHeight(e.element, e.height); + } })); this._register(this.tree.onDidFocus(() => { this._onDidFocus.fire(); @@ -848,6 +926,16 @@ export class ChatWidget extends Disposable implements IChatWidget { this._onDidChangeContentHeight.fire(); } + private getWidgetViewKindTag(): string { + if (!this.viewContext) { + return 'editor'; + } else if ('viewId' in this.viewContext) { + return 'view'; + } else { + return 'quick'; + } + } + private createInput(container: HTMLElement, options?: { renderFollowups: boolean; renderStyle?: 'compact' | 'minimal' }): void { this.inputPart = this._register(this.instantiationService.createInstance(ChatInputPart, this.location, @@ -859,6 +947,8 @@ export class ChatWidget extends Disposable implements IChatWidget { enableImplicitContext: this.viewOptions.enableImplicitContext, renderWorkingSet: this.viewOptions.enableWorkingSet === 'explicit', supportsChangingModes: this.viewOptions.supportsChangingModes, + dndContainer: this.viewOptions.dndContainer, + widgetViewKindTag: this.getWidgetViewKindTag() }, this.styles, () => this.collectInputState() @@ -923,7 +1013,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this._onDidChangeContentHeight.fire(); })); - this._register(this.inputPart.attachmentModel.onDidChangeContext(() => { + this._register(this.inputPart.attachmentModel.onDidChange(() => { if (this._editingSession) { // TODO still needed? Do this inside input part and fire onDidChangeHeight? this.renderChatEditingSessionState(); @@ -944,6 +1034,10 @@ export class ChatWidget extends Disposable implements IChatWidget { this.renderWelcomeViewContentIfNeeded(); this.refreshParsedInput(); })); + this._register(autorun(r => { + this.input.selectedToolsModel.tools.read(r); // SIGNAL + this.refreshParsedInput(); + })); } private onDidStyleChange(): void { @@ -970,7 +1064,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.container.setAttribute('data-session-id', model.sessionId); this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection); - this.viewModelDisposables.add(Event.accumulate(this.viewModel.onDidChange, 0)(events => { + this.viewModelDisposables.add(Event.runAndSubscribe(Event.accumulate(this.viewModel.onDidChange, 0), (events => { if (!this.viewModel) { return; } @@ -980,14 +1074,14 @@ export class ChatWidget extends Disposable implements IChatWidget { this.canRequestBePaused.set(this.viewModel.requestPausibility !== ChatPauseState.NotPausable); this.onDidChangeItems(); - if (events.some(e => e?.kind === 'addRequest') && this.visible) { + if (events?.some(e => e?.kind === 'addRequest') && this.visible) { this.scrollToEnd(); } if (this._editingSession) { this.renderChatEditingSessionState(); } - })); + }))); this.viewModelDisposables.add(this.viewModel.onDidDisposeModel(() => { // Ensure that view state is saved here, because we will load it again when a new model is assigned this.inputPart.saveState(); @@ -996,7 +1090,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.viewModel = undefined; this.onDidChangeItems(); })); - this.inputPart.initForNewChatModel(viewState); + this.inputPart.initForNewChatModel(viewState, model.getRequests().length === 0); this.contribs.forEach(c => { if (c.setInputState && viewState.inputState?.[c.id]) { c.setInputState(viewState.inputState?.[c.id]); @@ -1084,10 +1178,6 @@ export class ChatWidget extends Disposable implements IChatWidget { return await this.chatService.resendRequest(lastRequest, options); } - async acceptInputWithPrefix(prefix: string): Promise { - this._acceptInput({ prefix }); - } - private collectInputState(): IChatInputState { const inputState: IChatInputState = {}; this.contribs.forEach(c => { @@ -1098,38 +1188,54 @@ export class ChatWidget extends Disposable implements IChatWidget { return inputState; } - private async _acceptInput(query: { query: string } | { prefix: string } | undefined, options?: IChatAcceptInputOptions): Promise { + private async _handlePromptSlashCommand(input: string, attachedContext: IChatRequestVariableEntry[]): Promise { + + const agentSlashPromptPart = this.parsedInput.parts.find((r): r is ChatRequestSlashPromptPart => r instanceof ChatRequestSlashPromptPart); + if (!agentSlashPromptPart) { + return input; + } + // remove the slash command from the input + input = this.parsedInput.parts.filter(part => !(part instanceof ChatRequestSlashPromptPart)).map(part => part.text).join('').trim(); + + const promptPath = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.slashPromptCommand); + if (!promptPath) { + return input; + } + + if (!attachedContext.some(variable => isPromptFileChatVariable(variable) && isEqual(toUri(variable), promptPath.uri))) { + // not yet attached, so attach it + const variable = toChatVariable({ uri: promptPath.uri, isPromptFile: true }, true); + attachedContext.push(variable); + } + + return input; + } + + private async _acceptInput(query: { query: string } | undefined, options?: IChatAcceptInputOptions): Promise { if (this.viewModel?.requestInProgress && this.viewModel.requestPausibility !== ChatPauseState.Paused) { return; } if (this.viewModel) { this._onDidAcceptInput.fire(); - if (!checkModeOption(this.input.currentMode, this.viewOptions.autoScroll)) { - this.scrollLock = false; - } + this.scrollLock = !!checkModeOption(this.input.currentMode, this.viewOptions.autoScroll); const editorValue = this.getInput(); const requestId = this.chatAccessibilityService.acceptRequest(); - const input = !query ? editorValue : - 'query' in query ? query.query : - `${query.prefix} ${editorValue}`; - const isUserQuery = !query || 'prefix' in query; + let input = !query ? editorValue : query.query; + const isUserQuery = !query; + + let attachedContext = await this.inputPart.getAttachedAndImplicitContext(this.viewModel.sessionId); const { promptInstructions } = this.inputPart.attachmentModel; const instructionsEnabled = promptInstructions.featureEnabled; if (instructionsEnabled) { - // instruction files may have nested child references to other prompt - // files that are resolved asynchronously, hence we need to wait for - // the entire prompt instruction tree to be processed - const instructionsStarted = performance.now(); - await promptInstructions.allSettled(); - // allow-any-unicode-next-line - this.logService.trace(`[⏱] instructions tree resolved in ${performance.now() - instructionsStarted}ms`); + input = await this._handlePromptSlashCommand(input, attachedContext); + await this.autoAttachInstructions(attachedContext); + input = await this.setupChatModeAndTools(input, attachedContext); } - let attachedContext = this.inputPart.getAttachedAndImplicitContext(this.viewModel.sessionId); - if (this.viewOptions.enableWorkingSet !== undefined) { + if (this.viewOptions.enableWorkingSet !== undefined && this.input.currentMode === ChatMode.Edit && !this.chatService.edits2Enabled) { const uniqueWorkingSetEntries = new ResourceSet(); // NOTE: this is used for bookkeeping so the UI can avoid rendering references in the UI that are already shown in the working set const editingSessionAttachedContext: IChatRequestVariableEntry[] = attachedContext; @@ -1137,7 +1243,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const previousRequests = this.viewModel.model.getRequests(); for (const request of previousRequests) { for (const variable of request.variableData.variables) { - if (URI.isUri(variable.value) && variable.isFile) { + if (URI.isUri(variable.value) && variable.kind === 'file') { const uri = variable.value; if (!uniqueWorkingSetEntries.has(uri)) { editingSessionAttachedContext.push(variable); @@ -1163,6 +1269,19 @@ export class ChatWidget extends Disposable implements IChatWidget { this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionId); + this.input.validateCurrentMode(); + + let userSelectedTools: string[] | undefined; + let userSelectedTools2: Record | undefined; + if (this.input.currentMode === ChatMode.Agent) { + userSelectedTools = this.inputPart.selectedToolsModel.tools.get().map(tool => tool.id); + + userSelectedTools2 = {}; + for (const [tool, enablement] of this.inputPart.selectedToolsModel.asEnablementMap()) { + userSelectedTools2[tool.id] = enablement; + } + } + const result = await this.chatService.sendRequest(this.viewModel.sessionId, input, { mode: this.inputPart.currentMode, userSelectedModelId: this.inputPart.currentLanguageModel, @@ -1171,8 +1290,8 @@ export class ChatWidget extends Disposable implements IChatWidget { parserContext: { selectedAgent: this._lastSelectedAgent, mode: this.inputPart.currentMode }, attachedContext, noCommandDetection: options?.noCommandDetection, - hasInstructionAttachments: this.inputPart.hasInstructionAttachments, - userSelectedTools: this.input.currentMode === ChatMode.Agent ? this.inputPart.selectedToolsModel.tools.get().map(tool => tool.id) : undefined + userSelectedTools, + userSelectedTools2, }); if (result) { @@ -1232,30 +1351,32 @@ export class ChatWidget extends Disposable implements IChatWidget { width = Math.min(width, 850); this.bodyDimension = new dom.Dimension(width, height); - const inputPartMaxHeight = this._dynamicMessageLayoutData?.enabled ? this._dynamicMessageLayoutData.maxHeight : height; - this.inputPart.layout(inputPartMaxHeight, width); - const inputPartHeight = this.inputPart.inputPartHeight; + this.inputPart.layout(this._dynamicMessageLayoutData?.enabled ? this._dynamicMessageLayoutData.maxHeight : height, width); + const inputHeight = this.inputPart.inputPartHeight; const lastElementVisible = this.tree.scrollTop + this.tree.renderHeight >= this.tree.scrollHeight - 2; - const listHeight = Math.max(0, height - inputPartHeight); - if (!checkModeOption(this.input.currentMode, this.viewOptions.autoScroll)) { - this.listContainer.style.setProperty('--chat-current-response-min-height', listHeight * .75 + 'px'); - } else { + const contentHeight = Math.max(0, height - inputHeight); + if (this.viewOptions.renderStyle === 'compact' || this.viewOptions.renderStyle === 'minimal') { this.listContainer.style.removeProperty('--chat-current-response-min-height'); + } else { + this.listContainer.style.setProperty('--chat-current-response-min-height', contentHeight * .75 + 'px'); } - this.tree.layout(listHeight, width); - this.tree.getHTMLElement().style.height = `${listHeight}px`; + this.tree.layout(contentHeight, width); + this.tree.getHTMLElement().style.height = `${contentHeight}px`; - // Push the welcome message down so it doesn't change position when followups or working set appear - let extraOffset: number = 0; + // Push the welcome message down so it doesn't change position + // when followups, attachments or working set appear + let welcomeOffset = 100; if (this.viewOptions.renderFollowups) { - extraOffset = Math.max(100 - this.inputPart.followupsHeight, 0); - } else if (this.viewOptions.enableWorkingSet) { - extraOffset = Math.max(100 - this.inputPart.editSessionWidgetHeight, 0); + welcomeOffset = Math.max(welcomeOffset - this.inputPart.followupsHeight, 0); } - this.welcomeMessageContainer.style.height = `${listHeight - extraOffset}px`; - this.welcomeMessageContainer.style.paddingBottom = `${extraOffset}px`; + if (this.viewOptions.enableWorkingSet) { + welcomeOffset = Math.max(welcomeOffset - this.inputPart.editSessionWidgetHeight, 0); + } + welcomeOffset = Math.max(welcomeOffset - this.inputPart.attachmentsHeight, 0); + this.welcomeMessageContainer.style.height = `${contentHeight - welcomeOffset}px`; + this.welcomeMessageContainer.style.paddingBottom = `${welcomeOffset}px`; this.renderer.layout(width); const lastItem = this.viewModel?.getItems().at(-1); @@ -1264,7 +1385,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.scrollToEnd(); } - this.listContainer.style.height = `${listHeight}px`; + this.listContainer.style.height = `${contentHeight}px`; this._onDidChangeHeight.fire(height); } @@ -1370,11 +1491,6 @@ export class ChatWidget extends Disposable implements IChatWidget { saveState(): void { this.inputPart.saveState(); - - const welcomeContent = this.chatAgentService.getDefaultAgent(this.location, this.input.currentMode)?.metadata.welcomeMessageContent; - if (welcomeContent) { - this.storageService.store(`${PersistWelcomeMessageContentKey}.${this.location}`, welcomeContent, StorageScope.APPLICATION, StorageTarget.MACHINE); - } } getViewState(): IChatViewState { @@ -1388,8 +1504,154 @@ export class ChatWidget extends Disposable implements IChatWidget { const currentAgent = this.parsedInput.parts.find(part => part instanceof ChatRequestAgentPart); this.agentInInput.set(!!currentAgent); } + + /** + * Set's up the `chat mode` and selects required `tools` based on + * the metadata defined in headers of attached prompt files. + */ + private async setupChatModeAndTools( + input: string, + attachedContext: readonly IChatRequestVariableEntry[], + ): Promise { + // process prompt files starting from the 'root' ones + const promptFileVariables = attachedContext + .filter(isPromptFileChatVariable) + .filter(pick('isRoot')); + const promptUris = promptFileVariables.map(toUri); + + if (promptFileVariables.length === 0) { + return input; + } + + if (!input.trim()) { + const promptNames = (promptUris.length === 1) + ? `'${basename(promptUris[0])}'` + : `the prompt files`; + + input = `Follow instructions from ${promptNames}.`; + } + + + const metadata = await this.promptsService + .getCombinedToolsMetadata(promptUris); + + if (metadata === null) { + return input; + } + + const { mode, tools } = metadata; + + // switch to appropriate chat mode if needed + if (mode && mode !== this.inputPart.currentMode) { + await this.commandService.executeCommand( + ToggleAgentModeActionId, + { mode } satisfies IToggleChatModeArgs, + ); + } + + // if not tools to enable are present, we are done + if (tools === undefined) { + return input; + } + + // sanity check on the logic of the `getPromptFilesMetadata` method + // and the code above in case this block is moved around somewhere else: + // if we have some tools present, the mode must have been equal to `agent` + assert( + this.inputPart.currentMode === ChatMode.Agent, + `Chat mode must be 'agent' when there are 'tools' defined, got ${this.inputPart.currentMode}.`, + ); + + // convert tools names to tool IDs + const toolIds = tools + .map((toolName) => { + const tool = this.toolsService.getToolByName(toolName); + + if (tool === undefined) { + this.logService.warn( + `[setup tools]: cannot to find tool '${toolName}'`, + ); + } + + return tool; + }) + .filter(isDefined) + .map(pick('id')); + + // if there are some tools defined in the prompt files, select only the specified tools + this.inputPart + .selectedToolsModel + .selectOnly(toolIds); + + return input; + } + + /** + * Resolves instructions that have `include` metadata that can + * match file references in the attached context and then attaches + * such instructions to the context. + */ + private async autoAttachInstructions( + attachedContext: IChatRequestVariableEntry[], + ): Promise { + const variableUris = attachedContext + .filter(hasAddressableValue) + .map(toUri); + + const automaticInstructions = await this.promptsService + .findInstructionFilesFor(variableUris); + + // add instructions to the final context list + attachedContext.push( + ...automaticInstructions.map((uri) => { + return toChatVariable({ uri, isPromptFile: true }, true); + }), + ); + + // add to attached list to make the instructions sticky + this.inputPart + .attachmentModel + .promptInstructions.add(automaticInstructions); + } } +/** + * Type for any "addressable" object - i.e., an object that has + * the `value` property that is either a {@link URI} or a {@link Location}. + */ +export type TAddressable = T & { value: URI | Location }; + +/** + * Check if provided object is "addressable" - i.e., has the `value` + * property that is either a {@link URI} or a {@link Location}. + */ +const hasAddressableValue = ( + thing: T, +): thing is TAddressable => { + if ((!thing) || (('value' in thing) === false)) { + return false; + } + + if (URI.isUri(thing.value) || isLocation(thing.value)) { + return true; + } + + return false; +}; + +/** + * Returns URI of a provided "addressable" object. + */ +const toUri = ( + thing: TAddressable, +): URI => { + const { value } = thing; + + return URI.isUri(value) + ? value + : value.uri; +}; + export class ChatWidgetService extends Disposable implements IChatWidgetService { declare readonly _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/chat/browser/codeBlockPart.css b/src/vs/workbench/contrib/chat/browser/codeBlockPart.css index 44f05c472b0..901afc3a57b 100644 --- a/src/vs/workbench/contrib/chat/browser/codeBlockPart.css +++ b/src/vs/workbench/contrib/chat/browser/codeBlockPart.css @@ -9,7 +9,8 @@ } .interactive-result-code-block .interactive-result-code-block-toolbar { - display: none; + opacity: 0; + pointer-events: none; } .interactive-result-code-block .interactive-result-code-block-toolbar > .monaco-action-bar, @@ -48,12 +49,14 @@ .interactive-result-code-block:hover .interactive-result-code-block-toolbar, .interactive-result-code-block .interactive-result-code-block-toolbar:focus-within, .interactive-result-code-block.focused .interactive-result-code-block-toolbar { - display: initial; + opacity: 1; border-radius: 2px; + pointer-events: auto; } -.interactive-result-code-block .interactive-result-code-block-toolbar.force-visibility .monaco-toolbar { - display: initial !important; +.interactive-result-code-block .interactive-result-code-block-toolbar.force-visibility { + opacity: 1 !important; + pointer-events: auto !important; } .interactive-item-container .value .rendered-markdown [data-code] { diff --git a/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts index 05276d45608..9ef202f62c7 100644 --- a/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts @@ -69,6 +69,8 @@ import { ChatTreeItem } from './chat.js'; import { IChatRendererDelegate } from './chatListRenderer.js'; import { ChatEditorOptions } from './chatOptions.js'; import { emptyProgressRunner, IEditorProgressService } from '../../../../platform/progress/common/progress.js'; +import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; +import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; const $ = dom.$; @@ -87,6 +89,8 @@ export interface ICodeBlockData { readonly parentContextKeyService?: IContextKeyService; readonly renderOptions?: ICodeBlockRenderOptions; + + readonly chatSessionId: string; } /** @@ -133,6 +137,8 @@ export interface ICodeBlockActionContext { languageId?: string; codeBlockIndex: number; element: unknown; + + chatSessionId: string | undefined; } export interface ICodeBlockRenderOptions { @@ -140,6 +146,7 @@ export interface ICodeBlockRenderOptions { verticalPadding?: number; reserveWidth?: number; editorOptions?: IEditorOptions; + maxHeightInLines?: number; } const defaultCodeblockPadding = 10; @@ -312,6 +319,8 @@ export class CodeBlockPart extends Disposable { GlyphHoverController.ID, MessageController.ID, GotoDefinitionAtPositionEditorContribution.ID, + SuggestController.ID, + SnippetController2.ID, ColorDetector.ID, LinkDetector.ID, @@ -362,9 +371,15 @@ export class CodeBlockPart extends Disposable { layout(width: number): void { const contentHeight = this.getContentHeight(); + + let height = contentHeight; + if (this.currentCodeBlockData?.renderOptions?.maxHeightInLines) { + height = Math.min(contentHeight, this.editor.getOption(EditorOption.lineHeight) * this.currentCodeBlockData?.renderOptions?.maxHeightInLines); + } + const editorBorder = 2; width = width - editorBorder - (this.currentCodeBlockData?.renderOptions?.reserveWidth ?? 0); - this.editor.layout({ width, height: contentHeight }); + this.editor.layout({ width, height }); this.updatePaddingForLayout(); } @@ -394,12 +409,12 @@ export class CodeBlockPart extends Disposable { return; } - this.layout(width); this.editor.updateOptions({ ...this.getEditorOptionsFromConfig(), ariaLabel: localize('chat.codeBlockLabel', "Code block {0}", data.codeBlockIndex + 1), }); - this.toolbar.setAriaLabel(localize('chat.codeBlockToolbarLabel', "Toolbar for code block {0}", data.codeBlockIndex + 1)); + this.layout(width); + this.toolbar.setAriaLabel(localize('chat.codeBlockToolbarLabel', "Code block {0}", data.codeBlockIndex + 1)); if (data.renderOptions?.hideToolbar) { dom.hide(this.toolbar.getElement()); } else { @@ -440,6 +455,7 @@ export class CodeBlockPart extends Disposable { element: data.element, languageId: textModel.getLanguageId(), codemapperUri: data.codemapperUri, + chatSessionId: data.chatSessionId } satisfies ICodeBlockActionContext; this.resourceContextKey.set(textModel.uri); } @@ -693,7 +709,7 @@ export class CodeCompareBlockPart extends Disposable { const toolbarElt = this.toolbar.getElement(); if (this.accessibilityService.isScreenReaderOptimized()) { toolbarElt.style.display = 'block'; - toolbarElt.ariaLabel = this.configurationService.getValue(AccessibilityVerbositySettingId.Chat) ? localize('chat.codeBlock.toolbarVerbose', 'Toolbar for code block which can be reached via tab') : localize('chat.codeBlock.toolbar', 'Code block toolbar'); + toolbarElt.ariaLabel = localize('chat.codeBlock.toolbar', 'Code block toolbar'); } else { toolbarElt.style.display = ''; } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts index 67848145eb8..1e9f6118492 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts @@ -3,40 +3,35 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { coalesce, groupBy } from '../../../../../base/common/arrays.js'; -import { assertNever } from '../../../../../base/common/assert.js'; +import { coalesce } from '../../../../../base/common/arrays.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { isCancellationError } from '../../../../../base/common/errors.js'; import * as glob from '../../../../../base/common/glob.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, dispose, isDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; -import { basename, dirname, joinPath, relativePath } from '../../../../../base/common/resources.js'; +import { basename, dirname, extUri, joinPath, relativePath } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { IRange, Range } from '../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../editor/common/editorCommon.js'; import { Command, isLocation } from '../../../../../editor/common/languages.js'; -import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; -import { localize } from '../../../../../nls.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { FileType, IFileService } from '../../../../../platform/files/common/files.js'; +import { FileKind, FileType, IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IMarkerService, MarkerSeverity } from '../../../../../platform/markers/common/markers.js'; import { PromptsConfig } from '../../../../../platform/prompts/common/config.js'; -import { IQuickAccessOptions } from '../../../../../platform/quickinput/common/quickAccess.js'; -import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; -import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IHistoryService } from '../../../../services/history/common/history.js'; import { getExcludes, IFileQuery, ISearchComplete, ISearchConfiguration, ISearchService, QueryType } from '../../../../services/search/common/search.js'; -import { ISymbolQuickPickItem } from '../../../search/browser/symbolsQuickAccess.js'; -import { IDiagnosticVariableEntryFilterData } from '../../common/chatModel.js'; -import { IChatRequestProblemsVariable, IChatRequestVariableValue, IDynamicVariable } from '../../common/chatVariables.js'; +import { IChatRequestVariableValue, IDynamicVariable } from '../../common/chatVariables.js'; import { IChatWidget } from '../chat.js'; import { ChatWidget, IChatWidgetContrib } from '../chatWidget.js'; import { ChatFileReference } from './chatDynamicVariables/chatFileReference.js'; @@ -61,6 +56,8 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC return ChatDynamicVariableModel.ID; } + private decorationData: { id: string; text: string }[] = []; + constructor( private readonly widget: IChatWidget, @ILabelService private readonly labelService: ILabelService, @@ -70,43 +67,62 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC super(); this._register(widget.inputEditor.onDidChangeModelContent(e => { - e.changes.forEach(c => { - // Don't mutate entries in _variables, since they will be returned from the getter - this._variables = coalesce(this._variables.map(ref => { - const intersection = Range.intersectRanges(ref.range, c.range); - if (intersection && !intersection.isEmpty()) { - // The reference text was changed, it's broken. - // But if the whole reference range was deleted (eg history navigation) then don't try to change the editor. - if (!Range.containsRange(c.range, ref.range)) { - const rangeToDelete = new Range(ref.range.startLineNumber, ref.range.startColumn, ref.range.endLineNumber, ref.range.endColumn - 1); - this.widget.inputEditor.executeEdits(this.id, [{ - range: rangeToDelete, - text: '', - }]); - this.widget.refreshParsedInput(); - } - // dispose the reference if possible before dropping it off - if ('dispose' in ref && typeof ref.dispose === 'function') { - ref.dispose(); - } + const removed: TDynamicVariable[] = []; + let didChange = false; - return null; - } else if (Range.compareRangesUsingStarts(ref.range, c.range) > 0) { - const delta = c.text.length - c.rangeLength; - ref.range = { - startLineNumber: ref.range.startLineNumber, - startColumn: ref.range.startColumn + delta, - endLineNumber: ref.range.endLineNumber, - endColumn: ref.range.endColumn + delta, - }; + // Don't mutate entries in _variables, since they will be returned from the getter + this._variables = coalesce(this._variables.map((ref, idx): TDynamicVariable | null => { + const model = widget.inputEditor.getModel(); - return ref; - } + if (!model) { + removed.push(ref); + return null; + } + const data = this.decorationData[idx]; + const newRange = model.getDecorationRange(data.id); + + if (!newRange) { + // gone + removed.push(ref); + return null; + } + + const newText = model.getValueInRange(newRange); + if (newText !== data.text) { + + this.widget.inputEditor.executeEdits(this.id, [{ + range: newRange, + text: '', + }]); + this.widget.refreshParsedInput(); + + removed.push(ref); + return null; + } + + if (newRange.equalsRange(ref.range)) { + // all good return ref; - })); - }); + } + + didChange = true; + + if (ref instanceof ChatFileReference) { + ref.range = newRange; + return ref; + } else { + return { ...ref, range: newRange }; + } + })); + + // cleanup disposable variables + dispose(removed.filter(isDisposable)); + + if (didChange || removed.length > 0) { + this.widget.refreshParsedInput(); + } this.updateDecorations(); })); @@ -154,7 +170,7 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC // if the `prompt snippets` feature is enabled, and file is a `prompt snippet`, // start resolving nested file references immediately and subscribe to updates - if (variable instanceof ChatFileReference && variable.isPromptSnippet) { + if (variable instanceof ChatFileReference && variable.isPromptFile) { // subscribe to variable changes variable.onUpdate(() => { this.updateDecorations(); @@ -165,10 +181,19 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC } private updateDecorations(): void { - this.widget.inputEditor.setDecorationsByType('chat', dynamicVariableDecorationType, this._variables.map((r): IDecorationOptions => ({ + + const decorationIds = this.widget.inputEditor.setDecorationsByType('chat', dynamicVariableDecorationType, this._variables.map((r): IDecorationOptions => ({ range: r.range, hoverMessage: this.getHoverForReference(r) }))); + + this.decorationData = []; + for (let i = 0; i < decorationIds.length; i++) { + this.decorationData.push({ + id: decorationIds[i], + text: this.widget.inputEditor.getModel()!.getValueInRange(this._variables[i].range) + }); + } } private getHoverForReference(ref: IDynamicVariable): IMarkdownString | undefined { @@ -189,7 +214,7 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC */ private disposeVariables(): void { for (const variable of this._variables) { - if ('dispose' in variable && typeof variable.dispose === 'function') { + if (isDisposable(variable)) { variable.dispose(); } } @@ -213,166 +238,30 @@ function isDynamicVariable(obj: any): obj is IDynamicVariable { ChatWidget.CONTRIBS.push(ChatDynamicVariableModel); -interface SelectAndInsertActionContext { - widget: IChatWidget; - range: IRange; -} -function isSelectAndInsertActionContext(context: any): context is SelectAndInsertActionContext { - return 'widget' in context && 'range' in context; -} - -export class SelectAndInsertFileAction extends Action2 { - static readonly Name = 'files'; - static readonly Item = { - label: localize('allFiles', 'All Files'), - description: localize('allFilesDescription', 'Search for relevant files in the workspace and provide context from them'), - }; - static readonly ID = 'workbench.action.chat.selectAndInsertFile'; - - constructor() { - super({ - id: SelectAndInsertFileAction.ID, - title: '' // not displayed - }); - } - - async run(accessor: ServicesAccessor, ...args: any[]) { - const textModelService = accessor.get(ITextModelService); - const logService = accessor.get(ILogService); - const quickInputService = accessor.get(IQuickInputService); - - const context = args[0]; - if (!isSelectAndInsertActionContext(context)) { - return; - } - - const doCleanup = () => { - // Failed, remove the dangling `file` - context.widget.inputEditor.executeEdits('chatInsertFile', [{ range: context.range, text: `` }]); - }; - - let options: IQuickAccessOptions | undefined; - // TODO: have dedicated UX for this instead of using the quick access picker - const picks = await quickInputService.quickAccess.pick('', options); - if (!picks?.length) { - logService.trace('SelectAndInsertFileAction: no file selected'); - doCleanup(); - return; - } - - const editor = context.widget.inputEditor; - const range = context.range; - - // Handle the special case of selecting all files - if (picks[0] === SelectAndInsertFileAction.Item) { - const text = `#${SelectAndInsertFileAction.Name}`; - const success = editor.executeEdits('chatInsertFile', [{ range, text: text + ' ' }]); - if (!success) { - logService.trace(`SelectAndInsertFileAction: failed to insert "${text}"`); - doCleanup(); - } - return; - } - - // Handle the case of selecting a specific file - const resource = (picks[0] as unknown as { resource: unknown }).resource as URI; - if (!textModelService.canHandleResource(resource)) { - logService.trace('SelectAndInsertFileAction: non-text resource selected'); - doCleanup(); - return; - } - - const fileName = basename(resource); - const text = `#file:${fileName}`; - const success = editor.executeEdits('chatInsertFile', [{ range, text: text + ' ' }]); - if (!success) { - logService.trace(`SelectAndInsertFileAction: failed to insert "${text}"`); - doCleanup(); - return; - } - - context.widget.getContrib(ChatDynamicVariableModel.ID)?.addReference({ - id: 'vscode.file', - isFile: true, - prefix: 'file', - range: { startLineNumber: range.startLineNumber, startColumn: range.startColumn, endLineNumber: range.endLineNumber, endColumn: range.startColumn + text.length }, - data: resource - }); - } -} -registerAction2(SelectAndInsertFileAction); - -export class SelectAndInsertFolderAction extends Action2 { - static readonly Name = 'folder'; - static readonly ID = 'workbench.action.chat.selectAndInsertFolder'; - - constructor() { - super({ - id: SelectAndInsertFolderAction.ID, - title: '' // not displayed - }); - } - - async run(accessor: ServicesAccessor, ...args: any[]) { - const logService = accessor.get(ILogService); - - const context = args[0]; - if (!isSelectAndInsertActionContext(context)) { - return; - } - - const doCleanup = () => { - // Failed, remove the dangling `folder` - context.widget.inputEditor.executeEdits('chatInsertFolder', [{ range: context.range, text: `` }]); - }; - - const folder = await createFolderQuickPick(accessor); - if (!folder) { - logService.trace('SelectAndInsertFolderAction: no folder selected'); - doCleanup(); - return; - } - - const editor = context.widget.inputEditor; - const range = context.range; - - const folderName = basename(folder); - const text = `#folder:${folderName}`; - const success = editor.executeEdits('chatInsertFolder', [{ range, text: text + ' ' }]); - if (!success) { - logService.trace(`SelectAndInsertFolderAction: failed to insert "${text}"`); - doCleanup(); - return; - } - - context.widget.getContrib(ChatDynamicVariableModel.ID)?.addReference({ - id: 'vscode.folder', - isFile: false, - isDirectory: true, - prefix: 'folder', - range: { startLineNumber: range.startLineNumber, startColumn: range.startColumn, endLineNumber: range.endLineNumber, endColumn: range.startColumn + text.length }, - data: folder - }); - } - -} -registerAction2(SelectAndInsertFolderAction); - -export async function createFolderQuickPick(accessor: ServicesAccessor): Promise { +export async function createFilesAndFolderQuickPick(accessor: ServicesAccessor): Promise { const quickInputService = accessor.get(IQuickInputService); const searchService = accessor.get(ISearchService); const configurationService = accessor.get(IConfigurationService); const workspaceService = accessor.get(IWorkspaceContextService); const fileService = accessor.get(IFileService); const labelService = accessor.get(ILabelService); + const modelService = accessor.get(IModelService); + const languageService = accessor.get(ILanguageService); + const historyService = accessor.get(IHistoryService); + + type ResourcePick = IQuickPickItem & { resource: URI; kind: FileKind }; const workspaces = workspaceService.getWorkspace().folders.map(folder => folder.uri); - const topLevelFolderItems = (await getTopLevelFolders(workspaces, fileService)).map(createQuickPickItem); - const quickPick = quickInputService.createQuickPick(); + const defaultItems: ResourcePick[] = []; + (await getTopLevelFolders(workspaces, fileService)).forEach(uri => defaultItems.push(createQuickPickItem(uri, FileKind.FOLDER))); + historyService.getHistory().filter(a => a.resource).slice(0, 30).forEach(uri => defaultItems.push(createQuickPickItem(uri.resource!, FileKind.FILE))); + defaultItems.sort((a, b) => extUri.compare(a.resource, b.resource)); + + const quickPick = quickInputService.createQuickPick(); quickPick.placeholder = 'Search folder by name'; - quickPick.items = topLevelFolderItems; + quickPick.items = defaultItems; return await new Promise(_resolve => { @@ -385,24 +274,32 @@ export async function createFolderQuickPick(accessor: ServicesAccessor): Promise disposables.add(quickPick.onDidChangeValue(async value => { if (value === '') { - quickPick.items = topLevelFolderItems; + quickPick.items = defaultItems; return; } - const workspaceFolders = await Promise.all( - workspaces.map(workspace => - searchFolders( - workspace, - value, - true, - undefined, - undefined, - configurationService, - searchService - ) - )); + const picks: ResourcePick[] = []; - quickPick.items = workspaceFolders.flat().map(createQuickPickItem); + await Promise.all(workspaces.map(async workspace => { + const result = await searchFilesAndFolders( + workspace, + value, + true, + undefined, + undefined, + configurationService, + searchService + ); + + for (const folder of result.folders) { + picks.push(createQuickPickItem(folder, FileKind.FOLDER)); + } + for (const file of result.files) { + picks.push(createQuickPickItem(file, FileKind.FILE)); + } + })); + + quickPick.items = picks.sort((a, b) => extUri.compare(a.resource, b.resource)); })); disposables.add(quickPick.onDidAccept((e) => { @@ -417,15 +314,16 @@ export async function createFolderQuickPick(accessor: ServicesAccessor): Promise quickPick.show(); }); - function createQuickPickItem(folder: URI): IQuickPickItem & { resource: URI } { + function createQuickPickItem(resource: URI, kind: FileKind): ResourcePick { return { - type: 'item', - id: folder.toString(), - resource: folder, + resource, + kind, + id: resource.toString(), alwaysShow: true, - label: basename(folder), - description: labelService.getUriLabel(dirname(folder), { relative: true }), - iconClass: ThemeIcon.asClassName(Codicon.folder), + label: basename(resource), + description: labelService.getUriLabel(dirname(resource), { relative: true }), + iconClasses: kind === FileKind.FILE ? getIconClasses(modelService, languageService, resource, FileKind.FILE) : undefined, + iconClass: kind === FileKind.FOLDER ? ThemeIcon.asClassName(Codicon.folder) : undefined }; } } @@ -450,7 +348,7 @@ export async function getTopLevelFolders(workspaces: URI[], fileService: IFileSe return folders; } -export async function searchFolders( +export async function searchFilesAndFolders( workspace: URI, pattern: string, fuzzyMatch: boolean, @@ -458,7 +356,7 @@ export async function searchFolders( cacheKey: string | undefined, configurationService: IConfigurationService, searchService: ISearchService -): Promise { +): Promise<{ folders: URI[]; files: URI[] }> { const segmentMatchPattern = caseInsensitiveGlobPattern(fuzzyMatch ? fuzzyMatchingGlobPattern(pattern) : continousMatchingGlobPattern(pattern)); const searchExcludePattern = getExcludes(configurationService.getValue({ resource: workspace })) || {}; @@ -471,23 +369,26 @@ export async function searchFolders( shouldGlobMatchFilePattern: true, cacheKey, excludePattern: searchExcludePattern, + sortByScore: true, }; - let folderResults: ISearchComplete | undefined; + let searchResult: ISearchComplete | undefined; try { - folderResults = await searchService.fileSearch({ ...searchOptions, filePattern: `**/${segmentMatchPattern}/**` }, token); + searchResult = await searchService.fileSearch({ ...searchOptions, filePattern: `{**/${segmentMatchPattern}/**,${pattern}}` }, token); } catch (e) { if (!isCancellationError(e)) { throw e; } } - if (!folderResults || token?.isCancellationRequested) { - return []; + if (!searchResult || token?.isCancellationRequested) { + return { files: [], folders: [] }; } - const folderResources = getMatchingFoldersFromFiles(folderResults.results.map(result => result.resource), workspace, segmentMatchPattern); - return folderResources; + const fileResources = searchResult.results.map(result => result.resource); + const folderResources = getMatchingFoldersFromFiles(fileResources, workspace, segmentMatchPattern); + + return { folders: folderResources, files: fileResources }; } function fuzzyMatchingGlobPattern(pattern: string): string { @@ -549,68 +450,6 @@ function getMatchingFoldersFromFiles(resources: URI[], workspace: URI, segmentMa return matchingFolders; } -export class SelectAndInsertSymAction extends Action2 { - static readonly Name = 'symbols'; - static readonly ID = 'workbench.action.chat.selectAndInsertSym'; - - constructor() { - super({ - id: SelectAndInsertSymAction.ID, - title: '' // not displayed - }); - } - - async run(accessor: ServicesAccessor, ...args: any[]) { - const textModelService = accessor.get(ITextModelService); - const logService = accessor.get(ILogService); - const quickInputService = accessor.get(IQuickInputService); - - const context = args[0]; - if (!isSelectAndInsertActionContext(context)) { - return; - } - - const doCleanup = () => { - // Failed, remove the dangling `sym` - context.widget.inputEditor.executeEdits('chatInsertSym', [{ range: context.range, text: `` }]); - }; - - // TODO: have dedicated UX for this instead of using the quick access picker - const picks = await quickInputService.quickAccess.pick('#', { enabledProviderPrefixes: ['#'] }); - if (!picks?.length) { - logService.trace('SelectAndInsertSymAction: no symbol selected'); - doCleanup(); - return; - } - - const editor = context.widget.inputEditor; - const range = context.range; - - // Handle the case of selecting a specific file - const symbol = (picks[0] as ISymbolQuickPickItem).symbol; - if (!symbol || !textModelService.canHandleResource(symbol.location.uri)) { - logService.trace('SelectAndInsertSymAction: non-text resource selected'); - doCleanup(); - return; - } - - const text = `#sym:${symbol.name}`; - const success = editor.executeEdits('chatInsertSym', [{ range, text: text + ' ' }]); - if (!success) { - logService.trace(`SelectAndInsertSymAction: failed to insert "${text}"`); - doCleanup(); - return; - } - - context.widget.getContrib(ChatDynamicVariableModel.ID)?.addReference({ - id: 'vscode.symbol', - prefix: 'symbol', - range: { startLineNumber: range.startLineNumber, startColumn: range.startColumn, endLineNumber: range.endLineNumber, endColumn: range.startColumn + text.length }, - data: symbol.location - }); - } -} -registerAction2(SelectAndInsertSymAction); export interface IAddDynamicVariableContext { id: string; @@ -676,139 +515,8 @@ export class AddDynamicVariableAction extends Action2 { id: context.id, range: range, isFile: true, - prefix: 'file', data: variableData }); } } registerAction2(AddDynamicVariableAction); - -export async function createMarkersQuickPick(accessor: ServicesAccessor, level: 'problem' | 'file', onBackgroundAccept?: (item: IDiagnosticVariableEntryFilterData[]) => void): Promise { - const markers = accessor.get(IMarkerService).read({ severities: MarkerSeverity.Error | MarkerSeverity.Warning | MarkerSeverity.Info }); - if (!markers.length) { - return; - } - - const uriIdentityService = accessor.get(IUriIdentityService); - const labelService = accessor.get(ILabelService); - const grouped = groupBy(markers, (a, b) => uriIdentityService.extUri.compare(a.resource, b.resource)); - - const severities = new Set(); - type MarkerPickItem = IQuickPickItem & { resource?: URI; entry: IDiagnosticVariableEntryFilterData }; - const items: (MarkerPickItem | IQuickPickSeparator)[] = []; - - let pickCount = 0; - for (const group of grouped) { - const resource = group[0].resource; - if (level === 'problem') { - items.push({ type: 'separator', label: labelService.getUriLabel(resource, { relative: true }) }); - for (const marker of group) { - pickCount++; - severities.add(marker.severity); - items.push({ - type: 'item', - resource: marker.resource, - label: marker.message, - description: localize('markers.panel.at.ln.col.number', "[Ln {0}, Col {1}]", '' + marker.startLineNumber, '' + marker.startColumn), - entry: IDiagnosticVariableEntryFilterData.fromMarker(marker), - }); - } - } else if (level === 'file') { - const entry = { filterUri: resource }; - pickCount++; - items.push({ - type: 'item', - resource, - label: IDiagnosticVariableEntryFilterData.label(entry), - description: group[0].message + (group.length > 1 ? localize('problemsMore', '+ {0} more', group.length - 1) : ''), - entry, - }); - for (const marker of group) { - severities.add(marker.severity); - } - } else { - assertNever(level); - } - } - - if (pickCount < 2) { // single error in a URI - return items.find((i): i is MarkerPickItem => i.type === 'item')?.entry; - } - - if (level === 'file') { - items.unshift({ type: 'separator', label: localize('markers.panel.files', 'Files') }); - } - - items.unshift({ type: 'item', label: localize('markers.panel.allErrors', 'All Problems'), entry: { filterSeverity: MarkerSeverity.Info } }); - - const quickInputService = accessor.get(IQuickInputService); - const store = new DisposableStore(); - const quickPick = store.add(quickInputService.createQuickPick({ useSeparators: true })); - quickPick.canAcceptInBackground = !onBackgroundAccept; - quickPick.placeholder = localize('pickAProblem', 'Pick a problem to attach...'); - quickPick.items = items; - - return new Promise(resolve => { - store.add(quickPick.onDidHide(() => resolve(undefined))); - store.add(quickPick.onDidAccept(ev => { - if (ev.inBackground) { - onBackgroundAccept?.(quickPick.selectedItems.map(i => i.entry)); - } else { - resolve(quickPick.selectedItems[0]?.entry); - quickPick.dispose(); - } - })); - quickPick.show(); - }).finally(() => store.dispose()); -} - -export class SelectAndInsertProblemAction extends Action2 { - static readonly Name = 'problems'; - static readonly ID = 'workbench.action.chat.selectAndInsertProblems'; - - constructor() { - super({ - id: SelectAndInsertProblemAction.ID, - title: '' // not displayed - }); - } - - async run(accessor: ServicesAccessor, ...args: any[]) { - const logService = accessor.get(ILogService); - const context = args[0]; - if (!isSelectAndInsertActionContext(context)) { - return; - } - - const doCleanup = () => { - // Failed, remove the dangling `problem` - context.widget.inputEditor.executeEdits('chatInsertProblems', [{ range: context.range, text: `` }]); - }; - - const pick = await createMarkersQuickPick(accessor, 'file'); - if (!pick) { - doCleanup(); - return; - } - - const editor = context.widget.inputEditor; - const originalRange = context.range; - const insertText = `#${SelectAndInsertProblemAction.Name}:${pick.filterUri ? basename(pick.filterUri) : MarkerSeverity.toString(pick.filterSeverity!)}`; - - const varRange = new Range(originalRange.startLineNumber, originalRange.startColumn, originalRange.endLineNumber, originalRange.startColumn + insertText.length); - const success = editor.executeEdits('chatInsertProblems', [{ range: varRange, text: insertText + ' ' }]); - if (!success) { - logService.trace(`SelectAndInsertProblemsAction: failed to insert "${insertText}"`); - doCleanup(); - return; - } - - context.widget.getContrib(ChatDynamicVariableModel.ID)?.addReference({ - id: 'vscode.problems', - prefix: SelectAndInsertProblemAction.Name, - range: varRange, - data: { id: 'vscode.problems', filter: pick } satisfies IChatRequestProblemsVariable, - }); - } -} -registerAction2(SelectAndInsertProblemAction); diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables/chatFileReference.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables/chatFileReference.ts index cb6ce3b9d2d..7fe2a6d72dd 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables/chatFileReference.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables/chatFileReference.ts @@ -9,6 +9,7 @@ import { IDynamicVariable } from '../../../common/chatVariables.js'; import { IRange } from '../../../../../../editor/common/core/range.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { FilePromptParser } from '../../../common/promptSyntax/parsers/filePromptParser.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; /** @@ -23,6 +24,7 @@ export class ChatFileReference extends FilePromptParser implements IDynamicVaria constructor( public readonly reference: IDynamicVariable, @IInstantiationService initService: IInstantiationService, + @IWorkspaceContextService workspaceService: IWorkspaceContextService, @ILogService logService: ILogService, ) { const { data } = reference; @@ -32,7 +34,7 @@ export class ChatFileReference extends FilePromptParser implements IDynamicVaria `Variable data must be an URI, got '${data}'.`, ); - super(data, [], initService, logService); + super(data, {}, initService, workspaceService, logService); } /** @@ -57,10 +59,6 @@ export class ChatFileReference extends FilePromptParser implements IDynamicVaria return this.uri; } - public get prefix() { - return this.reference.prefix; - } - public get isFile() { return this.reference.isFile; } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts index afcd62f2218..9b532a8c7b4 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts @@ -8,22 +8,27 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename } from '../../../../../base/common/resources.js'; +import { basename, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { Location } from '../../../../../editor/common/languages.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { EditorsOrder } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { getNotebookEditorFromEditorPane, INotebookEditor } from '../../../notebook/browser/notebookBrowser.js'; import { IChatEditingService } from '../../common/chatEditingService.js'; -import { IBaseChatRequestVariableEntry, IChatRequestImplicitVariableEntry } from '../../common/chatModel.js'; +import { IChatRequestFileEntry, IChatRequestImplicitVariableEntry } from '../../common/chatModel.js'; import { IChatService } from '../../common/chatService.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { ILanguageModelIgnoredFilesService } from '../../common/ignoredFiles.js'; +import { PROMPT_LANGUAGE_ID } from '../../common/promptSyntax/constants.js'; +import { IPromptsService, TSharedPrompt } from '../../common/promptSyntax/service/types.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; +import { toChatVariable } from '../chatAttachmentModel/chatPromptAttachmentsCollection.js'; export class ChatImplicitContextContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'chat.implicitContext'; @@ -55,13 +60,29 @@ export class ChatImplicitContextContribution extends Disposable implements IWork Event.any( codeEditor.onDidChangeModel, codeEditor.onDidChangeCursorSelection, - codeEditor.onDidScrollChange), + codeEditor.onDidScrollChange, + codeEditor.onDidChangeModelLanguage), () => undefined, 500)(() => this.updateImplicitContext())); } const notebookEditor = this.findActiveNotebookEditor(); if (notebookEditor) { + const activeCellDisposables = activeEditorDisposables.add(new DisposableStore()); + activeEditorDisposables.add(notebookEditor.onDidChangeActiveCell(() => { + activeCellDisposables.clear(); + const codeEditor = this.codeEditorService.getActiveCodeEditor(); + if (codeEditor && codeEditor.getModel()?.uri.scheme === Schemas.vscodeNotebookCell) { + activeCellDisposables.add(Event.debounce( + Event.any( + codeEditor.onDidChangeModel, + codeEditor.onDidChangeCursorSelection, + codeEditor.onDidScrollChange), + () => undefined, + 500)(() => this.updateImplicitContext())); + } + })); + activeEditorDisposables.add(Event.debounce( Event.any( notebookEditor.onDidChangeModel, @@ -89,7 +110,7 @@ export class ChatImplicitContextContribution extends Disposable implements IWork return; } if (this._implicitContextEnablement[widget.location] === 'first' && widget.viewModel?.getItems().length !== 0) { - widget.input.implicitContext.setValue(undefined, false); + widget.input.implicitContext.setValue(undefined, false, undefined); } })); this._register(this.chatWidgetService.onDidAddWidget(async (widget) => { @@ -138,7 +159,10 @@ export class ChatImplicitContextContribution extends Disposable implements IWork const selection = codeEditor?.getSelection(); let newValue: Location | URI | undefined; let isSelection = false; + + let languageId: string | undefined; if (model) { + languageId = model.getLanguageId(); if (selection && !selection.isEmpty()) { newValue = { uri: model.uri, range: selection } satisfies Location; isSelection = true; @@ -162,14 +186,34 @@ export class ChatImplicitContextContribution extends Disposable implements IWork if (notebookEditor) { const activeCell = notebookEditor.getActiveCell(); if (activeCell) { + const codeEditor = this.codeEditorService.getActiveCodeEditor(); + const selection = codeEditor?.getSelection(); + const visibleRanges = codeEditor?.getVisibleRanges() || []; newValue = activeCell.uri; + if (isEqual(codeEditor?.getModel()?.uri, activeCell.uri)) { + if (selection && !selection.isEmpty()) { + newValue = { uri: activeCell.uri, range: selection } satisfies Location; + isSelection = true; + } else if (visibleRanges.length > 0) { + // Merge visible ranges. Maybe the reference value could actually be an array of Locations? + // Something like a Location with an array of Ranges? + let range = visibleRanges[0]; + visibleRanges.slice(1).forEach(r => { + range = range.plusRange(r); + }); + newValue = { uri: activeCell.uri, range } satisfies Location; + } + } } else { newValue = notebookEditor.textModel?.uri; } } const uri = newValue instanceof URI ? newValue : newValue?.uri; - if (uri && await this.ignoredFilesService.fileIsIgnored(uri, cancelTokenSource.token)) { + if (uri && ( + await this.ignoredFilesService.fileIsIgnored(uri, cancelTokenSource.token) || + uri.path.endsWith('.copilotmd')) + ) { newValue = undefined; } @@ -177,7 +221,7 @@ export class ChatImplicitContextContribution extends Disposable implements IWork return; } - const widgets = updateWidget ? [updateWidget] : [...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel), ...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.EditingSession), ...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Editor)]; + const widgets = updateWidget ? [updateWidget] : [...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel), ...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Editor)]; for (const widget of widgets) { if (!widget.input.implicitContext) { continue; @@ -185,18 +229,30 @@ export class ChatImplicitContextContribution extends Disposable implements IWork const setting = this._implicitContextEnablement[widget.location]; const isFirstInteraction = widget.viewModel?.getItems().length === 0; if (setting === 'first' && !isFirstInteraction) { - widget.input.implicitContext.setValue(undefined, false); + widget.input.implicitContext.setValue(undefined, false, undefined); } else if (setting === 'always' || setting === 'first' && isFirstInteraction) { - widget.input.implicitContext.setValue(newValue, isSelection); + widget.input.implicitContext.setValue(newValue, isSelection, languageId); } else if (setting === 'never') { - widget.input.implicitContext.setValue(undefined, false); + widget.input.implicitContext.setValue(undefined, false, undefined); } } } } export class ChatImplicitContext extends Disposable implements IChatRequestImplicitVariableEntry { + /** + * If the implicit context references a prompt file, this field + * holds a reference to an associated prompt parser instance. + */ + private prompt: TSharedPrompt | undefined; + get id() { + if (this.prompt !== undefined) { + const variable = toChatVariable(this.prompt, true); + + return variable.id; + } + if (URI.isUri(this.value)) { return 'vscode.implicit.file'; } else if (this.value) { @@ -211,6 +267,12 @@ export class ChatImplicitContext extends Disposable implements IChatRequestImpli } get name(): string { + if (this.prompt !== undefined) { + const variable = toChatVariable(this.prompt, true); + + return variable.name; + } + if (URI.isUri(this.value)) { return `file:${basename(this.value)}`; } else if (this.value) { @@ -223,6 +285,12 @@ export class ChatImplicitContext extends Disposable implements IChatRequestImpli readonly kind = 'implicit'; get modelDescription(): string { + if (this.prompt !== undefined) { + const variable = toChatVariable(this.prompt, true); + + return variable.modelDescription; + } + if (URI.isUri(this.value)) { return `User's active file`; } else if (this._isSelection) { @@ -239,7 +307,7 @@ export class ChatImplicitContext extends Disposable implements IChatRequestImpli return this._isSelection; } - private _onDidChangeValue = new Emitter(); + private _onDidChangeValue = this._register(new Emitter()); readonly onDidChangeValue = this._onDidChangeValue.event; private _value: Location | URI | undefined; @@ -257,24 +325,97 @@ export class ChatImplicitContext extends Disposable implements IChatRequestImpli this._onDidChangeValue.fire(); } - constructor(value?: Location | URI) { + constructor( + @IPromptsService private readonly promptsService: IPromptsService, + @IModelService private readonly modelService: IModelService, + @ILogService private readonly logService: ILogService, + ) { super(); - this._value = value; } - setValue(value: Location | URI | undefined, isSelection: boolean) { + setValue(value: Location | URI | undefined, isSelection: boolean, languageId?: string): void { this._value = value; this._isSelection = isSelection; + + // remove and dispose existent prompt parser instance + this.removePrompt(); + // if language ID is a 'prompt' language, create a prompt parser instance + if (value && (languageId === PROMPT_LANGUAGE_ID)) { + this.addPrompt(value); + } + this._onDidChangeValue.fire(); } - toBaseEntry(): IBaseChatRequestVariableEntry { - return { - id: this.id, - name: this.name, - value: this.value, - isFile: true, - modelDescription: this.modelDescription - }; + public async toBaseEntries(): Promise { + // chat variable for non-prompt file attachment + if (this.prompt === undefined) { + return [{ + kind: 'file', + id: this.id, + name: this.name, + value: this.value, + modelDescription: this.modelDescription, + }]; + + } + + // prompt can have any number of nested references, hence + // collect all of valid ones and return the entire list + await this.prompt.allSettled(); + return [ + // add all valid child references in the prompt + ...this.prompt.allValidReferences.map((link) => { + return toChatVariable(link, false); + }), + // and then the root prompt reference itself + toChatVariable({ + uri: this.prompt.uri, + // the attached file must have been a prompt file therefore + // we force that assumption here; this makes sure that prompts + // in untitled documents can be also attached to the chat input + isPromptFile: true, + }, true), + ]; + } + + /** + * Whether the implicit context references a prompt file. + */ + public get isPromptFile() { + return (this.prompt !== undefined); + } + + /** + * Add prompt parser instance for the provided value. + */ + private addPrompt( + value: URI | Location, + ): void { + const uri = URI.isUri(value) + ? value + : value.uri; + + const model = this.modelService.getModel(uri); + const modelExists = (model !== null); + if ((modelExists === false) || model.isDisposed()) { + return this.logService.warn( + `cannot create prompt parser instance for ${uri.path} (model exists: ${modelExists})`, + ); + } + + this.prompt = this.promptsService.getSyntaxParserFor(model); + } + + /** + * Remove and dispose prompt parser instance. + */ + private removePrompt(): void { + delete this.prompt; + } + + public override dispose(): void { + this.removePrompt(); + super.dispose(); } } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts index 5755bac4e0c..ba44e6e0e96 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts @@ -9,7 +9,6 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { isPatternInWord } from '../../../../../base/common/filters.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; -import { dirname } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { isCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; @@ -24,35 +23,34 @@ import { localize } from '../../../../../nls.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { FileKind } from '../../../../../platform/files/common/files.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IMarkerService } from '../../../../../platform/markers/common/markers.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../../common/contributions.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IHistoryService } from '../../../../services/history/common/history.js'; import { LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; -import { QueryBuilder } from '../../../../services/search/common/queryBuilder.js'; import { ISearchService } from '../../../../services/search/common/search.js'; import { IChatAgentData, IChatAgentNameService, IChatAgentService, getFullyQualifiedId } from '../../common/chatAgents.js'; import { IChatEditingService } from '../../common/chatEditingService.js'; -import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestTextPart, ChatRequestToolPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from '../../common/chatParserTypes.js'; +import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from '../../common/chatParserTypes.js'; import { IChatSlashCommandService } from '../../common/chatSlashCommands.js'; import { IDynamicVariable } from '../../common/chatVariables.js'; import { ChatAgentLocation, ChatMode } from '../../common/constants.js'; -import { ILanguageModelToolsService } from '../../common/languageModelToolsService.js'; -import { ChatEditingSessionSubmitAction, ChatSubmitAction } from '../actions/chatExecuteActions.js'; +import { IPromptsService } from '../../common/promptSyntax/service/types.js'; +import { ChatSubmitAction } from '../actions/chatExecuteActions.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; import { ChatInputPart } from '../chatInputPart.js'; -import { ChatDynamicVariableModel, SelectAndInsertFileAction, SelectAndInsertFolderAction, SelectAndInsertProblemAction, SelectAndInsertSymAction, getTopLevelFolders, searchFolders } from './chatDynamicVariables.js'; +import { ChatDynamicVariableModel, searchFilesAndFolders } from './chatDynamicVariables.js'; class SlashCommandCompletions extends Disposable { constructor( @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService + @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, + @IPromptsService private readonly promptsService: IPromptsService, ) { super(); @@ -82,7 +80,7 @@ class SlashCommandCompletions extends Disposable { return; } - const slashCommands = this.chatSlashCommandService.getCommands(widget.location); + const slashCommands = this.chatSlashCommandService.getCommands(widget.location, widget.input.currentMode); if (!slashCommands) { return null; } @@ -97,7 +95,7 @@ class SlashCommandCompletions extends Disposable { range, sortText: c.sortText ?? 'a'.repeat(i + 1), kind: CompletionItemKind.Text, // The icons are disabled here anyway, - command: c.executeImmediately ? { id: widget.location === ChatAgentLocation.EditingSession ? ChatEditingSessionSubmitAction.ID : ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` }] } : undefined, + command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` }] } : undefined, }; }) }; @@ -122,7 +120,7 @@ class SlashCommandCompletions extends Disposable { return; } - const slashCommands = this.chatSlashCommandService.getCommands(widget.location); + const slashCommands = this.chatSlashCommandService.getCommands(widget.location, widget.input.currentMode); if (!slashCommands) { return null; } @@ -138,7 +136,54 @@ class SlashCommandCompletions extends Disposable { filterText: `${chatAgentLeader}${c.command}`, sortText: c.sortText ?? 'z'.repeat(i + 1), kind: CompletionItemKind.Text, // The icons are disabled here anyway, - command: c.executeImmediately ? { id: widget.location === ChatAgentLocation.EditingSession ? ChatEditingSessionSubmitAction.ID : ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` }] } : undefined, + command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` }] } : undefined, + }; + }) + }; + } + })); + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'promptSlashCommands', + triggerCharacters: ['/'], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget || !widget.viewModel) { + return null; + } + + const range = computeCompletionRanges(model, position, /\/\w*/g); + if (!range) { + return null; + } + + if (!isEmptyUpToCompletionWord(model, range)) { + // No text allowed before the completion + return; + } + + const parsedRequest = widget.parsedInput.parts; + const usedAgent = parsedRequest.find(p => p instanceof ChatRequestAgentPart); + if (usedAgent) { + // No (classic) global slash commands when an agent is used + return; + } + + const promptCommands = await this.promptsService.findPromptSlashCommands(); + if (promptCommands.length === 0) { + return null; + } + + return { + suggestions: promptCommands.map((c, i): CompletionItem => { + const label = `/${c.command}`; + const description = c.promptPath?.storage === 'user' ? localize('promptFileDescription', 'User Prompt File') : localize('promptFileDescriptionWorkspace', 'Workspace Prompt File'); + return { + label: { label, description }, + insertText: `${label} `, + documentation: c.detail, + range, + sortText: 'a'.repeat(i + 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway, }; }) }; @@ -179,8 +224,8 @@ class AgentCompletions extends Disposable { return; } - const usedSubcommand = parsedRequest.find(p => p instanceof ChatRequestAgentSubcommandPart); - if (usedSubcommand) { + const usedOtherCommand = parsedRequest.find(p => p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashPromptPart); + if (usedOtherCommand) { // Only one allowed return; } @@ -452,9 +497,8 @@ interface IVariableCompletionsDetails { class BuiltinDynamicCompletions extends Disposable { private static readonly addReferenceCommand = '_addReferenceCmd'; - private static readonly VariableNameDef = new RegExp(`${chatVariableLeader}[\\w:]*`, 'g'); // MUST be using `g`-flag + private static readonly VariableNameDef = new RegExp(`${chatVariableLeader}[\\w:-]*`, 'g'); // MUST be using `g`-flag - private readonly queryBuilder: QueryBuilder; constructor( @IHistoryService private readonly historyService: IHistoryService, @@ -464,68 +508,23 @@ class BuiltinDynamicCompletions extends Disposable { @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IInstantiationService private readonly instantiationService: IInstantiationService, @IOutlineModelService private readonly outlineService: IOutlineModelService, @IEditorService private readonly editorService: IEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IFileService private readonly fileService: IFileService, - @IMarkerService markerService: IMarkerService, ) { super(); - // File completions - this.registerVariableCompletions('file', async ({ widget, range, position, model }, token) => { + // File/Folder completions in one go and m + const fileWordPattern = new RegExp(`${chatVariableLeader}[^\\s]*`, 'g'); + this.registerVariableCompletions('fileAndFolder', async ({ widget, range }, token) => { if (!widget.supportsFileReferences) { - return null; + return; } - const result: CompletionList = { suggestions: [] }; - - const afterRange = new Range(position.lineNumber, range.replace.startColumn, position.lineNumber, range.replace.startColumn + '#file:'.length); - result.suggestions.push({ - label: `${chatVariableLeader}file`, - insertText: `${chatVariableLeader}file:`, - documentation: localize('pickFileLabel', "Pick a file"), - range, - kind: CompletionItemKind.Text, - command: { id: SelectAndInsertFileAction.ID, title: SelectAndInsertFileAction.ID, arguments: [{ widget, range: afterRange }] }, - sortText: 'z' - }); - - const range2 = computeCompletionRanges(model, position, new RegExp(`${chatVariableLeader}[^\\s]*`, 'g'), true); - if (range2) { - await this.addFileEntries(widget, result, range2, token); - } - + await this.addFileAndFolderEntries(widget, result, range, token); return result; - }); - // Folder completions - this.registerVariableCompletions('folder', async ({ widget, range, position, model }, token) => { - if (!widget.supportsFileReferences) { - return null; - } - - const result: CompletionList = { suggestions: [] }; - - const afterRange = new Range(position.lineNumber, range.replace.startColumn, position.lineNumber, range.replace.startColumn + '#folder:'.length); - result.suggestions.push({ - label: `${chatVariableLeader}folder`, - insertText: `${chatVariableLeader}folder:`, - documentation: localize('pickFolderLabel', "Pick a folder"), - range, - kind: CompletionItemKind.Text, - command: { id: SelectAndInsertFolderAction.ID, title: SelectAndInsertFolderAction.ID, arguments: [{ widget, range: afterRange }] }, - sortText: 'z' - }); - - const range2 = computeCompletionRanges(model, position, new RegExp(`${chatVariableLeader}[^\\s]*`, 'g'), true); - if (range2) { - await this.addFolderEntries(widget, result, range2, token); - } - - return result; - }); + }, fileWordPattern); // Selection completion this.registerVariableCompletions('selection', ({ widget, range }, token) => { @@ -564,7 +563,6 @@ class BuiltinDynamicCompletions extends Disposable { command: { id: BuiltinDynamicCompletions.addReferenceCommand, title: '', arguments: [new ReferenceArgument(widget, { id: 'vscode.selection', - prefix: 'file', isFile: true, range: { startLineNumber: range.replace.startLineNumber, startColumn: range.replace.startColumn, endLineNumber: range.replace.endLineNumber, endColumn: range.replace.startColumn + text.length }, data: { range: currentSelection, uri: currentResource } satisfies Location @@ -581,18 +579,6 @@ class BuiltinDynamicCompletions extends Disposable { } const result: CompletionList = { suggestions: [] }; - - const afterRangeSym = new Range(position.lineNumber, range.replace.startColumn, position.lineNumber, range.replace.startColumn + '#sym:'.length); - result.suggestions.push({ - label: `${chatVariableLeader}sym`, - insertText: `${chatVariableLeader}sym:`, - documentation: localize('pickSymbolLabel', "Pick a symbol"), - range, - kind: CompletionItemKind.Text, - command: { id: SelectAndInsertSymAction.ID, title: SelectAndInsertSymAction.ID, arguments: [{ widget, range: afterRangeSym }] }, - sortText: 'z' - }); - const range2 = computeCompletionRanges(model, position, new RegExp(`${chatVariableLeader}[^\\s]*`, 'g'), true); if (range2) { this.addSymbolEntries(widget, result, range2, token); @@ -601,36 +587,10 @@ class BuiltinDynamicCompletions extends Disposable { return result; }); - // Problems completions, we just attach all problems in this case - this.registerVariableCompletions(SelectAndInsertProblemAction.Name, ({ widget, range, position, model }, token) => { - const stats = markerService.getStatistics(); - if (!stats.errors && !stats.warnings) { - return null; - } - - const result: CompletionList = { suggestions: [] }; - - const completedText = `${chatVariableLeader}${SelectAndInsertProblemAction.Name}:`; - const afterTextRange = new Range(position.lineNumber, range.replace.startColumn, position.lineNumber, range.replace.startColumn + completedText.length); - result.suggestions.push({ - label: `${chatVariableLeader}${SelectAndInsertProblemAction.Name}`, - insertText: completedText, - documentation: localize('pickProblemsLabel', "Problems in your workspace"), - range, - kind: CompletionItemKind.Text, - command: { id: SelectAndInsertProblemAction.ID, title: SelectAndInsertProblemAction.ID, arguments: [{ widget, range: afterTextRange }] }, - sortText: 'z' - }); - - return result; - }); - this._register(CommandsRegistry.registerCommand(BuiltinDynamicCompletions.addReferenceCommand, (_services, arg) => this.cmdAddReference(arg))); - - this.queryBuilder = this.instantiationService.createInstance(QueryBuilder); } - private registerVariableCompletions(debugName: string, provider: (details: IVariableCompletionsDetails, token: CancellationToken) => ProviderResult) { + private registerVariableCompletions(debugName: string, provider: (details: IVariableCompletionsDetails, token: CancellationToken) => ProviderResult, wordPattern: RegExp = BuiltinDynamicCompletions.VariableNameDef) { this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { _debugDisplayName: `chatVarCompletions-${debugName}`, triggerCharacters: [chatVariableLeader], @@ -640,7 +600,7 @@ class BuiltinDynamicCompletions extends Disposable { return; } - const range = computeCompletionRanges(model, position, BuiltinDynamicCompletions.VariableNameDef, true); + const range = computeCompletionRanges(model, position, wordPattern, true); if (range) { return provider({ model, position, widget, range, context }, token); } @@ -652,9 +612,9 @@ class BuiltinDynamicCompletions extends Disposable { private cacheKey?: { key: string; time: number }; - private async addFileEntries(widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) { + private async addFileAndFolderEntries(widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) { - const makeFileCompletionItem = (resource: URI, description?: string): CompletionItem => { + const makeCompletionItem = (resource: URI, kind: FileKind, description?: string): CompletionItem => { const basename = this.labelService.getUriBasenameLabel(resource); const text = `${chatVariableLeader}file:${basename}`; @@ -669,13 +629,13 @@ class BuiltinDynamicCompletions extends Disposable { filterText: `${chatVariableLeader}${basename}`, insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text, range: info, - kind: CompletionItemKind.File, + kind: kind === FileKind.FILE ? CompletionItemKind.File : CompletionItemKind.Folder, sortText, command: { id: BuiltinDynamicCompletions.addReferenceCommand, title: '', arguments: [new ReferenceArgument(widget, { - id: 'vscode.file', - prefix: 'file', - isFile: true, + id: resource.toString(), + isFile: kind === FileKind.FILE, + isDirectory: kind === FileKind.FOLDER, range: { startLineNumber: info.replace.startLineNumber, startColumn: info.replace.startColumn, endLineNumber: info.replace.endLineNumber, endColumn: info.replace.startColumn + text.length }, data: resource })] @@ -692,15 +652,14 @@ class BuiltinDynamicCompletions extends Disposable { const len = result.suggestions.length; // RELATED FILES - if (widget.input.currentMode !== ChatMode.Ask && widget.viewModel && this._chatEditingService.getEditingSession(widget.viewModel.sessionId)) { + if (widget.input.currentMode !== ChatMode.Ask && widget.viewModel && widget.viewModel.model.editingSession) { const relatedFiles = (await raceTimeout(this._chatEditingService.getRelatedFiles(widget.viewModel.sessionId, widget.getInput(), widget.attachmentModel.fileAttachments, token), 200)) ?? []; for (const relatedFileGroup of relatedFiles) { for (const relatedFile of relatedFileGroup.files) { - if (seen.has(relatedFile.uri)) { - continue; + if (!seen.has(relatedFile.uri)) { + seen.add(relatedFile.uri); + result.suggestions.push(makeCompletionItem(relatedFile.uri, FileKind.FILE, relatedFile.description)); } - seen.add(relatedFile.uri); - result.suggestions.push(makeFileCompletionItem(relatedFile.uri, relatedFile.description)); } } } @@ -708,8 +667,8 @@ class BuiltinDynamicCompletions extends Disposable { // HISTORY // always take the last N items for (const item of this.historyService.getHistory()) { - if (!item.resource || !this.workspaceContextService.getWorkspaceFolder(item.resource)) { - // ignore "forgein" editors + if (!item.resource || seen.has(item.resource)) { + // ignore editors without a resource continue; } @@ -722,7 +681,7 @@ class BuiltinDynamicCompletions extends Disposable { } seen.add(item.resource); - const newLen = result.suggestions.push(makeFileCompletionItem(item.resource)); + const newLen = result.suggestions.push(makeCompletionItem(item.resource, FileKind.FILE)); if (newLen - len >= 5) { break; } @@ -733,90 +692,22 @@ class BuiltinDynamicCompletions extends Disposable { if (pattern) { const cacheKey = this.updateCacheKey(); + const workspaces = this.workspaceContextService.getWorkspace().folders.map(folder => folder.uri); - const query = this.queryBuilder.file(this.workspaceContextService.getWorkspace().folders, { - filePattern: pattern, - sortByScore: true, - maxResults: 250, - cacheKey: cacheKey.key - }); - - const data = await this.searchService.fileSearch(query, token); - for (const match of data.results) { - if (seen.has(match.resource)) { - // already included via history - continue; + for (const workspace of workspaces) { + const { folders, files } = await searchFilesAndFolders(workspace, pattern, true, token, cacheKey.key, this.configurationService, this.searchService); + for (const file of files) { + if (!seen.has(file)) { + result.suggestions.push(makeCompletionItem(file, FileKind.FILE)); + seen.add(file); + } } - result.suggestions.push(makeFileCompletionItem(match.resource)); - } - } - - // mark results as incomplete because further typing might yield - // in more search results - result.incomplete = true; - } - - private async addFolderEntries(widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) { - - const folderLeader = `${chatVariableLeader}folder:`; - - const makeFolderCompletionItem = (resource: URI, description?: string): CompletionItem => { - - const basename = this.labelService.getUriBasenameLabel(resource); - const text = `${folderLeader}${basename}`; - const uriLabel = this.labelService.getUriLabel(dirname(resource), { relative: true }); - const labelDescription = description - ? localize('folderEntryDescription', '{0} ({1})', uriLabel, description) - : uriLabel; - const sortText = description ? 'z' : '{'; // after `z` - - return { - label: { label: basename, description: labelDescription }, - filterText: `${folderLeader}${basename}`, - insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text, - range: info, - kind: CompletionItemKind.Folder, - sortText, - command: { - id: BuiltinDynamicCompletions.addReferenceCommand, title: '', arguments: [new ReferenceArgument(widget, { - id: 'vscode.folder', - prefix: 'folder', - isFile: false, - isDirectory: true, - range: { startLineNumber: info.replace.startLineNumber, startColumn: info.replace.startColumn, endLineNumber: info.replace.endLineNumber, endColumn: info.replace.startColumn + text.length }, - data: resource - })] + for (const folder of folders) { + if (!seen.has(folder)) { + result.suggestions.push(makeCompletionItem(folder, FileKind.FOLDER)); + seen.add(folder); + } } - }; - }; - - const seen = new ResourceSet(); - const workspaces = this.workspaceContextService.getWorkspace().folders.map(folder => folder.uri); - - let pattern: string | undefined; - if (info.varWord?.word && info.varWord.word.startsWith(folderLeader)) { - pattern = info.varWord.word.toLowerCase().slice(folderLeader.length); - - for (const folder of await getTopLevelFolders(workspaces, this.fileService)) { - result.suggestions.push(makeFolderCompletionItem(folder)); - seen.add(folder); - } - } - - // SEARCH - // use folder search when having a pattern - if (pattern) { - - const cacheKey = this.updateCacheKey(); - - const folders = await Promise.all(workspaces.map(workspace => searchFolders(workspace, pattern, true, token, cacheKey.key, this.configurationService, this.searchService))); - for (const resource of folders.flat()) { - if (seen.has(resource)) { - // already included via history - continue; - } - seen.add(resource); - result.suggestions.push(makeFolderCompletionItem(resource)); } } @@ -842,11 +733,11 @@ class BuiltinDynamicCompletions extends Disposable { sortText, command: { id: BuiltinDynamicCompletions.addReferenceCommand, title: '', arguments: [new ReferenceArgument(widget, { - id: 'vscode.symbol', - prefix: 'sym', + id: `vscode.symbol/${JSON.stringify(symbolItem.location)}`, fullName: symbolItem.name, range: { startLineNumber: info.replace.startLineNumber, startColumn: info.replace.startColumn, endLineNumber: info.replace.endLineNumber, endColumn: info.replace.startColumn + text.length }, - data: symbolItem.location + data: symbolItem.location, + icon: SymbolKinds.toIcon(symbolItem.kind) })] } }; @@ -859,29 +750,13 @@ class BuiltinDynamicCompletions extends Disposable { const symbolsToAdd: { symbol: DocumentSymbol; uri: URI }[] = []; for (const outlineModel of this.outlineService.getCachedModels()) { - if (pattern) { - symbolsToAdd.push(...outlineModel.asListOfDocumentSymbols().map(symbol => ({ symbol, uri: outlineModel.uri }))); - } else { - symbolsToAdd.push(...outlineModel.getTopLevelSymbols().map(symbol => ({ symbol, uri: outlineModel.uri }))); + const symbols = outlineModel.asListOfDocumentSymbols(); + for (const symbol of symbols) { + symbolsToAdd.push({ symbol, uri: outlineModel.uri }); } } - const symbolsToAddFiltered = symbolsToAdd.filter(fileSymbol => { - switch (fileSymbol.symbol.kind) { - case SymbolKind.Enum: - case SymbolKind.Class: - case SymbolKind.Method: - case SymbolKind.Function: - case SymbolKind.Namespace: - case SymbolKind.Module: - case SymbolKind.Interface: - return true; - default: - return false; - } - }); - - for (const symbol of symbolsToAddFiltered) { + for (const symbol of symbolsToAdd) { result.suggestions.push(makeSymbolCompletionItem({ ...symbol.symbol, location: { uri: symbol.uri, range: symbol.symbol.range } }, pattern ?? '')); } @@ -966,7 +841,6 @@ class ToolCompletions extends Disposable { constructor( @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @ILanguageModelToolsService toolsService: ILanguageModelToolsService ) { super(); @@ -987,14 +861,22 @@ class ToolCompletions extends Disposable { const usedTools = widget.parsedInput.parts.filter((p): p is ChatRequestToolPart => p instanceof ChatRequestToolPart); const usedToolNames = new Set(usedTools.map(v => v.toolName)); const toolItems: CompletionItem[] = []; - toolItems.push(...Array.from(toolsService.getTools()) + toolItems.push(...widget.input.selectedToolsModel.tools.get() .filter(t => t.canBeReferencedInPrompt) .filter(t => !usedToolNames.has(t.toolReferenceName ?? '')) .map((t): CompletionItem => { + const source = t.source; + const detail = source.type === 'mcp' + ? localize('desc', "MCP Server: {0}", source.label) + : source.type === 'extension' + ? source.label + : undefined; + const withLeader = `${chatVariableLeader}${t.toolReferenceName}`; return { label: withLeader, range, + detail, insertText: withLeader + ' ', documentation: t.userDescription, kind: CompletionItemKind.Text, diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts index 06957dd285a..3f2bb1b4b84 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts @@ -4,16 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { themeColorFromId } from '../../../../../base/common/themables.js'; import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../editor/common/editorCommon.js'; +import { TrackedRangeStickiness } from '../../../../../editor/common/model.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { inputPlaceholderForeground } from '../../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentService } from '../../common/chatAgents.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../common/chatColors.js'; -import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestToolPart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../common/chatParserTypes.js'; +import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../common/chatParserTypes.js'; import { ChatRequestParser } from '../../common/chatRequestParser.js'; import { IChatWidget } from '../chat.js'; import { ChatWidget } from '../chatWidget.js'; @@ -46,8 +48,7 @@ class InputEditorDecorations extends Disposable { this.codeEditorService.registerDecorationType(decorationDescription, placeholderDecorationType, {}); - this._register(this.themeService.onDidColorThemeChange(() => this.updateRegisteredDecorationTypes())); - this.updateRegisteredDecorationTypes(); + this.registeredDecorationTypes(); this.updateInputEditorDecorations(); this._register(this.widget.inputEditor.onDidChangeModelContent(() => this.updateInputEditorDecorations())); @@ -73,28 +74,30 @@ class InputEditorDecorations extends Disposable { }); } - private updateRegisteredDecorationTypes() { - this.codeEditorService.removeDecorationType(variableTextDecorationType); - this.codeEditorService.removeDecorationType(dynamicVariableDecorationType); - this.codeEditorService.removeDecorationType(slashCommandTextDecorationType); + private registeredDecorationTypes() { - const theme = this.themeService.getColorTheme(); this.codeEditorService.registerDecorationType(decorationDescription, slashCommandTextDecorationType, { - color: theme.getColor(chatSlashCommandForeground)?.toString(), - backgroundColor: theme.getColor(chatSlashCommandBackground)?.toString(), + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), borderRadius: '3px' }); this.codeEditorService.registerDecorationType(decorationDescription, variableTextDecorationType, { - color: theme.getColor(chatSlashCommandForeground)?.toString(), - backgroundColor: theme.getColor(chatSlashCommandBackground)?.toString(), + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), borderRadius: '3px' }); this.codeEditorService.registerDecorationType(decorationDescription, dynamicVariableDecorationType, { - color: theme.getColor(chatSlashCommandForeground)?.toString(), - backgroundColor: theme.getColor(chatSlashCommandBackground)?.toString(), - borderRadius: '3px' + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges }); - this.updateInputEditorDecorations(); + + this._register(toDisposable(() => { + this.codeEditorService.removeDecorationType(variableTextDecorationType); + this.codeEditorService.removeDecorationType(dynamicVariableDecorationType); + this.codeEditorService.removeDecorationType(slashCommandTextDecorationType); + })); } private getPlaceholderColor(): string | undefined { @@ -139,6 +142,7 @@ class InputEditorDecorations extends Disposable { const agentPart = parsedRequest.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart); const agentSubcommandPart = parsedRequest.find((p): p is ChatRequestAgentSubcommandPart => p instanceof ChatRequestAgentSubcommandPart); const slashCommandPart = parsedRequest.find((p): p is ChatRequestSlashCommandPart => p instanceof ChatRequestSlashCommandPart); + const slashPromptPart = parsedRequest.find((p): p is ChatRequestSlashPromptPart => p instanceof ChatRequestSlashPromptPart); const exactlyOneSpaceAfterPart = (part: IParsedChatRequestPart): boolean => { const partIdx = parsedRequest.indexOf(part); @@ -223,6 +227,10 @@ class InputEditorDecorations extends Disposable { textDecorations.push({ range: slashCommandPart.editorRange }); } + if (slashPromptPart) { + textDecorations.push({ range: slashPromptPart.editorRange }); + } + this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, textDecorations); const varDecorations: IDecorationOptions[] = []; @@ -304,7 +312,7 @@ class ChatTokenDeleter extends Disposable { const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionId, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentMode }); // For dynamic variables, this has to happen in ChatDynamicVariableModel with the other bookkeeping - const deletableTokens = previousParsedValue.parts.filter(p => p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashCommandPart || p instanceof ChatRequestToolPart); + const deletableTokens = previousParsedValue.parts.filter(p => p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashCommandPart || p instanceof ChatRequestSlashPromptPart || p instanceof ChatRequestToolPart); deletableTokens.forEach(token => { const deletedRangeOfToken = Range.intersectRanges(token.editorRange, change.range); // Part of this token was deleted, or the space after it was deleted, and the deletion range doesn't go off the front of the token, for simpler math diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts index 44c4d6d8946..5b1f9bd553c 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts @@ -98,7 +98,7 @@ export class ChatRelatedFilesContribution extends Disposable implements IWorkben disposableStore.add(onDebouncedType(() => { this._updateRelatedFileSuggestions(currentEditingSession, widget); })); - disposableStore.add(widget.attachmentModel.onDidChangeContext(() => { + disposableStore.add(widget.attachmentModel.onDidChange(() => { this._updateRelatedFileSuggestions(currentEditingSession, widget); })); disposableStore.add(currentEditingSession.onDidDispose(() => { diff --git a/src/vs/workbench/contrib/chat/browser/contrib/screenshot.ts b/src/vs/workbench/contrib/chat/browser/contrib/screenshot.ts index ca08338bb43..f030477d0df 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/screenshot.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/screenshot.ts @@ -3,16 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { VSBuffer } from '../../../../../base/common/buffer.js'; import { localize } from '../../../../../nls.js'; import { IChatRequestVariableEntry } from '../../common/chatModel.js'; export const ScreenshotVariableId = 'screenshot-focused-window'; -export function convertBufferToScreenshotVariable(buffer: ArrayBufferLike): IChatRequestVariableEntry { +export function convertBufferToScreenshotVariable(buffer: VSBuffer): IChatRequestVariableEntry { return { id: ScreenshotVariableId, name: localize('screenshot', 'Screenshot'), - value: new Uint8Array(buffer), - isImage: true, + value: buffer.buffer, + kind: 'image' }; } diff --git a/src/vs/workbench/contrib/chat/browser/imageUtils.ts b/src/vs/workbench/contrib/chat/browser/imageUtils.ts index b5f426737fd..caa02352006 100644 --- a/src/vs/workbench/contrib/chat/browser/imageUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/imageUtils.ts @@ -3,6 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; /** * Resizes an image provided as a UInt8Array string. Resizing is based on Open AI's algorithm for tokenzing images. @@ -11,23 +16,24 @@ * @returns A promise that resolves to the UInt8Array string of the resized image. */ -export async function resizeImage(data: Uint8Array | string): Promise { +export async function resizeImage(data: Uint8Array | string, mimeType?: string): Promise { + const isGif = mimeType === 'image/gif'; if (typeof data === 'string') { data = convertStringToUInt8Array(data); } - const blob = new Blob([data]); - const img = new Image(); - const url = URL.createObjectURL(blob); - img.src = url; - return new Promise((resolve, reject) => { + const blob = new Blob([data], { type: mimeType }); + const img = new Image(); + const url = URL.createObjectURL(blob); + img.src = url; + img.onload = () => { URL.revokeObjectURL(url); let { width, height } = img; - if (width <= 768 || height <= 768) { + if ((width <= 768 || height <= 768) && !isGif) { resolve(data); return; } @@ -102,3 +108,51 @@ function isValidBase64(str: string): boolean { } })(); } + +export async function createFileForMedia(fileService: IFileService, imagesFolder: URI, dataTransfer: Uint8Array, mimeType: string): Promise { + const exists = await fileService.exists(imagesFolder); + if (!exists) { + await fileService.createFolder(imagesFolder); + } + + const ext = mimeType.split('/')[1] || 'png'; + const filename = `image-${Date.now()}.${ext}`; + const fileUri = joinPath(imagesFolder, filename); + + const buffer = VSBuffer.wrap(dataTransfer); + await fileService.writeFile(fileUri, buffer); + + return fileUri; +} + +export async function cleanupOldImages(fileService: IFileService, logService: ILogService, imagesFolder: URI): Promise { + const exists = await fileService.exists(imagesFolder); + if (!exists) { + return; + } + + const duration = 7 * 24 * 60 * 60 * 1000; // 7 days + const files = await fileService.resolve(imagesFolder); + if (!files.children) { + return; + } + + await Promise.all(files.children.map(async (file) => { + try { + const timestamp = getTimestampFromFilename(file.name); + if (timestamp && (Date.now() - timestamp > duration)) { + await fileService.del(file.resource); + } + } catch (err) { + logService.error('Failed to clean up old images', err); + } + })); +} + +function getTimestampFromFilename(filename: string): number | undefined { + const match = filename.match(/image-(\d+)\./); + if (match) { + return parseInt(match[1], 10); + } + return undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts index 253d448671d..8e61ee3d41e 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts @@ -6,26 +6,44 @@ import { renderStringAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { toErrorMessage } from '../../../../base/common/errorMessage.js'; import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; import { Iterable } from '../../../../base/common/iterator.js'; -import { Disposable, DisposableStore, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Lazy } from '../../../../base/common/lazy.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { LRUCache } from '../../../../base/common/map.js'; +import { localize } from '../../../../nls.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import * as JSONContributionRegistry from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { ChatModel } from '../common/chatModel.js'; import { ChatToolInvocation } from '../common/chatProgressTypes/chatToolInvocation.js'; import { IChatService } from '../common/chatService.js'; -import { CountTokensCallback, ILanguageModelToolsService, IToolData, IToolImpl, IToolInvocation, IToolResult } from '../common/languageModelToolsService.js'; +import { ChatConfiguration } from '../common/constants.js'; +import { CountTokensCallback, createToolSchemaUri, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolResult, stringifyPromptTsxPart } from '../common/languageModelToolsService.js'; + +const jsonSchemaRegistry = Registry.as(JSONContributionRegistry.Extensions.JSONContribution); interface IToolEntry { data: IToolData; impl?: IToolImpl; } +interface ITrackedCall { + invocation?: ChatToolInvocation; + store: IDisposable; +} + export class LanguageModelToolsService extends Disposable implements ILanguageModelToolsService { _serviceBrand: undefined; @@ -39,18 +57,28 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo private _toolContextKeys = new Set(); private readonly _ctxToolsCount: IContextKey; - private _callsByRequestId = new Map(); + private _callsByRequestId = new Map(); + + private _workspaceToolConfirmStore: Lazy; + private _profileToolConfirmStore: Lazy; + private _memoryToolConfirmStore = new Set(); constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, @IExtensionService private readonly _extensionService: IExtensionService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IChatService private readonly _chatService: IChatService, @IDialogService private readonly _dialogService: IDialogService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILogService private readonly _logService: ILogService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService ) { super(); + this._workspaceToolConfirmStore = new Lazy(() => this._register(this._instantiationService.createInstance(ToolConfirmStore, StorageScope.WORKSPACE))); + this._profileToolConfirmStore = new Lazy(() => this._register(this._instantiationService.createInstance(ToolConfirmStore, StorageScope.PROFILE))); + this._register(this._contextKeyService.onDidChangeContext(e => { if (e.affectsSome(this._toolContextKeys)) { // Not worth it to compute a delta here unless we have many tools changing often @@ -58,6 +86,12 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } })); + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.ExtensionToolsEnabled)) { + this._onDidChangeToolsScheduler.schedule(); + } + })); + this._ctxToolsCount = ChatContextKeys.Tools.toolsCount.bindTo(_contextKeyService); } @@ -72,7 +106,16 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo toolData.when?.keys().forEach(key => this._toolContextKeys.add(key)); + let store: DisposableStore | undefined; + if (toolData.inputSchema) { + store = new DisposableStore(); + const schemaUrl = createToolSchemaUri(toolData.id).toString(); + jsonSchemaRegistry.registerSchema(schemaUrl, toolData.inputSchema, store); + store.add(jsonSchemaRegistry.registerSchemaAssociation(schemaUrl, `/lm/tool/${toolData.id}/tool_input.json`)); + } + return toDisposable(() => { + store?.dispose(); this._tools.delete(toolData.id); this._ctxToolsCount.set(this._tools.size); this._refreshAllToolContextKeys(); @@ -105,7 +148,16 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo getTools(): Iterable> { const toolDatas = Iterable.map(this._tools.values(), i => i.data); - return Iterable.filter(toolDatas, toolData => !toolData.when || this._contextKeyService.contextMatchesRules(toolData.when)); + const extensionToolsEnabled = this._configurationService.getValue(ChatConfiguration.ExtensionToolsEnabled); + return Iterable.filter( + toolDatas, + toolData => { + const satisfiesWhenClause = !toolData.when || this._contextKeyService.contextMatchesRules(toolData.when); + const satisfiesExternalToolCheck = toolData.source.type === 'extension' && !extensionToolsEnabled ? + !toolData.source.isExternalTool : + true; + return satisfiesWhenClause && satisfiesExternalToolCheck; + }); } getTool(id: string): IToolData | undefined { @@ -130,6 +182,22 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } + setToolAutoConfirmation(toolId: string, scope: 'workspace' | 'profile' | 'memory', autoConfirm = true): void { + if (scope === 'workspace') { + this._workspaceToolConfirmStore.value.setAutoConfirm(toolId, autoConfirm); + } else if (scope === 'profile') { + this._profileToolConfirmStore.value.setAutoConfirm(toolId, autoConfirm); + } else { + this._memoryToolConfirmStore.add(toolId); + } + } + + resetToolAutoConfirmation(): void { + this._workspaceToolConfirmStore.value.reset(); + this._profileToolConfirmStore.value.reset(); + this._memoryToolConfirmStore.clear(); + } + async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`); @@ -165,12 +233,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const request = model.getRequests().at(-1)!; requestId = request.id; + dto.modelId = request.modelId; // Replace the token with a new token that we can cancel when cancelToolCallsForRequest is called if (!this._callsByRequestId.has(requestId)) { this._callsByRequestId.set(requestId, []); } - this._callsByRequestId.get(requestId)!.push(store); + const trackedCall: ITrackedCall = { store }; + this._callsByRequestId.get(requestId)!.push(trackedCall); const source = new CancellationTokenSource(); store.add(toDisposable(() => { @@ -185,25 +255,30 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo })); token = source.token; - const prepared = tool.impl.prepareToolInvocation ? - await tool.impl.prepareToolInvocation(dto.parameters, token) - : undefined; + const prepared = await this.prepareToolInvocation(tool, dto, token); + toolInvocation = new ChatToolInvocation(prepared, tool.data, dto.callId); + trackedCall.invocation = toolInvocation; + if (this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace)) { + toolInvocation.confirmed.complete(true); + } - toolInvocation = new ChatToolInvocation(prepared, tool.data); model.acceptResponseProgress(request, toolInvocation); if (prepared?.confirmationMessages) { + this._accessibilityService.alert(localize('toolConfirmationMessage', "Action required: {0}", prepared.confirmationMessages.title)); const userConfirmed = await toolInvocation.confirmed.p; if (!userConfirmed) { throw new CancellationError(); } dto.toolSpecificData = toolInvocation?.toolSpecificData; + + if (dto.toolSpecificData?.kind === 'input') { + dto.parameters = dto.toolSpecificData.rawInput; + dto.toolSpecificData = undefined; + } } } else { - const prepared = tool.impl.prepareToolInvocation ? - await tool.impl.prepareToolInvocation(dto.parameters, token) - : undefined; - + const prepared = await this.prepareToolInvocation(tool, dto, token); if (prepared?.confirmationMessages) { const result = await this._dialogService.confirm({ message: prepared.confirmationMessages.title, detail: renderStringAsPlaintext(prepared.confirmationMessages.message) }); if (!result.confirmed) { @@ -216,14 +291,21 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo throw new CancellationError(); } - toolResult = await tool.impl.invoke(dto, countTokens, token); + toolResult = await tool.impl.invoke(dto, countTokens, { + report: step => { + toolInvocation?.acceptProgress(step); + } + }, token); + this.ensureToolDetails(dto, toolResult, tool.data); + this._telemetryService.publicLog2( 'languageModelToolInvoked', { result: 'success', chatSessionId: dto.context?.sessionId, toolId: tool.data.id, - toolExtensionId: tool.data.extensionId?.value, + toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined, + toolSourceKind: tool.data.source.type, }); return toolResult; } catch (err) { @@ -234,8 +316,17 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo result, chatSessionId: dto.context?.sessionId, toolId: tool.data.id, - toolExtensionId: tool.data.extensionId?.value, + toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined, + toolSourceKind: tool.data.source.type, }); + this._logService.error(`[LanguageModelToolsService#invokeTool] Error from tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}:\n${toErrorMessage(err, true)}`); + + toolResult ??= { content: [] }; + toolResult.toolResultError = err instanceof Error ? err.message : String(err); + if (tool.data.alwaysDisplayInputOutput) { + toolResult.toolResultDetails = { input: this.formatToolInput(dto), output: String(err), isError: true }; + } + throw err; } finally { toolInvocation?.complete(toolResult); @@ -246,10 +337,78 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } + private async prepareToolInvocation(tool: IToolEntry, dto: IToolInvocation, token: CancellationToken): Promise { + const prepared = tool.impl!.prepareToolInvocation ? + await tool.impl!.prepareToolInvocation(dto.parameters, token) + : undefined; + + if (prepared?.confirmationMessages) { + if (prepared.toolSpecificData?.kind !== 'terminal' && typeof prepared.confirmationMessages.allowAutoConfirm !== 'boolean') { + prepared.confirmationMessages.allowAutoConfirm = true; + } + + if (!prepared.toolSpecificData && tool.data.alwaysDisplayInputOutput) { + prepared.toolSpecificData = { + kind: 'input', + rawInput: dto.parameters, + }; + } + } + + return prepared; + } + + private ensureToolDetails(dto: IToolInvocation, toolResult: IToolResult, toolData: IToolData): void { + if (!toolResult.toolResultDetails && toolData.alwaysDisplayInputOutput) { + toolResult.toolResultDetails = { + input: this.formatToolInput(dto), + output: this.toolResultToString(toolResult), + }; + } + } + + private formatToolInput(dto: IToolInvocation): string { + return JSON.stringify(dto.parameters, undefined, 2); + } + + private toolResultToString(toolResult: IToolResult): string { + const strs = []; + for (const part of toolResult.content) { + if (part.kind === 'text') { + strs.push(part.value); + } else if (part.kind === 'promptTsx') { + strs.push(stringifyPromptTsxPart(part)); + } else if (part.kind === 'data') { + strs.push(`\n\n${localize('toolResultData', "Tool result data of type {0} ({1} bytes)", part.value.mimeType, part.value.data.byteLength)}\n\n`); + } + } + return strs.join(''); + } + + private shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined): boolean { + if (this._workspaceToolConfirmStore.value.getAutoConfirm(toolId) || this._profileToolConfirmStore.value.getAutoConfirm(toolId) || this._memoryToolConfirmStore.has(toolId)) { + return true; + } + + const config = this._configurationService.inspect>('chat.tools.autoApprove'); + + // If we know the tool runs at a global level, only consider the global config. + // If we know the tool runs at a workspace level, use those specific settings when appropriate. + let value = config.value ?? config.defaultValue; + if (typeof runsInWorkspace === 'boolean') { + value = config.userLocalValue ?? config.applicationValue; + if (runsInWorkspace) { + value = config.workspaceValue ?? config.workspaceFolderValue ?? config.userRemoteValue ?? value; + } + } + + return value === true || (typeof value === 'object' && value.hasOwnProperty(toolId) && value[toolId] === true); + } + private cleanupCallDisposables(requestId: string, store: DisposableStore): void { const disposables = this._callsByRequestId.get(requestId); if (disposables) { - const index = disposables.indexOf(store); + const index = disposables.findIndex(d => d.store === store); if (index > -1) { disposables.splice(index, 1); } @@ -263,7 +422,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo cancelToolCallsForRequest(requestId: string): void { const calls = this._callsByRequestId.get(requestId); if (calls) { - calls.forEach(call => call.dispose()); + calls.forEach(call => call.store.dispose()); this._callsByRequestId.delete(requestId); } } @@ -271,7 +430,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo public override dispose(): void { super.dispose(); - this._callsByRequestId.forEach(calls => dispose(calls)); + this._callsByRequestId.forEach(calls => calls.forEach(call => call.store.dispose())); this._ctxToolsCount.reset(); } } @@ -281,6 +440,7 @@ type LanguageModelToolInvokedEvent = { chatSessionId: string | undefined; toolId: string; toolExtensionId: string | undefined; + toolSourceKind: string; }; type LanguageModelToolInvokedClassification = { @@ -288,6 +448,58 @@ type LanguageModelToolInvokedClassification = { chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat session that the tool was used within, if applicable.' }; toolId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the tool used.' }; toolExtensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension that contributed the tool.' }; + toolSourceKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source (mcp/extension/internal) of the tool.' }; owner: 'roblourens'; comment: 'Provides insight into the usage of language model tools.'; }; + +class ToolConfirmStore extends Disposable { + private static readonly STORED_KEY = 'chat/autoconfirm'; + + private _autoConfirmTools: LRUCache = new LRUCache(100); + private _didChange = false; + + constructor( + private readonly _scope: StorageScope, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + const stored = storageService.getObject(ToolConfirmStore.STORED_KEY, this._scope); + if (stored) { + for (const key of stored) { + this._autoConfirmTools.set(key, true); + } + } + + this._register(storageService.onWillSaveState(() => { + if (this._didChange) { + this.storageService.store(ToolConfirmStore.STORED_KEY, [...this._autoConfirmTools.keys()], this._scope, StorageTarget.MACHINE); + this._didChange = false; + } + })); + } + + public reset() { + this._autoConfirmTools.clear(); + this._didChange = true; + } + + public getAutoConfirm(toolId: string): boolean { + if (this._autoConfirmTools.get(toolId)) { + this._didChange = true; + return true; + } + + return false; + } + + public setAutoConfirm(toolId: string, autoConfirm: boolean): void { + if (autoConfirm) { + this._autoConfirmTools.set(toolId, true); + } else { + this._autoConfirmTools.delete(toolId); + } + this._didChange = true; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index 56396183cc5..238c1a4b023 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -6,6 +6,7 @@ .interactive-session { max-width: 850px; margin: auto; + position: relative; /* For chat dnd */ } .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row > .monaco-tl-row > .monaco-tl-twistie { @@ -24,7 +25,7 @@ -webkit-user-select: text; } -.interactive-item-container .header { +.interactive-item-container:not(:has(.chat-extensions-content-part)) .header { display: flex; align-items: center; justify-content: space-between; @@ -200,8 +201,14 @@ margin-bottom: 8px; } -.interactive-item-container .value .rendered-markdown .codicon { - font-size: inherit; +.interactive-item-container .value .rendered-markdown:not(:has(.chat-extensions-content-part)) { + .codicon { + font-size: inherit; + } + + .interactive-result-code-block .codicon { + font-size: initial; + } } .interactive-item-container .value .rendered-markdown blockquote { @@ -234,6 +241,10 @@ color: var(--vscode-textLink-foreground); } +.interactive-item-container .value .rendered-markdown .chat-extensions-content-part a { + color: inherit; +} + .interactive-item-container .value .rendered-markdown a { user-select: text; } @@ -306,13 +317,13 @@ font-weight: unset; } -.interactive-item-container .value .rendered-markdown { - /* Codicons next to text need to be aligned with the text */ - .codicon { - position: relative; - top: 2px; - } +/* Codicons next to text need to be aligned with the text */ +.interactive-item-container .value .rendered-markdown:not(:has(.chat-extensions-content-part)) .codicon { + position: relative; + top: 2px; +} +.interactive-item-container .value .rendered-markdown { .chat-codeblock-pill-widget .codicon { top: -1px; } @@ -373,6 +384,37 @@ .progress-container .rendered-markdown [data-code] { margin: 0; } + + .tool-input-output-part { + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .tool-input-output-part .rendered-markdown p { + margin: inherit; + } + + .tool-input-output-part .expando { + display: flex; + align-items: center; + cursor: pointer; + } + + .tool-input-output-part .input-output { + display: none; + padding: 6px 0; + flex-basis: 100%; + width: 100%; + } + + .tool-input-output-part.expanded .input-output { + display: inherit; + } + + &:not(:last-child) { + margin-bottom: 8px; + } } .interactive-item-container .value > .rendered-markdown li > p { @@ -514,7 +556,7 @@ have to be updated for changes to the rules above, or to support more deeply nes right: 0; } -.interactive-session .chat-dnd-overlay { +.chat-dnd-overlay { position: absolute; top: 0; left: 0; @@ -525,13 +567,13 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } -.interactive-session .chat-dnd-overlay.visible { +.chat-dnd-overlay.visible { display: flex; align-items: center; justify-content: center; } -.interactive-session .chat-dnd-overlay .attach-context-overlay-text { +.chat-dnd-overlay .attach-context-overlay-text { padding: 0.6em; margin: 0.2em; line-height: 12px; @@ -541,7 +583,7 @@ have to be updated for changes to the rules above, or to support more deeply nes text-align: center; } -.interactive-session .chat-dnd-overlay .attach-context-overlay-text .codicon { +.chat-dnd-overlay .attach-context-overlay-text .codicon { height: 12px; font-size: 12px; margin-right: 3px; @@ -688,6 +730,7 @@ have to be updated for changes to the rules above, or to support more deeply nes gap: 4px; margin-top: 6px; flex-wrap: wrap; + cursor: default; } .chat-related-files { @@ -859,28 +902,13 @@ have to be updated for changes to the rules above, or to support more deeply nes display: flex; } -@container chat-input-container (max-width: 130px) { - .interactive-session .chat-input-toolbars .chat-modelPicker-item { - /* Hides modelpicker when the container width is 130px or less */ - display: none; - } -} - -.interactive-session .interactive-input-part:not(.compact) .chat-input-toolbars > .chat-execute-toolbar { - container-type: inline-size; - container-name: chat-input-container; - flex: 1; - display: flex; - justify-content: flex-end; +.interactive-session .chat-input-toolbars :first-child { + margin-right: auto; } .interactive-session .chat-input-toolbars > .chat-execute-toolbar { min-width: 0px; - .monaco-action-bar { - min-width: 0px; - } - .chat-modelPicker-item { min-width: 0px; @@ -903,7 +931,7 @@ have to be updated for changes to the rules above, or to support more deeply nes color: var(--vscode-icon-foreground) !important; } -.interactive-session .chat-input-toolbars .chat-dropdown-item .action-label { +.interactive-session .chat-input-toolbars .chat-modelPicker-item .action-label { height: 16px; padding: 3px 0px 3px 6px; display: flex; @@ -911,7 +939,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } -.interactive-session .chat-input-toolbars .chat-dropdown-item .action-label .codicon-chevron-down { +.interactive-session .chat-input-toolbars .chat-modelPicker-item .action-label .codicon-chevron-down { font-size: 12px; margin-left: 2px; } @@ -1024,7 +1052,8 @@ have to be updated for changes to the rules above, or to support more deeply nes color: var(--vscode-notificationsWarningIcon-foreground); } -.chat-attached-context .chat-attached-context-attachment.show-file-icons.warning { +.chat-attached-context .chat-attached-context-attachment.show-file-icons.warning, +.chat-attached-context .chat-attached-context-attachment.show-file-icons.partial-warning { border-color: var(--vscode-notificationsWarningIcon-foreground); } @@ -1125,21 +1154,7 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 8px 0 0 0 } -.chat-attachment-toolbar .action-item:not(:last-child) { - margin-right: 4px; -} - -.action-item.chat-attached-context-attachment.chat-add-files { - height: 20px; - color: var(--vscode-descriptionForeground); -} - -.action-item.chat-attached-context-attachment.chat-add-files span.keybinding { - display: none; -} - -.action-item.chat-mcp .action-label, -.action-item.chat-attached-context-attachment.chat-add-files .action-label, +.action-item.chat-attachment-button .action-label, .interactive-session .chat-attached-context .chat-attached-context-attachment { display: flex; gap: 2px; @@ -1153,10 +1168,8 @@ have to be updated for changes to the rules above, or to support more deeply nes width: fit-content; } -.action-item.chat-attached-context-attachment.chat-add-files .action-label { - color: var(--vscode-descriptionForeground); - font-family: unset; - gap: 5px; +.action-item.chat-attachment-button > .action-label > .codicon { + font-size: 14px; } .action-item.chat-mcp { @@ -1198,11 +1211,6 @@ have to be updated for changes to the rules above, or to support more deeply nes } } -.quick-input-list .quick-input-list-rows > .quick-input-list-row .monaco-icon-label.tool-pick .codicon[class*='codicon-'] { - font-size: 14px; -} - - .action-item.chat-attached-context-attachment.chat-add-files .action-label.codicon::before { font: normal normal normal 16px/1 codicon; } @@ -1235,8 +1243,8 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label-container .monaco-highlighted-label { - display: block !important; - align-items: center !important; + display: inline-flex; + align-items: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -1249,16 +1257,22 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label .codicon { - padding-left: 4px; - font-size: 100% !important; + font-size: 14px; +} + +.interactive-session .chat-input-container .chat-attached-context { + display: contents; } .interactive-session .chat-attached-context { display: flex; flex-wrap: wrap; - cursor: default; gap: 4px; - max-width: 100%; +} + +.interactive-session .chat-attachment-toolbar .actions-container { + gap: 4px; + flex-wrap: wrap; } .interactive-session .interactive-input-part.compact .chat-attached-context { @@ -1309,15 +1323,10 @@ have to be updated for changes to the rules above, or to support more deeply nes opacity: 0.8; } -.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label { - gap: 4px; -} - .interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label::before { - height: 1em; - width: auto; + height: 16px; padding: 0; - line-height: 1em !important; + line-height: 100% !important; align-self: center; background-size: contain; @@ -1655,12 +1664,16 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-item-container .chat-command-button .monaco-button, -.chat-confirmation-widget .chat-confirmation-buttons-container .monaco-button { +.chat-confirmation-widget .chat-confirmation-buttons-container .monaco-button:not(.monaco-dropdown-button) { text-align: left; width: initial; padding: 4px 8px; } +.chat-confirmation-widget .chat-confirmation-buttons-container .monaco-button.monaco-dropdown-button { + padding: 0 3px; +} + .interactive-item-container .chat-confirmation-widget .interactive-result-code-block { margin-bottom: 8px; } @@ -1686,14 +1699,50 @@ have to be updated for changes to the rules above, or to support more deeply nes color: var(--vscode-textLink-foreground); } -.chat-attached-context-hover .chat-attached-context-image { +.chat-attached-context-hover { + padding: 0 6px; +} + +.chat-attached-context-hover .chat-attached-context-image-container { + padding: 6px 0 4px; height: auto; - max-height: 512px; - max-width: 512px; width: 100%; display: block; } +.chat-attached-context-hover .chat-attached-context-image-container .chat-attached-context-image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + max-height: 350px; + max-width: 100%; + min-width: 200px; + min-height: 200px; + +} + +.chat-attached-context-hover .chat-attached-context-url { + color: var(--vscode-textLink-foreground); + cursor: pointer; + margin-top: 4px; + padding: 2px 0; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + display: block; +} + +.chat-attached-context-hover .chat-attached-context-url-separator { + border-top: 1px solid var(--vscode-chat-requestBorder); + left: 0; + right: 0; + position: absolute; + margin-top: 2px; +} + .chat-attached-context-attachment .chat-attached-context-pill { font-size: 12px; display: inline-flex; @@ -1715,6 +1764,7 @@ have to be updated for changes to the rules above, or to support more deeply nes width: 14px; height: 14px; border-radius: 2px; + object-fit: cover; } .chat-attached-context-attachment .chat-attached-context-custom-text { @@ -1734,6 +1784,10 @@ have to be updated for changes to the rules above, or to support more deeply nes text-decoration: line-through; } +.chat-attached-context-attachment.show-file-icons.partial-warning .chat-attached-context-custom-text { + color: var(--vscode-notificationsWarningIcon-foreground); +} + .interactive-session .chat-scroll-down { display: none; position: absolute; diff --git a/src/vs/workbench/contrib/chat/browser/media/chatEditingEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/media/chatEditingEditorOverlay.css index 9356b690797..462d898b3d3 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatEditingEditorOverlay.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatEditingEditorOverlay.css @@ -35,40 +35,22 @@ .chat-editor-overlay-widget .chat-editor-overlay-progress { align-items: center; display: none; - padding: 0px 5px; + padding: 5px 0 5px 5px; font-size: 12px; - font-variant-numeric: tabular-nums; overflow: hidden; - white-space: nowrap; + gap: 6px; } .chat-editor-overlay-widget.busy .chat-editor-overlay-progress { display: inline-flex; } - -@keyframes ellipsis { - 0% { - content: ""; - } - 25% { - content: "."; - } - 50% { - content: ".."; - } - 75% { - content: "..."; - } - 100% { - content: ""; - } -} - -.chat-editor-overlay-widget.busy.paused .chat-editor-overlay-progress { - .codicon-loading { - display: none; - } +.chat-editor-overlay-widget .chat-editor-overlay-progress .progress-message { + white-space: nowrap; + max-width: 13em; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 8px; } .chat-editor-overlay-widget .action-item > .action-label { @@ -76,14 +58,6 @@ font-size: 12px; } -.chat-editor-overlay-widget.busy .action-item > .action-label.busy::after { - content: ""; - display: inline-flex; - white-space: nowrap; - overflow: hidden; - width: 3ch; - animation: ellipsis steps(4, end) 1s infinite; -} .chat-editor-overlay-widget .action-item:first-child > .action-label { padding-left: 7px; diff --git a/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css deleted file mode 100644 index 79c7251217f..00000000000 --- a/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css +++ /dev/null @@ -1,146 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.chat-editor-overlay-widget { - padding: 0px; - color: var(--vscode-button-foreground); - background-color: var(--vscode-button-background); - border-radius: 5px; - border: 1px solid var(--vscode-contrastBorder); - display: flex; - align-items: center; - z-index: 10; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); - overflow: hidden; -} - -@keyframes pulse { - 0% { - box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); - } - 50% { - box-shadow: 0 2px 8px 4px var(--vscode-widget-shadow); - } - 100% { - box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); - } -} - -.chat-editor-overlay-widget.busy { - animation: pulse ease-in 2.3s infinite; -} - -.chat-editor-overlay-widget .chat-editor-overlay-progress { - align-items: center; - display: none; - padding: 0px 5px; - font-size: 12px; - font-variant-numeric: tabular-nums; - overflow: hidden; - white-space: nowrap; -} - -.chat-editor-overlay-widget.busy .chat-editor-overlay-progress { - display: inline-flex; -} - -.chat-editor-overlay-widget.busy .chat-editor-overlay-progress .busy-label { - padding: 5px; - /* font-style: italic; */ -} - -@keyframes ellipsis { - 0% { - content: ""; - } - 25% { - content: "."; - } - 50% { - content: ".."; - } - 75% { - content: "..."; - } - 100% { - content: ""; - } -} - -.chat-editor-overlay-widget.busy .chat-editor-overlay-progress .busy-label::after { - content: ""; - display: inline-flex; - white-space: nowrap; - overflow: hidden; - width: 3ch; - animation: ellipsis steps(4, end) 1s infinite; -} - -.chat-editor-overlay-widget.busy.paused .chat-editor-overlay-progress { - .codicon-loading { - display: none; - } - - .busy-label::after { - animation-duration: 5s; - } -} - -.chat-editor-overlay-widget.busy .chat-editor-overlay-toolbar { - display: none; -} - -.chat-editor-overlay-widget .action-item > .action-label { - padding: 5px; - font-size: 12px; -} - -.chat-editor-overlay-widget .action-item:first-child > .action-label { - padding-left: 7px; -} - -.chat-editor-overlay-widget .action-item:last-child > .action-label { - padding-right: 7px; -} - -.chat-editor-overlay-widget.busy .chat-editor-overlay-progress .codicon, -.chat-editor-overlay-widget .action-item > .action-label.codicon { - color: var(--vscode-button-foreground); -} - -.chat-editor-overlay-widget .monaco-action-bar .action-item.disabled > .action-label.codicon::before, -.chat-editor-overlay-widget .monaco-action-bar .action-item.disabled > .action-label.codicon, -.chat-editor-overlay-widget .monaco-action-bar .action-item.disabled > .action-label, -.chat-editor-overlay-widget .monaco-action-bar .action-item.disabled > .action-label:hover { - color: var(--vscode-button-foreground); - opacity: 0.7; -} - - -.chat-editor-overlay-widget .action-item.label-item { - font-variant-numeric: tabular-nums; -} - -.chat-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label, -.chat-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label:hover { - color: var(--vscode-button-foreground); - opacity: 1; -} - -.chat-editor-overlay-widget .action-item.auto { - position: relative; - overflow: hidden; -} - -.chat-editor-overlay-widget .action-item.auto::before { - content: ''; - position: absolute; - top: 0; - left: var(--vscode-action-item-auto-timeout, -100%); - width: 100%; - height: 100%; - background-color: var(--vscode-toolbar-hoverBackground); - transition: left 0.5s linear; -} diff --git a/src/vs/workbench/contrib/chat/browser/media/chatSetup.css b/src/vs/workbench/contrib/chat/browser/media/chatSetup.css index aa255b4ee92..3d1e37d5e2f 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatSetup.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatSetup.css @@ -4,46 +4,25 @@ *--------------------------------------------------------------------------------------------*/ .chat-setup-dialog { - overflow: hidden; -} - -.chat-welcome-view .chat-setup-view { - - text-align: center; - - .chat-features-container { - display: flex; - justify-content: center; - text-align: initial; - border-radius: 2px; - border: 1px solid var(--vscode-chat-requestBorder); - background-color: var(--vscode-chat-requestBackground); - } -} - -.dialog-message-body .chat-setup-view { - - p.legal { - font-size: 12px; - color: var(--vscode-descriptionForeground); - } - - .chat-setup-dialog-icon-background { - position: absolute; - right: -50px; - top: -20px; - font-size: 240px !important; - opacity: 0.2; - } -} - -.dialog-message-body .chat-setup-view, -.chat-welcome-view .chat-setup-view { p { + margin-top: 0; + margin-bottom: 0; width: 100%; } + p.setup-legal { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-top: 2em; + } + + p.setup-settings { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-top: 1em; + } + .chat-feature-container { display: flex; align-items: center; diff --git a/src/vs/workbench/contrib/chat/browser/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/media/chatStatus.css index 82f35aebb20..55f193f87ef 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatStatus.css @@ -16,11 +16,17 @@ } .chat-status-bar-entry-tooltip div.header { + display: flex; + align-items: center; color: var(--vscode-descriptionForeground); margin-bottom: 4px; font-weight: 600; } +.chat-status-bar-entry-tooltip div.header .monaco-action-bar { + margin-left: auto; +} + .chat-status-bar-entry-tooltip div.description { color: var(--vscode-descriptionForeground); } @@ -48,15 +54,25 @@ .chat-status-bar-entry-tooltip .quota-indicator .quota-label { display: flex; justify-content: space-between; + gap: 20px; margin-bottom: 3px; } +.chat-status-bar-entry-tooltip .quota-indicator .quota-label .quota-value { + color: var(--vscode-descriptionForeground); +} + .chat-status-bar-entry-tooltip .quota-indicator .quota-bar { width: 100%; height: 4px; background-color: var(--vscode-gauge-foreground); border-radius: 4px; border: 1px solid var(--vscode-gauge-border); + margin: 4px 0; +} + +.chat-status-bar-entry-tooltip .quota-indicator.unlimited .quota-bar { + display: none; } .chat-status-bar-entry-tooltip .quota-indicator .quota-bar .quota-bit { @@ -100,10 +116,6 @@ margin-right: 5px; } -.chat-status-bar-entry-tooltip .settings .setting .codicon { - font-size: 12px; -} - .chat-status-bar-entry-tooltip .settings .setting .setting-label { cursor: pointer; } @@ -111,3 +123,25 @@ .chat-status-bar-entry-tooltip .settings .setting.disabled .setting-label { color: var(--vscode-disabledForeground); } + +/* Contributions */ + +.chat-status-bar-entry-tooltip .contribution .body { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + + .description, + .detail-item { + display: flex; + align-items: center; + gap: 3px; + } + + .detail-item, + .detail-item a { + margin-left: auto; + color: var(--vscode-descriptionForeground); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/media/chatViewWelcome.css b/src/vs/workbench/contrib/chat/browser/media/chatViewWelcome.css index 4d99cd50999..ebee24797da 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatViewWelcome.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatViewWelcome.css @@ -57,14 +57,6 @@ div.chat-welcome-view { margin-top: 5px; gap: 9px; justify-content: center; - - & > .chat-welcome-view-indicator { - font-size: 11px; - color: var(--vscode-badge-foreground); - background: var(--vscode-badge-background); - padding: 1px 8px; - border-radius: 14px; - } } & > .chat-welcome-view-message { diff --git a/src/vs/workbench/contrib/chat/browser/media/simpleBrowserOverlay.css b/src/vs/workbench/contrib/chat/browser/media/simpleBrowserOverlay.css new file mode 100644 index 00000000000..bd5ce1394a9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/media/simpleBrowserOverlay.css @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.element-selection-message, +.element-expand-container { + position: absolute; + bottom: 10px; + right: 10px; + padding: 8px 10px; + background: var(--vscode-notifications-background); + color: var(--vscode-notifications-foreground); + border-radius: 4px; + font-size: 12px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + display: flex; + align-items: center; + gap: 8px; + width: max-content; + z-index: 1 +} + +.element-selection-message { + bottom: 10px; + right: 10px; +} + +.element-expand-container { + bottom: 18px; + right: 15px; +} + +.element-selection-cancel, +.element-selection-start { + padding: 3px 8px; + width: fit-content; +} + +.element-selection-message .monaco-button.codicon.codicon-close, +.element-expand-container .monaco-button.codicon.codicon-layout, +.element-selection-message .monaco-button.codicon.codicon-chevron-right { + width: 17px; + height: 17px; + padding: 2px 2px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--vscode-descriptionForeground); + border: none; + outline: none; + padding: 0; + border-radius: 5px; + cursor: pointer; +} + +.element-selection .monaco-button { + height: 17px; + width: fit-content; + padding: 2px 6px; + font-size: 11px; + background-color: var(--vscode-button-background); + border: 1px solid var(--vscode-button-border); + color: var(--vscode-button-foreground); +} + +.element-selection-message .monaco-button:hover, +.element-expand-container .monaco-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.element-selection-message .hidden, +.element-expand-container.hidden, +.element-selection-message.hidden { + display: none !important; +} diff --git a/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts new file mode 100644 index 00000000000..4cb22275c66 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { renderLabelWithIcons } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IAction } from '../../../../../base/common/actions.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ActionWidgetDropdownActionViewItem } from '../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; +import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownActionProvider, IActionWidgetDropdownOptions } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IChatAgentService } from '../../common/chatAgents.js'; +import { ChatMode, modeToString } from '../../common/constants.js'; +import { getOpenChatActionIdForMode } from '../actions/chatActions.js'; +import { IToggleChatModeArgs } from '../actions/chatExecuteActions.js'; + +export interface IModePickerDelegate { + onDidChangeMode: Event; + getMode(): ChatMode; +} + +export class ModePickerActionItem extends ActionWidgetDropdownActionViewItem { + constructor( + action: MenuItemAction, + private readonly delegate: IModePickerDelegate, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IChatAgentService chatAgentService: IChatAgentService, + @IKeybindingService keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + const makeAction = (mode: ChatMode): IAction => ({ + ...action, + id: getOpenChatActionIdForMode(mode), + label: modeToString(mode), + class: undefined, + enabled: true, + checked: delegate.getMode() === mode, + run: async () => { + const result = await action.run({ mode } satisfies IToggleChatModeArgs); + this.renderLabel(this.element!); + return result; + } + }); + + const actionProvider: IActionWidgetDropdownActionProvider = { + getActions: () => { + const agentStateActions = [ + makeAction(ChatMode.Edit), + ]; + if (chatAgentService.hasToolsAgent) { + agentStateActions.push(makeAction(ChatMode.Agent)); + } + + agentStateActions.unshift(makeAction(ChatMode.Ask)); + return agentStateActions; + } + }; + + const modelPickerActionWidgetOptions: Omit = { + actionProvider, + }; + + super(action, modelPickerActionWidgetOptions, actionWidgetService, keybindingService, contextKeyService); + + this._register(delegate.onDidChangeMode(() => this.renderLabel(this.element!))); + } + + protected override renderLabel(element: HTMLElement): IDisposable | null { + this.setAriaLabelAttributes(element); + const state = modeToString(this.delegate.getMode()); + dom.reset(element, dom.$('span.chat-model-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`)); + return null; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('chat-modelPicker-item'); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts new file mode 100644 index 00000000000..4096e758f80 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAction } from '../../../../../base/common/actions.js'; +import { Event } from '../../../../../base/common/event.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../common/languageModels.js'; +import { localize } from '../../../../../nls.js'; +import * as dom from '../../../../../base/browser/dom.js'; +import { renderLabelWithIcons } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ActionWidgetDropdownActionViewItem } from '../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider, IActionWidgetDropdownOptions } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { getFlatActionBarActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../common/chatEntitlementService.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; + +export interface IModelPickerDelegate { + readonly onDidChangeModel: Event; + getCurrentModel(): ILanguageModelChatMetadataAndIdentifier | undefined; + setModel(model: ILanguageModelChatMetadataAndIdentifier): void; + getModels(): ILanguageModelChatMetadataAndIdentifier[]; +} + +function modelDelegateToWidgetActionsProvider(delegate: IModelPickerDelegate): IActionWidgetDropdownActionProvider { + return { + getActions: () => { + return delegate.getModels().map(model => { + return { + id: model.metadata.id, + enabled: true, + checked: model.metadata.id === delegate.getCurrentModel()?.metadata.id, + category: model.metadata.modelPickerCategory, + class: undefined, + description: model.metadata.cost, + tooltip: model.metadata.description ?? model.metadata.name, + label: model.metadata.name, + run: () => { + delegate.setModel(model); + } + } satisfies IActionWidgetDropdownAction; + }); + } + }; +} + +function getModelPickerActionBarActions(menuService: IMenuService, contextKeyService: IContextKeyService, commandService: ICommandService, chatEntitlementService: IChatEntitlementService): IAction[] { + const menuActions = menuService.createMenu(MenuId.ChatModelPicker, contextKeyService); + const menuContributions = getFlatActionBarActions(menuActions.getActions()); + menuActions.dispose(); + + const additionalActions: IAction[] = []; + + // Add menu contributions from extensions + if (menuContributions.length > 0) { + additionalActions.push(...menuContributions); + } + + // Add upgrade option if entitlement is limited + if (chatEntitlementService.entitlement === ChatEntitlement.Limited) { + additionalActions.push({ + id: 'moreModels', + label: localize('chat.moreModels', "Add Premium Models"), + enabled: true, + tooltip: localize('chat.moreModels.tooltip', "Add premium models"), + class: undefined, + run: () => { + const commandId = 'workbench.action.chat.upgradePlan'; + commandService.executeCommand(commandId); + } + }); + } + + return additionalActions; +} + +/** + * Action view item for selecting a language model in the chat interface. + */ +export class ModelPickerActionItem extends ActionWidgetDropdownActionViewItem { + constructor( + action: IAction, + private currentModel: ILanguageModelChatMetadataAndIdentifier, + delegate: IModelPickerDelegate, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IMenuService menuService: IMenuService, + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService, + @IChatEntitlementService chatEntitlementService: IChatEntitlementService, + @IKeybindingService keybindingService: IKeybindingService, + ) { + // Modify the original action with a different label and make it show the current model + const actionWithLabel: IAction = { + ...action, + label: currentModel.metadata.name, + tooltip: localize('chat.modelPicker.label', "Pick Model"), + run: () => { } + }; + + const modelPickerActionWidgetOptions: Omit = { + actionProvider: modelDelegateToWidgetActionsProvider(delegate), + actionBarActions: getModelPickerActionBarActions(menuService, contextKeyService, commandService, chatEntitlementService) + }; + + super(actionWithLabel, modelPickerActionWidgetOptions, actionWidgetService, keybindingService, contextKeyService); + + // Listen for model changes from the delegate + this._register(delegate.onDidChangeModel(model => { + this.currentModel = model; + if (this.element) { + this.renderLabel(this.element); + } + })); + } + + protected override renderLabel(element: HTMLElement): IDisposable | null { + this.setAriaLabelAttributes(element); + dom.reset(element, dom.$('span.chat-model-label', undefined, this.currentModel.metadata.name), ...renderLabelWithIcons(`$(chevron-down)`)); + return null; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('chat-modelPicker-item'); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/usePromptCommand.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/attachInstructionsCommand.ts similarity index 59% rename from src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/usePromptCommand.ts rename to src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/attachInstructionsCommand.ts index 87aa3636fe4..d6a8b99f892 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/usePromptCommand.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/attachInstructionsCommand.ts @@ -7,34 +7,32 @@ import { localize } from '../../../../../../nls.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CHAT_CATEGORY } from '../../actions/chatActions.js'; import { IChatWidget, IChatWidgetService } from '../../chat.js'; -import { KeyMod, KeyCode } from '../../../../../../base/common/keyCodes.js'; -import { PromptsConfig } from '../../../../../../platform/prompts/common/config.js'; -import { IViewsService } from '../../../../../services/views/common/viewsService.js'; -import { isPromptFile } from '../../../../../../platform/prompts/common/constants.js'; -import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IActiveCodeEditor, isCodeEditor, isDiffEditor } from '../../../../../../editor/browser/editorBrowser.js'; -import { KeybindingsRegistry, KeybindingWeight } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { IChatAttachPromptActionOptions, ATTACH_PROMPT_ACTION_ID } from '../../actions/chatAttachPromptAction/chatAttachPromptAction.js'; -import { MenuId, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; import { ChatContextKeys } from '../../../common/chatContextKeys.js'; +import { KeyMod, KeyCode } from '../../../../../../base/common/keyCodes.js'; +import { runAttachInstructionsAction } from '../../actions/promptActions/index.js'; +import { PromptsConfig } from '../../../../../../platform/prompts/common/config.js'; +import { INSTRUCTIONS_LANGUAGE_ID } from '../../../common/promptSyntax/constants.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { MenuId, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ICodeEditorService } from '../../../../../../editor/browser/services/codeEditorService.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; /** - * Command ID of the "Use Prompt" command. + * Command ID of the "Attach Instructions" command. */ -export const COMMAND_ID = 'workbench.command.prompts.use'; +export const INSTRUCTIONS_COMMAND_ID = 'workbench.command.instructions.attach'; /** - * Keybinding of the "Use Prompt" command. + * Keybinding of the "Use Instructions" command. * The `cmd + /` is the current keybinding for 'attachment', so we use - * the `alt` key modifier to convey the "prompt attachment" action. + * the `alt` key modifier to convey the "instructions attachment" action. */ -const COMMAND_KEY_BINDING = KeyMod.CtrlCmd | KeyCode.Slash | KeyMod.Alt; +const INSTRUCTIONS_COMMAND_KEY_BINDING = KeyMod.CtrlCmd | KeyCode.Slash | KeyMod.Alt; /** - * Implementation of the "Use Prompt" command. The command works in the following way. + * Implementation of the "Use Instructions" command. The command works in the following way. * * When executed, it tries to see if a `prompt file` was open in the active code editor * (see {@link IChatAttachPromptActionOptions.resource resource}), and if a chat input @@ -55,19 +53,15 @@ const command = async ( accessor: ServicesAccessor, ): Promise => { const commandService = accessor.get(ICommandService); - const viewsService = accessor.get(IViewsService); - const options: IChatAttachPromptActionOptions = { - resource: getActivePromptUri(accessor), + await runAttachInstructionsAction(commandService, { + resource: getActiveInstructionsFileUri(accessor), widget: getFocusedChatWidget(accessor), - viewsService, - }; - - await commandService.executeCommand(ATTACH_PROMPT_ACTION_ID, options); + }); }; /** - * Get chat widget reference to attach prompt to. + * Get chat widget reference to attach instructions to. */ export function getFocusedChatWidget(accessor: ServicesAccessor): IChatWidget | undefined { const chatWidgetService = accessor.get(IChatWidgetService); @@ -86,65 +80,37 @@ export function getFocusedChatWidget(accessor: ServicesAccessor): IChatWidget | } /** - * Gets active editor instance, if any. + * Gets `URI` of a instructions file open in an active editor instance, if any. */ -export function getActiveCodeEditor(accessor: ServicesAccessor): IActiveCodeEditor | undefined { - const editorService = accessor.get(IEditorService); - const { activeTextEditorControl } = editorService; - - if (isCodeEditor(activeTextEditorControl) && activeTextEditorControl.hasModel()) { - return activeTextEditorControl; - } - - if (isDiffEditor(activeTextEditorControl)) { - const originalEditor = activeTextEditorControl.getOriginalEditor(); - if (!originalEditor.hasModel()) { - return undefined; - } - - return originalEditor; - } - - return undefined; -} - -/** - * Gets `URI` of a prompt file open in an active editor instance, if any. - */ -const getActivePromptUri = ( +export const getActiveInstructionsFileUri = ( accessor: ServicesAccessor, ): URI | undefined => { - const activeEditor = getActiveCodeEditor(accessor); - if (!activeEditor) { - return undefined; + const codeEditorService = accessor.get(ICodeEditorService); + const model = codeEditorService.getActiveCodeEditor()?.getModel(); + if (model?.getLanguageId() === INSTRUCTIONS_LANGUAGE_ID) { + return model.uri; } - - const { uri } = activeEditor.getModel(); - if (isPromptFile(uri)) { - return uri; - } - return undefined; }; /** - * Register the "Use Prompt" command with its keybinding. + * Register the "Attach Instructions" command with its keybinding. */ KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: COMMAND_ID, + id: INSTRUCTIONS_COMMAND_ID, weight: KeybindingWeight.WorkbenchContrib, - primary: COMMAND_KEY_BINDING, + primary: INSTRUCTIONS_COMMAND_KEY_BINDING, handler: command, when: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), }); /** - * Register the "Use Prompt" command in the `command palette`. + * Register the "Use Instructions" command in the `command palette`. */ MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { - id: COMMAND_ID, - title: localize('commands.prompts.use.title', "Use Prompt"), + id: INSTRUCTIONS_COMMAND_ID, + title: localize('attach-instructions.capitalized.ellipses', "Attach Instructions..."), category: CHAT_CATEGORY }, when: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled) diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/createPromptCommand.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/createPromptCommand.ts index 6f73d7ee036..2a3e4c6cff9 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/createPromptCommand.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/createPromptCommand.ts @@ -3,72 +3,69 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isEqual } from '../../../../../../../base/common/resources.js'; +import { getCodeEditor } from '../../../../../../../editor/browser/editorBrowser.js'; +import { SnippetController2 } from '../../../../../../../editor/contrib/snippet/browser/snippetController2.js'; import { localize } from '../../../../../../../nls.js'; -import { createPromptFile } from './utils/createPromptFile.js'; -import { CHAT_CATEGORY } from '../../../actions/chatActions.js'; -import { askForPromptName } from './dialogs/askForPromptName.js'; -import { askForPromptSourceFolder } from './dialogs/askForPromptSourceFolder.js'; +import { MenuId, MenuRegistry } from '../../../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { IFileService } from '../../../../../../../platform/files/common/files.js'; +import { ServicesAccessor } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILabelService } from '../../../../../../../platform/label/common/label.js'; +import { ILogService } from '../../../../../../../platform/log/common/log.js'; +import { INotificationService, NeverShowAgainScope, Severity } from '../../../../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { PromptsConfig } from '../../../../../../../platform/prompts/common/config.js'; -import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; -import { IPromptPath, IPromptsService } from '../../../../common/promptSyntax/service/types.js'; import { IQuickInputService } from '../../../../../../../platform/quickinput/common/quickInput.js'; -import { ServicesAccessor } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IUserDataSyncEnablementService, SyncResource } from '../../../../../../../platform/userDataSync/common/userDataSync.js'; import { IWorkspaceContextService } from '../../../../../../../platform/workspace/common/workspace.js'; -import { KeybindingsRegistry, KeybindingWeight } from '../../../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { MenuId, MenuRegistry } from '../../../../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../../../../platform/contextkey/common/contextkey.js'; +import { IEditorService } from '../../../../../../services/editor/common/editorService.js'; +import { CONFIGURE_SYNC_COMMAND_ID } from '../../../../../../services/userDataSync/common/userDataSync.js'; +import { ISnippetsService } from '../../../../../snippets/browser/snippets.js'; import { ChatContextKeys } from '../../../../common/chatContextKeys.js'; - -/** - * Base command ID prefix. - */ -const BASE_COMMAND_ID = 'workbench.command.prompts.create'; - -/** - * Command ID for creating a 'local' prompt. - */ -const LOCAL_COMMAND_ID = `${BASE_COMMAND_ID}.local`; - -/** - * Command ID for creating a 'user' prompt. - */ -const USER_COMMAND_ID = `${BASE_COMMAND_ID}.user`; - -/** - * Title of the 'create local prompt' command. - */ -const LOCAL_COMMAND_TITLE = localize('commands.prompts.create.title.local', "Create Prompt"); - -/** - * Title of the 'create user prompt' command. - */ -const USER_COMMAND_TITLE = localize('commands.prompts.create.title.user', "Create User Prompt"); +import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../../common/promptSyntax/constants.js'; +import { IPromptsService, TPromptsType } from '../../../../common/promptSyntax/service/types.js'; +import { CHAT_CATEGORY } from '../../../actions/chatActions.js'; +import { askForPromptFileName } from './dialogs/askForPromptName.js'; +import { askForPromptSourceFolder } from './dialogs/askForPromptSourceFolder.js'; +import { createPromptFile } from './utils/createPromptFile.js'; /** * The command implementation. */ const command = async ( accessor: ServicesAccessor, - type: IPromptPath['type'], + type: TPromptsType, ): Promise => { + const logService = accessor.get(ILogService); const fileService = accessor.get(IFileService); const labelService = accessor.get(ILabelService); const openerService = accessor.get(IOpenerService); - const commandService = accessor.get(ICommandService); const promptsService = accessor.get(IPromptsService); + const commandService = accessor.get(ICommandService); const quickInputService = accessor.get(IQuickInputService); + const notificationService = accessor.get(INotificationService); const workspaceService = accessor.get(IWorkspaceContextService); + const userDataSyncEnablementService = accessor.get(IUserDataSyncEnablementService); + const snippetService = accessor.get(ISnippetsService); + const editorService = accessor.get(IEditorService); - const fileName = await askForPromptName(type, quickInputService); - if (!fileName) { - return; - } + + const placeHolder = (type === 'instructions') + ? localize( + 'workbench.command.instructions.create.location.placeholder', + "Select a location to create the instructions file in...", + ) + : localize( + 'workbench.command.prompt.create.location.placeholder', + "Select a location to create the prompt file in...", + ); const selectedFolder = await askForPromptSourceFolder({ - type: type, + type, + placeHolder, labelService, openerService, promptsService, @@ -80,70 +77,118 @@ const command = async ( return; } - const content = localize( - 'workbench.command.prompts.create.initial-content', - "Add prompt contents..", - ); + const fileName = await askForPromptFileName(type, quickInputService); + if (!fileName) { + return; + } + const promptUri = await createPromptFile({ fileName, - folder: selectedFolder, - content, + folder: selectedFolder.uri, + content: '', fileService, - commandService, + openerService, }); await openerService.open(promptUri); + + const editor = getCodeEditor(editorService.activeTextEditorControl); + if (editor && editor.hasModel() && isEqual(editor.getModel().uri, promptUri)) { + const languageId = type === 'instructions' ? INSTRUCTIONS_LANGUAGE_ID : PROMPT_LANGUAGE_ID; + + const snippets = await snippetService.getSnippets(languageId, { fileTemplateSnippets: true, noRecencySort: true, includeNoPrefixSnippets: true }); + if (snippets.length > 0) { + SnippetController2.get(editor)?.apply([{ + range: editor.getModel().getFullModelRange(), + template: snippets[0].body + }]); + } + } + + if (selectedFolder.storage !== 'user') { + return; + } + + // due to PII concerns, synchronization of the 'user' reusable prompts + // is disabled by default, but we want to make that fact clear to the user + // hence after a 'user' prompt is create, we check if the synchronization + // was explicitly configured before, and if it wasn't, we show a suggestion + // to enable the synchronization logic in the Settings Sync configuration + + const isConfigured = userDataSyncEnablementService + .isResourceEnablementConfigured(SyncResource.Prompts); + const isSettingsSyncEnabled = userDataSyncEnablementService.isEnabled(); + + // if prompts synchronization has already been configured before or + // if settings sync service is currently disabled, nothing to do + if ((isConfigured === true) || (isSettingsSyncEnabled === false)) { + return; + } + + // show suggestion to enable synchronization of the user prompts and instructions to the user + notificationService.prompt( + Severity.Info, + localize( + 'workbench.command.prompts.create.user.enable-sync-notification', + "User prompts and instructions are not currently synchronized. Do you want to enable synchronization of the user prompts and instructions?", + ), + [ + { + label: localize('enable.capitalized', "Enable"), + run: () => { + commandService.executeCommand(CONFIGURE_SYNC_COMMAND_ID) + .catch((error) => { + logService.error(`Failed to run '${CONFIGURE_SYNC_COMMAND_ID}' command: ${error}.`); + }); + }, + } + ], + { + neverShowAgain: { + id: 'workbench.command.prompts.create.user.enable-sync-notification', + scope: NeverShowAgainScope.PROFILE, + }, + }, + ); }; -/** - * Factory for creating the command handler with specific prompt `type`. - */ -const commandFactory = (type: 'local' | 'user') => { - return async (accessor: ServicesAccessor): Promise => { - return command(accessor, type); - }; -}; +function register(type: TPromptsType, id: string, title: string) { + /** + * Register the command. + */ + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id, + weight: KeybindingWeight.WorkbenchContrib, + handler: async (accessor: ServicesAccessor): Promise => { + return command(accessor, type); + }, + when: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), + }); -/** - * Register the "Create Prompt" command. - */ -KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: LOCAL_COMMAND_ID, - weight: KeybindingWeight.WorkbenchContrib, - handler: commandFactory('local'), - when: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), -}); + /** + * Register the command in the command palette. + */ + MenuRegistry.appendMenuItem(MenuId.CommandPalette, { + command: { + id, + title, + category: CHAT_CATEGORY + }, + when: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled) + }); +} -/** - * Register the "Create User Prompt" command. - */ -KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: USER_COMMAND_ID, - weight: KeybindingWeight.WorkbenchContrib, - handler: commandFactory('user'), - when: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled), -}); +export const NEW_PROMPT_COMMAND_ID = 'workbench.command.new.prompt'; +export const NEW_INSTRUCTIONS_COMMAND_ID = 'workbench.command.new.instructions'; -/** - * Register the "Create Prompt" command in the command palette. - */ -MenuRegistry.appendMenuItem(MenuId.CommandPalette, { - command: { - id: LOCAL_COMMAND_ID, - title: LOCAL_COMMAND_TITLE, - category: CHAT_CATEGORY - }, - when: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled) -}); +register( + 'instructions', + NEW_INSTRUCTIONS_COMMAND_ID, + localize('commands.new.instructions.local.title', "New Instructions File...") +); +register( + 'prompt', + NEW_PROMPT_COMMAND_ID, + localize('commands.new.prompt.local.title', "New Prompt File...") +); -/** - * Register the "Create User Prompt" command in the command palette. - */ -MenuRegistry.appendMenuItem(MenuId.CommandPalette, { - command: { - id: USER_COMMAND_ID, - title: USER_COMMAND_TITLE, - category: CHAT_CATEGORY, - }, - when: ContextKeyExpr.and(PromptsConfig.enabledCtx, ChatContextKeys.enabled) -}); diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/dialogs/askForPromptName.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/dialogs/askForPromptName.ts index cd412256291..494dcf0ce7e 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/dialogs/askForPromptName.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/dialogs/askForPromptName.ts @@ -4,25 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../../../../../nls.js'; -import { PROMPT_FILE_EXTENSION } from '../../../../../../../../platform/prompts/common/constants.js'; +import { TPromptsType } from '../../../../../common/promptSyntax/service/types.js'; +import { getPromptFileExtension } from '../../../../../../../../platform/prompts/common/constants.js'; import { IQuickInputService } from '../../../../../../../../platform/quickinput/common/quickInput.js'; /** - * Asks the user for a prompt name. + * Asks the user for a file name. */ -export const askForPromptName = async ( - _type: 'local' | 'user', +export const askForPromptFileName = async ( + type: TPromptsType, quickInputService: IQuickInputService, ): Promise => { - const result = await quickInputService.input( - { - placeHolder: localize( - 'commands.prompts.create.ask-name.placeholder', - "Provide a prompt name", - PROMPT_FILE_EXTENSION, - ), - }); + const placeHolder = (type === 'instructions') + ? localize('askForInstructionsFileName.placeholder', "Enter the name of the instructions file") + : localize('askForPromptFileName.placeholder', "Enter the name of the prompt file"); + const result = await quickInputService.input({ placeHolder }); if (!result) { return undefined; } @@ -32,9 +29,10 @@ export const askForPromptName = async ( return undefined; } - const cleanName = (trimmedName.endsWith(PROMPT_FILE_EXTENSION)) + const fileExtension = getPromptFileExtension(type); + const cleanName = (trimmedName.endsWith(fileExtension)) ? trimmedName - : `${trimmedName}${PROMPT_FILE_EXTENSION}`; + : `${trimmedName}${fileExtension}`; return cleanName; }; diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/dialogs/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/dialogs/askForPromptSourceFolder.ts index 2f446a81472..bf71ed3e107 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/dialogs/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/dialogs/askForPromptSourceFolder.ts @@ -6,22 +6,21 @@ import { localize } from '../../../../../../../../nls.js'; import { URI } from '../../../../../../../../base/common/uri.js'; import { WithUriValue } from '../../../../../../../../base/common/types.js'; -import { DOCUMENTATION_URL } from '../../../../../common/promptSyntax/constants.js'; import { basename, extUri } from '../../../../../../../../base/common/resources.js'; -import { IPromptsService } from '../../../../../common/promptSyntax/service/types.js'; import { ILabelService } from '../../../../../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../../../../../platform/opener/common/opener.js'; +import { PROMPT_DOCUMENTATION_URL } from '../../../../../common/promptSyntax/constants.js'; import { IWorkspaceContextService } from '../../../../../../../../platform/workspace/common/workspace.js'; +import { IPromptPath, IPromptsService, TPromptsType } from '../../../../../common/promptSyntax/service/types.js'; import { IPickOptions, IQuickInputService, IQuickPickItem } from '../../../../../../../../platform/quickinput/common/quickInput.js'; /** * Options for {@link askForPromptSourceFolder} dialog. */ interface IAskForFolderOptions { - /** - * Prompt type. - */ - readonly type: 'local' | 'user'; + + readonly type: TPromptsType; + readonly placeHolder: string; readonly labelService: ILabelService; readonly openerService: IOpenerService; @@ -30,14 +29,18 @@ interface IAskForFolderOptions { readonly workspaceService: IWorkspaceContextService; } +interface IFolderQuickPickItem extends IQuickPickItem { + readonly folder: IPromptPath; +} + /** * Asks the user for a specific prompt folder, if multiple folders provided. * Returns immediately if only one folder available. */ export const askForPromptSourceFolder = async ( options: IAskForFolderOptions, -): Promise => { - const { type, promptsService, quickInputService, labelService, openerService, workspaceService } = options; +): Promise => { + const { type, placeHolder, promptsService, quickInputService, labelService, openerService, workspaceService } = options; // get prompts source folders based on the prompt type const folders = promptsService.getSourceFolders(type); @@ -52,20 +55,31 @@ export const askForPromptSourceFolder = async ( // if there is only one folder, no need to ask // note! when we add more actions to the dialog, this will have to go if (folders.length === 1) { - return folders[0].uri; + return folders[0]; } - const pickOptions: IPickOptions> = { - placeHolder: localize( - 'commands.prompts.create.ask-folder.placeholder', - "Select a prompt source folder", - ), + const pickOptions: IPickOptions = { + placeHolder, canPickMany: false, matchOnDescription: true, }; // create list of source folder locations - const foldersList = folders.map(({ uri }): WithUriValue => { + const foldersList = folders.map(folder => { + const uri = folder.uri; + if (folder.storage === 'user') { + return { + type: 'item', + label: localize( + 'commands.prompts.create.source-folder.user', + "User Data Folder", + ), + description: labelService.getUriLabel(uri), + tooltip: uri.fsPath, + folder + }; + } + const { folders } = workspaceService.getWorkspace(); const isMultirootWorkspace = (folders.length > 1); @@ -79,7 +93,7 @@ export const askForPromptSourceFolder = async ( label: basename(uri), description: labelService.getUriLabel(uri, { relative: true }), tooltip: uri.fsPath, - value: uri, + folder, }; } @@ -94,7 +108,7 @@ export const askForPromptSourceFolder = async ( // use absolute path as the description description: labelService.getUriLabel(uri, { relative: false }), tooltip: uri.fsPath, - value: uri, + folder, }; }); @@ -103,7 +117,7 @@ export const askForPromptSourceFolder = async ( return; } - return answer.value; + return answer.folder; }; /** @@ -122,9 +136,9 @@ const showNoFoldersDialog = async ( 'commands.prompts.create.ask-folder.empty.docs-label', 'Learn how to configure reusable prompts', ), - description: DOCUMENTATION_URL, - tooltip: DOCUMENTATION_URL, - value: URI.parse(DOCUMENTATION_URL), + description: PROMPT_DOCUMENTATION_URL, + tooltip: PROMPT_DOCUMENTATION_URL, + value: URI.parse(PROMPT_DOCUMENTATION_URL), }; const result = await quickInputService.pick( diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/utils/createPromptFile.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/utils/createPromptFile.ts index 710637d2fdf..dbea375a77d 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/utils/createPromptFile.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/contributions/createPromptCommand/utils/createPromptFile.ts @@ -9,8 +9,8 @@ import { assert } from '../../../../../../../../base/common/assert.js'; import { VSBuffer } from '../../../../../../../../base/common/buffer.js'; import { dirname } from '../../../../../../../../base/common/resources.js'; import { IFileService } from '../../../../../../../../platform/files/common/files.js'; -import { ICommandService } from '../../../../../../../../platform/commands/common/commands.js'; -import { isPromptFile, PROMPT_FILE_EXTENSION } from '../../../../../../../../platform/prompts/common/constants.js'; +import { IOpenerService } from '../../../../../../../../platform/opener/common/opener.js'; +import { isPromptOrInstructionsFile, PROMPT_FILE_EXTENSION } from '../../../../../../../../platform/prompts/common/constants.js'; /** * Options for the {@link createPromptFile} utility. @@ -33,7 +33,7 @@ interface ICreatePromptFileOptions { readonly content: string; fileService: IFileService; - commandService: ICommandService; + openerService: IOpenerService; } /** @@ -47,12 +47,12 @@ interface ICreatePromptFileOptions { export const createPromptFile = async ( options: ICreatePromptFileOptions, ): Promise => { - const { fileName, folder, content, fileService, commandService } = options; + const { fileName, folder, content, fileService, openerService } = options; const promptUri = URI.joinPath(folder, fileName); assert( - isPromptFile(promptUri), + isPromptOrInstructionsFile(promptUri), new InvalidPromptName(fileName), ); @@ -67,7 +67,7 @@ export const createPromptFile = async ( ); // prompt file already exists so open it - await commandService.executeCommand('vscode.open', promptUri); + await openerService.open(promptUri); return promptUri; } diff --git a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts index d4ff4fb01c6..efd3a6b87e6 100644 --- a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts +++ b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts @@ -10,7 +10,7 @@ import { Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { IMarkdownRenderResult, MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; import { localize } from '../../../../../nls.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -111,6 +111,7 @@ export interface IChatViewWelcomeContent { icon?: ThemeIcon; title: string; message: IMarkdownString | ((disposables: DisposableStore) => HTMLElement); + additionalMessage?: string | IMarkdownString; tips?: IMarkdownString; } @@ -151,7 +152,6 @@ export class ChatViewWelcomePart extends Disposable { if (typeof content.message !== 'function' && options?.isWidgetAgentWelcomeViewContent) { const container = dom.append(this.element, $('.chat-welcome-view-indicator-container')); dom.append(container, $('.chat-welcome-view-subtitle', undefined, localize('agentModeSubtitle', "Agent Mode"))); - dom.append(container, $('.chat-welcome-view-indicator', undefined, localize('experimental', "EXPERIMENTAL"))); } // Message @@ -159,21 +159,19 @@ export class ChatViewWelcomePart extends Disposable { if (typeof content.message === 'function') { dom.append(message, content.message(this._register(new DisposableStore()))); } else { - const messageResult = this._register(renderer.render(content.message)); - const firstLink = options?.firstLinkToButton ? messageResult.element.querySelector('a') : undefined; - if (firstLink) { - const target = firstLink.getAttribute('data-href'); - const button = this._register(new Button(firstLink.parentElement!, defaultButtonStyles)); - button.label = firstLink.textContent ?? ''; - if (target) { - this._register(button.onDidClick(() => { - this.openerService.open(target, { allowCommands: true }); - })); - } - firstLink.replaceWith(button.element); - } - + const messageResult = this.renderMarkdownMessageContent(renderer, content.message, options); dom.append(message, messageResult.element); + + } + + // Additional message + if (typeof content.additionalMessage === 'string') { + const element = $(''); + element.textContent = content.additionalMessage; + dom.append(message, element); + } else if (content.additionalMessage) { + const additionalMessageResult = this.renderMarkdownMessageContent(renderer, content.additionalMessage, options); + dom.append(message, additionalMessageResult.element); } // Tips @@ -186,4 +184,21 @@ export class ChatViewWelcomePart extends Disposable { this.logService.error('Failed to render chat view welcome content', err); } } + + private renderMarkdownMessageContent(renderer: MarkdownRenderer, content: IMarkdownString, options: IChatViewWelcomeRenderOptions | undefined): IMarkdownRenderResult { + const messageResult = this._register(renderer.render(content)); + const firstLink = options?.firstLinkToButton ? messageResult.element.querySelector('a') : undefined; + if (firstLink) { + const target = firstLink.getAttribute('data-href'); + const button = this._register(new Button(firstLink.parentElement!, defaultButtonStyles)); + button.label = firstLink.textContent ?? ''; + if (target) { + this._register(button.onDidClick(() => { + this.openerService.open(target, { allowCommands: true }); + })); + } + firstLink.replaceWith(button.element); + } + return messageResult; + } } diff --git a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewsWelcome.ts b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewsWelcome.ts index e0e7383bf40..f1b94e924b6 100644 --- a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewsWelcome.ts +++ b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewsWelcome.ts @@ -5,7 +5,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -27,9 +27,9 @@ export interface IChatViewsWelcomeContributionRegistry { register(descriptor: IChatViewsWelcomeDescriptor): void; } -class ChatViewsWelcomeContributionRegistry implements IChatViewsWelcomeContributionRegistry { +class ChatViewsWelcomeContributionRegistry extends Disposable implements IChatViewsWelcomeContributionRegistry { private readonly descriptors: IChatViewsWelcomeDescriptor[] = []; - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = this._register(new Emitter()); public readonly onDidChange: Event = this._onDidChange.event; public register(descriptor: IChatViewsWelcomeDescriptor): void { diff --git a/src/vs/workbench/contrib/chat/common/annotations.ts b/src/vs/workbench/contrib/chat/common/annotations.ts index c06eda1fdf6..fa770db98d8 100644 --- a/src/vs/workbench/contrib/chat/common/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/annotations.ts @@ -57,7 +57,8 @@ export function annotateSpecialMarkdownContent(response: Iterable${item.uri.toString()}`; + const isEditText = item.isEdit ? ` isEdit` : ''; + const markdownText = `${item.uri.toString()}`; const merged = appendMarkdownString(previousItem.content, new MarkdownString(markdownText)); result[previousItemIndex] = { ...previousItem, content: merged }; } @@ -99,12 +100,15 @@ export function annotateVulnerabilitiesInText(response: ReadonlyArray(.*?)<\/vscode_codeblock_uri>/ms.exec(text); - if (match && match[1]) { - const result = URI.parse(match[1]); - const textWithoutResult = text.substring(0, match.index) + text.substring(match.index + match[0].length); - return { uri: result, textWithoutResult }; +export function extractCodeblockUrisFromText(text: string): { uri: URI; isEdit?: boolean; textWithoutResult: string } | undefined { + const match = /(.*?)<\/vscode_codeblock_uri>/ms.exec(text); + if (match) { + const [all, isEdit, uriString] = match; + if (uriString) { + const result = URI.parse(uriString); + const textWithoutResult = text.substring(0, match.index) + text.substring(match.index + all.length); + return { uri: result, textWithoutResult, isEdit: !!isEdit }; + } } return undefined; } diff --git a/src/vs/workbench/contrib/chat/common/chatAgents.ts b/src/vs/workbench/contrib/chat/common/chatAgents.ts index ce8309c498a..4237022ac05 100644 --- a/src/vs/workbench/contrib/chat/common/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/chatAgents.ts @@ -7,7 +7,7 @@ import { findLast } from '../../../../base/common/arraysFind.js'; import { timeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { IMarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; +import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { revive } from '../../../../base/common/marshalling.js'; @@ -24,7 +24,7 @@ import { IProductService } from '../../../../platform/product/common/productServ import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ChatContextKeys } from './chatContextKeys.js'; -import { IChatProgressHistoryResponseContent, IChatRequestVariableData, ISerializableChatAgentData } from './chatModel.js'; +import { IChatAgentEditedFileEvent, IChatProgressHistoryResponseContent, IChatRequestVariableData, ISerializableChatAgentData } from './chatModel.js'; import { IRawChatCommandContribution } from './chatParticipantContribTypes.js'; import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from './chatService.js'; import { ChatAgentLocation, ChatMode } from './constants.js'; @@ -42,6 +42,7 @@ export interface IChatAgentData { name: string; fullName?: string; description?: string; + /** This is string, not ContextKeyExpression, because dealing with serializing/deserializing is hard and need a better pattern for this */ when?: string; extensionId: ExtensionIdentifier; extensionPublisherId: string; @@ -50,13 +51,14 @@ export interface IChatAgentData { extensionDisplayName: string; /** The agent invoked when no agent is specified */ isDefault?: boolean; - /** The default agent when "agent-mode" is enabled */ - isToolsAgent?: boolean; /** This agent is not contributed in package.json, but is registered dynamically */ isDynamic?: boolean; + /** This agent is contributed from core and not from an extension */ + isCore?: boolean; metadata: IChatAgentMetadata; slashCommands: IChatAgentCommand[]; locations: ChatAgentLocation[]; + modes: ChatMode[]; disambiguation: { category: string; description: string; examples: string[] }[]; } @@ -66,18 +68,10 @@ export interface IChatWelcomeMessageContent { message: IMarkdownString; } -export function isChatWelcomeMessageContent(obj: any): obj is IChatWelcomeMessageContent { - return obj && - ThemeIcon.isThemeIcon(obj.icon) && - typeof obj.title === 'string' && - isMarkdownString(obj.message); -} - export interface IChatAgentImplementation { invoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; setRequestPaused?(requestId: string, isPaused: boolean): void; provideFollowups?(request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; - provideWelcomeMessage?(token: CancellationToken): ProviderResult; provideChatTitle?: (history: IChatAgentHistoryEntry[], token: CancellationToken) => Promise; provideSampleQuestions?(location: ChatAgentLocation, token: CancellationToken): ProviderResult; } @@ -116,7 +110,6 @@ export interface IChatAgentMetadata { helpTextPrefix?: string | IMarkdownString; helpTextVariablesPrefix?: string | IMarkdownString; helpTextPostfix?: string | IMarkdownString; - isSecondary?: boolean; // Invoked by ctrl/cmd+enter icon?: URI; iconDark?: URI; themeIcon?: ThemeIcon; @@ -125,7 +118,7 @@ export interface IChatAgentMetadata { followupPlaceholder?: string; isSticky?: boolean; requester?: IChatRequesterInformation; - welcomeMessageContent?: IChatWelcomeMessageContent; + additionalWelcomeMessage?: string | IMarkdownString; } @@ -145,6 +138,9 @@ export interface IChatAgentRequest { rejectedConfirmationData?: any[]; userSelectedModelId?: string; userSelectedTools?: string[]; + userSelectedTools2?: Record; + toolSelectionIsExclusive?: boolean; + editedFileEvents?: IChatAgentEditedFileEvent[]; } export interface IChatQuestion { @@ -217,7 +213,6 @@ export interface IChatAgentService { * Get the default agent data that has been contributed (may not be activated yet) */ getContributedDefaultAgent(location: ChatAgentLocation): IChatAgentData | undefined; - getSecondaryAgent(): IChatAgentData | undefined; updateAgent(id: string, updateMetadata: IChatAgentMetadata): void; } @@ -234,9 +229,10 @@ export class ChatAgentService extends Disposable implements IChatAgentService { private readonly _agentsContextKeys = new Set(); private readonly _hasDefaultAgent: IContextKey; + private readonly _extensionAgentRegistered: IContextKey; private readonly _defaultAgentRegistered: IContextKey; private readonly _editingAgentRegistered: IContextKey; - private readonly _hasToolsAgentContextKey: IContextKey; + private _hasToolsAgent = false; private _chatParticipantDetectionProviders = new Map(); @@ -245,6 +241,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService { ) { super(); this._hasDefaultAgent = ChatContextKeys.enabled.bindTo(this.contextKeyService); + this._extensionAgentRegistered = ChatContextKeys.extensionParticipantRegistered.bindTo(this.contextKeyService); this._defaultAgentRegistered = ChatContextKeys.panelParticipantRegistered.bindTo(this.contextKeyService); this._editingAgentRegistered = ChatContextKeys.editingParticipantRegistered.bindTo(this.contextKeyService); this._register(contextKeyService.onDidChangeContext((e) => { @@ -252,8 +249,6 @@ export class ChatAgentService extends Disposable implements IChatAgentService { this._updateContextKeys(); } })); - - this._hasToolsAgentContextKey = ChatContextKeys.Editing.hasToolsAgent.bindTo(contextKeyService); } registerAgent(id: string, data: IChatAgentData): IDisposable { @@ -299,23 +294,29 @@ export class ChatAgentService extends Disposable implements IChatAgentService { private _updateContextKeys(): void { let editingAgentRegistered = false; + let extensionAgentRegistered = false; let defaultAgentRegistered = false; let toolsAgentRegistered = false; for (const agent of this.getAgents()) { - if (agent.isDefault && agent.locations.includes(ChatAgentLocation.EditingSession)) { - editingAgentRegistered = true; - if (agent.isToolsAgent) { - toolsAgentRegistered = true; + if (agent.isDefault) { + if (!agent.isCore) { + extensionAgentRegistered = true; + } + if (agent.modes.includes(ChatMode.Agent)) { + toolsAgentRegistered = true; + } else if (agent.modes.includes(ChatMode.Edit)) { + editingAgentRegistered = true; + } else { + defaultAgentRegistered = true; } - } else if (agent.isDefault) { - defaultAgentRegistered = true; } } this._editingAgentRegistered.set(editingAgentRegistered); this._defaultAgentRegistered.set(defaultAgentRegistered); - if (toolsAgentRegistered !== this._hasToolsAgentContextKey.get()) { - this._hasToolsAgentContextKey.set(toolsAgentRegistered); - this._onDidChangeAgents.fire(this.getDefaultAgent(ChatAgentLocation.EditingSession)); + this._extensionAgentRegistered.set(extensionAgentRegistered); + if (toolsAgentRegistered !== this._hasToolsAgent) { + this._hasToolsAgent = toolsAgentRegistered; + this._onDidChangeAgents.fire(this.getDefaultAgent(ChatAgentLocation.Panel, ChatMode.Agent)); } } @@ -380,31 +381,30 @@ export class ChatAgentService extends Disposable implements IChatAgentService { this._onDidChangeAgents.fire(new MergedChatAgent(agent.data, agent.impl)); } - getDefaultAgent(location: ChatAgentLocation, mode?: ChatMode): IChatAgent | undefined { - if (mode === ChatMode.Edit || mode === ChatMode.Agent) { - location = ChatAgentLocation.EditingSession; - } - - return findLast(this.getActivatedAgents(), a => { - if ((mode === ChatMode.Agent) !== !!a.isToolsAgent) { + getDefaultAgent(location: ChatAgentLocation, mode: ChatMode = ChatMode.Ask): IChatAgent | undefined { + return this._preferExtensionAgent(this.getActivatedAgents().filter(a => { + if (mode && !a.modes.includes(mode)) { return false; } return !!a.isDefault && a.locations.includes(location); - }); + })); } public get hasToolsAgent(): boolean { - return !!this._hasToolsAgentContextKey.get(); + return !!this._hasToolsAgent; } getContributedDefaultAgent(location: ChatAgentLocation): IChatAgentData | undefined { - return this.getAgents().find(a => !!a.isDefault && a.locations.includes(location)); + return this._preferExtensionAgent(this.getAgents().filter(a => !!a.isDefault && a.locations.includes(location))); } - getSecondaryAgent(): IChatAgentData | undefined { - // TODO also static - return Iterable.find(this._agents.values(), a => !!a.data.metadata.isSecondary)?.data; + private _preferExtensionAgent(agents: T[]): T | undefined { + // We potentially have multiple agents on the same location, + // contributed from core and from extensions. + // This method will prefer the last extensions provided agent + // falling back to the last core agent if no extension agent is found. + return findLast(agents, agent => !agent.isCore) ?? agents.at(-1); } getAgent(id: string, includeDisabled = false): IChatAgentData | undefined { @@ -446,7 +446,16 @@ export class ChatAgentService extends Disposable implements IChatAgentService { } getAgentsByName(name: string): IChatAgentData[] { - return this.getAgents().filter(a => a.name === name); + return this._preferExtensionAgents(this.getAgents().filter(a => a.name === name)); + } + + private _preferExtensionAgents(agents: T[]): T[] { + // We potentially have multiple agents on the same location, + // contributed from core and from extensions. + // This method will prefer the extensions provided agents + // falling back to the original agents array extension agent is found. + const extensionAgents = agents.filter(a => !a.isCore); + return extensionAgents.length > 0 ? extensionAgents : agents; } agentHasDupeName(id: string): boolean { @@ -479,11 +488,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService { async getFollowups(id: string, request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { const data = this._agents.get(id); - if (!data?.impl) { - throw new Error(`No activated agent with id "${id}"`); - } - - if (!data.impl?.provideFollowups) { + if (!data?.impl?.provideFollowups) { return []; } @@ -574,10 +579,11 @@ export class MergedChatAgent implements IChatAgent { get extensionPublisherDisplayName() { return this.data.publisherDisplayName; } get extensionDisplayName(): string { return this.data.extensionDisplayName; } get isDefault(): boolean | undefined { return this.data.isDefault; } - get isToolsAgent(): boolean | undefined { return this.data.isToolsAgent; } + get isCore(): boolean | undefined { return this.data.isCore; } get metadata(): IChatAgentMetadata { return this.data.metadata; } get slashCommands(): IChatAgentCommand[] { return this.data.slashCommands; } get locations(): ChatAgentLocation[] { return this.data.locations; } + get modes(): ChatMode[] { return this.data.modes; } get disambiguation(): { category: string; description: string; examples: string[] }[] { return this.data.disambiguation; } async invoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { @@ -598,14 +604,6 @@ export class MergedChatAgent implements IChatAgent { return []; } - provideWelcomeMessage(token: CancellationToken): ProviderResult { - if (this.impl.provideWelcomeMessage) { - return this.impl.provideWelcomeMessage(token); - } - - return undefined; - } - provideSampleQuestions(location: ChatAgentLocation, token: CancellationToken): ProviderResult { if (this.impl.provideSampleQuestions) { return this.impl.provideSampleQuestions(location, token); @@ -699,6 +697,10 @@ export class ChatAgentNameService implements IChatAgentNameService { * Returns true if the agent is allowed to use this name */ getAgentNameRestriction(chatAgentData: IChatAgentData): boolean { + if (chatAgentData.isCore) { + return true; // core agents are always allowed to use any name + } + // TODO would like to use observables here but nothing uses it downstream and I'm not sure how to combine these two const nameAllowed = this.checkAgentNameRestriction(chatAgentData.name, chatAgentData).get(); const fullNameAllowed = !chatAgentData.fullName || this.checkAgentNameRestriction(chatAgentData.fullName.replace(/\s/g, ''), chatAgentData).get(); diff --git a/src/vs/workbench/contrib/chat/common/chatCodeMapperService.ts b/src/vs/workbench/contrib/chat/common/chatCodeMapperService.ts index e761fc19358..749850a7aee 100644 --- a/src/vs/workbench/contrib/chat/common/chatCodeMapperService.ts +++ b/src/vs/workbench/contrib/chat/common/chatCodeMapperService.ts @@ -24,6 +24,8 @@ export interface ICodeMapperCodeBlock { export interface ICodeMapperRequest { readonly codeBlocks: ICodeMapperCodeBlock[]; readonly chatRequestId?: string; + readonly chatRequestModel?: string; + readonly chatSessionId?: string; readonly location?: string; } diff --git a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts index 5ce22fda7b9..9129359e861 100644 --- a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts @@ -7,7 +7,7 @@ import { localize } from '../../../../nls.js'; import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { RemoteNameContext } from '../../../common/contextkeys.js'; -import { ChatAgentLocation, ChatConfiguration, ChatMode } from './constants.js'; +import { ChatAgentLocation, ChatMode } from './constants.js'; export namespace ChatContextKeys { export const responseVote = new RawContextKey('chatSessionResponseVote', '', { type: 'string', description: localize('interactiveSessionResponseVote', "When the response has been voted up, is set to 'up'. When voted down, is set to 'down'. Otherwise an empty string.") }); @@ -30,13 +30,13 @@ export namespace ChatContextKeys { export const inputHasFocus = new RawContextKey('chatInputHasFocus', false, { type: 'boolean', description: localize('interactiveInputHasFocus', "True when the chat input has focus.") }); export const inChatInput = new RawContextKey('inChatInput', false, { type: 'boolean', description: localize('inInteractiveInput', "True when focus is in the chat input, false otherwise.") }); export const inChatSession = new RawContextKey('inChat', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") }); - export const inUnifiedChat = new RawContextKey('inUnifiedChat', false, { type: 'boolean', description: localize('inUnifiedChat', "True when focus is in the unified chat widget, false otherwise.") }); - export const instructionsAttached = new RawContextKey('chatInstructionsAttached', false, { type: 'boolean', description: localize('chatInstructionsAttachedContextDescription', "True when the chat has a prompt instructions attached.") }); + export const hasPromptFile = new RawContextKey('chatPromptFileAttached', false, { type: 'boolean', description: localize('chatPromptFileAttachedContextDescription', "True when the chat has a prompt file attached.") }); export const chatMode = new RawContextKey('chatMode', ChatMode.Ask, { type: 'string', description: localize('chatMode', "The current chat mode.") }); - export const supported = ContextKeyExpr.or(IsWebContext.toNegated(), RemoteNameContext.notEqualsTo('')); // supported on desktop and in web only with a remote connection + export const supported = ContextKeyExpr.or(IsWebContext.negate(), RemoteNameContext.notEqualsTo('')); // supported on desktop and in web only with a remote connection export const enabled = new RawContextKey('chatIsEnabled', false, { type: 'boolean', description: localize('chatIsEnabled', "True when chat is enabled because a default chat participant is activated with an implementation.") }); + export const extensionParticipantRegistered = new RawContextKey('chatPanelExtensionParticipantRegistered', false, { type: 'boolean', description: localize('chatPanelExtensionParticipantRegistered', "True when a default chat participant is registered for the panel from an extension.") }); export const panelParticipantRegistered = new RawContextKey('chatPanelParticipantRegistered', false, { type: 'boolean', description: localize('chatParticipantRegistered', "True when a default chat participant is registered for the panel.") }); export const editingParticipantRegistered = new RawContextKey('chatEditingParticipantRegistered', false, { type: 'boolean', description: localize('chatEditingParticipantRegistered', "True when a default chat participant is registered for editing.") }); export const chatEditingCanUndo = new RawContextKey('chatEditingCanUndo', false, { type: 'boolean', description: localize('chatEditingCanUndo', "True when it is possible to undo an interaction in the editing panel.") }); @@ -52,8 +52,7 @@ export namespace ChatContextKeys { export const Setup = { hidden: new RawContextKey('chatSetupHidden', false, true), // True when chat setup is explicitly hidden. - installed: new RawContextKey('chatSetupInstalled', false, true), // True when the chat extension is installed. - fromDialog: ContextKeyExpr.has('config.chat.experimental.setupFromDialog'), + installed: new RawContextKey('chatSetupInstalled', false, true) // True when the chat extension is installed. }; export const Entitlement = { @@ -63,49 +62,20 @@ export namespace ChatContextKeys { pro: new RawContextKey('chatPlanPro', false, true) // True when user is a chat pro user. }; - export const SetupViewKeys = new Set([ChatContextKeys.Setup.hidden.key, ChatContextKeys.Setup.installed.key, ChatContextKeys.Entitlement.signedOut.key, ChatContextKeys.Entitlement.canSignUp.key, ...Setup.fromDialog.keys()]); - export const SetupViewCondition = ContextKeyExpr.or( - ContextKeyExpr.and( - ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.installed.negate(), - Setup.fromDialog.negate() - ), - ContextKeyExpr.and( - ChatContextKeys.Entitlement.canSignUp, - ChatContextKeys.Setup.installed - ), - ContextKeyExpr.and( - ChatContextKeys.Entitlement.signedOut, - ChatContextKeys.Setup.installed - ) - )!; - export const chatQuotaExceeded = new RawContextKey('chatQuotaExceeded', false, true); export const completionsQuotaExceeded = new RawContextKey('completionsQuotaExceeded', false, true); export const Editing = { - hasToolsAgent: new RawContextKey('chatHasToolsAgent', false, { type: 'boolean', description: localize('chatEditingHasToolsAgent', "True when a tools agent is registered.") }), agentModeDisallowed: new RawContextKey('chatAgentModeDisallowed', undefined, { type: 'boolean', description: localize('chatAgentModeDisallowed', "True when agent mode is not allowed.") }), // experiment-driven disablement hasToolConfirmation: new RawContextKey('chatHasToolConfirmation', false, { type: 'boolean', description: localize('chatEditingHasToolConfirmation', "True when a tool confirmation is present.") }), }; export const Tools = { - toolsCount: new RawContextKey('toolsCount', 0, { type: 'number', description: localize('toolsCount', "The count of tools available in the chat.") }) }; } export namespace ChatContextKeyExprs { - export const unifiedChatEnabled = ContextKeyExpr.has(`config.${ChatConfiguration.UnifiedChatView}`); - - export const inEditsOrUnified = ContextKeyExpr.or( - ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), - ChatContextKeys.inUnifiedChat); - - export const inNonUnifiedPanel = ContextKeyExpr.and( - ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), - ChatContextKeys.inUnifiedChat.negate()); - export const inEditingMode = ContextKeyExpr.or( ChatContextKeys.chatMode.isEqualTo(ChatMode.Edit), ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent), diff --git a/src/vs/workbench/contrib/chat/common/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/chatEditingService.ts index 0de480c9d88..e79982344b1 100644 --- a/src/vs/workbench/contrib/chat/common/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/chatEditingService.ts @@ -14,7 +14,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IEditorPane } from '../../../common/editor.js'; import { ICellEditOperation } from '../../notebook/common/notebookCommon.js'; -import { IChatResponseModel } from './chatModel.js'; +import { ChatModel, IChatResponseModel } from './chatModel.js'; export const IChatEditingService = createDecorator('chatEditingService'); @@ -22,7 +22,7 @@ export interface IChatEditingService { _serviceBrand: undefined; - startOrContinueGlobalEditingSession(chatSessionId: string): Promise; + startOrContinueGlobalEditingSession(chatModel: ChatModel): Promise; getEditingSession(chatSessionId: string): IChatEditingSession | undefined; @@ -34,7 +34,7 @@ export interface IChatEditingService { /** * Creates a new short lived editing session */ - createEditingSession(chatSessionId: string): Promise; + createEditingSession(chatModel: ChatModel): Promise; //#region related files @@ -65,7 +65,7 @@ export interface IChatRelatedFilesProvider { } export interface WorkingSetDisplayMetadata { - state: WorkingSetEntryState; + state: ModifiedFileEntryState; description?: string; } @@ -82,12 +82,11 @@ export const chatEditingSnapshotScheme = 'chat-editing-snapshot-text-model'; export interface IChatEditingSession extends IDisposable { readonly isGlobalEditingSession: boolean; readonly chatSessionId: string; - readonly onDidChange: Event; readonly onDidDispose: Event; readonly state: IObservable; readonly entries: IObservable; - show(): Promise; - remove(reason: WorkingSetEntryRemovalReason, ...uris: URI[]): void; + show(previousChanges?: boolean): Promise; + remove(...uris: URI[]): void; accept(...uris: URI[]): Promise; reject(...uris: URI[]): Promise; getEntry(uri: URI): IModifiedFileEntry | undefined; @@ -119,7 +118,7 @@ export interface IChatEditingSession extends IDisposable { * the next one. * @returns The observable or undefined if there is no diff between the stops. */ - getEntryDiffBetweenStops(uri: URI, requestId: string, stopId: string | undefined): IObservable | undefined; + getEntryDiffBetweenStops(uri: URI, requestId: string | undefined, stopId: string | undefined): IObservable | undefined; readonly canUndo: IObservable; readonly canRedo: IObservable; @@ -142,23 +141,10 @@ export interface IEditSessionEntryDiff { removed: number; } -export const enum WorkingSetEntryRemovalReason { - User, - Programmatic -} - -export const enum WorkingSetEntryState { +export const enum ModifiedFileEntryState { Modified, Accepted, Rejected, - Transient, // TODO@joyceerhl remove this - Attached, // TODO@joyceerhl remove this - Sent, // TODO@joyceerhl remove this -} - -export const enum ChatEditingSessionChangeType { - WorkingSet, - Other, } /** @@ -179,7 +165,7 @@ export interface IModifiedFileEntryEditorIntegration extends IDisposable { /** * Reveal the first (`true`) or last (`false`) change */ - reveal(firstOrLast: boolean): void; + reveal(firstOrLast: boolean, preserveFocus?: boolean): void; /** * Go to next change and increate `currentIndex` @@ -202,17 +188,19 @@ export interface IModifiedFileEntryEditorIntegration extends IDisposable { * Accept the change given or the nearest * @param change An opaque change object */ - acceptNearestChange(change: IModifiedFileEntryChangeHunk): void; + acceptNearestChange(change?: IModifiedFileEntryChangeHunk): Promise; /** * @see `acceptNearestChange` */ - rejectNearestChange(change: IModifiedFileEntryChangeHunk): void; + rejectNearestChange(change?: IModifiedFileEntryChangeHunk): Promise; /** * Toggle between diff-editor and normal editor + * @param change An opaque change object + * @param show Optional boolean to control if the diff should show */ - toggleDiff(change: IModifiedFileEntryChangeHunk | undefined): Promise; + toggleDiff(change: IModifiedFileEntryChangeHunk | undefined, show?: boolean): Promise; } export interface IModifiedFileEntry { @@ -222,8 +210,9 @@ export interface IModifiedFileEntry { readonly lastModifyingRequestId: string; - readonly state: IObservable; + readonly state: IObservable; readonly isCurrentlyBeingModifiedBy: IObservable; + readonly lastModifyingResponse: IObservable; readonly rewriteRatio: IObservable; accept(transaction: ITransaction | undefined): Promise; @@ -255,7 +244,7 @@ export const enum ChatEditingSessionState { export const CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME = 'chat-editing-multi-diff-source'; -export const chatEditingWidgetFileStateContextKey = new RawContextKey('chatEditingWidgetFileState', undefined, localize('chatEditingWidgetFileState', "The current state of the file in the chat editing widget")); +export const chatEditingWidgetFileStateContextKey = new RawContextKey('chatEditingWidgetFileState', undefined, localize('chatEditingWidgetFileState', "The current state of the file in the chat editing widget")); export const chatEditingAgentSupportsReadonlyReferencesContextKey = new RawContextKey('chatEditingAgentSupportsReadonlyReferences', undefined, localize('chatEditingAgentSupportsReadonlyReferences', "Whether the chat editing agent supports readonly references (temporary)")); export const decidedChatEditingResourceContextKey = new RawContextKey('decidedChatEditingResource', []); export const chatEditingResourceContextKey = new RawContextKey('chatEditingResource', undefined); @@ -281,9 +270,17 @@ export function isChatEditingActionContext(thing: unknown): thing is IChatEditin return typeof thing === 'object' && !!thing && 'sessionId' in thing; } -export function getMultiDiffSourceUri(session: IChatEditingSession): URI { +export function getMultiDiffSourceUri(session: IChatEditingSession, showPreviousChanges?: boolean): URI { return URI.from({ scheme: CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, authority: session.chatSessionId, + query: showPreviousChanges ? 'previous' : undefined, }); } + +export function parseChatMultiDiffUri(uri: URI): { chatSessionId: string; showPreviousChanges: boolean } { + const chatSessionId = uri.authority; + const showPreviousChanges = uri.query === 'previous'; + + return { chatSessionId, showPreviousChanges }; +} diff --git a/src/vs/workbench/contrib/chat/common/chatEntitlementService.ts b/src/vs/workbench/contrib/chat/common/chatEntitlementService.ts index ab6ffdb0324..94c1f8b4757 100644 --- a/src/vs/workbench/contrib/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/contrib/chat/common/chatEntitlementService.ts @@ -8,7 +8,7 @@ import { Barrier } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { IRequestContext } from '../../../../base/parts/request/common/request.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -21,8 +21,7 @@ import { IProductService } from '../../../../platform/product/common/productServ import { asText, IRequestService } from '../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService, TelemetryLevel } from '../../../../platform/telemetry/common/telemetry.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { AuthenticationSession, IAuthenticationExtensionsService, IAuthenticationService } from '../../../services/authentication/common/authentication.js'; +import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExtensionsService, IAuthenticationService } from '../../../services/authentication/common/authentication.js'; import { IWorkbenchExtensionEnablementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtension, IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; import { ChatContextKeys } from './chatContextKeys.js'; @@ -31,6 +30,8 @@ import { URI } from '../../../../base/common/uri.js'; import Severity from '../../../../base/common/severity.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { isWeb } from '../../../../base/common/platform.js'; +import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; +import { Mutable } from '../../../../base/common/types.js'; export const IChatEntitlementService = createDecorator('chatEntitlementService'); @@ -58,18 +59,6 @@ export enum ChatSentiment { Installed = 3 } -export interface IChatQuotas { - readonly chatQuotaExceeded: boolean; - readonly completionsQuotaExceeded: boolean; - readonly quotaResetDate: Date | undefined; - - readonly chatTotal?: number; - readonly completionsTotal?: number; - - readonly chatRemaining?: number; - readonly completionsRemaining?: number; -} - export interface IChatEntitlementService { _serviceBrand: undefined; @@ -81,7 +70,7 @@ export interface IChatEntitlementService { readonly onDidChangeQuotaExceeded: Event; readonly onDidChangeQuotaRemaining: Event; - readonly quotas: IChatQuotas; + readonly quotas: IQuotas; update(token: CancellationToken): Promise; @@ -108,7 +97,7 @@ const defaultChat = { interface IChatQuotasAccessor { clearQuotas(): void; - acceptQuotas(quotas: IChatQuotas): void; + acceptQuotas(quotas: IQuotas): void; } export class ChatEntitlementService extends Disposable implements IChatEntitlementService { @@ -194,7 +183,7 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme private readonly _onDidChangeQuotaRemaining = this._register(new Emitter()); readonly onDidChangeQuotaRemaining = this._onDidChangeQuotaRemaining.event; - private _quotas: IChatQuotas = { chatQuotaExceeded: false, completionsQuotaExceeded: false, quotaResetDate: undefined }; + private _quotas: IQuotas = {}; get quotas() { return this._quotas; } private readonly chatQuotaExceededContextKey: IContextKey; @@ -206,77 +195,61 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme }; private registerListeners(): void { - const chatQuotaExceededSet = new Set([this.ExtensionQuotaContextKeys.chatQuotaExceeded]); - const completionsQuotaExceededSet = new Set([this.ExtensionQuotaContextKeys.completionsQuotaExceeded]); + const quotaExceededSet = new Set([this.ExtensionQuotaContextKeys.chatQuotaExceeded, this.ExtensionQuotaContextKeys.completionsQuotaExceeded]); + const cts = this._register(new MutableDisposable()); this._register(this.contextKeyService.onDidChangeContext(e => { - let changed = false; - if (e.affectsSome(chatQuotaExceededSet)) { - const newChatQuotaExceeded = this.contextKeyService.getContextKeyValue(this.ExtensionQuotaContextKeys.chatQuotaExceeded); - if (typeof newChatQuotaExceeded === 'boolean' && newChatQuotaExceeded !== this._quotas.chatQuotaExceeded) { - this._quotas = { - ...this._quotas, - chatQuotaExceeded: newChatQuotaExceeded, - }; - changed = true; + if (e.affectsSome(quotaExceededSet)) { + if (cts.value) { + cts.value.cancel(); } - } - - if (e.affectsSome(completionsQuotaExceededSet)) { - const newCompletionsQuotaExceeded = this.contextKeyService.getContextKeyValue(this.ExtensionQuotaContextKeys.completionsQuotaExceeded); - if (typeof newCompletionsQuotaExceeded === 'boolean' && newCompletionsQuotaExceeded !== this._quotas.completionsQuotaExceeded) { - this._quotas = { - ...this._quotas, - completionsQuotaExceeded: newCompletionsQuotaExceeded, - }; - changed = true; - } - } - - if (changed) { - this.updateContextKeys(); - this._onDidChangeQuotaExceeded.fire(); + cts.value = new CancellationTokenSource(); + this.update(cts.value.token); } })); } - acceptQuotas(quotas: IChatQuotas): void { + acceptQuotas(quotas: IQuotas): void { const oldQuota = this._quotas; this._quotas = quotas; this.updateContextKeys(); - if ( - oldQuota.chatQuotaExceeded !== this._quotas.chatQuotaExceeded || - oldQuota.completionsQuotaExceeded !== this._quotas.completionsQuotaExceeded - ) { + const { changed: chatChanged } = this.compareQuotas(oldQuota.chat, quotas.chat); + const { changed: completionsChanged } = this.compareQuotas(oldQuota.completions, quotas.completions); + const { changed: premiumChatChanged } = this.compareQuotas(oldQuota.premiumChat, quotas.premiumChat); + + if (chatChanged.exceeded || completionsChanged.exceeded || premiumChatChanged.exceeded) { this._onDidChangeQuotaExceeded.fire(); } - if ( - oldQuota.chatRemaining !== this._quotas.chatRemaining || - oldQuota.completionsRemaining !== this._quotas.completionsRemaining - ) { + if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining) { this._onDidChangeQuotaRemaining.fire(); } } + private compareQuotas(oldQuota: IQuotaSnapshot | undefined, newQuota: IQuotaSnapshot | undefined): { changed: { exceeded: boolean; remaining: boolean } } { + return { + changed: { + exceeded: (oldQuota?.percentRemaining === 0) !== (newQuota?.percentRemaining === 0), + remaining: oldQuota?.percentRemaining !== newQuota?.percentRemaining + } + }; + } + clearQuotas(): void { - if (this.quotas.chatQuotaExceeded || this.quotas.completionsQuotaExceeded) { - this.acceptQuotas({ chatQuotaExceeded: false, completionsQuotaExceeded: false, quotaResetDate: undefined }); - } + this.acceptQuotas({}); } private updateContextKeys(): void { - this.chatQuotaExceededContextKey.set(this._quotas.chatQuotaExceeded); - this.completionsQuotaExceededContextKey.set(this._quotas.completionsQuotaExceeded); + this.chatQuotaExceededContextKey.set(this._quotas.chat?.percentRemaining === 0); + this.completionsQuotaExceededContextKey.set(this._quotas.completions?.percentRemaining === 0); } //#endregion //#region --- Sentiment - private readonly _onDidChangeSentiment = this._register(new Emitter()); - readonly onDidChangeSentiment = this._onDidChangeSentiment.event; + readonly onDidChangeSentiment: Event; get sentiment(): ChatSentiment { if (this.contextKeyService.getContextKeyValue(ChatContextKeys.Setup.installed.key) === true) { @@ -302,8 +275,9 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme type EntitlementClassification = { tid: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; comment: 'The anonymized analytics id returned by the service'; endpoint: 'GoogleAnalyticsId' }; entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating the chat entitlement state' }; - quotaChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat completions available to the user' }; - quotaCompletions: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat completions available to the user' }; + quotaChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat requests available to the user' }; + quotaPremiumChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of premium chat requests available to the user' }; + quotaCompletions: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of code completions available to the user' }; quotaResetDate: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The date the quota will reset' }; owner: 'bpasero'; comment: 'Reporting chat entitlements'; @@ -313,16 +287,21 @@ type EntitlementEvent = { entitlement: ChatEntitlement; tid: string; quotaChat: number | undefined; + quotaPremiumChat: number | undefined; quotaCompletions: number | undefined; quotaResetDate: string | undefined; }; -interface IEntitlementsResponse { - readonly access_type_sku: string; - readonly assigned_date: string; - readonly can_signup_for_limited: boolean; - readonly chat_enabled: boolean; - readonly analytics_tracking_id: string; +interface IQuotaSnapshotResponse { + readonly entitlement: number; + readonly overage_count: number; + readonly overage_permitted: boolean; + readonly percent_remaining: number; + readonly remaining: number; + readonly unlimited: boolean; +} + +interface ILegacyQuotaSnapshotResponse { readonly limited_user_quotas?: { readonly chat: number; readonly completions: number; @@ -331,7 +310,21 @@ interface IEntitlementsResponse { readonly chat: number; readonly completions: number; }; - readonly limited_user_reset_date: string; +} + +interface IEntitlementsResponse extends ILegacyQuotaSnapshotResponse { + readonly access_type_sku: string; + readonly assigned_date: string; + readonly can_signup_for_limited: boolean; + readonly chat_enabled: boolean; + readonly analytics_tracking_id: string; + readonly limited_user_reset_date?: string; // for Copilot Free + readonly quota_reset_date?: string; // for all other Copilot SKUs + readonly quota_snapshots?: { + chat?: IQuotaSnapshotResponse; + completions?: IQuotaSnapshotResponse; + premium_interactions?: IQuotaSnapshotResponse; + }; } interface IEntitlements { @@ -339,14 +332,21 @@ interface IEntitlements { readonly quotas?: IQuotas; } +export interface IQuotaSnapshot { + readonly total: number; + readonly percentRemaining: number; + + readonly overageEnabled: boolean; + readonly overageCount: number; + + readonly unlimited: boolean; +} + interface IQuotas { - readonly chatTotal?: number; - readonly completionsTotal?: number; - - readonly chatRemaining?: number; - readonly completionsRemaining?: number; - readonly resetDate?: string; + readonly chat?: IQuotaSnapshot; + readonly completions?: IQuotaSnapshot; + readonly premiumChat?: IQuotaSnapshot; } export class ChatEntitlementRequests extends Disposable { @@ -375,6 +375,7 @@ export class ChatEntitlementRequests extends Disposable { @IOpenerService private readonly openerService: IOpenerService, @IConfigurationService private readonly configurationService: IConfigurationService, @IAuthenticationExtensionsService private readonly authenticationExtensionsService: IAuthenticationExtensionsService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, ) { super(); @@ -465,8 +466,17 @@ export class ChatEntitlementRequests extends Disposable { } private async doGetSessions(providerId: string): Promise { + const preferredAccountName = this.authenticationExtensionsService.getAccountPreference(defaultChat.chatExtensionId, providerId) ?? this.authenticationExtensionsService.getAccountPreference(defaultChat.extensionId, providerId); + let preferredAccount: AuthenticationSessionAccount | undefined; + for (const account of await this.authenticationService.getAccounts(providerId)) { + if (account.label === preferredAccountName) { + preferredAccount = account; + break; + } + } + try { - return await this.authenticationService.getSessions(providerId); + return await this.authenticationService.getSessions(providerId, undefined, preferredAccount); } catch (error) { // ignore - errors can throw if a provider is not registered } @@ -510,7 +520,10 @@ export class ChatEntitlementRequests extends Disposable { if (response.res.statusCode && response.res.statusCode !== 200) { this.logService.trace(`[chat entitlement]: unexpected status code ${response.res.statusCode}`); - return { entitlement: ChatEntitlement.Unresolved }; + return ( + response.res.statusCode === 401 || // oauth token being unavailable (expired/revoked) + response.res.statusCode === 404 // missing scopes/permissions, service pretends the endpoint doesn't exist + ) ? { entitlement: ChatEntitlement.Unknown /* treat as signed out */ } : { entitlement: ChatEntitlement.Unresolved }; } let responseText: string | null = null; @@ -548,32 +561,82 @@ export class ChatEntitlementRequests extends Disposable { entitlement = ChatEntitlement.Unavailable; } - const chatRemaining = entitlementsResponse.limited_user_quotas?.chat; - const completionsRemaining = entitlementsResponse.limited_user_quotas?.completions; - const entitlements: IEntitlements = { entitlement, - quotas: { - chatTotal: entitlementsResponse.monthly_quotas?.chat, - completionsTotal: entitlementsResponse.monthly_quotas?.completions, - chatRemaining: typeof chatRemaining === 'number' ? Math.max(0, chatRemaining) : undefined, - completionsRemaining: typeof completionsRemaining === 'number' ? Math.max(0, completionsRemaining) : undefined, - resetDate: entitlementsResponse.limited_user_reset_date - } + quotas: this.toQuotas(entitlementsResponse) }; this.logService.trace(`[chat entitlement]: resolved to ${entitlements.entitlement}, quotas: ${JSON.stringify(entitlements.quotas)}`); this.telemetryService.publicLog2('chatInstallEntitlement', { entitlement: entitlements.entitlement, tid: entitlementsResponse.analytics_tracking_id, - quotaChat: entitlementsResponse.limited_user_quotas?.chat, - quotaCompletions: entitlementsResponse.limited_user_quotas?.completions, - quotaResetDate: entitlementsResponse.limited_user_reset_date + quotaChat: entitlementsResponse?.quota_snapshots?.chat?.remaining, + quotaPremiumChat: entitlementsResponse?.quota_snapshots?.premium_interactions?.remaining, + quotaCompletions: entitlementsResponse?.quota_snapshots?.completions?.remaining, + quotaResetDate: entitlementsResponse.quota_reset_date ?? entitlementsResponse.limited_user_reset_date }); return entitlements; } + private toQuotas(response: IEntitlementsResponse): IQuotas { + const quotas: Mutable = { + resetDate: response.quota_reset_date ?? response.limited_user_reset_date + }; + + // Legacy Free SKU Quota + if (response.monthly_quotas?.chat && typeof response.limited_user_quotas?.chat === 'number') { + quotas.chat = { + total: response.monthly_quotas.chat, + percentRemaining: Math.round((response.limited_user_quotas.chat / response.monthly_quotas.chat) * 100), + overageEnabled: false, + overageCount: 0, + unlimited: false + }; + } + + if (response.monthly_quotas?.completions && typeof response.limited_user_quotas?.completions === 'number') { + quotas.completions = { + total: response.monthly_quotas.completions, + percentRemaining: Math.round((response.limited_user_quotas.completions / response.monthly_quotas.completions) * 100), + overageEnabled: false, + overageCount: 0, + unlimited: false + }; + } + + // New Quota Snapshot + if (response.quota_snapshots) { + for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) { + const rawQuotaSnapshot = response.quota_snapshots[quotaType]; + if (!rawQuotaSnapshot) { + continue; + } + const quotaSnapshot: IQuotaSnapshot = { + total: rawQuotaSnapshot.entitlement, + percentRemaining: rawQuotaSnapshot.percent_remaining, + overageEnabled: rawQuotaSnapshot.overage_permitted, + overageCount: rawQuotaSnapshot.overage_count, + unlimited: rawQuotaSnapshot.unlimited + }; + + switch (quotaType) { + case 'chat': + quotas.chat = quotaSnapshot; + break; + case 'completions': + quotas.completions = quotaSnapshot; + break; + case 'premium_interactions': + quotas.premiumChat = quotaSnapshot; + break; + } + } + } + + return quotas; + } + private async request(url: string, type: 'GET', body: undefined, session: AuthenticationSession, token: CancellationToken): Promise; private async request(url: string, type: 'POST', body: object, session: AuthenticationSession, token: CancellationToken): Promise; private async request(url: string, type: 'GET' | 'POST', body: object | undefined, session: AuthenticationSession, token: CancellationToken): Promise { @@ -602,15 +665,7 @@ export class ChatEntitlementRequests extends Disposable { this.context.update({ entitlement: this.state.entitlement }); if (state.quotas) { - this.chatQuotasAccessor.acceptQuotas({ - chatQuotaExceeded: typeof state.quotas.chatRemaining === 'number' ? state.quotas.chatRemaining <= 0 : false, - completionsQuotaExceeded: typeof state.quotas.completionsRemaining === 'number' ? state.quotas.completionsRemaining <= 0 : false, - quotaResetDate: state.quotas.resetDate ? new Date(state.quotas.resetDate) : undefined, - chatTotal: state.quotas.chatTotal, - completionsTotal: state.quotas.completionsTotal, - chatRemaining: state.quotas.chatRemaining, - completionsRemaining: state.quotas.completionsRemaining - }); + this.chatQuotasAccessor.acceptQuotas(state.quotas); } } @@ -688,34 +743,40 @@ export class ChatEntitlementRequests extends Disposable { private async onUnknownSignUpError(detail: string, logMessage: string): Promise { this.logService.error(logMessage); - const { confirmed } = await this.dialogService.confirm({ - type: Severity.Error, - message: localize('unknownSignUpError', "An error occurred while signing up for Copilot Free. Would you like to try again?"), - detail, - primaryButton: localize('retry', "Retry") - }); + if (!this.lifecycleService.willShutdown) { + const { confirmed } = await this.dialogService.confirm({ + type: Severity.Error, + message: localize('unknownSignUpError', "An error occurred while signing up for the Copilot Free plan. Would you like to try again?"), + detail, + primaryButton: localize('retry', "Retry") + }); - return confirmed; + return confirmed; + } + + return false; } private onUnprocessableSignUpError(logMessage: string, logDetails: string): void { this.logService.error(logMessage); - this.dialogService.prompt({ - type: Severity.Error, - message: localize('unprocessableSignUpError', "An error occurred while signing up for Copilot Free."), - detail: logDetails, - buttons: [ - { - label: localize('ok', "OK"), - run: () => { /* noop */ } - }, - { - label: localize('learnMore', "Learn More"), - run: () => this.openerService.open(URI.parse(defaultChat.upgradePlanUrl)) - } - ] - }); + if (!this.lifecycleService.willShutdown) { + this.dialogService.prompt({ + type: Severity.Error, + message: localize('unprocessableSignUpError', "An error occurred while signing up for the Copilot Free plan."), + detail: logDetails, + buttons: [ + { + label: localize('ok', "OK"), + run: () => { /* noop */ } + }, + { + label: localize('learnMore', "Learn More"), + run: () => this.openerService.open(URI.parse(defaultChat.upgradePlanUrl)) + } + ] + }); + } } async signIn() { @@ -773,7 +834,6 @@ export class ChatEntitlementContext extends Disposable { constructor( @IContextKeyService contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, @ILogService private readonly logService: ILogService, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @@ -851,12 +911,6 @@ export class ChatEntitlementContext extends Disposable { private updateContextSync(): void { this.logService.trace(`[chat entitlement context] updateContext(): ${JSON.stringify(this._state)}`); - if (!this._state.hidden && !this._state.installed) { - // this is ugly but fixes flicker from a previous chat install - this.storageService.remove('chat.welcomeMessageContent.panel', StorageScope.APPLICATION); - this.storageService.remove('interactive.sessions', this.workspaceContextService.getWorkspace().folders.length ? StorageScope.WORKSPACE : StorageScope.APPLICATION); - } - this.signedOutContextKey.set(this._state.entitlement === ChatEntitlement.Unknown); this.canSignUpContextKey.set(this._state.entitlement === ChatEntitlement.Available); this.limitedContextKey.set(this._state.entitlement === ChatEntitlement.Limited); diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 9b276befbe8..42dd90f95db 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -9,10 +9,11 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; import { revive } from '../../../../base/common/marshalling.js'; import { Schemas } from '../../../../base/common/network.js'; import { equals } from '../../../../base/common/objects.js'; -import { IObservable, ITransaction, observableValue } from '../../../../base/common/observable.js'; +import { IObservable, ITransaction, ObservablePromise, observableValue } from '../../../../base/common/observable.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI, UriComponents, UriDto, isUriComponents } from '../../../../base/common/uri.js'; @@ -25,40 +26,62 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IMarker, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; import { CellUri, ICellEditOperation } from '../../notebook/common/notebookCommon.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentResult, IChatAgentService, reviveSerializedAgent } from './chatAgents.js'; +import { IChatEditingService, IChatEditingSession } from './chatEditingService.js'; import { ChatRequestTextPart, IParsedChatRequest, reviveParsedChatRequest } from './chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatTask, IChatTextEdit, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsedContext, IChatWarningMessage, isIUsedContext } from './chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatTask, IChatTextEdit, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsedContext, IChatWarningMessage, isIUsedContext } from './chatService.js'; import { IChatRequestVariableValue } from './chatVariables.js'; -import { ChatAgentLocation } from './constants.js'; +import { ChatAgentLocation, ChatMode } from './constants.js'; -export interface IBaseChatRequestVariableEntry { +interface IBaseChatRequestVariableEntry { id: string; fullName?: string; icon?: ThemeIcon; name: string; modelDescription?: string; + + /** + * The offset-range in the prompt. This means this entry has been explicitly typed out + * by the user. + */ range?: IOffsetRange; value: IChatRequestVariableValue; references?: IChatContentReference[]; - mimeType?: string; - // TODO these represent different kinds, should be extracted to new interfaces with kind tags - kind?: never; - isFile?: boolean; - isDirectory?: boolean; - isTool?: boolean; - isImage?: boolean; - isOmitted?: boolean; + omittedState?: OmittedState; } -export interface IChatRequestImplicitVariableEntry extends Omit { +export interface IGenericChatRequestVariableEntry extends IBaseChatRequestVariableEntry { + kind: 'generic'; +} + +export interface IChatRequestDirectoryEntry extends IBaseChatRequestVariableEntry { + kind: 'directory'; +} + +export interface IChatRequestFileEntry extends IBaseChatRequestVariableEntry { + kind: 'file'; +} + +export const enum OmittedState { + NotOmitted, + Partial, + Full, +} + +export interface IChatRequestToolEntry extends IBaseChatRequestVariableEntry { + readonly kind: 'tool'; +} + +export interface IChatRequestImplicitVariableEntry extends IBaseChatRequestVariableEntry { readonly kind: 'implicit'; readonly isFile: true; readonly value: URI | Location | undefined; readonly isSelection: boolean; + readonly isPromptFile: boolean; enabled: boolean; } -export interface IChatRequestPasteVariableEntry extends Omit { +export interface IChatRequestPasteVariableEntry extends IBaseChatRequestVariableEntry { readonly kind: 'paste'; code: string; language: string; @@ -74,25 +97,27 @@ export interface IChatRequestPasteVariableEntry extends Omit { +export interface ISymbolVariableEntry extends IBaseChatRequestVariableEntry { readonly kind: 'symbol'; readonly value: Location; readonly symbolKind: SymbolKind; } -export interface ICommandResultVariableEntry extends Omit { +export interface ICommandResultVariableEntry extends IBaseChatRequestVariableEntry { readonly kind: 'command'; } -export interface ILinkVariableEntry extends Omit { - readonly kind: 'link'; - readonly value: URI; -} - -export interface IImageVariableEntry extends Omit { +export interface IImageVariableEntry extends IBaseChatRequestVariableEntry { readonly kind: 'image'; readonly isPasted?: boolean; readonly isURL?: boolean; + readonly mimeType?: string; +} + +export interface INotebookOutputVariableEntry extends Omit { + readonly kind: 'notebookOutput'; + readonly outputIndex?: number; + readonly mimeType?: string; } export interface IDiagnosticVariableEntryFilterData { @@ -103,6 +128,16 @@ export interface IDiagnosticVariableEntryFilterData { readonly filterRange?: IRange; } +/** + * Chat variable that represents an attached prompt file. + */ +export interface IPromptVariableEntry extends IBaseChatRequestVariableEntry { + readonly kind: 'file'; + readonly value: URI | Location; + readonly isRoot: boolean; + readonly modelDescription: string; +} + export namespace IDiagnosticVariableEntryFilterData { export const icon = Codicon.error; @@ -121,8 +156,7 @@ export namespace IDiagnosticVariableEntryFilterData { name: label(data), icon, value: data, - kind: 'diagnostic' as const, - range: data.filterRange ? new OffsetRange(data.filterRange.startLineNumber, data.filterRange.endLineNumber) : undefined, + kind: 'diagnostic', ...data, }; } @@ -158,11 +192,17 @@ export namespace IDiagnosticVariableEntryFilterData { } } -export interface IDiagnosticVariableEntry extends Omit, IDiagnosticVariableEntryFilterData { +export interface IDiagnosticVariableEntry extends IBaseChatRequestVariableEntry, IDiagnosticVariableEntryFilterData { readonly kind: 'diagnostic'; } -export type IChatRequestVariableEntry = IChatRequestImplicitVariableEntry | IChatRequestPasteVariableEntry | ISymbolVariableEntry | ICommandResultVariableEntry | ILinkVariableEntry | IBaseChatRequestVariableEntry | IDiagnosticVariableEntry | IImageVariableEntry; +export interface IElementVariableEntry extends IBaseChatRequestVariableEntry { + readonly kind: 'element'; +} + +export type IChatRequestVariableEntry = IGenericChatRequestVariableEntry | IChatRequestImplicitVariableEntry | IChatRequestPasteVariableEntry + | ISymbolVariableEntry | ICommandResultVariableEntry | IDiagnosticVariableEntry | IImageVariableEntry | IChatRequestToolEntry + | IChatRequestDirectoryEntry | IChatRequestFileEntry | INotebookOutputVariableEntry | IElementVariableEntry; export function isImplicitVariableEntry(obj: IChatRequestVariableEntry): obj is IChatRequestImplicitVariableEntry { return obj.kind === 'implicit'; @@ -172,18 +212,26 @@ export function isPasteVariableEntry(obj: IChatRequestVariableEntry): obj is ICh return obj.kind === 'paste'; } -export function isLinkVariableEntry(obj: IChatRequestVariableEntry): obj is ILinkVariableEntry { - return obj.kind === 'link'; -} - export function isImageVariableEntry(obj: IChatRequestVariableEntry): obj is IImageVariableEntry { return obj.kind === 'image'; } +export function isNotebookOutputVariableEntry(obj: IChatRequestVariableEntry): obj is INotebookOutputVariableEntry { + return obj.kind === 'notebookOutput'; +} + +export function isElementVariableEntry(obj: IChatRequestVariableEntry): obj is IElementVariableEntry { + return obj.kind === 'element'; +} + export function isDiagnosticsVariableEntry(obj: IChatRequestVariableEntry): obj is IDiagnosticVariableEntry { return obj.kind === 'diagnostic'; } +export function isChatRequestFileEntry(obj: IChatRequestVariableEntry): obj is IChatRequestFileEntry { + return obj.kind === 'file'; +} + export function isChatRequestVariableEntry(obj: unknown): obj is IChatRequestVariableEntry { const entry = obj as IChatRequestVariableEntry; return typeof entry === 'object' && @@ -210,6 +258,7 @@ export interface IChatRequestModel { readonly attachedContext?: IChatRequestVariableEntry[]; readonly isCompleteAddedRequest: boolean; readonly response?: IChatResponseModel; + readonly editedFileEvents?: IChatAgentEditedFileEvent[]; shouldBeRemovedOnSend: IChatRequestDisablement | undefined; } @@ -260,7 +309,8 @@ export type IChatProgressHistoryResponseContent = | IChatTask | IChatTextEditGroup | IChatNotebookEditGroup - | IChatConfirmation; + | IChatConfirmation + | IChatExtensionsContent; /** * "Normal" progress kinds that are rendered as parts of the stream of content. @@ -338,18 +388,42 @@ export type ChatResponseModelChangeReason = const defaultChatResponseModelChangeReason: ChatResponseModelChangeReason = { reason: 'other' }; +export interface IChatRequestModelParameters { + session: ChatModel; + message: IParsedChatRequest; + variableData: IChatRequestVariableData; + timestamp: number; + attempt?: number; + confirmation?: string; + locationData?: IChatLocationData; + attachedContext?: IChatRequestVariableEntry[]; + isCompleteAddedRequest?: boolean; + modelId?: string; + restoredId?: string; + editedFileEvents?: IChatAgentEditedFileEvent[]; +} + export class ChatRequestModel implements IChatRequestModel { - - public response: ChatResponseModel | undefined; - public readonly id: string; + public response: ChatResponseModel | undefined; + public shouldBeRemovedOnSend: IChatRequestDisablement | undefined; + public readonly timestamp: number; + public readonly message: IParsedChatRequest; + public readonly isCompleteAddedRequest: boolean; + public readonly modelId?: string; - public get session() { + private _session: ChatModel; + private readonly _attempt: number; + private _variableData: IChatRequestVariableData; + private readonly _confirmation?: string; + private readonly _locationData?: IChatLocationData; + private readonly _attachedContext?: IChatRequestVariableEntry[]; + private readonly _editedFileEvents?: IChatAgentEditedFileEvent[]; + + public get session(): ChatModel { return this._session; } - public shouldBeRemovedOnSend: IChatRequestDisablement | undefined; - public get username(): string { return this.session.requesterUsername; } @@ -382,20 +456,23 @@ export class ChatRequestModel implements IChatRequestModel { return this._attachedContext; } - constructor( - private _session: ChatModel, - public readonly message: IParsedChatRequest, - private _variableData: IChatRequestVariableData, - public readonly timestamp: number, - private _attempt: number = 0, - private _confirmation?: string, - private _locationData?: IChatLocationData, - private _attachedContext?: IChatRequestVariableEntry[], - public readonly isCompleteAddedRequest = false, - restoredId?: string, - ) { - this.id = restoredId ?? 'request_' + generateUuid(); - // this.timestamp = Date.now(); + public get editedFileEvents(): IChatAgentEditedFileEvent[] | undefined { + return this._editedFileEvents; + } + + constructor(params: IChatRequestModelParameters) { + this._session = params.session; + this.message = params.message; + this._variableData = params.variableData; + this.timestamp = params.timestamp; + this._attempt = params.attempt ?? 0; + this._confirmation = params.confirmation; + this._locationData = params.locationData; + this._attachedContext = params.attachedContext; + this.isCompleteAddedRequest = params.isCompleteAddedRequest ?? false; + this.modelId = params.modelId; + this.id = params.restoredId ?? 'request_' + generateUuid(); + this._editedFileEvents = params.editedFileEvents; } adoptTo(session: ChatModel) { @@ -464,6 +541,7 @@ class AbstractResponse implements IResponse { case 'codeblockUri': case 'toolInvocation': case 'toolInvocationSerialized': + case 'extensions': case 'undoStop': // Ignore continue; @@ -659,12 +737,39 @@ export class Response extends AbstractResponse implements IDisposable { } } +export interface IChatResponseModelParameters { + responseContent: IMarkdownString | ReadonlyArray; + session: ChatModel; + agent?: IChatAgentData; + slashCommand?: IChatAgentCommand; + requestId: string; + isComplete?: boolean; + isCanceled?: boolean; + vote?: ChatAgentVoteDirection; + voteDownReason?: ChatAgentVoteDownReason; + result?: IChatAgentResult; + followups?: ReadonlyArray; + isCompleteAddedRequest?: boolean; + shouldBeRemovedOnSend?: IChatRequestDisablement; + restoredId?: string; +} export class ChatResponseModel extends Disposable implements IChatResponseModel { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; public readonly id: string; + public readonly requestId: string; + private _session: ChatModel; + private _agent: IChatAgentData | undefined; + private _slashCommand: IChatAgentCommand | undefined; + private _isComplete: boolean; + private _isCanceled: boolean; + private _vote?: ChatAgentVoteDirection; + private _voteDownReason?: ChatAgentVoteDownReason; + private _result?: IChatAgentResult; + private _shouldBeRemovedOnSend: IChatRequestDisablement | undefined; + public readonly isCompleteAddedRequest: boolean; public get session() { return this._session; @@ -785,31 +890,28 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel /** Functions run once the chat response is unpaused. */ private bufferedPauseContent?: (() => void)[]; - constructor( - _response: IMarkdownString | ReadonlyArray, - private _session: ChatModel, - private _agent: IChatAgentData | undefined, - private _slashCommand: IChatAgentCommand | undefined, - public readonly requestId: string, - private _isComplete: boolean = false, - private _isCanceled = false, - private _vote?: ChatAgentVoteDirection, - private _voteDownReason?: ChatAgentVoteDownReason, - private _result?: IChatAgentResult, - followups?: ReadonlyArray, - public readonly isCompleteAddedRequest = false, - private _shouldBeRemovedOnSend: IChatRequestDisablement | undefined = undefined, - restoredId?: string - ) { + constructor(params: IChatResponseModelParameters) { super(); - // If we are creating a response with some existing content, consider it stale - this._isStale = Array.isArray(_response) && (_response.length !== 0 || isMarkdownString(_response) && _response.value.length !== 0); + this._session = params.session; + this._agent = params.agent; + this._slashCommand = params.slashCommand; + this.requestId = params.requestId; + this._isComplete = params.isComplete ?? false; + this._isCanceled = params.isCanceled ?? false; + this._vote = params.vote; + this._voteDownReason = params.voteDownReason; + this._result = params.result; + this._followups = params.followups ? [...params.followups] : undefined; + this.isCompleteAddedRequest = params.isCompleteAddedRequest ?? false; + this._shouldBeRemovedOnSend = params.shouldBeRemovedOnSend; - this._followups = followups ? [...followups] : undefined; - this._response = this._register(new Response(_response)); + // If we are creating a response with some existing content, consider it stale + this._isStale = Array.isArray(params.responseContent) && (params.responseContent.length !== 0 || isMarkdownString(params.responseContent) && params.responseContent.value.length !== 0); + + this._response = this._register(new Response(params.responseContent)); this._register(this._response.onDidChangeValue(() => this._onDidChange.fire(defaultChatResponseModelChangeReason))); - this.id = restoredId ?? 'response_' + generateUuid(); + this.id = params.restoredId ?? 'response_' + generateUuid(); } /** @@ -952,6 +1054,8 @@ export interface IChatModel { readonly requestInProgress: boolean; readonly requestPausibility: ChatPauseState; readonly inputPlaceholder?: string; + readonly editingSessionObs?: ObservablePromise | undefined; + readonly editingSession?: IChatEditingSession | undefined; toggleLastRequestPaused(paused?: boolean): void; /** * Sets requests as 'disabled', removing them from the UI. If a request ID @@ -995,6 +1099,8 @@ export interface ISerializableChatRequestData { contentReferences?: ReadonlyArray; codeCitations?: ReadonlyArray; timestamp?: number; + confirmation?: string; + editedFileEvents?: IChatAgentEditedFileEvent[]; } export interface IExportableChatData { @@ -1083,6 +1189,10 @@ function normalizeOldFields(raw: ISerializableChatDataIn): void { raw.lastMessageDate = getLastYearDate(); } } + + if ((raw.initialLocation as any) === 'editing-session') { + raw.initialLocation = ChatAgentLocation.Panel; + } } function getLastYearDate(): number { @@ -1260,7 +1370,7 @@ export class ChatModel extends Disposable implements IChatModel { } private get _defaultAgent() { - return this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); + return this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel, ChatMode.Ask); } get requesterUsername(): string { @@ -1307,11 +1417,21 @@ export class ChatModel extends Disposable implements IChatModel { return this._initialLocation; } + private _editingSession: ObservablePromise | undefined; + get editingSessionObs(): ObservablePromise | undefined { + return this._editingSession; + } + + get editingSession(): IChatEditingSession | undefined { + return this._editingSession?.promiseResult.get()?.data; + } + constructor( private readonly initialData: ISerializableChatData | IExportableChatData | undefined, private readonly _initialLocation: ChatAgentLocation, @ILogService private readonly logService: ILogService, @IChatAgentService private readonly chatAgentService: IChatAgentService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, ) { super(); @@ -1331,6 +1451,30 @@ export class ChatModel extends Disposable implements IChatModel { this._initialResponderAvatarIconUri = isUriComponents(initialData?.responderAvatarIconUri) ? URI.revive(initialData.responderAvatarIconUri) : initialData?.responderAvatarIconUri; } + startEditingSession(isGlobalEditingSession?: boolean): void { + const editingSessionPromise = isGlobalEditingSession ? + this.chatEditingService.startOrContinueGlobalEditingSession(this) : + this.chatEditingService.createEditingSession(this); + this._editingSession = new ObservablePromise(editingSessionPromise); + this._editingSession.promise.then(editingSession => { + this._store.isDisposed ? editingSession.dispose() : this._register(editingSession); + }); + } + + private currentEditedFileEvents = new ResourceMap(); + notifyEditingAction(action: IChatEditingSessionAction): void { + const state = action.outcome === 'accepted' ? ChatRequestEditedFileEventKind.Keep : + action.outcome === 'rejected' ? ChatRequestEditedFileEventKind.Undo : + action.outcome === 'userModified' ? ChatRequestEditedFileEventKind.UserModification : null; + if (state === null) { + return; + } + + if (!this.currentEditedFileEvents.has(action.uri) || this.currentEditedFileEvents.get(action.uri)?.eventKind === ChatRequestEditedFileEventKind.Keep) { + this.currentEditedFileEvents.set(action.uri, { eventKind: state, uri: action.uri }); + } + } + private _deserialize(obj: IExportableChatData): ChatRequestModel[] { const requests = obj.requests; if (!Array.isArray(requests)) { @@ -1347,7 +1491,15 @@ export class ChatModel extends Disposable implements IChatModel { // Old messages don't have variableData, or have it in the wrong (non-array) shape const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData); - const request = new ChatRequestModel(this, parsedRequest, variableData, raw.timestamp ?? -1, undefined, undefined, undefined, undefined, undefined, raw.requestId); + const request = new ChatRequestModel({ + session: this, + message: parsedRequest, + variableData, + timestamp: raw.timestamp ?? -1, + restoredId: raw.requestId, + confirmation: raw.confirmation, + editedFileEvents: raw.editedFileEvents, + }); request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; if (raw.response || raw.result || (raw as any).responseErrorDetails) { const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format @@ -1357,7 +1509,20 @@ export class ChatModel extends Disposable implements IChatModel { const result = 'responseErrorDetails' in raw ? // eslint-disable-next-line local/code-no-dangerous-type-assertions { errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result; - request.response = new ChatResponseModel(raw.response ?? [new MarkdownString(raw.response)], this, agent, raw.slashCommand, request.id, true, raw.isCanceled, raw.vote, raw.voteDownReason, result, raw.followups, undefined, undefined, raw.responseId); + request.response = new ChatResponseModel({ + responseContent: raw.response ?? [new MarkdownString(raw.response)], + session: this, + agent, + slashCommand: raw.slashCommand, + requestId: request.id, + isComplete: true, + isCanceled: raw.isCanceled, + vote: raw.vote, + voteDownReason: raw.voteDownReason, + result, + followups: raw.followups, + restoredId: raw.responseId + }); request.response.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway? request.response.applyReference(revive(raw.usedContext)); @@ -1383,6 +1548,7 @@ export class ChatModel extends Disposable implements IChatModel { // Old variables format if (v && 'values' in v && Array.isArray(v.values)) { return { + kind: 'generic', id: v.id ?? '', name: v.name, value: v.values[0]?.value, @@ -1479,9 +1645,30 @@ export class ChatModel extends Disposable implements IChatModel { }); } - addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[], workingSet?: URI[], isCompleteAddedRequest?: boolean): ChatRequestModel { - const request = new ChatRequestModel(this, message, variableData, Date.now(), attempt, confirmation, locationData, attachments, isCompleteAddedRequest); - request.response = new ChatResponseModel([], this, chatAgent, slashCommand, request.id, undefined, undefined, undefined, undefined, undefined, undefined, isCompleteAddedRequest); + addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[], isCompleteAddedRequest?: boolean, modelId?: string): ChatRequestModel { + const editedFileEvents = [...this.currentEditedFileEvents.values()]; + this.currentEditedFileEvents.clear(); + const request = new ChatRequestModel({ + session: this, + message, + variableData, + timestamp: Date.now(), + attempt, + confirmation, + locationData, + attachedContext: attachments, + isCompleteAddedRequest, + modelId, + editedFileEvents: editedFileEvents.length ? editedFileEvents : undefined, + }); + request.response = new ChatResponseModel({ + responseContent: [], + session: this, + agent: chatAgent, + slashCommand, + requestId: request.id, + isCompleteAddedRequest + }); this._requests.push(request); this._lastMessageDate = Date.now(); @@ -1501,7 +1688,7 @@ export class ChatModel extends Disposable implements IChatModel { adoptRequest(request: ChatRequestModel): void { // this doesn't use `removeRequest` because it must not dispose the request object const oldOwner = request.session; - const index = oldOwner._requests.findIndex(candidate => candidate.id === request.id); + const index = oldOwner._requests.findIndex((candidate: ChatRequestModel) => candidate.id === request.id); if (index === -1) { return; @@ -1519,7 +1706,11 @@ export class ChatModel extends Disposable implements IChatModel { acceptResponseProgress(request: ChatRequestModel, progress: IChatProgress, quiet?: boolean): void { if (!request.response) { - request.response = new ChatResponseModel([], this, undefined, undefined, request.id); + request.response = new ChatResponseModel({ + responseContent: [], + session: this, + requestId: request.id + }); } if (request.response.isComplete) { @@ -1538,6 +1729,7 @@ export class ChatModel extends Disposable implements IChatModel { progress.kind === 'warning' || progress.kind === 'progressTask' || progress.kind === 'confirmation' || + progress.kind === 'extensions' || progress.kind === 'toolInvocation' ) { request.response.updateContent(progress, quiet); @@ -1573,7 +1765,11 @@ export class ChatModel extends Disposable implements IChatModel { setResponse(request: ChatRequestModel, result: IChatAgentResult): void { if (!request.response) { - request.response = new ChatResponseModel([], this, undefined, undefined, request.id); + request.response = new ChatResponseModel({ + responseContent: [], + session: this, + requestId: request.id + }); } request.response.setResult(result); @@ -1612,7 +1808,7 @@ export class ChatModel extends Disposable implements IChatModel { requests: this._requests.map((r): ISerializableChatRequestData => { const message = { ...r.message, - parts: r.message.parts.map(p => p && 'toJSON' in p ? (p.toJSON as Function)() : p) + parts: r.message.parts.map((p: any) => p && 'toJSON' in p ? (p.toJSON as Function)() : p) }; const agent = r.response?.agent; const agentJson = agent && 'toJSON' in agent ? (agent.toJSON as Function)() : @@ -1645,7 +1841,9 @@ export class ChatModel extends Disposable implements IChatModel { usedContext: r.response?.usedContext, contentReferences: r.response?.contentReferences, codeCitations: r.response?.codeCitations, - timestamp: r.timestamp + timestamp: r.timestamp, + confirmation: r.confirmation, + editedFileEvents: r.editedFileEvents, }; }), }; @@ -1724,3 +1922,14 @@ export function getCodeCitationsMessage(citations: ReadonlyArray(this, { progress: 0 }); + + constructor(preparedInvocation: IPreparedToolInvocation | undefined, toolData: IToolData, public readonly toolCallId: string) { const defaultMessage = localize('toolInvocationMessage', "Using {0}", `"${toolData.displayName}"`); const invocationMessage = preparedInvocation?.invocationMessage ?? defaultMessage; this.invocationMessage = invocationMessage; this.pastTenseMessage = preparedInvocation?.pastTenseMessage; + this.originMessage = preparedInvocation?.originMessage; this._confirmationMessages = preparedInvocation?.confirmationMessages; this.presentation = preparedInvocation?.presentation; this.toolSpecificData = preparedInvocation?.toolSpecificData; + this.toolId = toolData.id; if (!this._confirmationMessages) { // No confirmation needed @@ -82,16 +89,27 @@ export class ChatToolInvocation implements IChatToolInvocation { return this._confirmationMessages; } + public acceptProgress(step: IToolProgressStep) { + const prev = this.progress.get(); + this.progress.set({ + progress: step.increment ? (prev.progress + step.increment) : prev.progress, + message: step.message, + }, undefined); + } + public toJSON(): IChatToolInvocationSerialized { return { kind: 'toolInvocationSerialized', presentation: this.presentation, invocationMessage: this.invocationMessage, pastTenseMessage: this.pastTenseMessage, + originMessage: this.originMessage, isConfirmed: this._isConfirmed, isComplete: this._isComplete, resultDetails: this._resultDetails, toolSpecificData: this.toolSpecificData, + toolCallId: this.toolCallId, + toolId: this.toolId, }; } } diff --git a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts index 15afd91e0b2..cacaa8bff22 100644 --- a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts @@ -7,15 +7,16 @@ import { OffsetRange } from '../../../../editor/common/core/offsetRange.js'; import { IPosition, Position } from '../../../../editor/common/core/position.js'; import { Range } from '../../../../editor/common/core/range.js'; import { IChatAgentData, IChatAgentService } from './chatAgents.js'; -import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestToolPart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './chatParserTypes.js'; +import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './chatParserTypes.js'; import { IChatSlashCommandService } from './chatSlashCommands.js'; import { IChatVariablesService, IDynamicVariable } from './chatVariables.js'; import { ChatAgentLocation, ChatMode } from './constants.js'; -import { ILanguageModelToolsService } from './languageModelToolsService.js'; +import { IToolData } from './languageModelToolsService.js'; +import { IPromptsService } from './promptSyntax/service/types.js'; const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent const variableReg = /^#([\w_\-]+)(:\d+)?(?=(\s|$|\b))/i; // A #-variable with an optional numeric : arg (@response:2) -const slashReg = /\/([\w_\-]+)(?=(\s|$|\b))/i; // A / command +const slashReg = /\/([\w_\-\.:]+)(?=(\s|$|\b))/i; // A / command export interface IChatParserContext { /** Used only as a disambiguator, when the query references an agent that has a duplicate with the same name. */ @@ -28,12 +29,13 @@ export class ChatRequestParser { @IChatAgentService private readonly agentService: IChatAgentService, @IChatVariablesService private readonly variableService: IChatVariablesService, @IChatSlashCommandService private readonly slashCommandService: IChatSlashCommandService, - @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, + @IPromptsService private readonly promptsService: IPromptsService, ) { } parseChatRequest(sessionId: string, message: string, location: ChatAgentLocation = ChatAgentLocation.Panel, context?: IChatParserContext): IParsedChatRequest { const parts: IParsedChatRequestPart[] = []; const references = this.variableService.getDynamicVariables(sessionId); // must access this list before any async calls + const toolsByName = new Map((this.variableService.getSelectedTools(sessionId)).filter(t => t.toolReferenceName).map(t => [t.toolReferenceName!, t])); let lineNumber = 1; let column = 1; @@ -43,7 +45,7 @@ export class ChatRequestParser { let newPart: IParsedChatRequestPart | undefined; if (previousChar.match(/\s/) || i === 0) { if (char === chatVariableLeader) { - newPart = this.tryToParseVariable(message.slice(i), i, new Position(lineNumber, column), parts); + newPart = this.tryToParseVariable(message.slice(i), i, new Position(lineNumber, column), parts, toolsByName); } else if (char === chatAgentLeader) { newPart = this.tryToParseAgent(message.slice(i), message, i, new Position(lineNumber, column), parts, location, context); } else if (char === chatSubcommandLeader) { @@ -141,7 +143,7 @@ export class ChatRequestParser { return new ChatRequestAgentPart(agentRange, agentEditorRange, agent); } - private tryToParseVariable(message: string, offset: number, position: IPosition, parts: ReadonlyArray): ChatRequestAgentPart | ChatRequestToolPart | undefined { + private tryToParseVariable(message: string, offset: number, position: IPosition, parts: ReadonlyArray, toolsByName: ReadonlyMap): ChatRequestAgentPart | ChatRequestToolPart | undefined { const nextVariableMatch = message.match(variableReg); if (!nextVariableMatch) { return; @@ -151,7 +153,7 @@ export class ChatRequestParser { const varRange = new OffsetRange(offset, offset + full.length); const varEditorRange = new Range(position.lineNumber, position.column, position.lineNumber, position.column + full.length); - const tool = this.toolsService.getToolByName(name); + const tool = toolsByName.get(name); if (tool && tool.canBeReferencedInPrompt) { return new ChatRequestToolPart(varRange, varEditorRange, name, tool.id, tool.displayName, tool.icon); } @@ -159,7 +161,7 @@ export class ChatRequestParser { return; } - private tryToParseSlashCommand(remainingMessage: string, fullMessage: string, offset: number, position: IPosition, parts: ReadonlyArray, location: ChatAgentLocation, context?: IChatParserContext): ChatRequestSlashCommandPart | ChatRequestAgentSubcommandPart | undefined { + private tryToParseSlashCommand(remainingMessage: string, fullMessage: string, offset: number, position: IPosition, parts: ReadonlyArray, location: ChatAgentLocation, context?: IChatParserContext): ChatRequestSlashCommandPart | ChatRequestAgentSubcommandPart | ChatRequestSlashPromptPart | undefined { const nextSlashMatch = remainingMessage.match(slashReg); if (!nextSlashMatch) { return; @@ -194,7 +196,7 @@ export class ChatRequestParser { return new ChatRequestAgentSubcommandPart(slashRange, slashEditorRange, subCommand); } } else { - const slashCommands = this.slashCommandService.getCommands(location); + const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatMode.Ask); const slashCommand = slashCommands.find(c => c.command === command); if (slashCommand) { // Valid standalone slash command @@ -208,8 +210,13 @@ export class ChatRequestParser { return new ChatRequestAgentSubcommandPart(slashRange, slashEditorRange, subCommand); } } - } + // if there's no agent, check if it's a prompt command + const promptCommand = this.promptsService.asPromptSlashCommand(command); + if (promptCommand) { + return new ChatRequestSlashPromptPart(slashRange, slashEditorRange, promptCommand); + } + } return; } diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index f05a1429e0c..e2a930da330 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -7,6 +7,7 @@ import { DeferredPromise } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { IObservable } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { IRange, Range } from '../../../../editor/common/core/range.js'; @@ -169,6 +170,7 @@ export interface IChatAgentVulnerabilityDetails { export interface IChatResponseCodeblockUriPart { kind: 'codeblockUri'; uri: URI; + isEdit?: boolean; } export interface IChatAgentMarkdownContentWithVulnerability { @@ -217,17 +219,26 @@ export interface IChatTerminalToolInvocationData { language: string; } +export interface IChatToolInputInvocationData { + kind: 'input'; + rawInput: any; +} + export interface IChatToolInvocation { presentation: IPreparedToolInvocation['presentation']; - toolSpecificData?: IChatTerminalToolInvocationData; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData; /** Presence of this property says that confirmation is required */ confirmationMessages?: IToolConfirmationMessages; confirmed: DeferredPromise; /** A 3-way: undefined=don't know yet. */ isConfirmed: boolean | undefined; + originMessage: string | IMarkdownString | undefined; invocationMessage: string | IMarkdownString; pastTenseMessage: string | IMarkdownString | undefined; resultDetails: IToolResult['toolResultDetails']; + progress: IObservable<{ message?: string | IMarkdownString; progress: number }>; + readonly toolId: string; + readonly toolCallId: string; isCompletePromise: Promise; isComplete: boolean; @@ -240,15 +251,23 @@ export interface IChatToolInvocation { */ export interface IChatToolInvocationSerialized { presentation: IPreparedToolInvocation['presentation']; - toolSpecificData?: IChatTerminalToolInvocationData; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData; invocationMessage: string | IMarkdownString; + originMessage: string | IMarkdownString | undefined; pastTenseMessage: string | IMarkdownString | undefined; resultDetails: IToolResult['toolResultDetails']; isConfirmed: boolean | undefined; isComplete: boolean; + toolCallId: string; + toolId: string; kind: 'toolInvocationSerialized'; } +export interface IChatExtensionsContent { + extensions: string[]; + kind: 'extensions'; +} + export type IChatProgress = | IChatMarkdownContent | IChatAgentMarkdownContentWithVulnerability @@ -269,6 +288,7 @@ export type IChatProgress = | IChatConfirmation | IChatToolInvocation | IChatToolInvocationSerialized + | IChatExtensionsContent | IChatUndoStop; export interface IChatFollowup { @@ -365,7 +385,7 @@ export interface IChatEditingSessionAction { kind: 'chatEditingSessionAction'; uri: URI; hasRemainingEdits: boolean; - outcome: 'accepted' | 'rejected' | 'saved'; + outcome: 'accepted' | 'rejected' | 'userModified'; } export type ChatUserAction = IChatVoteAction | IChatCopyAction | IChatInsertAction | IChatApplyAction | IChatTerminalAction | IChatCommandAction | IChatFollowupAction | IChatBugReportAction | IChatInlineChatCodeAction | IChatEditingSessionAction; @@ -448,6 +468,8 @@ export interface IChatSendRequestOptions { mode?: ChatMode; userSelectedModelId?: string; userSelectedTools?: string[]; + userSelectedTools2?: Record; + toolSelectionIsExclusive?: boolean; location?: ChatAgentLocation; locationData?: IChatLocationData; parserContext?: IChatParserContext; @@ -465,11 +487,6 @@ export interface IChatSendRequestOptions { * The label of the confirmation action that was selected. */ confirmation?: string; - - /** - * Flag to indicate whether a prompt instructions attachment is present. - */ - hasInstructionAttachments?: boolean; } export const IChatService = createDecorator('IChatService'); @@ -482,9 +499,10 @@ export interface IChatService { isEnabled(location: ChatAgentLocation): boolean; hasSessions(): boolean; - startSession(location: ChatAgentLocation, token: CancellationToken): ChatModel; + startSession(location: ChatAgentLocation, token: CancellationToken, isGlobalEditingSession?: boolean): ChatModel; getSession(sessionId: string): IChatModel | undefined; getOrRestoreSession(sessionId: string): Promise; + isPersistedSessionEmpty(sessionId: string): boolean; loadSessionFromContent(data: IExportableChatData | ISerializableChatData): IChatModel | undefined; /** @@ -511,8 +529,9 @@ export interface IChatService { transferChatSession(transferredSessionData: IChatTransferredSessionData, toWorkspace: URI): void; - readonly unifiedViewEnabled: boolean; - isEditingLocation(location: ChatAgentLocation): boolean; + activateDefaultAgent(location: ChatAgentLocation): Promise; + + readonly edits2Enabled: boolean; } export const KEYWORD_ACTIVIATION_SETTING_ID = 'accessibility.voice.keywordActivation'; diff --git a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts index 3e01942ceb6..1c93225df81 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -27,14 +27,14 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IChatAgent, IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from './chatAgents.js'; -import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData, toChatHistoryContent, updateRanges } from './chatModel.js'; +import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatRequestVariableEntry, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, isImageVariableEntry, normalizeSerializableChatData, toChatHistoryContent, updateRanges } from './chatModel.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, IParsedChatRequest, chatAgentLeader, chatSubcommandLeader, getPromptText } from './chatParserTypes.js'; import { ChatRequestParser } from './chatRequestParser.js'; import { IChatCompleteResponse, IChatDetail, IChatFollowup, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatTransferredSessionData, IChatUserActionEvent } from './chatService.js'; import { ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { ChatSessionStore, IChatTransfer2 } from './chatSessionStore.js'; import { IChatSlashCommandService } from './chatSlashCommands.js'; -import { IChatVariablesService } from './chatVariables.js'; +import { IChatTransferService } from './chatTransferService.js'; import { ChatAgentLocation, ChatConfiguration, ChatMode } from './constants.js'; import { ChatMessageRole, IChatMessage } from './languageModels.js'; import { ILanguageModelToolsService } from './languageModelToolsService.js'; @@ -131,16 +131,15 @@ export class ChatService extends Disposable implements IChatService { private readonly _chatServiceTelemetry: ChatServiceTelemetry; private readonly _chatSessionStore: ChatSessionStore; - @memoize - public get unifiedViewEnabled(): boolean { - return !!this.configurationService.getValue(ChatConfiguration.UnifiedChatView); - } - @memoize private get useFileStorage(): boolean { return this.configurationService.getValue(ChatConfiguration.UseFileStorage); } + public get edits2Enabled(): boolean { + return this.configurationService.getValue(ChatConfiguration.Edits2Enabled); + } + private get isEmptyWindow(): boolean { const workspace = this.workspaceContextService.getWorkspace(); return !workspace.configuration && workspace.folders.length === 0; @@ -154,10 +153,10 @@ export class ChatService extends Disposable implements IChatService { @ITelemetryService private readonly telemetryService: ITelemetryService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, - @IChatVariablesService private readonly chatVariablesService: IChatVariablesService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, + @IChatTransferService private readonly chatTransferService: IChatTransferService, ) { super(); @@ -201,7 +200,7 @@ export class ChatService extends Disposable implements IChatService { private saveState(): void { const liveChats = Array.from(this._sessionModels.values()) - .filter(session => session.initialLocation === ChatAgentLocation.Panel || session.initialLocation === ChatAgentLocation.EditingSession); + .filter(session => session.initialLocation === ChatAgentLocation.Panel); if (this.useFileStorage) { this._chatSessionStore.storeSessions(liveChats); @@ -216,23 +215,6 @@ export class ChatService extends Disposable implements IChatService { .filter(session => session.requests.length)); allSessions.sort((a, b) => (b.creationDate ?? 0) - (a.creationDate ?? 0)); - // Only keep one persisted edit session, the latest one. This would be the current one if it's live. - // No way to know whether it's currently live or if it has been cleared and there is no current session. - // But ensure that we don't store multiple edit sessions. - let hasPersistedEditSession = false; - allSessions = allSessions.filter(s => { - if (s.initialLocation === ChatAgentLocation.EditingSession) { - if (hasPersistedEditSession) { - return false; - } else { - hasPersistedEditSession = true; - return true; - } - } - - return true; - }); - allSessions = allSessions.slice(0, maxPersistedSessions); if (allSessions.length) { @@ -306,6 +288,12 @@ export class ChatService extends Disposable implements IChatService { notifyUserAction(action: IChatUserActionEvent): void { this._chatServiceTelemetry.notifyUserAction(action); this._onDidPerformUserAction.fire(action); + if (action.action.kind === 'chatEditingSessionAction') { + const model = this._sessionModels.get(action.sessionId); + if (model) { + model.notifyEditingAction(action.action); + } + } } async setChatSessionTitle(sessionId: string, title: string): Promise { @@ -395,7 +383,7 @@ export class ChatService extends Disposable implements IChatService { async getHistory(): Promise { if (this.useFileStorage) { const liveSessionItems = Array.from(this._sessionModels.values()) - .filter(session => !session.isImported && session.initialLocation !== ChatAgentLocation.EditingSession) + .filter(session => !session.isImported) .map(session => { const title = session.title || localize('newChat', "New Chat"); return { @@ -421,7 +409,7 @@ export class ChatService extends Disposable implements IChatService { .filter(session => !this._sessionModels.has(session.sessionId)); const persistedSessionItems = persistedSessions - .filter(session => !session.isImported && session.initialLocation !== ChatAgentLocation.EditingSession) + .filter(session => !session.isImported) .map(session => { const title = session.customTitle ?? ChatModel.getDefaultTitle(session.requests); return { @@ -432,7 +420,7 @@ export class ChatService extends Disposable implements IChatService { } satisfies IChatDetail; }); const liveSessionItems = Array.from(this._sessionModels.values()) - .filter(session => !session.isImported && session.initialLocation !== ChatAgentLocation.EditingSession) + .filter(session => !session.isImported) .map(session => { const title = session.title || localize('newChat', "New Chat"); return { @@ -469,13 +457,17 @@ export class ChatService extends Disposable implements IChatService { this.saveState(); } - startSession(location: ChatAgentLocation, token: CancellationToken): ChatModel { + startSession(location: ChatAgentLocation, token: CancellationToken, isGlobalEditingSession: boolean = true): ChatModel { this.trace('startSession'); - return this._startSession(undefined, location, token); + return this._startSession(undefined, location, isGlobalEditingSession, token); } - private _startSession(someSessionHistory: IExportableChatData | ISerializableChatData | undefined, location: ChatAgentLocation, token: CancellationToken): ChatModel { + private _startSession(someSessionHistory: IExportableChatData | ISerializableChatData | undefined, location: ChatAgentLocation, isGlobalEditingSession: boolean, token: CancellationToken): ChatModel { const model = this.instantiationService.createInstance(ChatModel, someSessionHistory, location); + if (location === ChatAgentLocation.Panel) { + model.startEditingSession(isGlobalEditingSession); + } + this._sessionModels.set(model.sessionId, model); this.initializeSession(model, token); return model; @@ -486,21 +478,12 @@ export class ChatService extends Disposable implements IChatService { this.trace('initializeSession', `Initialize session ${model.sessionId}`); model.startInitialize(); - await this.extensionService.whenInstalledExtensionsRegistered(); - const defaultAgentData = this.chatAgentService.getContributedDefaultAgent(model.initialLocation) ?? this.chatAgentService.getContributedDefaultAgent(ChatAgentLocation.Panel); - if (!defaultAgentData) { - throw new ErrorNoTelemetry('No default agent contributed'); - } + // Activate the default extension provided agent but do not wait + // for it to be ready so that the session can be used immediately + // without having to wait for the agent to be ready. + this.activateDefaultAgent(model.initialLocation).catch(e => this.logService.error(e)); - await this.extensionService.activateByEvent(`onChatParticipant:${defaultAgentData.id}`); - - const defaultAgent = this.chatAgentService.getActivatedAgents().find(agent => agent.id === defaultAgentData.id); - if (!defaultAgent) { - throw new ErrorNoTelemetry('No default agent registered'); - } - - const sampleQuestions = await defaultAgent.provideSampleQuestions?.(model.initialLocation, token) ?? undefined; - model.initialize(sampleQuestions); + model.initialize(); } catch (err) { this.trace('startSession', `initializeSession failed: ${err}`); model.setInitializationError(err); @@ -509,6 +492,23 @@ export class ChatService extends Disposable implements IChatService { } } + async activateDefaultAgent(location: ChatAgentLocation): Promise { + await this.extensionService.whenInstalledExtensionsRegistered(); + + const defaultAgentData = this.chatAgentService.getContributedDefaultAgent(location) ?? this.chatAgentService.getContributedDefaultAgent(ChatAgentLocation.Panel); + if (!defaultAgentData) { + throw new ErrorNoTelemetry('No default agent contributed'); + } + + // No setup participant to fall back on- wait for extension activation + await this.extensionService.activateByEvent(`onChatParticipant:${defaultAgentData.id}`); + + const defaultAgent = this.chatAgentService.getActivatedAgents().find(agent => agent.id === defaultAgentData.id); + if (!defaultAgent) { + throw new ErrorNoTelemetry('No default agent registered'); + } + } + getSession(sessionId: string): IChatModel | undefined { return this._sessionModels.get(sessionId); } @@ -521,17 +521,17 @@ export class ChatService extends Disposable implements IChatService { } let sessionData: ISerializableChatData | undefined; - if (this.useFileStorage) { - sessionData = revive(await this._chatSessionStore.readSession(sessionId)); - } else { + if (!this.useFileStorage || this.transferredSessionData?.sessionId === sessionId) { sessionData = revive(this._persistedSessions[sessionId]); + } else { + sessionData = revive(await this._chatSessionStore.readSession(sessionId)); } if (!sessionData) { return undefined; } - const session = this._startSession(sessionData, sessionData.initialLocation ?? ChatAgentLocation.Panel, CancellationToken.None); + const session = this._startSession(sessionData, sessionData.initialLocation ?? ChatAgentLocation.Panel, true, CancellationToken.None); const isTransferred = this.transferredSessionData?.sessionId === sessionId; if (isTransferred) { @@ -543,8 +543,20 @@ export class ChatService extends Disposable implements IChatService { return session; } + /** + * This is really just for migrating data from the edit session location to the panel. + */ + isPersistedSessionEmpty(sessionId: string): boolean { + const session = this._persistedSessions[sessionId]; + if (session) { + return session.requests.length === 0; + } + + return this._chatSessionStore.isSessionEmpty(sessionId); + } + loadSessionFromContent(data: IExportableChatData | ISerializableChatData): IChatModel | undefined { - return this._startSession(data, data.initialLocation ?? ChatAgentLocation.Panel, CancellationToken.None); + return this._startSession(data, data.initialLocation ?? ChatAgentLocation.Panel, true, CancellationToken.None); } async resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise { @@ -572,7 +584,6 @@ export class ChatService extends Disposable implements IChatService { ...options, locationData: request.locationData, attachedContext: request.attachedContext, - hasInstructionAttachments: options?.hasInstructionAttachments ?? false, }; await this._sendRequestAsync(model, model.sessionId, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions).responseCompletePromise; } @@ -580,13 +591,8 @@ export class ChatService extends Disposable implements IChatService { async sendRequest(sessionId: string, request: string, options?: IChatSendRequestOptions): Promise { this.trace('sendRequest', `sessionId: ${sessionId}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); - // if text is not provided, but chat input has `prompt instructions` - // attached, use the default prompt text to avoid empty messages - if (!request.trim() && options?.hasInstructionAttachments) { - request = 'Follow these instructions.'; - } - if (!request.trim() && !options?.slashCommand && !options?.agentId && !options?.hasInstructionAttachments) { + if (!request.trim() && !options?.slashCommand && !options?.agentId) { this.trace('sendRequest', 'Rejected empty message'); return; } @@ -728,9 +734,9 @@ export class ChatService extends Disposable implements IChatService { let chatTitlePromise: Promise | undefined; if (agentPart || (defaultAgent && !commandPart)) { - const prepareChatAgentRequest = async (agent: IChatAgentData, command?: IChatAgentCommand, enableCommandDetection?: boolean, chatRequest?: ChatRequestModel, isParticipantDetected?: boolean): Promise => { + const prepareChatAgentRequest = (agent: IChatAgentData, command?: IChatAgentCommand, enableCommandDetection?: boolean, chatRequest?: ChatRequestModel, isParticipantDetected?: boolean): IChatAgentRequest => { const initVariableData: IChatRequestVariableData = { variables: [] }; - request = chatRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, agent, command, options?.confirmation, options?.locationData, options?.attachedContext); + request = chatRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, agent, command, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId); let variableData: IChatRequestVariableData; let message: string; @@ -738,7 +744,7 @@ export class ChatService extends Disposable implements IChatService { variableData = chatRequest.variableData; message = getPromptText(request.message).message; } else { - variableData = this.chatVariablesService.resolveVariables(parsedRequest, request.attachedContext); + variableData = { variables: this.prepareContext(request.attachedContext) }; model.updateRequest(request, variableData); const promptTextResult = getPromptText(request.message); @@ -761,7 +767,10 @@ export class ChatService extends Disposable implements IChatService { acceptedConfirmationData: options?.acceptedConfirmationData, rejectedConfirmationData: options?.rejectedConfirmationData, userSelectedModelId: options?.userSelectedModelId, - userSelectedTools: options?.userSelectedTools + userSelectedTools: options?.userSelectedTools, + userSelectedTools2: options?.userSelectedTools2, + toolSelectionIsExclusive: options?.toolSelectionIsExclusive, + editedFileEvents: request.editedFileEvents } satisfies IChatAgentRequest; }; @@ -770,7 +779,7 @@ export class ChatService extends Disposable implements IChatService { const defaultAgentHistory = this.getHistoryEntriesFromModel(requests, model.sessionId, location, defaultAgent.id); // Prepare the request object that we will send to the participant detection provider - const chatAgentRequest = await prepareChatAgentRequest(defaultAgent, undefined, enableCommandDetection, undefined, false); + const chatAgentRequest = prepareChatAgentRequest(defaultAgent, undefined, enableCommandDetection, undefined, false); const result = await this.chatAgentService.detectAgentOrCommand(chatAgentRequest, defaultAgentHistory, { location }, token); if (result && this.chatAgentService.getAgent(result.agent.id)?.locations?.includes(location)) { @@ -788,7 +797,7 @@ export class ChatService extends Disposable implements IChatService { // Recompute history in case the agent or command changed const history = this.getHistoryEntriesFromModel(requests, model.sessionId, location, agent.id); - const requestProps = await prepareChatAgentRequest(agent, command, enableCommandDetection, request /* Reuse the request object if we already created it for participant detection */, !!detectedAgent); + const requestProps = prepareChatAgentRequest(agent, command, enableCommandDetection, request /* Reuse the request object if we already created it for participant detection */, !!detectedAgent); const pendingRequest = this._pendingRequests.get(sessionId); if (pendingRequest && !pendingRequest.requestId) { pendingRequest.requestId = requestProps.requestId; @@ -799,17 +808,19 @@ export class ChatService extends Disposable implements IChatService { agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, history, followupsCancelToken); chatTitlePromise = model.getRequests().length === 1 && !model.customTitle ? this.chatAgentService.getChatTitle(defaultAgent.id, this.getHistoryEntriesFromModel(model.getRequests(), model.sessionId, location, agent.id), CancellationToken.None) : undefined; } else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) { - request = model.addRequest(parsedRequest, { variables: [] }, attempt); - completeResponseCreated(); + if (commandPart.slashCommand.silent !== true) { + request = model.addRequest(parsedRequest, { variables: [] }, attempt); + completeResponseCreated(); + } // contributed slash commands // TODO: spell this out in the UI const history: IChatMessage[] = []; - for (const request of model.getRequests()) { - if (!request.response) { + for (const modelRequest of model.getRequests()) { + if (!modelRequest.response) { continue; } - history.push({ role: ChatMessageRole.User, content: [{ type: 'text', value: request.message.text }] }); - history.push({ role: ChatMessageRole.Assistant, content: [{ type: 'text', value: request.response.response.toString() }] }); + history.push({ role: ChatMessageRole.User, content: [{ type: 'text', value: modelRequest.message.text }] }); + history.push({ role: ChatMessageRole.Assistant, content: [{ type: 'text', value: modelRequest.response.response.toString() }] }); } const message = parsedRequest.text; const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress(p => { @@ -910,8 +921,29 @@ export class ChatService extends Disposable implements IChatService { }; } + private prepareContext(attachedContextVariables: IChatRequestVariableEntry[] | undefined): IChatRequestVariableEntry[] { + attachedContextVariables ??= []; + + // "reverse", high index first so that replacement is simple + attachedContextVariables.sort((a, b) => { + // If either range is undefined, sort it to the back + if (!a.range && !b.range) { + return 0; // Keep relative order if both ranges are undefined + } + if (!a.range) { + return 1; // a goes after b + } + if (!b.range) { + return -1; // a goes before b + } + return b.range.start - a.range.start; + }); + + return attachedContextVariables; + } + private async checkAgentAllowed(agent: IChatAgentData): Promise { - if (agent.isToolsAgent) { + if (agent.modes.includes(ChatMode.Agent)) { const enabled = await this.experimentService.getTreatment('chatAgentEnabled'); if (enabled === false) { throw new Error('Agent is currently disabled'); @@ -926,7 +958,7 @@ export class ChatService extends Disposable implements IChatService { return 'implicit'; } else if (v.range) { // 'range' is range within the prompt text - if (v.isTool) { + if (v.kind === 'tool') { return 'toolInPrompt'; } else { return 'fileInPrompt'; @@ -935,11 +967,11 @@ export class ChatService extends Disposable implements IChatService { return 'command'; } else if (v.kind === 'symbol') { return 'symbol'; - } else if (v.isImage) { + } else if (isImageVariableEntry(v)) { return 'image'; - } else if (v.isDirectory) { + } else if (v.kind === 'directory') { return 'directory'; - } else if (v.isTool) { + } else if (v.kind === 'tool') { return 'tool'; } else { if (URI.isUri(v.value)) { @@ -975,7 +1007,8 @@ export class ChatService extends Disposable implements IChatService { message: promptTextResult.message, command: request.response.slashCommand?.name, variables: updateRanges(request.variableData, promptTextResult.diff), // TODO bit of a hack - location: ChatAgentLocation.Panel + location: ChatAgentLocation.Panel, + editedFileEvents: request.editedFileEvents, }; history.push({ request: historyRequest, response: toChatHistoryContent(request.response.response.value), result: request.response.result ?? {} }); } @@ -1035,7 +1068,7 @@ export class ChatService extends Disposable implements IChatService { const parsedRequest = typeof message === 'string' ? this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, message) : message; - const request = model.addRequest(parsedRequest, variableData || { variables: [] }, attempt ?? 0, undefined, undefined, undefined, undefined, undefined, undefined, true); + const request = model.addRequest(parsedRequest, variableData || { variables: [] }, attempt ?? 0, undefined, undefined, undefined, undefined, undefined, true); if (typeof response.message === 'string') { // TODO is this possible? model.acceptResponseProgress(request, { content: new MarkdownString(response.message), kind: 'markdownContent' }); @@ -1115,13 +1148,10 @@ export class ChatService extends Disposable implements IChatService { }); this.storageService.store(globalChatKey, JSON.stringify(existingRaw), StorageScope.PROFILE, StorageTarget.MACHINE); + this.chatTransferService.addWorkspaceToTransferred(toWorkspace); this.trace('transferChatSession', `Transferred session ${model.sessionId} to workspace ${toWorkspace.toString()}`); } - isEditingLocation(location: ChatAgentLocation): boolean { - return location === ChatAgentLocation.EditingSession || this.unifiedViewEnabled; - } - getChatStorageFolder(): URI { return this._chatSessionStore.getChatStorageFolder(); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/chatSessionStore.ts index 5ed0afefa7b..e7c018721c1 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionStore.ts @@ -17,6 +17,7 @@ import { FileOperationResult, IFileService, toFileOperationResult } from '../../ import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; import { ChatModel, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData } from './chatModel.js'; @@ -29,6 +30,7 @@ const ChatIndexStorageKey = 'chat.ChatSessionStore.index'; export class ChatSessionStore extends Disposable { private readonly storageRoot: URI; + private readonly previousEmptyWindowStorageRoot: URI | undefined; // private readonly transferredSessionStorageRoot: URI; private readonly storeQueue = new Sequencer(); @@ -44,15 +46,20 @@ export class ChatSessionStore extends Disposable { @ITelemetryService private readonly telemetryService: ITelemetryService, @IStorageService private readonly storageService: IStorageService, @ILifecycleService private readonly lifecycleService: ILifecycleService, + @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, ) { super(); const workspace = this.workspaceContextService.getWorkspace(); const isEmptyWindow = !workspace.configuration && workspace.folders.length === 0; - const workspaceId = isEmptyWindow ? - 'no-workspace' : - this.workspaceContextService.getWorkspace().id; - this.storageRoot = joinPath(this.environmentService.workspaceStorageHome, workspaceId, 'chatSessions'); + const workspaceId = this.workspaceContextService.getWorkspace().id; + this.storageRoot = isEmptyWindow ? + joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'emptyWindowChatSessions') : + joinPath(this.environmentService.workspaceStorageHome, workspaceId, 'chatSessions'); + + this.previousEmptyWindowStorageRoot = isEmptyWindow ? + joinPath(this.environmentService.workspaceStorageHome, 'no-workspace', 'chatSessions') : + undefined; // TODO tmpdir // this.transferredSessionStorageRoot = joinPath(this.environmentService.workspaceStorageHome, 'transferredChatSessions'); @@ -188,6 +195,11 @@ export class ChatSessionStore extends Disposable { return Object.keys(this.internalGetIndex().entries).length > 0; } + isSessionEmpty(sessionId: string): boolean { + const index = this.internalGetIndex(); + return index.entries[sessionId]?.isEmpty ?? true; + } + async deleteSession(sessionId: string): Promise { await this.storeQueue.queue(async () => { await this.internalDeleteSession(sessionId); @@ -304,13 +316,20 @@ export class ChatSessionStore extends Disposable { public async readSession(sessionId: string): Promise { return await this.storeQueue.queue(async () => { - let rawData: string; + let rawData: string | undefined; const storageLocation = this.getStorageLocation(sessionId); try { rawData = (await this.fileService.readFile(storageLocation)).value.toString(); } catch (e) { this.reportError('sessionReadFile', `Error reading chat session file ${sessionId}`, e); - return undefined; + + if (toFileOperationResult(e) === FileOperationResult.FILE_NOT_FOUND && this.previousEmptyWindowStorageRoot) { + rawData = await this.readSessionFromPreviousLocation(sessionId); + } + + if (!rawData) { + return undefined; + } } try { @@ -338,6 +357,23 @@ export class ChatSessionStore extends Disposable { }); } + private async readSessionFromPreviousLocation(sessionId: string): Promise { + let rawData: string | undefined; + + if (this.previousEmptyWindowStorageRoot) { + const storageLocation2 = joinPath(this.previousEmptyWindowStorageRoot, `${sessionId}.json`); + try { + rawData = (await this.fileService.readFile(storageLocation2)).value.toString(); + this.logService.info(`ChatSessionStore: Read chat session ${sessionId} from previous location`); + } catch (e) { + this.reportError('sessionReadFile', `Error reading chat session file ${sessionId} from previous location`, e); + return undefined; + } + } + + return rawData; + } + private getStorageLocation(chatSessionId: string): URI { return joinPath(this.storageRoot, `${chatSessionId}.json`); } diff --git a/src/vs/workbench/contrib/chat/common/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/chatSlashCommands.ts index e258f8f5c62..68480c1dddb 100644 --- a/src/vs/workbench/contrib/chat/common/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/chatSlashCommands.ts @@ -11,7 +11,7 @@ import { IProgress } from '../../../../platform/progress/common/progress.js'; import { IChatMessage } from './languageModels.js'; import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData } from './chatService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; -import { ChatAgentLocation } from './constants.js'; +import { ChatAgentLocation, ChatMode } from './constants.js'; //#region slash service, commands etc @@ -24,7 +24,18 @@ export interface IChatSlashData { * as it is entered. Defaults to `false`. */ executeImmediately?: boolean; + + /** + * Whether the command should be added as a request/response + * turn to the chat history. Defaults to `false`. + * + * For instance, the `/save` command opens an untitled document + * to the side hence does not contain any chatbot responses. + */ + silent?: boolean; + locations: ChatAgentLocation[]; + modes?: ChatMode[]; } export interface IChatSlashFragment { @@ -42,7 +53,7 @@ export interface IChatSlashCommandService { readonly onDidChangeCommands: Event; registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable; executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void>; - getCommands(location: ChatAgentLocation): Array; + getCommands(location: ChatAgentLocation, mode: ChatMode): Array; hasCommand(id: string): boolean; } @@ -81,8 +92,10 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom }); } - getCommands(location: ChatAgentLocation): Array { - return Array.from(this._commands.values(), v => v.data).filter(c => c.locations.includes(location)); + getCommands(location: ChatAgentLocation, mode: ChatMode): Array { + return Array + .from(this._commands.values(), v => v.data) + .filter(c => c.locations.includes(location) && (!c.modes || c.modes.includes(mode))); } hasCommand(id: string): boolean { diff --git a/src/vs/workbench/contrib/chat/common/chatTransferService.ts b/src/vs/workbench/contrib/chat/common/chatTransferService.ts index 22a2eb31aa8..bbc21070343 100644 --- a/src/vs/workbench/contrib/chat/common/chatTransferService.ts +++ b/src/vs/workbench/contrib/chat/common/chatTransferService.ts @@ -3,18 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; -import { isChatTransferredWorkspace, areWorkspaceFoldersEmpty } from '../../../services/workspaces/common/workspaceUtils.js'; +import { areWorkspaceFoldersEmpty } from '../../../services/workspaces/common/workspaceUtils.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../base/common/uri.js'; export const IChatTransferService = createDecorator('chatTransferService'); +const transferredWorkspacesKey = 'chat.transferedWorkspaces'; export interface IChatTransferService { readonly _serviceBrand: undefined; - checkAndSetWorkspaceTrust(): Promise; + checkAndSetTransferredWorkspaceTrust(): Promise; + addWorkspaceToTransferred(workspace: URI): void; } export class ChatTransferService implements IChatTransferService { @@ -27,10 +30,35 @@ export class ChatTransferService implements IChatTransferService { @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService ) { } - async checkAndSetWorkspaceTrust(): Promise { + deleteWorkspaceFromTransferredList(workspace: URI): void { + const transferredWorkspaces = this.storageService.getObject(transferredWorkspacesKey, StorageScope.PROFILE, []); + const updatedWorkspaces = transferredWorkspaces.filter(uri => uri !== workspace.toString()); + this.storageService.store(transferredWorkspacesKey, updatedWorkspaces, StorageScope.PROFILE, StorageTarget.MACHINE); + } + + addWorkspaceToTransferred(workspace: URI): void { + const transferredWorkspaces = this.storageService.getObject(transferredWorkspacesKey, StorageScope.PROFILE, []); + transferredWorkspaces.push(workspace.toString()); + this.storageService.store(transferredWorkspacesKey, transferredWorkspaces, StorageScope.PROFILE, StorageTarget.MACHINE); + } + + async checkAndSetTransferredWorkspaceTrust(): Promise { const workspace = this.workspaceService.getWorkspace(); - if (isChatTransferredWorkspace(workspace, this.storageService) && await areWorkspaceFoldersEmpty(workspace, this.fileService)) { + const currentWorkspaceUri = workspace.folders[0]?.uri; + if (!currentWorkspaceUri) { + return; + } + if (this.isChatTransferredWorkspace(currentWorkspaceUri, this.storageService) && await areWorkspaceFoldersEmpty(workspace, this.fileService)) { await this.workspaceTrustManagementService.setWorkspaceTrust(true); + this.deleteWorkspaceFromTransferredList(currentWorkspaceUri); } } + + isChatTransferredWorkspace(workspace: URI, storageService: IStorageService): boolean { + if (!workspace) { + return false; + } + const chatWorkspaceTransfer: URI[] = storageService.getObject(transferredWorkspacesKey, StorageScope.PROFILE, []); + return chatWorkspaceTransfer.some(item => item.toString() === workspace.toString()); + } } diff --git a/src/vs/workbench/contrib/chat/common/chatVariables.ts b/src/vs/workbench/contrib/chat/common/chatVariables.ts index 809d5637c53..a5f936c7360 100644 --- a/src/vs/workbench/contrib/chat/common/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/common/chatVariables.ts @@ -9,10 +9,9 @@ import { URI } from '../../../../base/common/uri.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { Location } from '../../../../editor/common/languages.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IChatModel, IChatRequestVariableData, IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData } from './chatModel.js'; -import { IParsedChatRequest } from './chatParserTypes.js'; +import { IChatModel, IDiagnosticVariableEntryFilterData } from './chatModel.js'; import { IChatContentReference, IChatProgressMessage } from './chatService.js'; -import { ChatAgentLocation } from './constants.js'; +import { IToolData } from './languageModelToolsService.js'; export interface IChatVariableData { id: string; @@ -47,12 +46,7 @@ export const IChatVariablesService = createDecorator('ICh export interface IChatVariablesService { _serviceBrand: undefined; getDynamicVariables(sessionId: string): ReadonlyArray; - attachContext(name: string, value: string | URI | Location | unknown, location: ChatAgentLocation): void; - - /** - * Resolves all variables that occur in `prompt` - */ - resolveVariables(prompt: IParsedChatRequest, attachedContextVariables: IChatRequestVariableEntry[] | undefined): IChatRequestVariableData; + getSelectedTools(sessionId: string): ReadonlyArray; } export interface IDynamicVariable { @@ -60,7 +54,6 @@ export interface IDynamicVariable { id: string; fullName?: string; icon?: ThemeIcon; - prefix?: string; modelDescription?: string; isFile?: boolean; isDirectory?: boolean; diff --git a/src/vs/workbench/contrib/chat/common/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/chatViewModel.ts index 063bd4b0a7f..cab2fd082c2 100644 --- a/src/vs/workbench/contrib/chat/common/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatViewModel.ts @@ -137,6 +137,7 @@ export interface IChatReferences { export interface IChatWorkingProgress { kind: 'working'; isPaused: boolean; + setPaused(paused: boolean): void; } /** @@ -583,7 +584,7 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi } this._register(_model.onDidChange(() => { - // This should be true, if the model is changing + // This is set when the response is loading, but the model can change later for other reasons if (this._contentUpdateTimings) { const now = Date.now(); const wordCount = countWords(_model.entireResponse.getMarkdown()); @@ -608,9 +609,6 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi lastWordCount: wordCount }; } - - } else { - this.logService.warn('ChatResponseViewModel#onDidChange: got model update but contentUpdateTimings is not initialized'); } // new data -> new id, new content to render diff --git a/src/vs/workbench/contrib/chat/common/chatWidgetHistoryService.ts b/src/vs/workbench/contrib/chat/common/chatWidgetHistoryService.ts index 4522c0b7bd3..865425b70e2 100644 --- a/src/vs/workbench/contrib/chat/common/chatWidgetHistoryService.ts +++ b/src/vs/workbench/contrib/chat/common/chatWidgetHistoryService.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { Memento } from '../../../common/memento.js'; -import { WorkingSetEntryState } from './chatEditingService.js'; +import { ModifiedFileEntryState } from './chatEditingService.js'; import { IChatRequestVariableEntry } from './chatModel.js'; import { CHAT_PROVIDER_ID } from './chatParticipantContribTypes.js'; import { ChatAgentLocation, ChatMode } from './constants.js'; @@ -22,7 +22,7 @@ export interface IChatHistoryEntry { export interface IChatInputState { [key: string]: any; chatContextAttachments?: ReadonlyArray; - chatWorkingSet?: ReadonlyArray<{ uri: URI; state: WorkingSetEntryState }>; + chatWorkingSet?: ReadonlyArray<{ uri: URI; state: ModifiedFileEntryState }>; chatMode?: ChatMode; } diff --git a/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts b/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts index 59c605b6481..1099dca6e39 100644 --- a/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts +++ b/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts @@ -21,10 +21,11 @@ interface CodeBlockContent { readonly isComplete: boolean; } -interface CodeBlockEntry { +export interface CodeBlockEntry { readonly model: Promise; readonly vulns: readonly IMarkdownVulnerability[]; readonly codemapperUri?: URI; + readonly isEdit?: boolean; } export class CodeBlockModelCollection extends Disposable { @@ -33,6 +34,7 @@ export class CodeBlockModelCollection extends Disposable { model: Promise>; vulns: readonly IMarkdownVulnerability[]; codemapperUri?: URI; + isEdit?: boolean; }>(); /** @@ -63,7 +65,8 @@ export class CodeBlockModelCollection extends Disposable { return { model: entry.model.then(ref => ref.object.textEditorModel), vulns: entry.vulns, - codemapperUri: entry.codemapperUri + codemapperUri: entry.codemapperUri, + isEdit: entry.isEdit, }; } @@ -117,7 +120,7 @@ export class CodeBlockModelCollection extends Disposable { const codeblockUri = extractCodeblockUrisFromText(newText); if (codeblockUri) { - this.setCodemapperUri(sessionId, chat, codeBlockIndex, codeblockUri.uri); + this.setCodemapperUri(sessionId, chat, codeBlockIndex, codeblockUri.uri, codeblockUri.isEdit); } if (content.isComplete) { @@ -144,7 +147,7 @@ export class CodeBlockModelCollection extends Disposable { const codeblockUri = extractCodeblockUrisFromText(newText); if (codeblockUri) { - this.setCodemapperUri(sessionId, chat, codeBlockIndex, codeblockUri.uri); + this.setCodemapperUri(sessionId, chat, codeBlockIndex, codeblockUri.uri, codeblockUri.isEdit); newText = codeblockUri.textWithoutResult; } @@ -182,10 +185,11 @@ export class CodeBlockModelCollection extends Disposable { return entry; } - private setCodemapperUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number, codemapperUri: URI) { + private setCodemapperUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number, codemapperUri: URI, isEdit?: boolean) { const entry = this._models.get(this.getKey(sessionId, chat, codeBlockIndex)); if (entry) { entry.codemapperUri = codemapperUri; + entry.isEdit = isEdit; } } @@ -246,7 +250,8 @@ export class CodeBlockModelCollection extends Disposable { function fixCodeText(text: string, languageId: string | undefined): string { if (languageId === 'php') { - if (!text.trim().startsWith('<')) { + // ; + +export type ToolDataSource = + | { + type: 'extension'; + label: string; + extensionId: ExtensionIdentifier; + /** + * True for tools contributed through extension API from third-party extensions, so they can be disabled by policy. + * False for built-in tools, MCP tools are handled differently. + */ + isExternalTool: boolean; + } + | { + type: 'mcp'; + label: string; + collectionId: string; + definitionId: string; + } + | { type: 'internal' }; + +export namespace ToolDataSource { + export function toKey(source: ToolDataSource): string { + switch (source.type) { + case 'extension': return `extension:${source.extensionId.value}`; + case 'mcp': return `mcp:${source.collectionId}:${source.definitionId}`; + case 'internal': return 'internal'; + } + } } export interface IToolInvocation { @@ -38,7 +86,8 @@ export interface IToolInvocation { context: IToolInvocationContext | undefined; chatRequestId?: string; chatInteractionId?: string; - toolSpecificData?: IChatTerminalToolInvocationData; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData; + modelId?: string; } export interface IToolInvocationContext { @@ -49,10 +98,25 @@ export function isToolInvocationContext(obj: any): obj is IToolInvocationContext return typeof obj === 'object' && typeof obj.sessionId === 'string'; } +export interface IToolResultInputOutputDetails { + readonly input: string; + readonly output: string; + readonly isError?: boolean; +} + +export function isToolResultInputOutputDetails(obj: any): obj is IToolResultInputOutputDetails { + return typeof obj === 'object' && typeof obj?.input === 'string' && typeof obj?.output === 'string'; +} + export interface IToolResult { - content: (IToolResultPromptTsxPart | IToolResultTextPart)[]; + content: (IToolResultPromptTsxPart | IToolResultTextPart | IToolResultDataPart)[]; toolResultMessage?: string | IMarkdownString; - toolResultDetails?: Array; + toolResultDetails?: Array | IToolResultInputOutputDetails; + toolResultError?: string; +} + +export function toolResultHasBuffers(result: IToolResult): boolean { + return result.content.some(part => part.kind === 'data'); } export interface IToolResultPromptTsxPart { @@ -60,26 +124,41 @@ export interface IToolResultPromptTsxPart { value: unknown; } +export function stringifyPromptTsxPart(part: IToolResultPromptTsxPart): string { + return stringifyPromptElementJSON(part.value as PromptElementJSON); +} + export interface IToolResultTextPart { kind: 'text'; value: string; } +export interface IToolResultDataPart { + kind: 'data'; + value: { + mimeType: string; + data: VSBuffer; + }; +} + export interface IToolConfirmationMessages { title: string; message: string | IMarkdownString; + allowAutoConfirm?: boolean; } export interface IPreparedToolInvocation { invocationMessage?: string | IMarkdownString; pastTenseMessage?: string | IMarkdownString; + originMessage?: string | IMarkdownString; confirmationMessages?: IToolConfirmationMessages; presentation?: 'hidden' | undefined; - toolSpecificData?: IChatTerminalToolInvocationData; + // When this gets extended, be sure to update `chatResponseAccessibleView.ts` to handle the new properties. + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData; } export interface IToolImpl { - invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise; + invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken): Promise; prepareToolInvocation?(parameters: any, token: CancellationToken): Promise; } @@ -96,5 +175,21 @@ export interface ILanguageModelToolsService { getTool(id: string): IToolData | undefined; getToolByName(name: string): IToolData | undefined; invokeTool(invocation: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise; + setToolAutoConfirmation(toolId: string, scope: 'workspace' | 'profile' | 'memory', autoConfirm?: boolean): void; + resetToolAutoConfirmation(): void; cancelToolCallsForRequest(requestId: string): void; } + +export function createToolInputUri(toolOrId: IToolData | string): URI { + if (typeof toolOrId !== 'string') { + toolOrId = toolOrId.id; + } + return URI.from({ scheme: Schemas.inMemory, path: `/lm/tool/${toolOrId}/tool_input.json` }); +} + +export function createToolSchemaUri(toolOrId: IToolData | string): URI { + if (typeof toolOrId !== 'string') { + toolOrId = toolOrId.id; + } + return URI.from({ scheme: Schemas.vscode, authority: 'schemas', path: `/lm/tool/${toolOrId}` }); +} diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 3db81d2941c..62ed7dcdf82 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Iterable } from '../../../../base/common/iterator.js'; @@ -14,6 +15,7 @@ import { IContextKey, IContextKeyService } from '../../../../platform/contextkey import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +// import { ChatImagePart } from '../../../api/common/extHostTypes.js'; import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js'; import { ExtensionsRegistry } from '../../../services/extensions/common/extensionsRegistry.js'; import { ChatContextKeys } from './chatContextKeys.js'; @@ -29,14 +31,57 @@ export interface IChatMessageTextPart { value: string; } +export interface IChatMessageImagePart { + type: 'image_url'; + value: IChatImageURLPart; +} + +export interface IChatMessageExtraDataPart { + type: 'extra_data'; + kind: string; + data: any; +} + +export interface IChatImageURLPart { + /** + * The image's MIME type (e.g., "image/png", "image/jpeg"). + */ + mimeType: ChatImageMimeType; + + /** + * The raw binary data of the image, encoded as a Uint8Array. Note: do not use base64 encoding. Maximum image size is 5MB. + */ + data: VSBuffer; +} + +/** + * Enum for supported image MIME types. + */ +export enum ChatImageMimeType { + PNG = 'image/png', + JPEG = 'image/jpeg', + GIF = 'image/gif', + WEBP = 'image/webp', + BMP = 'image/bmp', +} + +/** + * Specifies the detail level of the image. + */ +export enum ImageDetailLevel { + Low = 'low', + High = 'high' +} + + export interface IChatMessageToolResultPart { type: 'tool_result'; toolCallId: string; - value: (IChatResponseTextPart | IChatResponsePromptTsxPart)[]; + value: (IChatResponseTextPart | IChatResponsePromptTsxPart | IChatResponseDataPart)[]; isError?: boolean; } -export type IChatMessagePart = IChatMessageTextPart | IChatMessageToolResultPart | IChatResponseToolUsePart; +export type IChatMessagePart = IChatMessageTextPart | IChatMessageToolResultPart | IChatResponseToolUsePart | IChatMessageImagePart | IChatMessageExtraDataPart; export interface IChatMessage { readonly name?: string | undefined; @@ -54,6 +99,11 @@ export interface IChatResponsePromptTsxPart { value: unknown; } +export interface IChatResponseDataPart { + type: 'data'; + value: IChatImageURLPart; +} + export interface IChatResponseToolUsePart { type: 'tool_use'; name: string; @@ -61,7 +111,7 @@ export interface IChatResponseToolUsePart { parameters: any; } -export type IChatResponsePart = IChatResponseTextPart | IChatResponseToolUsePart; +export type IChatResponsePart = IChatResponseTextPart | IChatResponseToolUsePart | IChatResponseDataPart; export interface IChatResponseFragment { index: number; @@ -75,6 +125,8 @@ export interface ILanguageModelChatMetadata { readonly id: string; readonly vendor: string; readonly version: string; + readonly description?: string; + readonly cost?: string; readonly family: string; readonly maxInputTokens: number; readonly maxOutputTokens: number; @@ -82,6 +134,7 @@ export interface ILanguageModelChatMetadata { readonly isDefault?: boolean; readonly isUserSelectable?: boolean; + readonly modelPickerCategory: { label: string; order: number }; readonly auth?: { readonly providerLabel: string; readonly accountLabel?: string; diff --git a/src/vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorkerMain.ts b/src/vs/workbench/contrib/chat/common/modelPicker/modelPickerWidget.ts similarity index 65% rename from src/vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorkerMain.ts rename to src/vs/workbench/contrib/chat/common/modelPicker/modelPickerWidget.ts index d9ec848d715..779c011f797 100644 --- a/src/vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorkerMain.ts +++ b/src/vs/workbench/contrib/chat/common/modelPicker/modelPickerWidget.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { create } from './languageDetectionSimpleWorker.js'; -import { bootstrapSimpleWorker } from '../../../../base/common/worker/simpleWorkerBootstrap.js'; +import { localize } from '../../../../../nls.js'; -bootstrapSimpleWorker(create); +export const DEFAULT_MODEL_PICKER_CATEGORY = { label: localize('chat.modelPicker.other', "Other Models"), order: Number.MAX_SAFE_INTEGER }; diff --git a/src/vs/workbench/contrib/chat/common/promptFileReferenceErrors.ts b/src/vs/workbench/contrib/chat/common/promptFileReferenceErrors.ts index e19588df4e6..a190b50d6c2 100644 --- a/src/vs/workbench/contrib/chat/common/promptFileReferenceErrors.ts +++ b/src/vs/workbench/contrib/chat/common/promptFileReferenceErrors.ts @@ -121,7 +121,7 @@ export class RecursiveReference extends ResolveError { constructor( uri: URI, - public readonly recursivePath: string[], + public readonly recursivePath: readonly string[], ) { // sanity check - a recursive path must always have at least // two items in the list, otherwise it is not a recursive loop diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptCodec.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptCodec.ts index 7522f5acd0b..7a48fc44494 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptCodec.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptCodec.ts @@ -11,15 +11,15 @@ import { ICodec } from '../../../../../../base/common/codecs/types/ICodec.js'; /** * `ChatPromptCodec` type is a `ICodec` with specific types for * stream messages and return types of the `encode`/`decode` functions. - * @see {@linkcode ICodec} + * @see {@link ICodec} */ interface IChatPromptCodec extends ICodec { /** * Decode a stream of `VSBuffer`s into a stream of `TChatPromptToken`s. * - * @see {@linkcode TChatPromptToken} - * @see {@linkcode VSBuffer} - * @see {@linkcode ChatPromptDecoder} + * @see {@link TChatPromptToken} + * @see {@link VSBuffer} + * @see {@link ChatPromptDecoder} */ decode: (value: ReadableStream) => ChatPromptDecoder; } @@ -31,8 +31,8 @@ export const ChatPromptCodec: IChatPromptCodec = Object.freeze({ /** * Encode a stream of `TChatPromptToken`s into a stream of `VSBuffer`s. * - * @see {@linkcode ReadableStream} - * @see {@linkcode VSBuffer} + * @see {@link ReadableStream} + * @see {@link VSBuffer} */ encode: (_stream: ReadableStream): ReadableStream => { throw new Error('The `encode` method is not implemented.'); @@ -41,10 +41,10 @@ export const ChatPromptCodec: IChatPromptCodec = Object.freeze({ /** * Decode a of `VSBuffer`s into a readable of `TChatPromptToken`s. * - * @see {@linkcode TChatPromptToken} - * @see {@linkcode VSBuffer} - * @see {@linkcode ChatPromptDecoder} - * @see {@linkcode ReadableStream} + * @see {@link TChatPromptToken} + * @see {@link VSBuffer} + * @see {@link ChatPromptDecoder} + * @see {@link ReadableStream} */ decode: (stream: ReadableStream): ChatPromptDecoder => { return new ChatPromptDecoder(stream); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts index b1bd3de2b92..6aba6b0ead9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/chatPromptDecoder.ts @@ -4,20 +4,29 @@ *--------------------------------------------------------------------------------------------*/ import { PromptToken } from './tokens/promptToken.js'; +import { PromptAtMention } from './tokens/promptAtMention.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { PromptSlashCommand } from './tokens/promptSlashCommand.js'; import { assertNever } from '../../../../../../base/common/assert.js'; import { ReadableStream } from '../../../../../../base/common/stream.js'; +import { PartialPromptAtMention } from './parsers/promptAtMentionParser.js'; +import { PromptTemplateVariable } from './tokens/promptTemplateVariable.js'; +import { PartialPromptSlashCommand } from './parsers/promptSlashCommandParser.js'; import { BaseDecoder } from '../../../../../../base/common/codecs/baseDecoder.js'; import { PromptVariable, PromptVariableWithData } from './tokens/promptVariable.js'; +import { At } from '../../../../../../editor/common/codecs/simpleCodec/tokens/at.js'; import { Hash } from '../../../../../../editor/common/codecs/simpleCodec/tokens/hash.js'; -import { MarkdownLink } from '../../../../../../editor/common/codecs/markdownCodec/tokens/markdownLink.js'; +import { Slash } from '../../../../../../editor/common/codecs/simpleCodec/tokens/slash.js'; +import { DollarSign } from '../../../../../../editor/common/codecs/simpleCodec/tokens/dollarSign.js'; import { PartialPromptVariableName, PartialPromptVariableWithData } from './parsers/promptVariableParser.js'; import { MarkdownDecoder, TMarkdownToken } from '../../../../../../editor/common/codecs/markdownCodec/markdownDecoder.js'; +import { PartialPromptTemplateVariable, PartialPromptTemplateVariableStart, TPromptTemplateVariableParser } from './parsers/promptTemplateVariableParser.js'; /** * Tokens produced by this decoder. */ -export type TChatPromptToken = MarkdownLink | PromptVariable | PromptVariableWithData; +export type TChatPromptToken = TMarkdownToken | (PromptVariable | PromptVariableWithData) + | PromptAtMention | PromptSlashCommand | PromptTemplateVariable; /** * Decoder for the common chatbot prompt message syntax. @@ -29,7 +38,9 @@ export class ChatPromptDecoder extends BaseDecoder, @@ -38,32 +49,49 @@ export class ChatPromptDecoder extends BaseDecoder { return token.symbol; }); + +/** + * List of characters that cannot be in an at-mention name (excluding the {@link STOP_CHARACTERS}). + */ +export const INVALID_NAME_CHARACTERS: readonly string[] = [ExclamationMark, LeftAngleBracket, RightAngleBracket, LeftBracket, RightBracket] + .map((token) => { return token.symbol; }); + +/** + * The parser responsible for parsing a `prompt @mention` sequences. + * E.g., `@workspace` or `@github` participant mention. + */ +export class PartialPromptAtMention extends ParserBase { + constructor(token: At) { + super([token]); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + // if a `stop` character is encountered, finish the parsing process + if (STOP_CHARACTERS.includes(token.text)) { + try { + // if it is possible to convert current parser to `PromptAtMention`, return success result + return { + result: 'success', + nextParser: this.asPromptAtMention(), + wasTokenConsumed: false, + }; + } catch (error) { + // otherwise fail + return { + result: 'failure', + wasTokenConsumed: false, + }; + } finally { + // in any case this is an end of the parsing process + this.isConsumed = true; + } + } + + // variables cannot have {@link INVALID_NAME_CHARACTERS} in their names + if (INVALID_NAME_CHARACTERS.includes(token.text)) { + this.isConsumed = true; + + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + // otherwise it is a valid name character, so add it to the list of + // the current tokens and continue the parsing process + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + + /** + * Try to convert current parser instance into a fully-parsed {@link PromptAtMention} token. + * + * @throws if sequence of tokens received so far do not constitute a valid prompt variable, + * for instance, if there is only `1` starting `@` token is available. + */ + public asPromptAtMention(): PromptAtMention { + // if there is only one token before the stop character + // must be the starting `@` one), then fail + assert( + this.currentTokens.length > 1, + 'Cannot create a prompt @mention out of incomplete token sequence.', + ); + + const firstToken = this.currentTokens[0]; + const lastToken = this.currentTokens[this.currentTokens.length - 1]; + + // render the characters above into strings, excluding the starting `@` character + const nameTokens = this.currentTokens.slice(1); + const atMentionName = nameTokens.map(pick('text')).join(''); + + return new PromptAtMention( + new Range( + firstToken.range.startLineNumber, + firstToken.range.startColumn, + lastToken.range.endLineNumber, + lastToken.range.endColumn, + ), + atMentionName, + ); + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptSlashCommandParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptSlashCommandParser.ts new file mode 100644 index 00000000000..159ffd54bc8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptSlashCommandParser.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { pick } from '../../../../../../../base/common/arrays.js'; +import { assert } from '../../../../../../../base/common/assert.js'; +import { PromptSlashCommand } from '../tokens/promptSlashCommand.js'; +import { Range } from '../../../../../../../editor/common/core/range.js'; +import { At } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/at.js'; +import { Tab } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/tab.js'; +import { Hash } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/hash.js'; +import { Slash } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/slash.js'; +import { Space } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/space.js'; +import { Colon } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/colon.js'; +import { NewLine } from '../../../../../../../editor/common/codecs/linesCodec/tokens/newLine.js'; +import { FormFeed } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/formFeed.js'; +import { VerticalTab } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/verticalTab.js'; +import { TSimpleDecoderToken } from '../../../../../../../editor/common/codecs/simpleCodec/simpleDecoder.js'; +import { CarriageReturn } from '../../../../../../../editor/common/codecs/linesCodec/tokens/carriageReturn.js'; +import { ExclamationMark } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/exclamationMark.js'; +import { LeftBracket, RightBracket } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/brackets.js'; +import { LeftAngleBracket, RightAngleBracket } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/angleBrackets.js'; +import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../../../../../../editor/common/codecs/simpleCodec/parserBase.js'; + +/** + * List of characters that terminate the prompt at-mention sequence. + */ +export const STOP_CHARACTERS: readonly string[] = [Space, Tab, NewLine, CarriageReturn, VerticalTab, FormFeed, Colon, At, Hash, Slash] + .map((token) => { return token.symbol; }); + +/** + * List of characters that cannot be in an at-mention name (excluding the {@link STOP_CHARACTERS}). + */ +export const INVALID_NAME_CHARACTERS: readonly string[] = [ExclamationMark, LeftAngleBracket, RightAngleBracket, LeftBracket, RightBracket] + .map((token) => { return token.symbol; }); + +/** + * The parser responsible for parsing a `prompt /command` sequences. + * E.g., `/search` or `/explain` command. + */ +export class PartialPromptSlashCommand extends ParserBase { + constructor(token: Slash) { + super([token]); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + // if a `stop` character is encountered, finish the parsing process + if (STOP_CHARACTERS.includes(token.text)) { + try { + // if it is possible to convert current parser to `PromptSlashCommand`, return success result + return { + result: 'success', + nextParser: this.asPromptSlashCommand(), + wasTokenConsumed: false, + }; + } catch (error) { + // otherwise fail + return { + result: 'failure', + wasTokenConsumed: false, + }; + } finally { + // in any case this is an end of the parsing process + this.isConsumed = true; + } + } + + // variables cannot have {@link INVALID_NAME_CHARACTERS} in their names + if (INVALID_NAME_CHARACTERS.includes(token.text)) { + this.isConsumed = true; + + return { + result: 'failure', + wasTokenConsumed: false, + }; + } + + // otherwise it is a valid name character, so add it to the list of + // the current tokens and continue the parsing process + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + + /** + * Try to convert current parser instance into a fully-parsed {@link PromptSlashCommand} token. + * + * @throws if sequence of tokens received so far do not constitute a valid prompt variable, + * for instance, if there is only `1` starting `/` token is available. + */ + public asPromptSlashCommand(): PromptSlashCommand { + // if there is only one token before the stop character + // must be the starting `/` one), then fail + assert( + this.currentTokens.length > 1, + 'Cannot create a prompt /command out of incomplete token sequence.', + ); + + const firstToken = this.currentTokens[0]; + const lastToken = this.currentTokens[this.currentTokens.length - 1]; + + // render the characters above into strings, excluding the starting `/` character + const nameTokens = this.currentTokens.slice(1); + const atMentionName = nameTokens.map(pick('text')).join(''); + + return new PromptSlashCommand( + new Range( + firstToken.range.startLineNumber, + firstToken.range.startColumn, + lastToken.range.endLineNumber, + lastToken.range.endColumn, + ), + atMentionName, + ); + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptTemplateVariableParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptTemplateVariableParser.ts new file mode 100644 index 00000000000..c40a8c5622f --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptTemplateVariableParser.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../../../../../../../base/common/assert.js'; +import { PromptTemplateVariable } from '../tokens/promptTemplateVariable.js'; +import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; +import { TSimpleDecoderToken } from '../../../../../../../editor/common/codecs/simpleCodec/simpleDecoder.js'; +import { DollarSign, LeftCurlyBrace, RightCurlyBrace } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/index.js'; +import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../../../../../../editor/common/codecs/simpleCodec/parserBase.js'; + +/** + * Parsers of the `${variable}` token sequence in a prompt text. + */ +export type TPromptTemplateVariableParser = PartialPromptTemplateVariableStart | PartialPromptTemplateVariable; + +/** + * Parser that handles start sequence of a `${variable}` token sequence in + * a prompt text. Transitions to {@link PartialPromptTemplateVariable} parser + * as soon as the `${` character sequence is found. + */ +export class PartialPromptTemplateVariableStart extends ParserBase { + constructor(token: DollarSign) { + super([token]); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + if (token instanceof LeftCurlyBrace) { + this.currentTokens.push(token); + + this.isConsumed = true; + return { + result: 'success', + nextParser: new PartialPromptTemplateVariable(this.currentTokens), + wasTokenConsumed: true, + }; + } + + return { + result: 'failure', + wasTokenConsumed: false, + }; + } +} + +/** + * Parser that handles a partial `${variable}` token sequence in a prompt text. + */ +export class PartialPromptTemplateVariable extends ParserBase { + constructor(tokens: (DollarSign | LeftCurlyBrace)[]) { + super(tokens); + } + + @assertNotConsumed + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { + // template variables are terminated by the `}` character + if (token instanceof RightCurlyBrace) { + this.currentTokens.push(token); + + this.isConsumed = true; + return { + result: 'success', + nextParser: this.asPromptTemplateVariable(), + wasTokenConsumed: true, + }; + } + + // otherwise it is a valid name character, so add it to the list of + // the current tokens and continue the parsing process + this.currentTokens.push(token); + + return { + result: 'success', + nextParser: this, + wasTokenConsumed: true, + }; + } + + /** + * Returns a string representation of the prompt template variable + * contents, if any is present. + */ + private get contents(): string { + const contentTokens: TSimpleDecoderToken[] = []; + + // template variables are surrounded by `${}`, hence we need to have + // at least `${` plus one character for the contents to be non-empty + if (this.currentTokens.length < 3) { + return ''; + } + + // collect all tokens besides the first two (`${`) and a possible `}` at the end + for (let i = 2; i < this.currentTokens.length; i++) { + const token = this.currentTokens[i]; + const isLastToken = (i === this.currentTokens.length - 1); + + if ((token instanceof RightCurlyBrace) && (isLastToken === true)) { + break; + } + + contentTokens.push(token); + } + + return BaseToken.render(contentTokens); + } + + /** + * Try to convert current parser instance into a {@link PromptTemplateVariable} token. + * + * @throws if: + * - current tokens sequence cannot be converted to a valid template variable token + */ + public asPromptTemplateVariable(): PromptTemplateVariable { + const firstToken = this.currentTokens[0]; + const secondToken = this.currentTokens[1]; + const lastToken = this.currentTokens[this.currentTokens.length - 1]; + + // template variables are surrounded by `${}`, hence we need + // to have at least 3 tokens in the list for a valid one + assert( + this.currentTokens.length >= 3, + 'Prompt template variable should have at least 3 tokens.', + ); + + // a complete template variable must end with a `}` + assert( + lastToken instanceof RightCurlyBrace, + 'Last token is not a "}".', + ); + + // sanity checks of the first and second tokens + assert( + firstToken instanceof DollarSign, + 'First token must be a "$".', + ); + assert( + secondToken instanceof LeftCurlyBrace, + 'Second token must be a "{".', + ); + + return new PromptTemplateVariable( + BaseToken.fullRange(this.currentTokens), + this.contents, + ); + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts index d7fddaf9a06..376f251b2ff 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/parsers/promptVariableParser.ts @@ -3,18 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { pick } from '../../../../../../../base/common/arrays.js'; import { assert } from '../../../../../../../base/common/assert.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; +import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; import { PromptVariable, PromptVariableWithData } from '../tokens/promptVariable.js'; +import { At } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/at.js'; import { Tab } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/tab.js'; import { Hash } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/hash.js'; import { Space } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/space.js'; import { Colon } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/colon.js'; import { NewLine } from '../../../../../../../editor/common/codecs/linesCodec/tokens/newLine.js'; import { FormFeed } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/formFeed.js'; -import { TSimpleToken } from '../../../../../../../editor/common/codecs/simpleCodec/simpleDecoder.js'; import { VerticalTab } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/verticalTab.js'; +import { TSimpleDecoderToken } from '../../../../../../../editor/common/codecs/simpleCodec/simpleDecoder.js'; import { CarriageReturn } from '../../../../../../../editor/common/codecs/linesCodec/tokens/carriageReturn.js'; import { ExclamationMark } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/exclamationMark.js'; import { LeftBracket, RightBracket } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/brackets.js'; @@ -24,7 +25,7 @@ import { assertNotConsumed, ParserBase, TAcceptTokenResult } from '../../../../. /** * List of characters that terminate the prompt variable sequence. */ -export const STOP_CHARACTERS: readonly string[] = [Space, Tab, NewLine, CarriageReturn, VerticalTab, FormFeed] +export const STOP_CHARACTERS: readonly string[] = [Space, Tab, NewLine, CarriageReturn, VerticalTab, FormFeed, Hash, At] .map((token) => { return token.symbol; }); /** @@ -35,18 +36,18 @@ export const INVALID_NAME_CHARACTERS: readonly string[] = [Hash, Colon, Exclamat /** * The parser responsible for parsing a `prompt variable name`. - * E.g., `#selection` or `#workspace` variable. If the `:` character follows + * E.g., `#selection` or `#codebase` variable. If the `:` character follows * the variable name, the parser transitions to {@link PartialPromptVariableWithData} * that is also able to parse the `data` part of the variable. E.g., the `#file` part * of the `#file:/path/to/something.md` sequence. */ -export class PartialPromptVariableName extends ParserBase { +export class PartialPromptVariableName extends ParserBase { constructor(token: Hash) { super([token]); } @assertNotConsumed - public accept(token: TSimpleToken): TAcceptTokenResult { + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { // if a `stop` character is encountered, finish the parsing process if (STOP_CHARACTERS.includes(token.text)) { try { @@ -130,7 +131,7 @@ export class PartialPromptVariableName extends ParserBase { +export class PartialPromptVariableWithData extends ParserBase { - constructor(tokens: readonly TSimpleToken[]) { + constructor(tokens: readonly TSimpleDecoderToken[]) { const firstToken = tokens[0]; const lastToken = tokens[tokens.length - 1]; @@ -172,7 +173,7 @@ export class PartialPromptVariableWithData extends ParserBase { + public accept(token: TSimpleDecoderToken): TAcceptTokenResult { // if a `stop` character is encountered, finish the parsing process if (STOP_CHARACTERS.includes(token.text)) { // in any case, success of failure below, this is an end of the parsing process @@ -195,8 +196,8 @@ export class PartialPromptVariableWithData extends ParserBase(other: T): boolean { + if (!super.sameRange(other.range)) { + return false; + } + + if ((other instanceof PromptAtMention) === false) { + return false; + } + + if (this.text.length !== other.text.length) { + return false; + } + + return this.text === other.text; + } + + /** + * Return a string representation of the token. + */ + public override toString(): string { + return `${this.text}${this.range}`; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptSlashCommand.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptSlashCommand.ts new file mode 100644 index 00000000000..7b1e39b033d --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptSlashCommand.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptToken } from './promptToken.js'; +import { assert } from '../../../../../../../base/common/assert.js'; +import { Range } from '../../../../../../../editor/common/core/range.js'; +import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; +import { INVALID_NAME_CHARACTERS, STOP_CHARACTERS } from '../parsers/promptSlashCommandParser.js'; + +/** + * All prompt at-mentions start with `/` character. + */ +const START_CHARACTER: string = '/'; + +/** + * Represents a `/command` token in a prompt text. + */ +export class PromptSlashCommand extends PromptToken { + constructor( + range: Range, + /** + * The name of a command, excluding the `/` character at the start. + */ + public readonly name: string, + ) { + // sanity check of characters used in the provided command name + for (const character of name) { + assert( + (INVALID_NAME_CHARACTERS.includes(character) === false) && + (STOP_CHARACTERS.includes(character) === false), + `Slash command 'name' cannot contain character '${character}', got '${name}'.`, + ); + } + + super(range); + } + + /** + * Get full text of the token. + */ + public get text(): string { + return `${START_CHARACTER}${this.name}`; + } + + /** + * Check if this token is equal to another one. + */ + public override equals(other: T): boolean { + if (!super.sameRange(other.range)) { + return false; + } + + if ((other instanceof PromptSlashCommand) === false) { + return false; + } + + if (this.text.length !== other.text.length) { + return false; + } + + return this.text === other.text; + } + + /** + * Return a string representation of the token. + */ + public override toString(): string { + return `${this.text}${this.range}`; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptTemplateVariable.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptTemplateVariable.ts new file mode 100644 index 00000000000..31499f1d333 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/tokens/promptTemplateVariable.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 { PromptToken } from './promptToken.js'; +import { Range } from '../../../../../../../editor/common/core/range.js'; +import { BaseToken } from '../../../../../../../editor/common/codecs/baseToken.js'; +import { DollarSign } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/dollarSign.js'; +import { LeftCurlyBrace, RightCurlyBrace } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/curlyBraces.js'; + +/** + * Represents a `${variable}` token in a prompt text. + */ +export class PromptTemplateVariable extends PromptToken { + constructor( + range: Range, + /** + * The contents of the template variable, excluding + * the surrounding `${}` characters. + */ + public readonly contents: string, + ) { + super(range); + } + + /** + * Get full text of the token. + */ + public get text(): string { + return [ + DollarSign.symbol, + LeftCurlyBrace.symbol, + this.contents, + RightCurlyBrace.symbol, + ].join(''); + } + + /** + * Check if this token is equal to another one. + */ + public override equals(other: T): boolean { + if (!super.sameRange(other.range)) { + return false; + } + + if ((other instanceof PromptTemplateVariable) === false) { + return false; + } + + if (this.text.length !== other.text.length) { + return false; + } + + return this.text === other.text; + } + + /** + * Return a string representation of the token. + */ + public override toString(): string { + return `${this.text}${this.range}`; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/constants.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/constants.ts index 3768c6c5c7a..4dd0c536640 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/constants.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/constants.ts @@ -3,16 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PROMPT_FILE_EXTENSION } from '../../../../../platform/prompts/common/constants.js'; +import { LanguageSelector } from '../../../../../editor/common/languageSelector.js'; /** * Documentation link for the reusable prompts feature. */ -export const DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-prompt-snippets'; +export const PROMPT_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-prompt-snippets'; +export const INSTRUCTIONS_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-custom-instructions'; /** - * Prompt files language selector. + * Language ID for the reusable prompt syntax. */ -export const LANGUAGE_SELECTOR = Object.freeze({ - pattern: `**/*${PROMPT_FILE_EXTENSION}`, -}); +export const PROMPT_LANGUAGE_ID = 'prompt'; + +/** + * Language ID for instructions syntax. + */ +export const INSTRUCTIONS_LANGUAGE_ID = 'instructions'; + +/** + * Prompt and instructions files language selector. + */ +export const PROMPT_AND_INSTRUCTIONS_LANGUAGE_SELECTOR: LanguageSelector = [PROMPT_LANGUAGE_ID, INSTRUCTIONS_LANGUAGE_ID]; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/filePromptContentsProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/filePromptContentsProvider.ts index 4f35897df0a..723bf10ea3f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/filePromptContentsProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/filePromptContentsProvider.ts @@ -3,26 +3,55 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { PROMPT_LANGUAGE_ID } from '../constants.js'; import { IPromptContentsProvider } from './types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { assert } from '../../../../../../base/common/assert.js'; -import { assertDefined } from '../../../../../../base/common/types.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; -import { PromptContentsProviderBase } from './promptContentsProviderBase.js'; import { VSBufferReadableStream } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; +import { IPromptContentsProviderOptions, PromptContentsProviderBase } from './promptContentsProviderBase.js'; +import { isPromptOrInstructionsFile } from '../../../../../../platform/prompts/common/constants.js'; import { OpenFailed, NotPromptFile, ResolveError, FolderReference } from '../../promptFileReferenceErrors.js'; import { FileChangesEvent, FileChangeType, IFileService } from '../../../../../../platform/files/common/files.js'; /** - * Prompt contents provider for a file on the disk referenced by the provided {@linkcode URI}. + * Prompt contents provider for a file on the disk referenced by + * a provided {@link URI}. */ export class FilePromptContentProvider extends PromptContentsProviderBase implements IPromptContentsProvider { + public override get sourceName(): string { + return 'file'; + } + + public override get languageId(): string { + const model = this.modelService.getModel(this.uri); + + if (model !== null) { + return model.getLanguageId(); + } + + const inferredId = this.languageService + .guessLanguageIdByFilepathOrFirstLine(this.uri); + + if (inferredId !== null) { + return inferredId; + } + + // fallback to the default prompt language ID + return PROMPT_LANGUAGE_ID; + } + constructor( public readonly uri: URI, + options: Partial = {}, @IFileService private readonly fileService: IFileService, + @IModelService private readonly modelService: IModelService, + @ILanguageService private readonly languageService: ILanguageService, ) { - super(); + super(options); // make sure the object is updated on file changes this._register( @@ -79,34 +108,45 @@ export class FilePromptContentProvider extends PromptContentsProviderBase = {}, + ): IPromptContentsProvider { + return new FilePromptContentProvider( + promptContentsSource.uri, + options, + this.fileService, + this.modelService, + this.languageService, ); - - // after the promise above complete, this object can be already disposed or - // the cancellation could be requested, in that case destroy the stream and - // throw cancellation error - if (this.disposed || cancellationToken?.isCancellationRequested) { - fileStream.value.destroy(); - throw new CancellationError(); - } - - // if URI doesn't point to a prompt snippet file, don't try to resolve it - if (!this.isPromptSnippet()) { - throw new NotPromptFile(this.uri); - } - - return fileStream.value; } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/promptContentsProviderBase.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/promptContentsProviderBase.ts index 2aa8a3d0628..e90b3036aed 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/promptContentsProviderBase.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/promptContentsProviderBase.ts @@ -10,42 +10,50 @@ import { assert } from '../../../../../../base/common/assert.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { VSBufferReadableStream } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { isPromptFile } from '../../../../../../platform/prompts/common/constants.js'; import { ObservableDisposable } from '../../../../../../base/common/observableDisposable.js'; import { FailedToResolveContentsStream, ResolveError } from '../../promptFileReferenceErrors.js'; import { cancelPreviousCalls } from '../../../../../../base/common/decorators/cancelPreviousCalls.js'; +/** + * Options of the {@link PromptContentsProviderBase} class. + */ +export interface IPromptContentsProviderOptions { + /** + * Whether to allow files that don't have usual prompt + * file extension to be treated as a prompt file. + */ + readonly allowNonPromptFiles: boolean; +} + +/** + * Default {@link IPromptContentsProviderOptions} options. + */ +export const DEFAULT_OPTIONS: IPromptContentsProviderOptions = { + allowNonPromptFiles: false, +}; + /** * Base class for prompt contents providers. Classes that extend this one are responsible to: * - * - implement the {@linkcode getContentsStream} method to provide the contents stream + * - implement the {@link getContentsStream} method to provide the contents stream * of a prompt; this method should throw a `ResolveError` or its derivative if the contents * cannot be parsed for any reason - * - fire a {@linkcode TChangeEvent} event on the {@linkcode onChangeEmitter} event when + * - fire a {@link TChangeEvent} event on the {@link onChangeEmitter} event when * prompt contents change * - misc: - * - provide the {@linkcode uri} property that represents the URI of a prompt that + * - provide the {@link uri} property that represents the URI of a prompt that * the contents are for - * - implement the {@linkcode toString} method to return a string representation of this + * - implement the {@link toString} method to return a string representation of this * provider type to aid with debugging/tracing */ export abstract class PromptContentsProviderBase< TChangeEvent extends NonNullable, > extends ObservableDisposable implements IPromptContentsProvider { - /** - * Internal event emitter for the prompt contents change event. Classes that extend - * this abstract class are responsible to use this emitter to fire the contents change - * event when the prompt contents get modified. - */ - protected readonly onChangeEmitter = this._register(new Emitter()); - - constructor() { - super(); - // ensure that the `onChangeEmitter` always fires with the correct context - this.onChangeEmitter.fire = this.onChangeEmitter.fire.bind(this.onChangeEmitter); - // subscribe to the change event emitted by an extending class - this._register(this.onChangeEmitter.event(this.onContentsChanged, this)); - } + public abstract readonly uri: URI; + public abstract createNew(promptContentsSource: { uri: URI }): IPromptContentsProvider; + public abstract override toString(): string; + public abstract get languageId(): string; + public abstract get sourceName(): string; /** * Function to get contents stream for the provider. This function should @@ -61,19 +69,35 @@ export abstract class PromptContentsProviderBase< ): Promise; /** - * URI reference associated with the prompt contents. + * Internal event emitter for the prompt contents change event. Classes that extend + * this abstract class are responsible to use this emitter to fire the contents change + * event when the prompt contents get modified. */ - public abstract readonly uri: URI; + protected readonly onChangeEmitter = this._register(new Emitter()); /** - * Return a string representation of this object - * for debugging/tracing purposes. + * Options passed to the constructor, extended with + * value defaults from {@link DEFAULT_OPTIONS}. */ - public abstract override toString(): string; + protected readonly options: IPromptContentsProviderOptions; + + constructor( + options: Partial, + ) { + super(); + + this.options = { + ...DEFAULT_OPTIONS, + ...options, + }; + + // ensure that the `onChangeEmitter` always fires with the correct context + this.onChangeEmitter.fire = this.onChangeEmitter.fire.bind(this.onChangeEmitter); + } /** * Event emitter for the prompt contents change event. - * See {@linkcode onContentChanged} for more details. + * See {@link onContentChanged} for more details. */ private readonly onContentChangedEmitter = this._register(new Emitter()); @@ -84,7 +108,7 @@ export abstract class PromptContentsProviderBase< * * `Note!` this field is meant to be used by the external consumers of the prompt * contents provider that the classes that extend this abstract class. - * Please use the {@linkcode onChangeEmitter} event to provide a change + * Please use the {@link onChangeEmitter} event to provide a change * event in your prompt contents implementation instead. */ public readonly onContentChanged = this.onContentChangedEmitter.event; @@ -138,13 +162,9 @@ export abstract class PromptContentsProviderBase< // `'full'` means "everything has changed" this.onContentsChanged('full'); + // subscribe to the change event emitted by a child class + this._register(this.onChangeEmitter.event(this.onContentsChanged, this)); + return this; } - - /** - * Check if the current URI points to a prompt snippet. - */ - public isPromptSnippet(): boolean { - return isPromptFile(this.uri); - } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/textModelContentsProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/textModelContentsProvider.ts index b980da871fb..75f0623ff3b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/textModelContentsProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/textModelContentsProvider.ts @@ -3,27 +3,46 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IPromptContentsProvider } from './types.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; -import { PromptContentsProviderBase } from './promptContentsProviderBase.js'; +import { FilePromptContentProvider } from './filePromptContentsProvider.js'; +import { TextModel } from '../../../../../../editor/common/model/textModel.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { newWriteableStream, ReadableStream } from '../../../../../../base/common/stream.js'; import { IModelContentChangedEvent } from '../../../../../../editor/common/textModelEvents.js'; +import { IPromptContentsProviderOptions, PromptContentsProviderBase } from './promptContentsProviderBase.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; /** - * Prompt contents provider for a {@linkcode ITextModel} instance. + * Prompt contents provider for a {@link ITextModel} instance. */ export class TextModelContentsProvider extends PromptContentsProviderBase { /** * URI component of the prompt associated with this contents provider. */ - public readonly uri = this.model.uri; + public get uri(): URI { + return this.model.uri; + } + + public override get sourceName(): string { + return 'text-model'; + } + + public override get languageId(): string { + return this.model.getLanguageId(); + } constructor( private readonly model: ITextModel, + options: Partial = {}, + @IInstantiationService private readonly initService: IInstantiationService, + @ILogService private readonly logService: ILogService, ) { - super(); + super(options); this._register(this.model.onWillDispose(this.dispose.bind(this))); this._register(this.model.onDidChangeContent(this.onChangeEmitter.fire)); @@ -45,11 +64,21 @@ export class TextModelContentsProvider extends PromptContentsProviderBase> { const stream = newWriteableStream(null); - const linesCount = this.model.getLineCount(); - // provide the changed lines to the stream incrementaly and asynchronously + // the `getLineCount`method throws is model is already disposed + // hence to be extra safe, we check the model state before getting + // the number of available lines in the text model + if (this.model.isDisposed()) { + stream.end(); + stream.destroy(); + + return stream; + } + + // provide the changed lines to the stream incrementally and asynchronously // to avoid blocking the main thread and save system resources used let i = 1; + const linesCount = this.model.getLineCount(); const interval = setInterval(() => { // if we have written all lines or lines count is zero, // end the stream and stop the interval timer @@ -74,7 +103,7 @@ export class TextModelContentsProvider extends PromptContentsProviderBase = {}, + ): IPromptContentsProvider { + if (promptContentsSource instanceof TextModel) { + return this.initService.createInstance( + TextModelContentsProvider, + promptContentsSource, + options, + ); + } + + return this.initService.createInstance( + FilePromptContentProvider, + promptContentsSource.uri, + options, + ); + } + /** * String representation of this object. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/types.d.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/types.ts similarity index 77% rename from src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/types.d.ts rename to src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/types.ts index 3ff8c569b6d..3b32a4cf7b8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/types.d.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/types.ts @@ -19,6 +19,16 @@ export interface IPromptContentsProvider extends IDisposable { */ readonly uri: URI; + /** + * Language ID of the prompt contents. + */ + readonly languageId: string; + + /** + * Prompt contents source name. + */ + readonly sourceName: string; + /** * Start the contents provider to produce the underlying contents. */ @@ -32,4 +42,16 @@ export interface IPromptContentsProvider extends IDisposable { onContentChanged( callback: (streamOrError: VSBufferReadableStream | ResolveError) => void, ): IDisposable; + + /** + * Subscribe to `onDispose` event of the contents provider. + */ + onDispose(callback: () => void): this; + + /** + * Create a new instance of prompt contents provider. + */ + createNew( + promptContentsSource: { uri: URI }, + ): IPromptContentsProvider; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptLinkDiagnosticsProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptLinkDiagnosticsProvider.ts deleted file mode 100644 index 7d46c72dbbe..00000000000 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptLinkDiagnosticsProvider.ts +++ /dev/null @@ -1,224 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IPromptsService } from '../service/types.js'; -import { IPromptFileReference } from '../parsers/types.js'; -import { assert } from '../../../../../../base/common/assert.js'; -import { NotPromptFile } from '../../promptFileReferenceErrors.js'; -import { ITextModel } from '../../../../../../editor/common/model.js'; -import { assertDefined } from '../../../../../../base/common/types.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { IEditor } from '../../../../../../editor/common/editorCommon.js'; -import { ObjectCache } from '../../../../../../base/common/objectCache.js'; -import { TextModelPromptParser } from '../parsers/textModelPromptParser.js'; -import { Registry } from '../../../../../../platform/registry/common/platform.js'; -import { PromptsConfig } from '../../../../../../platform/prompts/common/config.js'; -import { isPromptFile } from '../../../../../../platform/prompts/common/constants.js'; -import { LifecyclePhase } from '../../../../../services/lifecycle/common/lifecycle.js'; -import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { ObservableDisposable } from '../../../../../../base/common/observableDisposable.js'; -import { IWorkbenchContributionsRegistry, Extensions } from '../../../../../common/contributions.js'; -import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../../../platform/markers/common/markers.js'; - -/** - * Unique ID of the markers provider class. - */ -const MARKERS_OWNER_ID = 'reusable-prompts-syntax'; - -/** - * Prompt links diagnostics provider for a single text model. - */ -class PromptLinkDiagnosticsProvider extends ObservableDisposable { - /** - * Reference to the current prompt syntax parser instance. - */ - private readonly parser: TextModelPromptParser; - - constructor( - private readonly editor: ITextModel, - @IMarkerService private readonly markerService: IMarkerService, - @IPromptsService private readonly promptsService: IPromptsService, - ) { - super(); - - this.parser = this.promptsService - .getSyntaxParserFor(this.editor) - .onUpdate(this.updateMarkers.bind(this)) - .onDispose(this.dispose.bind(this)) - .start(); - - // initialize markers - this.updateMarkers(); - } - - /** - * Update diagnostic markers for the current editor. - */ - private async updateMarkers() { - // ensure that parsing process is settled - await this.parser.allSettled(); - - // clean up all previously added markers - this.markerService.remove(MARKERS_OWNER_ID, [this.editor.uri]); - - const markers: IMarkerData[] = []; - for (const link of this.parser.references) { - const { topError, linkRange } = link; - - if (!topError || !linkRange) { - continue; - } - - const { originalError } = topError; - - // the `NotPromptFile` error is allowed because we allow users - // to include non-prompt file links in the prompt files - // note! this check also handles the `FolderReference` error - if (originalError instanceof NotPromptFile) { - continue; - } - - markers.push(toMarker(link)); - } - - this.markerService.changeOne( - MARKERS_OWNER_ID, - this.editor.uri, - markers, - ); - } -} - -/** - * Convert a prompt link with an issue to a marker data. - * - * @throws - * - if there is no link issue (e.g., `topError` undefined) - * - if there is no link range to highlight (e.g., `linkRange` undefined) - * - if the original error is of `NotPromptFile` type - we don't want to - * show diagnostic markers for non-prompt file links in the prompts - */ -const toMarker = ( - link: IPromptFileReference, -): IMarkerData => { - const { topError, linkRange } = link; - - // a sanity check because this function must be - // used only if these link attributes are present - assertDefined( - topError, - 'Top error must to be defined.', - ); - assertDefined( - linkRange, - 'Link range must to be defined.', - ); - - const { originalError } = topError; - assert( - !(originalError instanceof NotPromptFile), - 'Error must not be of "not prompt file" type.', - ); - - // `error` severity for the link itself, `warning` for any of its children - const severity = (topError.errorSubject === 'root') - ? MarkerSeverity.Error - : MarkerSeverity.Warning; - - return { - message: topError.localizedMessage, - severity, - ...linkRange, - }; -}; - -/** - * The class that manages creation and disposal of {@link PromptLinkDiagnosticsProvider} - * classes for each specific editor text model. - */ -export class PromptLinkDiagnosticsInstanceManager extends Disposable { - /** - * Currently available {@link PromptLinkDiagnosticsProvider} instances. - */ - private readonly providers: ObjectCache; - - constructor( - @IEditorService editorService: IEditorService, - @IInstantiationService initService: IInstantiationService, - @IConfigurationService configService: IConfigurationService, - ) { - super(); - - // cache of prompt marker providers - this.providers = this._register( - new ObjectCache((editor: ITextModel) => { - const parser: PromptLinkDiagnosticsProvider = initService.createInstance( - PromptLinkDiagnosticsProvider, - editor, - ); - - // this is a sanity check and the contract of the object cache, - // we must return a non-disposed object from this factory function - parser.assertNotDisposed( - 'Created prompt parser must not be disposed.', - ); - - return parser; - }), - ); - - // if the feature is disabled, do not create any providers - if (!PromptsConfig.enabled(configService)) { - return; - } - - // subscribe to changes of the active editor - this._register(editorService.onDidActiveEditorChange(() => { - const { activeTextEditorControl } = editorService; - if (!activeTextEditorControl) { - return; - } - - this.handleNewEditor(activeTextEditorControl); - })); - - // handle existing visible text editors - editorService - .visibleTextEditorControls - .forEach(this.handleNewEditor.bind(this)); - } - - /** - * Initialize a new {@link PromptLinkDiagnosticsProvider} for the given editor. - */ - private handleNewEditor(editor: IEditor): this { - const model = editor.getModel(); - if (!model) { - return this; - } - - // we support only `text editors` for now so filter out `diff` ones - if ('modified' in model || 'model' in model) { - return this; - } - - // enable this only for prompt file editors - if (!isPromptFile(model.uri)) { - return this; - } - - // note! calling `get` also creates a provider if it does not exist; - // and the provider is auto-removed when the model is disposed - this.providers.get(model); - - return this; - } -} - -// register the provider as a workbench contribution -Registry.as(Extensions.Workbench) - .registerWorkbenchContribution(PromptLinkDiagnosticsInstanceManager, LifecyclePhase.Eventually); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/frontMatterDecoration.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/frontMatterDecoration.ts new file mode 100644 index 00000000000..7dc4458ca48 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/frontMatterDecoration.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CssClassModifiers } from '../types.js'; +import { localize } from '../../../../../../../../../nls.js'; +import { FrontMatterMarkerDecoration } from './frontMatterMarkerDecoration.js'; +import { Position } from '../../../../../../../../../editor/common/core/position.js'; +import { BaseToken } from '../../../../../../../../../editor/common/codecs/baseToken.js'; +import { TAddAccessor, TDecorationStyles, ReactiveDecorationBase, asCssVariable } from './utils/index.js'; +import { contrastBorder, editorBackground } from '../../../../../../../../../platform/theme/common/colorRegistry.js'; +import { ColorIdentifier, darken, registerColor } from '../../../../../../../../../platform/theme/common/colorUtils.js'; +import { FrontMatterHeader } from '../../../../../../../../../editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterHeader.js'; + +/** + * Decoration CSS class names. + */ +export enum CssClassNames { + main = '.prompt-front-matter-decoration', + inline = '.prompt-front-matter-decoration-inline', + mainInactive = `${CssClassNames.main}${CssClassModifiers.inactive}`, + inlineInactive = `${CssClassNames.inline}${CssClassModifiers.inactive}`, +} + +/** + * Main background color of `active` Front Matter header block. + */ +export const BACKGROUND_COLOR: ColorIdentifier = registerColor( + 'prompt.frontMatter.background', + { dark: darken(editorBackground, 0.2), light: darken(editorBackground, 0.05), hcDark: contrastBorder, hcLight: contrastBorder }, + localize('chat.prompt.frontMatter.background.description', "Background color of a Front Matter header block."), +); + +/** + * Background color of `inactive` Front Matter header block. + */ +export const INACTIVE_BACKGROUND_COLOR: ColorIdentifier = registerColor( + 'prompt.frontMatter.inactiveBackground', + { dark: darken(editorBackground, 0.1), light: darken(editorBackground, 0.025), hcDark: contrastBorder, hcLight: contrastBorder }, + localize('chat.prompt.frontMatter.inactiveBackground.description', "Background color of an inactive Front Matter header block."), +); + +/** + * CSS styles for the decoration. + */ +export const CSS_STYLES = { + [CssClassNames.main]: [ + `background-color: ${asCssVariable(BACKGROUND_COLOR)};`, + 'z-index: -1;', // this is required to allow for selections to appear above the decoration background + ], + [CssClassNames.mainInactive]: [ + `background-color: ${asCssVariable(INACTIVE_BACKGROUND_COLOR)};`, + ], + [CssClassNames.inlineInactive]: [ + 'color: var(--vscode-disabledForeground);', + ], + ...FrontMatterMarkerDecoration.cssStyles, +}; + +/** + * Editor decoration for the Front Matter header token inside a prompt. + */ +export class FrontMatterDecoration extends ReactiveDecorationBase { + constructor( + accessor: TAddAccessor, + token: FrontMatterHeader, + ) { + super(accessor, token); + + this.childDecorators.push( + new FrontMatterMarkerDecoration(accessor, token.startMarker), + new FrontMatterMarkerDecoration(accessor, token.endMarker), + ); + } + + public override setCursorPosition( + position: Position | null | undefined, + ): this is { readonly changed: true } { + const result = super.setCursorPosition(position); + + for (const marker of this.childDecorators) { + if ((marker instanceof FrontMatterMarkerDecoration) === false) { + continue; + } + + // activate/deactivate markers based on the active state + // of the main Front Matter header decoration + marker.activate(this.active); + } + + return result; + } + + protected override get classNames() { + return CssClassNames; + } + + protected override get isWholeLine(): boolean { + return true; + } + + protected override get description(): string { + return 'Front Matter header decoration.'; + } + + public static get cssStyles(): TDecorationStyles { + return CSS_STYLES; + } + + /** + * Whether current decoration class can decorate provided token. + */ + public static handles( + token: BaseToken, + ): token is FrontMatterHeader { + return token instanceof FrontMatterHeader; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/frontMatterMarkerDecoration.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/frontMatterMarkerDecoration.ts new file mode 100644 index 00000000000..6955598cf06 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/frontMatterMarkerDecoration.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CssClassModifiers } from '../types.js'; +import { TDecorationStyles, ReactiveDecorationBase } from './utils/index.js'; +import { FrontMatterMarker } from '../../../../../../../../../editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterMarker.js'; + +/** + * Decoration CSS class names. + */ +export enum CssClassNames { + main = '.prompt-front-matter-decoration-marker', + inline = '.prompt-front-matter-decoration-marker-inline', + mainInactive = `${CssClassNames.main}${CssClassModifiers.inactive}`, + inlineInactive = `${CssClassNames.inline}${CssClassModifiers.inactive}`, +} + +/** + * Editor decoration for a `marker` token of a Front Matter header. + */ +export class FrontMatterMarkerDecoration extends ReactiveDecorationBase { + /** + * Activate/deactivate the decoration. + */ + public activate(state: boolean): this { + const position = (state === true) + ? this.token.range.getStartPosition() + : null; + + this.setCursorPosition(position); + + return this; + } + + protected override get classNames() { + return CssClassNames; + } + + protected override get description(): string { + return 'Marker decoration of a Front Matter header.'; + } + + public static get cssStyles(): TDecorationStyles { + return { + [CssClassNames.inline]: [ + 'color: var(--vscode-disabledForeground);', + ], + [CssClassNames.inlineInactive]: [ + 'opacity: 0.25;', + ], + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/decorationBase.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/decorationBase.ts new file mode 100644 index 00000000000..feaafbe966d --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/decorationBase.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../../../../../../../../editor/common/core/range.js'; +import { IMarkdownString } from '../../../../../../../../../../base/common/htmlContent.js'; +import { BaseToken } from '../../../../../../../../../../editor/common/codecs/baseToken.js'; +import { TrackedRangeStickiness } from '../../../../../../../../../../editor/common/model.js'; +import type { TAddAccessor, TChangeAccessor, TDecorationStyles, TRemoveAccessor } from './types.js'; +import { ModelDecorationOptions } from '../../../../../../../../../../editor/common/model/textModel.js'; + +/** + * Base class for all editor decorations. + */ +export abstract class DecorationBase< + TPromptToken extends BaseToken, + TCssClassName extends string = string, +> { + /** + * Description of the decoration. + */ + protected abstract get description(): string; + + /** + * Default CSS class name of the decoration. + */ + protected abstract get className(): TCssClassName; + + /** + * Inline CSS class name of the decoration. + */ + protected abstract get inlineClassName(): TCssClassName; + + /** + * Indicates whether the decoration spans the whole line(s). + */ + protected get isWholeLine(): boolean { + return false; + } + + /** + * Hover message of the decoration. + */ + protected get hoverMessage(): IMarkdownString | IMarkdownString[] | null { + return null; + } + + /** + * ID of editor decoration it was registered with. + */ + public readonly id: string; + + constructor( + accessor: TAddAccessor, + protected readonly token: TPromptToken, + ) { + this.id = accessor.addDecoration(this.range, this.decorationOptions); + } + + /** + * Range of the decoration. + */ + public get range(): Range { + return this.token.range; + } + + /** + * Changes the decoration in the editor. + */ + public change( + accessor: TChangeAccessor, + ): this { + accessor.changeDecorationOptions( + this.id, + this.decorationOptions, + ); + + return this; + } + + /** + * Removes associated editor decoration(s). + */ + public remove( + accessor: TRemoveAccessor, + ): this { + accessor.removeDecoration(this.id); + + return this; + } + + /** + * Get editor decoration options for this decorator. + */ + private get decorationOptions(): ModelDecorationOptions { + return ModelDecorationOptions.createDynamic({ + description: this.description, + hoverMessage: this.hoverMessage, + className: this.className, + inlineClassName: this.inlineClassName, + isWholeLine: this.isWholeLine, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + shouldFillLineOnLineBreak: true, + }); + } +} + +/** + * Type of a generic decoration class. + */ +export type TDecorationClass = { + new( + accessor: TAddAccessor, + token: TPromptToken, + ): DecorationBase; + + /** + * CSS styles for the decoration. + */ + readonly cssStyles: TDecorationStyles; + + /** + * Whether the decoration class handles the provided token. + */ + handles(token: BaseToken): token is TPromptToken; +}; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/index.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/index.ts new file mode 100644 index 00000000000..bfe3e55bb19 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/index.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ColorIdentifier } from '../../../../../../../../../../platform/theme/common/colorUtils.js'; + +/** + * Convert a registered color to a CSS variable string. + */ +export const asCssVariable = (color: ColorIdentifier): string => { + return `var(--vscode-${color.replaceAll('.', '-')})`; +}; + +export type * from './types.js'; +export { DecorationBase, type TDecorationClass } from './decorationBase.js'; +export { ReactiveDecorationBase, type TChangedDecorator } from './reactiveDecorationBase.js'; + diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/reactiveDecorationBase.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/reactiveDecorationBase.ts new file mode 100644 index 00000000000..d36262fc18c --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/reactiveDecorationBase.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DecorationBase } from './decorationBase.js'; +import { Position } from '../../../../../../../../../../editor/common/core/position.js'; +import { BaseToken } from '../../../../../../../../../../editor/common/codecs/baseToken.js'; +import type { IReactiveDecorationClassNames, TAddAccessor, TChangeAccessor, TRemoveAccessor } from './types.js'; + +/** + * Base class for all reactive editor decorations. A reactive decoration + * is a decoration that can change its appearance based on current cursor + * position in the editor, hence can "react" to the user's actions. + */ +export abstract class ReactiveDecorationBase< + TPromptToken extends BaseToken, + TCssClassName extends string = string, +> extends DecorationBase { + /** + * CSS class names of the decoration. + */ + protected abstract get classNames(): IReactiveDecorationClassNames; + + /** + * A list of child decorators that are part of this decoration. + * For instance a Front Matter header decoration can have child + * decorators for each of the header's `---` markers. + */ + protected readonly childDecorators: DecorationBase[]; + + /** + * Whether the decoration has changed since the last {@link change}. + */ + public get changed(): boolean { + // if any of the child decorators changed, this object is also + // considered to be changed + for (const marker of this.childDecorators) { + if ((marker instanceof ReactiveDecorationBase) === false) { + continue; + } + + if (marker.changed === true) { + return true; + } + } + + return this.didChange; + } + + constructor( + accessor: TAddAccessor, + token: TPromptToken, + ) { + super(accessor, token); + + this.childDecorators = []; + } + + /** + * Current position of cursor in the editor. + */ + private cursorPosition?: Position | null; + + /** + * Private field for the {@link changed} property. + */ + private didChange = true; + + /** + * Whether cursor is currently inside the decoration range. + */ + protected get active(): boolean { + return true; + + /** + * Temporarily disable until we have a proper way to get + * the cursor position inside active editor. + */ + /** + * if (!this.cursorPosition) { + * return false; + * } + * + * // when cursor is at the end of a range, the range considered to + * // not contain the position, but we want to include it + * const atEnd = (this.range.endLineNumber === this.cursorPosition.lineNumber) + * && (this.range.endColumn === this.cursorPosition.column); + * + * return atEnd || this.range.containsPosition(this.cursorPosition); + */ + } + + /** + * Set cursor position and update {@link changed} property if needed. + */ + public setCursorPosition( + position: Position | null | undefined, + ): this is { readonly changed: true } { + if (this.cursorPosition === position) { + return false; + } + + if (this.cursorPosition && position) { + if (this.cursorPosition.equals(position)) { + return false; + } + } + + const wasActive = this.active; + this.cursorPosition = position; + this.didChange = (wasActive !== this.active); + + return this.changed; + } + + public override change( + accessor: TChangeAccessor, + ): this { + if (this.didChange === false) { + return this; + } + + super.change(accessor); + this.didChange = false; + + for (const marker of this.childDecorators) { + marker.change(accessor); + } + + return this; + } + + public override remove( + accessor: TRemoveAccessor, + ): this { + super.remove(accessor); + + for (const marker of this.childDecorators) { + marker.remove(accessor); + } + + return this; + } + + protected override get className() { + return (this.active) + ? this.classNames.main + : this.classNames.mainInactive; + } + + protected override get inlineClassName() { + return (this.active) + ? this.classNames.inline + : this.classNames.inlineInactive; + } +} + +/** + * Type for a decorator with {@link ReactiveDecorationBase.changed changed} property set to `true`. + */ +export type TChangedDecorator = ReactiveDecorationBase & { readonly changed: true }; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/types.d.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/types.d.ts new file mode 100644 index 00000000000..a24ad4b2162 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/decorations/utils/types.d.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IModelDecorationsChangeAccessor, TrackedRangeStickiness } from '../../../../../../../../../../editor/common/model.js'; + +/** + * CSS class names of a `reactive` decoration. + */ +export interface IReactiveDecorationClassNames { + /** + * Main, default CSS class name of the decoration. + */ + readonly main: T; + + /** + * CSS class name of the decoration for the `inline`(text) styles. + */ + readonly inline: T; + + /** + * main CSS class name of the decoration for the `inactive` + * decoration state. + */ + readonly mainInactive: T; + + /** + * CSS class name of the decoration for the `inline`(text) + * styles when decoration is in the `inactive` state. + */ + readonly inlineInactive: T; +} + +/** + * CSS styles for a decoration to be registered with editor. + */ +export type TDecorationStyles = { + readonly [key in TClassNames]: readonly string[]; +}; + +/** + * A model decorations accessor that can be used to `add` a decoration. + */ +export type TAddAccessor = Pick; + +/** + * A model decorations accessor that can be used to `change` a decoration. + */ +export type TChangeAccessor = Pick; + +/** + * A model decorations accessor that can be used to `remove` a decoration. + */ +export type TRemoveAccessor = Pick; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/promptDecorationsProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/promptDecorationsProvider.ts new file mode 100644 index 00000000000..65bf1dcc328 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/promptDecorationsProvider.ts @@ -0,0 +1,206 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPromptsService } from '../../../service/types.js'; +import { ProviderInstanceBase } from '../providerInstanceBase.js'; +import { ITextModel } from '../../../../../../../../editor/common/model.js'; +import { FrontMatterDecoration } from './decorations/frontMatterDecoration.js'; +import { toDisposable } from '../../../../../../../../base/common/lifecycle.js'; +import { ProviderInstanceManagerBase } from '../providerInstanceManagerBase.js'; +import { Position } from '../../../../../../../../editor/common/core/position.js'; +import { BaseToken } from '../../../../../../../../editor/common/codecs/baseToken.js'; +import { registerThemingParticipant } from '../../../../../../../../platform/theme/common/themeService.js'; +import { FrontMatterHeader } from '../../../../../../../../editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterHeader.js'; +import { DecorationBase, ReactiveDecorationBase, type TDecorationClass, type TChangedDecorator } from './decorations/utils/index.js'; + +/** + * Prompt tokens that are decorated by this provider. + */ +type TDecoratedToken = FrontMatterHeader; + +/** + * List of all supported decorations. + */ +const SUPPORTED_DECORATIONS: readonly TDecorationClass[] = Object.freeze([ + FrontMatterDecoration, +]); + +/** + * Prompt syntax decorations provider for text models. + */ +export class PromptDecorator extends ProviderInstanceBase { + /** + * Currently active decorations. + */ + private readonly decorations: DecorationBase[] = []; + + constructor( + model: ITextModel, + @IPromptsService promptsService: IPromptsService, + ) { + super(model, promptsService); + + this.watchCursorPosition(); + } + + protected override async onPromptParserUpdate(): Promise { + await this.parser.allSettled(); + + // by the time the promise above completes, either this object + // or the text model might be already has been disposed + if (this.disposed || this.model.isDisposed()) { + return this; + } + + this.removeAllDecorations(); + this.addDecorations(); + + return this; + } + + /** + * Get the current cursor position inside an active editor. + * Note! Currently not implemented because the provider is disabled, and + * we need to do some refactoring to get accurate cursor position. + */ + private get cursorPosition(): Position | null { + if (this.model.isDisposed()) { + return null; + } + + return null; + } + + /** + * Watch editor cursor position and update reactive decorations accordingly. + */ + private watchCursorPosition(): this { + const interval = setInterval(() => { + const { cursorPosition } = this; + + const changedDecorations: TChangedDecorator[] = []; + for (const decoration of this.decorations) { + if ((decoration instanceof ReactiveDecorationBase) === false) { + continue; + } + + if (decoration.setCursorPosition(cursorPosition) === true) { + changedDecorations.push(decoration); + } + } + + if (changedDecorations.length === 0) { + return; + } + + this.changeModelDecorations(changedDecorations); + }, 25); + + this._register(toDisposable(() => { + clearInterval(interval); + })); + + return this; + } + + /** + * Update existing decorations. + */ + private changeModelDecorations( + decorations: readonly TChangedDecorator[], + ): this { + this.model.changeDecorations((accessor) => { + for (const decoration of decorations) { + decoration.change(accessor); + } + }); + + return this; + } + + /** + * Add decorations for all prompt tokens. + */ + private addDecorations(): this { + this.model.changeDecorations((accessor) => { + const { tokens } = this.parser; + + if (tokens.length === 0) { + return; + } + + for (const token of tokens) { + for (const Decoration of SUPPORTED_DECORATIONS) { + if (Decoration.handles(token) === false) { + continue; + } + + this.decorations.push( + new Decoration(accessor, token), + ); + break; + } + } + }); + + return this; + } + + /** + * Remove all existing decorations. + */ + private removeAllDecorations(): this { + if (this.decorations.length === 0) { + return this; + } + + this.model.changeDecorations((accessor) => { + for (const decoration of this.decorations) { + decoration.remove(accessor); + } + + this.decorations.splice(0); + }); + + return this; + } + + public override dispose(): void { + if (this.disposed) { + return; + } + + this.removeAllDecorations(); + + super.dispose(); + } + + /** + * Returns a string representation of this object. + */ + public override toString() { + return `text-model-prompt-decorator:${this.model.uri.path}`; + } +} + +/** + * Register CSS styles of the supported decorations. + */ +registerThemingParticipant((_theme, collector) => { + for (const Decoration of SUPPORTED_DECORATIONS) { + for (const [className, styles] of Object.entries(Decoration.cssStyles)) { + collector.addRule(`.monaco-editor ${className} { ${styles.join(' ')} }`); + } + } +}); + +/** + * Provider for prompt syntax decorators on text models. + */ +export class PromptDecorationsProviderInstanceManager extends ProviderInstanceManagerBase { + protected override get InstanceClass() { + return PromptDecorator; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/types.d.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/types.ts similarity index 68% rename from src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/types.d.ts rename to src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/types.ts index 85e7f645b75..a7ef26521d2 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/types.d.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/decorationsProvider/types.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IRange } from '../../../../../../editor/common/core/range.js'; -import { ModelDecorationOptions } from '../../../../../../editor/common/model/textModel.js'; +import { IRange } from '../../../../../../../../editor/common/core/range.js'; +import { ModelDecorationOptions } from '../../../../../../../../editor/common/model/textModel.js'; /** * Decoration object. @@ -35,3 +35,14 @@ export enum DecorationClassNames { */ fileReference = DecorationClassNames.default, } + +/** + * Decoration CSS class modifiers. + */ +export enum CssClassModifiers { + /** + * CSS class modifier for `active` state of + * a `reactive` prompt syntax decoration. + */ + inactive = '.prompt-decoration-inactive', +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/index.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/index.ts new file mode 100644 index 00000000000..5630fb95e60 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/index.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptLinkProvider } from './promptLinkProvider.js'; +import { isWindows } from '../../../../../../../base/common/platform.js'; +import { PromptPathAutocompletion } from './promptPathAutocompletion.js'; +import { Registry } from '../../../../../../../platform/registry/common/platform.js'; +import { LifecyclePhase } from '../../../../../../services/lifecycle/common/lifecycle.js'; +import { PromptLinkDiagnosticsInstanceManager } from './promptLinkDiagnosticsProvider.js'; +import { PromptHeaderDiagnosticsInstanceManager } from './promptHeaderDiagnosticsProvider.js'; +import { BrandedService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { PromptDecorationsProviderInstanceManager } from './decorationsProvider/promptDecorationsProvider.js'; +import { IWorkbenchContributionsRegistry, Extensions, IWorkbenchContribution } from '../../../../../../common/contributions.js'; + +/** + * Whether to enable decorations in the prompt editor. + */ +export const DECORATIONS_ENABLED = true; + +/** + * Register all language features related to reusable prompts files. + */ +export const registerReusablePromptLanguageFeatures = () => { + registerContribution(PromptLinkProvider); + registerContribution(PromptLinkDiagnosticsInstanceManager); + registerContribution(PromptHeaderDiagnosticsInstanceManager); + + if (DECORATIONS_ENABLED) { + registerContribution(PromptDecorationsProviderInstanceManager); + } + + /** + * We restrict this provider to `Unix` machines for now because of + * the filesystem paths differences on `Windows` operating system. + * + * Notes on `Windows` support: + * - we add the `./` for the first path component, which may not work on `Windows` + * - the first path component of the absolute paths must be a drive letter + */ + if (isWindows === false) { + registerContribution(PromptPathAutocompletion); + } +}; + +/** + * Register a specific workbench contribution. + */ +const registerContribution = ( + contribution: new (...services: TServices) => IWorkbenchContribution, +) => { + Registry.as(Extensions.Workbench) + .registerWorkbenchContribution(contribution, LifecyclePhase.Eventually); +}; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptHeaderDiagnosticsProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptHeaderDiagnosticsProvider.ts new file mode 100644 index 00000000000..db6dbce4cb7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptHeaderDiagnosticsProvider.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPromptsService } from '../../service/types.js'; +import { ProviderInstanceBase } from './providerInstanceBase.js'; +import { assertNever } from '../../../../../../../base/common/assert.js'; +import { ITextModel } from '../../../../../../../editor/common/model.js'; +import { ProviderInstanceManagerBase } from './providerInstanceManagerBase.js'; +import { TDiagnostic, PromptMetadataError, PromptMetadataWarning } from '../../parsers/promptHeader/diagnostics.js'; +import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../../../../platform/markers/common/markers.js'; + +/** + * Unique ID of the markers provider class. + */ +const MARKERS_OWNER_ID = 'prompts-header-diagnostics-provider'; + +/** + * Prompt header diagnostics provider for an individual text model + * of a prompt file. + */ +class PromptHeaderDiagnosticsProvider extends ProviderInstanceBase { + constructor( + model: ITextModel, + @IPromptsService promptsService: IPromptsService, + @IMarkerService private readonly markerService: IMarkerService, + ) { + super(model, promptsService); + } + + /** + * Update diagnostic markers for the current editor. + */ + protected override async onPromptParserUpdate(): Promise { + // ensure that parsing process is settled + await this.parser.allSettled(); + + // clean up all previously added markers + this.markerService.remove(MARKERS_OWNER_ID, [this.model.uri]); + + const { header } = this.parser; + if (header === undefined) { + return this; + } + + const markers: IMarkerData[] = []; + for (const diagnostic of header.diagnostics) { + markers.push(toMarker(diagnostic)); + } + + this.markerService.changeOne( + MARKERS_OWNER_ID, + this.model.uri, + markers, + ); + + return this; + } + + /** + * Returns a string representation of this object. + */ + public override toString() { + return `prompt-link-diagnostics:${this.model.uri.path}`; + } +} + +/** + * Convert a provided diagnostic object into a marker data object. + */ +const toMarker = ( + diagnostic: TDiagnostic, +): IMarkerData => { + if (diagnostic instanceof PromptMetadataWarning) { + return { + message: diagnostic.message, + severity: MarkerSeverity.Warning, + ...diagnostic.range, + }; + } + + if (diagnostic instanceof PromptMetadataError) { + return { + message: diagnostic.message, + severity: MarkerSeverity.Error, + ...diagnostic.range, + }; + } + + + assertNever( + diagnostic, + `Unknown prompt metadata diagnostic type '${diagnostic}'.`, + ); +}; + +/** + * The class that manages creation and disposal of {@link PromptHeaderDiagnosticsProvider} + * classes for each specific editor text model. + */ +export class PromptHeaderDiagnosticsInstanceManager extends ProviderInstanceManagerBase { + protected override get InstanceClass() { + return PromptHeaderDiagnosticsProvider; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptLinkDiagnosticsProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptLinkDiagnosticsProvider.ts new file mode 100644 index 00000000000..29400894fa1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptLinkDiagnosticsProvider.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPromptsService } from '../../service/types.js'; +import { IPromptFileReference } from '../../parsers/types.js'; +import { ProviderInstanceBase } from './providerInstanceBase.js'; +import { assert } from '../../../../../../../base/common/assert.js'; +import { NotPromptFile } from '../../../promptFileReferenceErrors.js'; +import { ITextModel } from '../../../../../../../editor/common/model.js'; +import { assertDefined } from '../../../../../../../base/common/types.js'; +import { ProviderInstanceManagerBase } from './providerInstanceManagerBase.js'; +import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../../../../platform/markers/common/markers.js'; + +/** + * Unique ID of the markers provider class. + */ +const MARKERS_OWNER_ID = 'prompt-link-diagnostics-provider'; + +/** + * Prompt links diagnostics provider for a single text model. + */ +class PromptLinkDiagnosticsProvider extends ProviderInstanceBase { + constructor( + model: ITextModel, + @IPromptsService promptsService: IPromptsService, + @IMarkerService private readonly markerService: IMarkerService, + ) { + super(model, promptsService); + } + + /** + * Update diagnostic markers for the current editor. + */ + protected override async onPromptParserUpdate() { + // ensure that parsing process is settled + await this.parser.allSettled(); + + // clean up all previously added markers + this.markerService.remove(MARKERS_OWNER_ID, [this.model.uri]); + + const markers: IMarkerData[] = []; + for (const link of this.parser.references) { + const { topError, linkRange } = link; + + if (!topError || !linkRange) { + continue; + } + + const { originalError } = topError; + + // the `NotPromptFile` error is allowed because we allow users + // to include non-prompt file links in the prompt files + // note! this check also handles the `FolderReference` error + if (originalError instanceof NotPromptFile) { + continue; + } + + markers.push(toMarker(link)); + } + + this.markerService.changeOne( + MARKERS_OWNER_ID, + this.model.uri, + markers, + ); + + return this; + } + + /** + * Returns a string representation of this object. + */ + public override toString() { + return `prompt-link-diagnostics:${this.model.uri.path}`; + } +} + +/** + * Convert a prompt link with an issue to a marker data. + * + * @throws + * - if there is no link issue (e.g., `topError` undefined) + * - if there is no link range to highlight (e.g., `linkRange` undefined) + * - if the original error is of `NotPromptFile` type - we don't want to + * show diagnostic markers for non-prompt file links in the prompts + */ +const toMarker = ( + link: IPromptFileReference, +): IMarkerData => { + const { topError, linkRange } = link; + + // a sanity check because this function must be + // used only if these link attributes are present + assertDefined( + topError, + 'Top error must to be defined.', + ); + assertDefined( + linkRange, + 'Link range must to be defined.', + ); + + const { originalError } = topError; + assert( + !(originalError instanceof NotPromptFile), + 'Error must not be of "not prompt file" type.', + ); + + // `error` severity for the link itself, `warning` for any of its children + const severity = (topError.errorSubject === 'root') + ? MarkerSeverity.Error + : MarkerSeverity.Warning; + + return { + message: topError.localizedMessage, + severity, + ...linkRange, + }; +}; + +/** + * The class that manages creation and disposal of {@link PromptLinkDiagnosticsProvider} + * classes for each specific editor text model. + */ +export class PromptLinkDiagnosticsInstanceManager extends ProviderInstanceManagerBase { + protected override get InstanceClass() { + return PromptLinkDiagnosticsProvider; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptLinkProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptLinkProvider.ts similarity index 61% rename from src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptLinkProvider.ts rename to src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptLinkProvider.ts index 9b40572a7af..362126eb3d9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptLinkProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptLinkProvider.ts @@ -3,20 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { LANGUAGE_SELECTOR } from '../constants.js'; -import { IPromptsService } from '../service/types.js'; -import { assert } from '../../../../../../base/common/assert.js'; -import { ITextModel } from '../../../../../../editor/common/model.js'; -import { assertDefined } from '../../../../../../base/common/types.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { CancellationError } from '../../../../../../base/common/errors.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Registry } from '../../../../../../platform/registry/common/platform.js'; -import { FolderReference, NotPromptFile } from '../../promptFileReferenceErrors.js'; -import { LifecyclePhase } from '../../../../../services/lifecycle/common/lifecycle.js'; -import { ILink, ILinksList, LinkProvider } from '../../../../../../editor/common/languages.js'; -import { IWorkbenchContributionsRegistry, Extensions } from '../../../../../common/contributions.js'; -import { ILanguageFeaturesService } from '../../../../../../editor/common/services/languageFeatures.js'; +import { IPromptsService } from '../../service/types.js'; +import { assert } from '../../../../../../../base/common/assert.js'; +import { ITextModel } from '../../../../../../../editor/common/model.js'; +import { assertDefined } from '../../../../../../../base/common/types.js'; +import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { CancellationError } from '../../../../../../../base/common/errors.js'; +import { PROMPT_AND_INSTRUCTIONS_LANGUAGE_SELECTOR } from '../../constants.js'; +import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { FolderReference, NotPromptFile } from '../../../promptFileReferenceErrors.js'; +import { ILink, ILinksList, LinkProvider } from '../../../../../../../editor/common/languages.js'; +import { ILanguageFeaturesService } from '../../../../../../../editor/common/services/languageFeatures.js'; /** * Provides link references for prompt files. @@ -28,7 +25,7 @@ export class PromptLinkProvider extends Disposable implements LinkProvider { ) { super(); - this._register(this.languageService.linkProvider.register(LANGUAGE_SELECTOR, this)); + this._register(this.languageService.linkProvider.register(PROMPT_AND_INSTRUCTIONS_LANGUAGE_SELECTOR, this)); } /** @@ -96,7 +93,3 @@ export class PromptLinkProvider extends Disposable implements LinkProvider { }; } } - -// register the provider as a workbench contribution -Registry.as(Extensions.Workbench) - .registerWorkbenchContribution(PromptLinkProvider, LifecyclePhase.Eventually); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptPathAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptPathAutocompletion.ts similarity index 79% rename from src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptPathAutocompletion.ts rename to src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptPathAutocompletion.ts index 26f630845d5..1a3dc385651 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/promptPathAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/promptPathAutocompletion.ts @@ -14,25 +14,21 @@ * - add `Windows` support */ -import { LANGUAGE_SELECTOR } from '../constants.js'; -import { IPromptsService } from '../service/types.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { assertOneOf } from '../../../../../../base/common/types.js'; -import { isWindows } from '../../../../../../base/common/platform.js'; -import { ITextModel } from '../../../../../../editor/common/model.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { CancellationError } from '../../../../../../base/common/errors.js'; -import { Position } from '../../../../../../editor/common/core/position.js'; -import { IPromptFileReference, IPromptReference } from '../parsers/types.js'; -import { dirname, extUri } from '../../../../../../base/common/resources.js'; -import { assert, assertNever } from '../../../../../../base/common/assert.js'; -import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Registry } from '../../../../../../platform/registry/common/platform.js'; -import { LifecyclePhase } from '../../../../../services/lifecycle/common/lifecycle.js'; -import { ILanguageFeaturesService } from '../../../../../../editor/common/services/languageFeatures.js'; -import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../../../common/contributions.js'; -import { CompletionContext, CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList } from '../../../../../../editor/common/languages.js'; +import { IPromptsService } from '../../service/types.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { extUri } from '../../../../../../../base/common/resources.js'; +import { assertOneOf } from '../../../../../../../base/common/types.js'; +import { ITextModel } from '../../../../../../../editor/common/model.js'; +import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { CancellationError } from '../../../../../../../base/common/errors.js'; +import { PROMPT_AND_INSTRUCTIONS_LANGUAGE_SELECTOR } from '../../constants.js'; +import { Position } from '../../../../../../../editor/common/core/position.js'; +import { IPromptFileReference, IPromptReference } from '../../parsers/types.js'; +import { assert, assertNever } from '../../../../../../../base/common/assert.js'; +import { IFileService } from '../../../../../../../platform/files/common/files.js'; +import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { ILanguageFeaturesService } from '../../../../../../../editor/common/services/languageFeatures.js'; +import { CompletionContext, CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList } from '../../../../../../../editor/common/languages.js'; /** * Type for a filesystem completion item - the one that has its {@link CompletionItem.kind kind} set @@ -100,12 +96,13 @@ export class PromptPathAutocompletion extends Disposable implements CompletionIt constructor( @IFileService private readonly fileService: IFileService, - @IPromptsService private readonly promptSyntaxService: IPromptsService, + @IPromptsService private readonly promptsService: IPromptsService, @ILanguageFeaturesService private readonly languageService: ILanguageFeaturesService, + ) { super(); - this._register(this.languageService.completionProvider.register(LANGUAGE_SELECTOR, this)); + this._register(this.languageService.completionProvider.register(PROMPT_AND_INSTRUCTIONS_LANGUAGE_SELECTOR, this)); } /** @@ -136,7 +133,7 @@ export class PromptPathAutocompletion extends Disposable implements CompletionIt `Prompt path autocompletion provider`, ); - const parser = this.promptSyntaxService.getSyntaxParserFor(model); + const parser = this.promptsService.getSyntaxParserFor(model); assert( !parser.disposed, 'Prompt parser must not be disposed.', @@ -159,7 +156,13 @@ export class PromptPathAutocompletion extends Disposable implements CompletionIt return undefined; } - const modelDirname = dirname(model.uri); + const { parentFolder } = parser; + + // if didn't find a folder URI to start the suggestions from, + // don't provide any suggestions + if (parentFolder === null) { + return undefined; + } // in the case of the '.' trigger character, we must check if this is the first // dot in the link path, otherwise the dot could be a part of a folder name @@ -167,7 +170,7 @@ export class PromptPathAutocompletion extends Disposable implements CompletionIt return { suggestions: await this.getFirstFolderSuggestions( triggerCharacter, - modelDirname, + parentFolder, fileReference, ), }; @@ -177,7 +180,7 @@ export class PromptPathAutocompletion extends Disposable implements CompletionIt return { suggestions: await this.getNonFirstFolderSuggestions( triggerCharacter, - modelDirname, + parentFolder, fileReference, ), }; @@ -307,8 +310,8 @@ export class PromptPathAutocompletion extends Disposable implements CompletionIt return []; } - const currenFolder = extUri.resolvePath(fileFolderUri, path); - let suggestions = await this.getFolderSuggestions(currenFolder); + const currentFolder = extUri.resolvePath(fileFolderUri, path); + let suggestions = await this.getFolderSuggestions(currentFolder); // when trigger character was a `.`, which is we know is inside // the folder/file name in the path, filter out to only items @@ -344,17 +347,3 @@ export class PromptPathAutocompletion extends Disposable implements CompletionIt }); } } - -/** - * We restrict this provider to `Unix` machines for now because of - * the filesystem paths differences on `Windows` operating system. - * - * Notes on `Windows` support: - * - we add the `./` for the first path component, which may not work on `Windows` - * - the first path component of the absolute paths must be a drive letter - */ -if (!isWindows) { - // register the provider as a workbench contribution - Registry.as(WorkbenchExtensions.Workbench) - .registerWorkbenchContribution(PromptPathAutocompletion, LifecyclePhase.Eventually); -} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/providerInstanceBase.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/providerInstanceBase.ts new file mode 100644 index 00000000000..2cb0b49b7b1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/providerInstanceBase.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPromptsService, TSharedPrompt } from '../../service/types.js'; +import { ITextModel } from '../../../../../../../editor/common/model.js'; +import { ObservableDisposable } from '../../../../../../../base/common/observableDisposable.js'; + +/** + * Abstract base class for all reusable prompt file providers. + */ +export abstract class ProviderInstanceBase extends ObservableDisposable { + /** + * Function that is called when the prompt parser is updated. + */ + protected abstract onPromptParserUpdate(): Promise; + + /** + * Returns a string representation of this object. + */ + public abstract override toString(): string; + + /** + * The prompt parser instance. + */ + protected readonly parser: TSharedPrompt; + + constructor( + protected readonly model: ITextModel, + @IPromptsService promptsService: IPromptsService, + ) { + super(); + + this.parser = promptsService.getSyntaxParserFor(model); + this.parser.onUpdate(this.onPromptParserUpdate.bind(this)); + this.parser.onDispose(this.dispose.bind(this)); + this.parser.start(); + + // initialize an update + this.onPromptParserUpdate(); + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/providerInstanceManagerBase.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/providerInstanceManagerBase.ts new file mode 100644 index 00000000000..9f302ec3ef4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageFeatures/providers/providerInstanceManagerBase.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ProviderInstanceBase } from './providerInstanceBase.js'; +import { assert } from '../../../../../../../base/common/assert.js'; +import { ITextModel } from '../../../../../../../editor/common/model.js'; +import { assertDefined } from '../../../../../../../base/common/types.js'; +import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { ObjectCache } from '../../../../../../../base/common/objectCache.js'; +import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../constants.js'; +import { IModelService } from '../../../../../../../editor/common/services/model.js'; +import { PromptsConfig } from '../../../../../../../platform/prompts/common/config.js'; +import { IEditorService } from '../../../../../../services/editor/common/editorService.js'; +import { IDiffEditor, IEditor, IEditorModel } from '../../../../../../../editor/common/editorCommon.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; + +/** + * Type for a text editor that is used for reusable prompt files. + */ +export interface IPromptFileEditor extends IEditor { + readonly getModel: () => ITextModel; +} + +/** + * A generic base class that manages creation and disposal of {@link TInstance} + * objects for each specific editor object that is used for reusable prompt files. + */ +export abstract class ProviderInstanceManagerBase extends Disposable { + /** + * Currently available {@link TInstance} instances. + */ + private readonly instances: ObjectCache; + + /** + * Class object of the managed {@link TInstance}. + */ + protected abstract get InstanceClass(): new (editor: ITextModel, ...args: any[]) => TInstance; + + constructor( + @IModelService modelService: IModelService, + @IEditorService editorService: IEditorService, + @IInstantiationService initService: IInstantiationService, + @IConfigurationService configService: IConfigurationService, + ) { + super(); + + // cache of managed instances + this.instances = this._register( + new ObjectCache((model: ITextModel) => { + assert( + model.isDisposed() === false, + 'Text model must not be disposed.', + ); + + // sanity check - the new TS/JS discrepancies regarding fields initialization + // logic mean that this can be `undefined` during runtime while defined in TS + assertDefined( + this.InstanceClass, + 'Instance class field must be defined.', + ); + + const instance: TInstance = initService.createInstance( + this.InstanceClass, + model, + ); + + // this is a sanity check and the contract of the object cache, + // we must return a non-disposed object from this factory function + instance.assertNotDisposed( + 'Created instance must not be disposed.', + ); + + return instance; + }), + ); + + // if the feature is disabled, do not create any providers + if (PromptsConfig.enabled(configService) === false) { + return; + } + + // subscribe to changes of the active editor + this._register(editorService.onDidActiveEditorChange(() => { + const { activeTextEditorControl } = editorService; + if (activeTextEditorControl === undefined) { + return; + } + + this.handleNewEditor(activeTextEditorControl); + })); + + // handle existing visible text editors + editorService + .visibleTextEditorControls + .forEach(this.handleNewEditor.bind(this)); + + // subscribe to "language change" events for all models + this._register( + modelService.onModelLanguageChanged((event) => { + const { model, oldLanguageId } = event; + + // if language is set to `prompt` or `instructions` language, handle that model + if (isPromptFileModel(model)) { + this.instances.get(model); + return; + } + + // if the language is changed away from `prompt` or `instructions`, + // remove and dispose provider for this model + if (isPromptOrInstructionsFile(oldLanguageId)) { + this.instances.remove(model, true); + return; + } + }), + ); + } + + /** + * Initialize a new {@link TInstance} for the given editor. + */ + private handleNewEditor(editor: IEditor | IDiffEditor): this { + const model = editor.getModel(); + if (model === null) { + return this; + } + + if (isPromptFileModel(model) === false) { + return this; + } + + // note! calling `get` also creates a provider if it does not exist; + // and the provider is auto-removed when the editor is disposed + this.instances.get(model); + + return this; + } +} + +/** + * Check if provided language ID is either + * the `prompt` or `instructions` one. + */ +const isPromptOrInstructionsFile = ( + languageId: string, +): boolean => { + return (languageId === PROMPT_LANGUAGE_ID) || (languageId === INSTRUCTIONS_LANGUAGE_ID); +}; + +/** + * Check if a provided model is used for prompt files. + */ +const isPromptFileModel = ( + model: IEditorModel, +): model is ITextModel => { + // we support only `text editors` for now so filter out `diff` ones + if ('modified' in model || 'model' in model) { + return false; + } + + if (model.isDisposed()) { + return false; + } + + if (isPromptOrInstructionsFile(model.getLanguageId()) === false) { + return false; + } + + return true; +}; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts index 86b1833c96c..aa76793b1ce 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts @@ -4,27 +4,54 @@ *--------------------------------------------------------------------------------------------*/ import { TopError } from './topError.js'; +import { ChatMode } from '../../constants.js'; +import { PromptHeader } from './promptHeader/header.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { PromptToken } from '../codecs/tokens/promptToken.js'; import { ChatPromptCodec } from '../codecs/chatPromptCodec.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { FileReference } from '../codecs/tokens/fileReference.js'; import { ChatPromptDecoder } from '../codecs/chatPromptDecoder.js'; -import { IRange } from '../../../../../../editor/common/core/range.js'; import { assertDefined } from '../../../../../../base/common/types.js'; import { IPromptContentsProvider } from '../contentProviders/types.js'; import { DeferredPromise } from '../../../../../../base/common/async.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { PromptVariableWithData } from '../codecs/tokens/promptVariable.js'; -import { basename, extUri } from '../../../../../../base/common/resources.js'; +import { IRange, Range } from '../../../../../../editor/common/core/range.js'; import { assert, assertNever } from '../../../../../../base/common/assert.js'; +import { BaseToken } from '../../../../../../editor/common/codecs/baseToken.js'; import { VSBufferReadableStream } from '../../../../../../base/common/buffer.js'; -import { isPromptFile } from '../../../../../../platform/prompts/common/constants.js'; +import { basename, dirname, extUri } from '../../../../../../base/common/resources.js'; +import { IPromptMetadata, IPromptReference, IResolveError, ITopError } from './types.js'; import { ObservableDisposable } from '../../../../../../base/common/observableDisposable.js'; -import { FilePromptContentProvider } from '../contentProviders/filePromptContentsProvider.js'; -import { IPromptFileReference, IPromptReference, IResolveError, ITopError } from './types.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { isPromptOrInstructionsFile } from '../../../../../../platform/prompts/common/constants.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { MarkdownLink } from '../../../../../../editor/common/codecs/markdownCodec/tokens/markdownLink.js'; +import { MarkdownToken } from '../../../../../../editor/common/codecs/markdownCodec/tokens/markdownToken.js'; +import { FrontMatterHeader } from '../../../../../../editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterHeader.js'; import { OpenFailed, NotPromptFile, RecursiveReference, FolderReference, ResolveError } from '../../promptFileReferenceErrors.js'; +import { IPromptContentsProviderOptions, DEFAULT_OPTIONS as CONTENTS_PROVIDER_DEFAULT_OPTIONS } from '../contentProviders/promptContentsProviderBase.js'; + +/** + * Options of the {@link BasePromptParser} class. + */ +export interface IPromptParserOptions extends IPromptContentsProviderOptions { + /** + * List of reference paths have been already seen before + * getting to the current prompt. Used to prevent infinite + * recursion in prompt file references. + */ + readonly seenReferences: readonly string[]; +} + +/** + * Default {@link IPromptContentsProviderOptions} options. + */ +const DEFAULT_OPTIONS: IPromptParserOptions = { + ...CONTENTS_PROVIDER_DEFAULT_OPTIONS, + seenReferences: [], +}; /** * Error conditions that may happen during the file reference resolution. @@ -35,11 +62,42 @@ export type TErrorCondition = OpenFailed | RecursiveReference | FolderReference * Base prompt parser class that provides a common interface for all * prompt parsers that are responsible for parsing chat prompt syntax. */ -export abstract class BasePromptParser extends ObservableDisposable { +export class BasePromptParser extends ObservableDisposable { + /** + * Options passed to the constructor, extended with + * value defaults from {@link DEFAULT_OPTIONS}. + */ + protected readonly options: IPromptParserOptions; + + /** + * List of all tokens that were parsed from the prompt contents so far. + */ + public get tokens(): readonly BaseToken[] { + return [...this.receivedTokens]; + } + /** + * Private field behind the readonly {@link tokens} property. + */ + private receivedTokens: BaseToken[] = []; + /** * List of file references in the current branch of the file reference tree. */ - private readonly _references: PromptFileReference[] = []; + private readonly _references: IPromptReference[] = []; + + /** + * Reference to the prompt header object that holds metadata associated + * with the prompt. + */ + private promptHeader?: PromptHeader; + + /** + * Reference to the prompt header object that holds metadata associated + * with the prompt. + */ + public get header(): PromptHeader | undefined { + return this.promptHeader; + } /** * The event is fired when lines or their content change. @@ -56,6 +114,10 @@ export abstract class BasePromptParser extend return this; } + /** + * If failed to parse prompt contents, this property has + * an error object that describes the failure reason. + */ private _errorCondition?: ResolveError; /** @@ -105,6 +167,12 @@ export abstract class BasePromptParser extend return this; } + // by the time when the `firstParseResult` promise is resolved, + // this object may have been already disposed, hence noop + if (this.disposed) { + return this; + } + assertDefined( this.stream, 'No stream reference found.', @@ -112,11 +180,16 @@ export abstract class BasePromptParser extend await this.stream.settled; + // if prompt header exists, also wait for it to be settled + if (this.promptHeader) { + await this.promptHeader.settled; + } + return this; } /** - * Same as {@linkcode settled} but also waits for all possible + * Same as {@link settled} but also waits for all possible * nested child prompt references and their children to be settled. */ public async allSettled(): Promise { @@ -132,15 +205,22 @@ export abstract class BasePromptParser extend } constructor( - private readonly promptContentsProvider: T, - seenReferences: string[] = [], + private readonly promptContentsProvider: TContentsProvider, + options: Partial, @IInstantiationService protected readonly instantiationService: IInstantiationService, + @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, @ILogService protected readonly logService: ILogService, ) { super(); + this.options = { + ...DEFAULT_OPTIONS, + ...options, + }; + this._onUpdate.fire = this._onUpdate.fire.bind(this._onUpdate); - this._register(promptContentsProvider); + + const seenReferences = [...this.options.seenReferences]; // to prevent infinite file recursion, we keep track of all references in // the current branch of the file reference tree and check if the current @@ -172,6 +252,9 @@ export abstract class BasePromptParser extend this.firstParseResult.complete(); }), ); + + // dispose self when contents provider is disposed + this.promptContentsProvider.onDispose(this.dispose.bind(this)); } /** @@ -198,6 +281,11 @@ export abstract class BasePromptParser extend this.stream?.dispose(); delete this.stream; delete this._errorCondition; + this.receivedTokens = []; + + // cleanup current prompt header object + this.promptHeader?.dispose(); + delete this.promptHeader; // dispose all currently existing references this.disposeReferences(); @@ -219,6 +307,22 @@ export abstract class BasePromptParser extend // when some tokens received, process and store the references this.stream.on('data', (token) => { + // store all markdown and prompt token references + if ((token instanceof MarkdownToken) || (token instanceof PromptToken)) { + this.receivedTokens.push(token); + } + + // if a prompt header token received, create a new prompt header instance + if (token instanceof FrontMatterHeader) { + this.promptHeader = new PromptHeader( + token.contentToken, + this.promptContentsProvider.languageId, + ).start(); + + return; + } + + // try to convert a prompt variable with data token into a file reference if (token instanceof PromptVariableWithData) { try { this.onReference(FileReference.from(token), [...seenReferences]); @@ -254,16 +358,28 @@ export abstract class BasePromptParser extend token: FileReference | MarkdownLink, seenReferences: string[], ): this { - const fileReference = this.instantiationService - .createInstance(PromptFileReference, token, this.dirname, seenReferences); + const { parentFolder } = this; - this._references.push(fileReference); + const referenceUri = (parentFolder !== null) + ? extUri.resolvePath(parentFolder, token.path) + : URI.file(token.path); - fileReference.onUpdate(this._onUpdate.fire); - fileReference.start(); + const contentProvider = this.promptContentsProvider.createNew({ uri: referenceUri }); + const reference = this.instantiationService + .createInstance(PromptReference, contentProvider, token, { seenReferences }); + + // the content provider is exclusively owned by the reference + // hence dispose it when the reference is disposed + reference.onDispose(contentProvider.dispose.bind(contentProvider)); + + this._references.push(reference); + + reference.onUpdate(this._onUpdate.fire); this._onUpdate.fire(); + reference.start(); + return this; } @@ -300,7 +416,7 @@ export abstract class BasePromptParser extend } /** - * Private attribute to track if the {@linkcode start} + * Private attribute to track if the {@link start} * method has been already called at least once. */ private started: boolean = false; @@ -334,10 +450,26 @@ export abstract class BasePromptParser extend } /** - * Get the parent folder of the file reference. + * Get the parent folder URI of the prompt. + * For instance, if prompt URI points to a file on a disk, this + * function will return the folder URI that contains that file, + * but if the URI points to an `untitled` document, will try to + * use a different folder URI based on the workspace state. */ - public get dirname() { - return URI.joinPath(this.uri, '..'); + public get parentFolder(): URI | null { + if (this.uri.scheme === 'file') { + return dirname(this.uri); + } + + const { folders } = this.workspaceService.getWorkspace(); + + // single-root workspace, use root folder URI + if (folders.length === 1) { + return folders[0].uri; + } + + // if a multi-root workspace, or no workspace at all + return null; } /** @@ -397,6 +529,79 @@ export abstract class BasePromptParser extend .map(child => child.uri); } + /** + * Valid metadata records defined in the prompt header. + */ + public get metadata(): IPromptMetadata { + if (this.header === undefined) { + return {}; + } + + const { metadata } = this.header; + if (metadata === undefined) { + return {}; + } + + const { tools, mode, description, applyTo } = metadata; + + // compute resulting mode based on presence + // of `tools` metadata in the prompt header + const resultingMode = (tools !== undefined) + ? ChatMode.Agent + : mode?.chatMode; + + return { + mode: resultingMode, + description: description?.text, + tools: tools?.toolNames, + applyTo: applyTo?.text, + }; + } + + /** + * Entire associated `tools` metadata for this reference and + * all possible nested child references. + */ + public get allToolsMetadata(): readonly string[] | null { + let hasTools = false; + const result: string[] = []; + + const { tools, mode } = this.metadata; + + if (tools !== undefined) { + result.push(...tools); + hasTools = true; + } + + const isRootInAgentMode = ((hasTools === true) || (mode === ChatMode.Agent)); + + // the top-level mode defines the overall mode for all + // nested prompt references, therefore if mode of + // the top-level prompt is not equal to `agent`, then + // ignore all `tools` metadata of the nested references + if (isRootInAgentMode === false) { + return null; + } + + for (const reference of this.references) { + const { allToolsMetadata } = reference; + + if (allToolsMetadata === null) { + continue; + } + + result.push(...allToolsMetadata); + hasTools = true; + } + + if (hasTools === false) { + return null; + } + + // return unique list of tools + return [...new Set(result)]; + } + /** * Get list of errors for the direct links of the current reference. */ @@ -496,8 +701,8 @@ export abstract class BasePromptParser extend /** * Check if the current reference points to a prompt snippet file. */ - public get isPromptSnippet(): boolean { - return isPromptFile(this.uri); + public get isPromptFile(): boolean { + return isPromptOrInstructionsFile(this.uri); } /** @@ -516,36 +721,41 @@ export abstract class BasePromptParser extend } this.disposeReferences(); + this.stream?.dispose(); - this._onUpdate.fire(); + delete this.stream; + + this.promptHeader?.dispose(); + delete this.promptHeader; super.dispose(); } } /** - * Prompt file reference object represents any file reference inside prompt - * text contents. For instance the file variable(`#file:/path/to/file.md`) - * or a markdown link(`[#file:file.md](/path/to/file.md)`). + * Prompt reference object represents any reference inside prompt text + * contents. For instance the file variable(`#file:/path/to/file.md`) or + * a markdown link(`[#file:file.md](/path/to/file.md)`). */ -export class PromptFileReference extends BasePromptParser implements IPromptFileReference { - public readonly type = 'file'; - - public readonly range = this.token.range; - public readonly path: string = this.token.path; - public readonly text: string = this.token.text; +export class PromptReference extends ObservableDisposable implements IPromptReference { + /** + * Instance of underlying prompt parser object. + */ + private readonly parser: BasePromptParser; constructor( + private readonly promptContentsProvider: IPromptContentsProvider, public readonly token: FileReference | MarkdownLink, - dirname: URI, - seenReferences: string[] = [], + options: Partial = {}, @IInstantiationService initService: IInstantiationService, - @ILogService logService: ILogService, ) { - const fileUri = extUri.resolvePath(dirname, token.path); - const provider = initService.createInstance(FilePromptContentProvider, fileUri); + super(); - super(provider, seenReferences, initService, logService); + this.parser = this._register(initService.createInstance( + BasePromptParser, + this.promptContentsProvider, + options, + )); } /** @@ -566,7 +776,26 @@ export class PromptFileReference extends BasePromptParser void): this { + this.parser.onUpdate(callback); + + return this; + } + + public get range(): Range { + return this.token.range; + } + + public get path(): string { + return this.token.path; + } + + public get text(): string { + return this.token.text; + } + + public get resolveFailed(): boolean | undefined { + return this.parser.resolveFailed; + } + + public get errorCondition(): ResolveError | undefined { + return this.parser.errorCondition; + } + + public get topError(): ITopError | undefined { + return this.parser.topError; + } + + public get uri(): URI { + return this.parser.uri; + } + + public get isPromptFile(): boolean { + return this.parser.isPromptFile; + } + + public get errors(): readonly ResolveError[] { + return this.parser.errors; + } + + public get allErrors(): readonly IResolveError[] { + return this.parser.allErrors; + } + + public get references(): readonly IPromptReference[] { + return this.parser.references; + } + + public get allReferences(): readonly IPromptReference[] { + return this.parser.allReferences; + } + + public get metadata(): IPromptMetadata { + return this.parser.metadata; + } + + public get allToolsMetadata(): readonly string[] | null { + return this.parser.allToolsMetadata; + } + + public get allValidReferences(): readonly IPromptReference[] { + return this.parser.allValidReferences; + } + + public async settled(): Promise { + await this.parser.settled(); + + return this; + } + + public async allSettled(): Promise { + await this.parser.allSettled(); + + return this; + } + /** * Returns a string representation of this object. */ public override toString() { - return `prompt-reference/${this.token}`; + return `prompt-reference/${this.type}:${this.subtype}/${this.token}`; } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/filePromptParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/filePromptParser.ts index f7fc6a5762d..ab4cd1fb7bc 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/filePromptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/filePromptParser.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BasePromptParser } from './basePromptParser.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { BasePromptParser, IPromptParserOptions } from './basePromptParser.js'; import { FilePromptContentProvider } from '../contentProviders/filePromptContentsProvider.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; /** @@ -16,12 +17,15 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ export class FilePromptParser extends BasePromptParser { constructor( uri: URI, - seenReferences: string[] = [], + options: Partial = {}, @IInstantiationService initService: IInstantiationService, + @IWorkspaceContextService workspaceService: IWorkspaceContextService, @ILogService logService: ILogService, ) { - const contentsProvider = initService.createInstance(FilePromptContentProvider, uri); - super(contentsProvider, seenReferences, initService, logService); + const contentsProvider = initService.createInstance(FilePromptContentProvider, uri, options); + super(contentsProvider, options, initService, workspaceService, logService); + + this._register(contentsProvider); } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/diagnostics.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/diagnostics.ts new file mode 100644 index 00000000000..754293f6dcf --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/diagnostics.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../../../../../../../editor/common/core/range.js'; + +/** + * List of all currently supported diagnostic types. + */ +export type TDiagnostic = PromptMetadataWarning | PromptMetadataError; + +/** + * Diagnostics object that hold information about some issue + * related to the prompt header metadata. + */ +export abstract class PromptMetadataDiagnostic { + constructor( + public readonly range: Range, + public readonly message: string, + ) { } + + /** + * String representation of the diagnostic object. + */ + public abstract toString(): string; +} + +/** + * Diagnostics object that hold information about some + * non-fatal issue related to the prompt header metadata. + */ +export class PromptMetadataWarning extends PromptMetadataDiagnostic { + public override toString(): string { + return `warning(${this.message})${this.range}`; + } +} + +/** + * Diagnostics object that hold information about some + * fatal issue related to the prompt header metadata. + */ +export class PromptMetadataError extends PromptMetadataDiagnostic { + public override toString(): string { + return `error(${this.message})${this.range}`; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/header.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/header.ts new file mode 100644 index 00000000000..0a5cf917f08 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/header.ts @@ -0,0 +1,310 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChatMode } from '../../../constants.js'; +import { localize } from '../../../../../../../nls.js'; +import { PromptApplyToMetadata } from './metadata/applyTo.js'; +import { assert } from '../../../../../../../base/common/assert.js'; +import { assertDefined } from '../../../../../../../base/common/types.js'; +import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { Text } from '../../../../../../../editor/common/codecs/baseToken.js'; +import { PromptMetadataError, PromptMetadataWarning, TDiagnostic } from './diagnostics.js'; +import { TokenStream } from '../../../../../../../editor/common/codecs/utils/tokenStream.js'; +import { SimpleToken } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/index.js'; +import { PromptToolsMetadata, PromptModeMetadata, PromptDescriptionMetadata } from './metadata/index.js'; +import { FrontMatterRecord } from '../../../../../../../editor/common/codecs/frontMatterCodec/tokens/index.js'; +import { FrontMatterDecoder, TFrontMatterToken } from '../../../../../../../editor/common/codecs/frontMatterCodec/frontMatterDecoder.js'; + +/** + * Metadata defined in the prompt header. + */ +export interface IHeaderMetadata { + /** + * Tools metadata in the prompt header. + */ + tools?: PromptToolsMetadata; + + /** + * Description metadata in the prompt header. + */ + description?: PromptDescriptionMetadata; + + /** + * Chat mode metadata in the prompt header. + */ + mode?: PromptModeMetadata; + + /** + * Chat 'applyTo' metadata in the prompt header. + */ + applyTo?: PromptApplyToMetadata; +} + +/** + * Prompt header holds all metadata records for a prompt. + */ +export class PromptHeader extends Disposable { + /** + * Underlying decoder for a Front Matter header. + */ + private readonly stream: FrontMatterDecoder; + + /** + * Metadata records. + */ + private readonly meta: IHeaderMetadata; + /** + * Metadata records. + */ + public get metadata(): Readonly { + return Object.freeze({ + ...this.meta, + }); + } + + /** + * List of all unique metadata record names. + */ + private readonly recordNames: Set; + + /** + * List of all issues found while parsing the prompt header. + */ + private readonly issues: TDiagnostic[]; + + /** + * List of all diagnostic issues found while parsing + * the prompt header. + */ + public get diagnostics(): readonly TDiagnostic[] { + return this.issues; + } + + constructor( + public readonly contentsToken: Text, + public readonly languageId: string, + ) { + super(); + + this.issues = []; + this.meta = {}; + this.recordNames = new Set(); + + this.stream = this._register( + new FrontMatterDecoder( + new TokenStream(contentsToken.tokens), + ), + ); + this.stream.onData(this.onData.bind(this)); + this.stream.onError(this.onError.bind(this)); + } + + /** + * Process front matter tokens, converting them into + * well-known prompt metadata records. + */ + private onData(token: TFrontMatterToken): void { + // we currently expect only front matter 'records' for + // the prompt metadata, hence add diagnostics for all + // other tokens and ignore them + if ((token instanceof FrontMatterRecord) === false) { + // unless its a simple token, in which case we just ignore it + if (token instanceof SimpleToken) { + return; + } + + this.issues.push( + new PromptMetadataError( + token.range, + localize( + 'prompt.header.diagnostics.unexpected-token', + "Unexpected token '{0}'.", + token.text, + ), + ), + ); + + return; + } + + const recordName = token.nameToken.text; + + // if we already have a record with this name, + // add a warning diagnostic and ignore it + if (this.recordNames.has(recordName)) { + this.issues.push( + new PromptMetadataWarning( + token.range, + localize( + 'prompt.header.metadata.diagnostics.duplicate-record', + "Duplicate metadata record '{0}' will be ignored.", + recordName, + ), + ), + ); + + return; + } + + // if the record might be a "description" metadata + // add it to the list of parsed metadata records + if (PromptDescriptionMetadata.isDescriptionRecord(token)) { + const descriptionMetadata = new PromptDescriptionMetadata(token, this.languageId); + const { diagnostics } = descriptionMetadata; + + this.issues.push(...diagnostics); + this.meta.description = descriptionMetadata; + this.recordNames.add(recordName); + return; + } + + // if the record might be a "tools" metadata + // add it to the list of parsed metadata records + if (PromptToolsMetadata.isToolsRecord(token)) { + const toolsMetadata = new PromptToolsMetadata(token, this.languageId); + const { diagnostics } = toolsMetadata; + + this.issues.push(...diagnostics); + this.meta.tools = toolsMetadata; + this.recordNames.add(recordName); + + return this.validateToolsAndModeCompatibility(); + } + + // if the record might be a "mode" metadata + // add it to the list of parsed metadata records + if (PromptModeMetadata.isModeRecord(token)) { + const modeMetadata = new PromptModeMetadata(token, this.languageId); + const { diagnostics } = modeMetadata; + + this.issues.push(...diagnostics); + this.meta.mode = modeMetadata; + this.recordNames.add(recordName); + + return this.validateToolsAndModeCompatibility(); + } + + // if the record might be a "applyTo" metadata + // add it to the list of parsed metadata records + if (PromptApplyToMetadata.isApplyToRecord(token)) { + const applyToMetadata = new PromptApplyToMetadata(token, this.languageId); + const { diagnostics } = applyToMetadata; + + this.issues.push(...diagnostics); + this.meta.applyTo = applyToMetadata; + this.recordNames.add(recordName); + + return; + } + + // all other records are currently not supported + this.issues.push( + new PromptMetadataWarning( + token.range, + localize( + 'prompt.header.metadata.diagnostics.unknown-record', + "Unknown metadata record '{0}' will be ignored.", + recordName, + ), + ), + ); + } + + /** + * Check if value of `tools` and `mode` metadata + * are compatible with each other. + */ + private get toolsAndModeCompatible(): boolean { + const { tools, mode } = this.meta; + + // if `tools` is not set, then the mode metadata + // can have any value so skip the validation + if (tools === undefined) { + return true; + } + + // if `mode` is not set or equal to `agent` mode, + // then the tools metadata can have any value so noop + if ((mode === undefined) || (mode.chatMode === ChatMode.Agent)) { + return true; + } + + // in the other cases when `tools` are defined and `mode` is not + // equal to `agent`, then the `tools` and `mode` are incompatible + return false; + } + + /** + * Validate that the `tools` and `mode` metadata are compatible + * with each other. If not, add a warning diagnostic. + */ + private validateToolsAndModeCompatibility(): void { + if (this.toolsAndModeCompatible === true) { + return; + } + + const { tools, mode } = this.meta; + + // sanity checks on the behavior of the `toolsAndModeCompatible` getter + assertDefined( + tools, + 'Tools metadata must have been present.', + ); + assertDefined( + mode, + 'Mode metadata must have been present.', + ); + assert( + mode.chatMode !== ChatMode.Agent, + 'Mode metadata must not be agent mode.', + ); + + this.issues.push( + new PromptMetadataWarning( + mode.range, + localize( + 'prompt.header.metadata.mode.diagnostics.incompatible-with-tools', + "Record '{0}' is implied to have the '{1}' value if '{2}' record is present so the specified value will be ignored.", + mode.recordName, + ChatMode.Agent, + tools.recordName, + ), + ), + ); + } + + /** + * Process errors from the underlying front matter decoder. + */ + private onError(error: Error): void { + this.issues.push( + new PromptMetadataError( + this.contentsToken.range, + localize( + 'prompt.header.diagnostics.parsing-error', + "Failed to parse prompt header: {0}", + error.message, + ), + ), + ); + } + + /** + * Promise that resolves when parsing process of + * the prompt header completes. + */ + public get settled(): Promise { + return this.stream.settled; + } + + /** + * Starts the parsing process of the prompt header. + */ + public start(): this { + this.stream.start(); + + return this; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/applyTo.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/applyTo.ts new file mode 100644 index 00000000000..62fd0dae572 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/applyTo.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptStringMetadata } from './record.js'; +import { localize } from '../../../../../../../../nls.js'; +import { INSTRUCTIONS_LANGUAGE_ID } from '../../../constants.js'; +import { isEmptyPattern, parse } from '../../../../../../../../base/common/glob.js'; +import { PromptMetadataDiagnostic, PromptMetadataError, PromptMetadataWarning } from '../diagnostics.js'; +import { FrontMatterRecord, FrontMatterToken } from '../../../../../../../../editor/common/codecs/frontMatterCodec/tokens/index.js'; + +/** + * Name of the metadata record in the prompt header. + */ +const RECORD_NAME = 'applyTo'; + +/** + * Prompt `applyTo` metadata record inside the prompt header. + */ +export class PromptApplyToMetadata extends PromptStringMetadata { + constructor( + recordToken: FrontMatterRecord, + languageId: string, + ) { + super(RECORD_NAME, recordToken, languageId); + } + + public override get recordName(): string { + return RECORD_NAME; + } + + protected override validate(): readonly PromptMetadataDiagnostic[] { + const result: PromptMetadataDiagnostic[] = [ + ...super.validate(), + ]; + + // if we don't have a value token, validation must + // has failed already so nothing to do more + if (this.valueToken === undefined) { + return result; + } + + // the applyTo metadata makes sense only for 'instruction' prompts + if (this.languageId !== INSTRUCTIONS_LANGUAGE_ID) { + result.push( + new PromptMetadataError( + this.range, + localize( + 'prompt.header.metadata.string.diagnostics.invalid-language', + "The '{0}' metadata record is only valid in instruction files.", + this.recordName, + ), + ), + ); + + delete this.valueToken; + return result; + } + + const { cleanText } = this.valueToken; + + // warn user if specified glob pattern is not valid + if (this.isValidGlob(cleanText) === false) { + result.push( + new PromptMetadataWarning( + this.valueToken.range, + localize( + 'prompt.header.metadata.applyTo.diagnostics.non-valid-glob', + "Invalid glob pattern '{0}'.", + cleanText, + ), + ), + ); + + delete this.valueToken; + return result; + } + + return result; + } + + /** + * Check if a provided string contains a valid glob pattern. + */ + private isValidGlob( + pattern: string, + ): boolean { + try { + const globPattern = parse(pattern); + if (isEmptyPattern(globPattern)) { + return false; + } + + return true; + } catch (_error) { + return false; + } + } + + /** + * Check if a provided front matter token is a metadata record + * with name equal to `applyTo`. + */ + public static isApplyToRecord( + token: FrontMatterToken, + ): boolean { + if ((token instanceof FrontMatterRecord) === false) { + return false; + } + + if (token.nameToken.text === RECORD_NAME) { + return true; + } + + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/description.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/description.ts new file mode 100644 index 00000000000..d36c92e48c7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/description.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptStringMetadata } from './record.js'; +import { FrontMatterRecord, FrontMatterToken } from '../../../../../../../../editor/common/codecs/frontMatterCodec/tokens/index.js'; + +/** + * Name of the metadata record in the prompt header. + */ +const RECORD_NAME = 'description'; + +/** + * Prompt `description` metadata record inside the prompt header. + */ +export class PromptDescriptionMetadata extends PromptStringMetadata { + public override get recordName(): string { + return RECORD_NAME; + } + + constructor( + recordToken: FrontMatterRecord, + languageId: string, + ) { + super(RECORD_NAME, recordToken, languageId); + } + + /** + * Check if a provided front matter token is a metadata record + * with name equal to `description`. + */ + public static isDescriptionRecord( + token: FrontMatterToken, + ): boolean { + if ((token instanceof FrontMatterRecord) === false) { + return false; + } + + if (token.nameToken.text === RECORD_NAME) { + return true; + } + + return false; + } +} diff --git a/src/vscode-dts/vscode.proposed.languageModelToolsForAgent.d.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/index.ts similarity index 68% rename from src/vscode-dts/vscode.proposed.languageModelToolsForAgent.d.ts rename to src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/index.ts index 9190a1c18b8..7c7b1e44115 100644 --- a/src/vscode-dts/vscode.proposed.languageModelToolsForAgent.d.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/index.ts @@ -3,6 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -declare module 'vscode' { - // Enables access to providing language model tools to agent mode -} +export { PromptModeMetadata } from './mode.js'; +export { PromptToolsMetadata } from './tools.js'; +export { PromptDescriptionMetadata } from './description.js'; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/mode.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/mode.ts new file mode 100644 index 00000000000..ad075b75c5c --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/mode.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptStringMetadata } from './record.js'; +import { ChatMode } from '../../../../constants.js'; +import { localize } from '../../../../../../../../nls.js'; +import { PromptMetadataDiagnostic, PromptMetadataError } from '../diagnostics.js'; +import { FrontMatterRecord, FrontMatterToken } from '../../../../../../../../editor/common/codecs/frontMatterCodec/tokens/index.js'; + +/** + * Name of the metadata record in the prompt header. + */ +const RECORD_NAME = 'mode'; + +/** + * Valid chat mode values. + */ +const VALID_MODES = Object.freeze([ + ChatMode.Ask, + ChatMode.Edit, + ChatMode.Agent, +]); + +/** + * Prompt `mode` metadata record inside the prompt header. + */ +export class PromptModeMetadata extends PromptStringMetadata { + constructor( + recordToken: FrontMatterRecord, + languageId: string, + ) { + super(RECORD_NAME, recordToken, languageId); + } + + public override get recordName(): string { + return RECORD_NAME; + } + + /** + * Private field for tracking the chat mode value. + */ + private value: ChatMode | undefined; + /** + * Chat mode value of the metadata record. + */ + public get chatMode(): ChatMode | undefined { + return this.value; + } + + protected override validate(): readonly PromptMetadataDiagnostic[] { + const result: PromptMetadataDiagnostic[] = [ + ...super.validate(), + ]; + + if (this.text === undefined) { + return result; + } + + // validate that the text value is one of the valid modes + const validModes: string[] = [...VALID_MODES]; + const index = validModes.indexOf(this.text); + if (index !== -1) { + this.value = VALID_MODES[index]; + return result; + } + + // if not valid mode value, add an appropriate diagnostic + result.push( + new PromptMetadataError( + this.range, + localize( + 'prompt.header.metadata.mode.diagnostics.invalid-value', + "Value of the '{0}' metadata must be one of ({1}), got '{2}'.", + RECORD_NAME, + VALID_MODES + .map((modeName) => { + return `'${modeName}'`; + }).join(', '), + this.text, + ), + ), + ); + + return result; + } + + /** + * Check if a provided front matter token is a metadata record + * with name equal to `mode`. + */ + public static isModeRecord( + token: FrontMatterToken, + ): boolean { + if ((token instanceof FrontMatterRecord) === false) { + return false; + } + + if (token.nameToken.text === RECORD_NAME) { + return true; + } + + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/record.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/record.ts new file mode 100644 index 00000000000..d0e779e7e4e --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/record.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../../../../nls.js'; +import { assert } from '../../../../../../../../base/common/assert.js'; +import { Range } from '../../../../../../../../editor/common/core/range.js'; +import { PromptMetadataDiagnostic, PromptMetadataError, PromptMetadataWarning } from '../diagnostics.js'; +import { FrontMatterRecord, FrontMatterString } from '../../../../../../../../editor/common/codecs/frontMatterCodec/tokens/index.js'; + +/** + * Abstract class for all metadata records in the prompt header. + */ +export abstract class PromptMetadataRecord { + + /** + * Private field for tracking all diagnostic issues + * related to this metadata record. + */ + private readonly issues: PromptMetadataDiagnostic[]; + + /** + * Full range of the metadata's record text in the prompt header. + */ + public get range(): Range { + return this.recordToken.range; + } + + constructor( + protected readonly recordToken: FrontMatterRecord, + protected readonly languageId: string, + ) { + + this.issues = []; + this.issues.push(...this.validate()); + } + + /** + * Validate the metadata record and collect all issues + * related to its content. + */ + protected abstract validate(): readonly PromptMetadataDiagnostic[]; + + /** + * Name of the metadata record. + */ + public abstract get recordName(): string; + + /** + * List of all diagnostic issues related to this metadata record. + */ + public get diagnostics(): readonly PromptMetadataDiagnostic[] { + return this.issues; + } + + /** + * List of all `error` issue diagnostics. + */ + public get errorDiagnostics(): readonly PromptMetadataError[] { + return this.diagnostics + .filter((diagnostic) => { + return (diagnostic instanceof PromptMetadataError); + }); + } + + /** + * List of all `warning` issue diagnostics. + */ + public get warningDiagnostics(): readonly PromptMetadataWarning[] { + return this.diagnostics + .filter((diagnostic) => { + return (diagnostic instanceof PromptMetadataWarning); + }); + } +} + +/** + * Base class for all metadata records with a `string` value. + */ +export abstract class PromptStringMetadata extends PromptMetadataRecord { + /** + * Value token reference of the record. + */ + protected valueToken: FrontMatterString | undefined; + + /** + * Clean text value of the record. + */ + public get text(): string | undefined { + return this.valueToken?.cleanText; + } + + constructor( + expectedRecordName: string, + recordToken: FrontMatterRecord, + languageId: string, + ) { + // sanity check on the name of the record + const recordName = recordToken.nameToken.text; + assert( + recordName === expectedRecordName, + `Record token must be '${expectedRecordName}', got '${recordName}'.`, + ); + + super(recordToken, languageId); + } + + /** + * Validate the metadata record has a 'string' value. + */ + protected override validate(): readonly PromptMetadataDiagnostic[] { + const { valueToken } = this.recordToken; + + const result: PromptMetadataDiagnostic[] = []; + + // validate that the record value is a string + if ((valueToken instanceof FrontMatterString) === false) { + result.push( + new PromptMetadataError( + valueToken.range, + localize( + 'prompt.header.metadata.string.diagnostics.invalid-value-type', + "Value of the '{0}' metadata must be '{1}', got '{2}'.", + this.recordName, + 'string', + valueToken.valueTypeName, + ), + ), + ); + + return result; + } + + this.valueToken = valueToken; + return result; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/tools.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/tools.ts new file mode 100644 index 00000000000..fe5929f47ff --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/tools.ts @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptMetadataRecord } from './record.js'; +import { localize } from '../../../../../../../../nls.js'; +import { assert } from '../../../../../../../../base/common/assert.js'; +import { PromptMetadataDiagnostic, PromptMetadataError, PromptMetadataWarning } from '../diagnostics.js'; +import { FrontMatterArray, FrontMatterRecord, FrontMatterString, FrontMatterToken, FrontMatterValueToken } from '../../../../../../../../editor/common/codecs/frontMatterCodec/tokens/index.js'; + +/** + * Name of the metadata record in the prompt header. + */ +const RECORD_NAME = 'tools'; + +/** + * Prompt `tools` metadata record inside the prompt header. + */ +export class PromptToolsMetadata extends PromptMetadataRecord { + public override get recordName(): string { + return RECORD_NAME; + } + + /** + * Value token reference of the record. + */ + protected valueToken: FrontMatterArray | undefined; + + /** + * List of all valid tool names that were found in + * this metadata record. + */ + private validToolNames: Set | undefined; + + /** + * List of all valid tool names that were found in + * this metadata record. + */ + public get toolNames(): readonly string[] { + if (this.validToolNames === undefined) { + return []; + } + + return [...this.validToolNames.values()]; + } + + constructor( + recordToken: FrontMatterRecord, + languageId: string, + ) { + // sanity check on the name of the tools record + assert( + PromptToolsMetadata.isToolsRecord(recordToken), + `Record token must be a tools token, got '${recordToken.nameToken.text}'.`, + ); + + super(recordToken, languageId); + } + + /** + * Validate the metadata record and collect all issues + * related to its content. + */ + protected override validate(): readonly PromptMetadataDiagnostic[] { + const result: PromptMetadataDiagnostic[] = []; + + const { valueToken } = this.recordToken; + + // validate that the record value is an array + if ((valueToken instanceof FrontMatterArray) === false) { + result.push( + new PromptMetadataError( + valueToken.range, + localize( + 'prompt.header.metadata.tools.diagnostics.invalid-value-type', + "Value of the '{0}' metadata must be '{1}', got '{2}'.", + RECORD_NAME, + 'array', + valueToken.valueTypeName, + ), + ), + ); + + return result; + } + + this.valueToken = valueToken; + + // validate that all array items + this.validToolNames = new Set(); + for (const item of this.valueToken.items) { + result.push( + ...this.validateToolName(item, this.validToolNames), + ); + } + + return result; + } + + /** + * Validate an individual provided value token that + * is used for a tool name. + */ + private validateToolName( + valueToken: FrontMatterValueToken, + validToolNames: Set, + ): readonly PromptMetadataDiagnostic[] { + const issues: PromptMetadataDiagnostic[] = []; + + // tool name must be a string + if ((valueToken instanceof FrontMatterString) === false) { + issues.push( + new PromptMetadataWarning( + valueToken.range, + localize( + 'prompt.header.metadata.tools.diagnostics.invalid-tool-name-type', + "Expected a tool name ({0}), got '{1}'.", + 'string', + valueToken.text, + ), + ), + ); + + return issues; + } + + const cleanToolName = valueToken.cleanText.trim(); + // the tool name should not be empty + if (cleanToolName.length === 0) { + issues.push( + new PromptMetadataWarning( + valueToken.range, + localize( + 'prompt.header.metadata.tools.diagnostics.empty-tool-name', + "Tool name cannot be empty.", + ), + ), + ); + + return issues; + } + + // the tool name should not be duplicated + if (validToolNames.has(cleanToolName)) { + issues.push( + new PromptMetadataWarning( + valueToken.range, + localize( + 'prompt.header.metadata.tools.diagnostics.duplicate-tool-name', + "Duplicate tool name '{0}'.", + cleanToolName, + ), + ), + ); + + return issues; + } + + validToolNames.add(cleanToolName); + return issues; + } + + /** + * Check if a provided front matter token is a metadata record + * with name equal to `tools`. + */ + public static isToolsRecord( + token: FrontMatterToken, + ): boolean { + if ((token instanceof FrontMatterRecord) === false) { + return false; + } + + if (token.nameToken.text === RECORD_NAME) { + return true; + } + + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptParser.ts new file mode 100644 index 00000000000..adca3da6a03 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptParser.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { assertDefined } from '../../../../../../base/common/types.js'; +import { IPromptContentsProvider } from '../contentProviders/types.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { BasePromptParser, IPromptParserOptions } from './basePromptParser.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { isUntitled } from '../../../../../../platform/prompts/common/constants.js'; +import { TextModelContentsProvider } from '../contentProviders/textModelContentsProvider.js'; +import { FilePromptContentProvider } from '../contentProviders/filePromptContentsProvider.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; + +/** + * Get prompt contents provider object based on the prompt type. + */ +const getContentsProvider = ( + uri: URI, + options: Partial, + modelService: IModelService, + instaService: IInstantiationService, +): IPromptContentsProvider => { + // use text model contents provider for `untitled` documents + if (isUntitled(uri)) { + const model = modelService.getModel(uri); + + assertDefined( + model, + `Cannot find model of untitled document '${uri.path}'.`, + ); + + return instaService + .createInstance(TextModelContentsProvider, model, options); + } + + return instaService + .createInstance(FilePromptContentProvider, uri, options); +}; + +/** + * General prompt parser class that automatically infers a prompt + * contents provider type by the type of provided prompt URI. + */ +export class PromptParser extends BasePromptParser { + /** + * Underlying prompt contents provider instance. + */ + private readonly contentsProvider: IPromptContentsProvider; + + constructor( + uri: URI, + options: Partial = {}, + @ILogService logService: ILogService, + @IModelService modelService: IModelService, + @IInstantiationService instaService: IInstantiationService, + @IWorkspaceContextService workspaceService: IWorkspaceContextService, + ) { + const contentsProvider = getContentsProvider(uri, options, modelService, instaService); + + super( + contentsProvider, + options, + instaService, + workspaceService, + logService, + ); + + this.contentsProvider = this._register(contentsProvider); + } + + /** + * Returns a string representation of this object. + */ + public override toString() { + const { sourceName } = this.contentsProvider; + + return `prompt-parser:${sourceName}:${this.uri.path}`; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/textModelPromptParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/textModelPromptParser.ts index 6a0e32b5080..72f21fd0a5e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/textModelPromptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/textModelPromptParser.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BasePromptParser } from './basePromptParser.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { BasePromptParser, IPromptParserOptions } from './basePromptParser.js'; import { TextModelContentsProvider } from '../contentProviders/textModelContentsProvider.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; /** @@ -16,14 +17,20 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ export class TextModelPromptParser extends BasePromptParser { constructor( model: ITextModel, - seenReferences: string[] = [], + options: Partial = {}, @IInstantiationService initService: IInstantiationService, + @IWorkspaceContextService workspaceService: IWorkspaceContextService, @ILogService logService: ILogService, ) { - const contentsProvider = initService.createInstance(TextModelContentsProvider, model) - .onDispose(() => this.dispose()); + const contentsProvider = initService.createInstance( + TextModelContentsProvider, + model, + options, + ); - super(contentsProvider, seenReferences, initService, logService); + super(contentsProvider, options, initService, workspaceService, logService); + + this._register(contentsProvider); } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/types.d.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/types.ts similarity index 80% rename from src/vs/workbench/contrib/chat/common/promptSyntax/parsers/types.d.ts rename to src/vs/workbench/contrib/chat/common/promptSyntax/parsers/types.ts index 04f07f388b8..8da3a8abaa9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/types.d.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/types.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { ChatMode } from '../../constants.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ResolveError } from '../../promptFileReferenceErrors.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -48,6 +49,31 @@ export interface ITopError extends IResolveError { readonly localizedMessage: string; } +/** + * Metadata defined in the prompt header. + */ +export interface IPromptMetadata { + /** + * Description metadata in the prompt header. + */ + description?: string; + + /** + * Tools metadata in the prompt header. + */ + tools?: readonly string[]; + + /** + * Chat mode metadata in the prompt header. + */ + mode?: ChatMode; + + /** + * Chat 'applyTo' metadata in the prompt header. + */ + applyTo?: string; +} + /** * Base interface for a generic prompt reference. */ @@ -70,7 +96,7 @@ interface IPromptReferenceBase extends IDisposable { /** * The full range of the prompt reference in the source text, - * including the {@linkcode linkRange} and any additional + * including the {@link linkRange} and any additional * parts the reference may contain (e.g., the `#file:` prefix). */ readonly range: Range; @@ -93,14 +119,14 @@ interface IPromptReferenceBase extends IDisposable { /** * Whether the current reference points to a prompt snippet file. */ - readonly isPromptSnippet: boolean; + readonly isPromptFile: boolean; /** * Flag that indicates if resolving this reference failed. * The `undefined` means that no attempt to resolve the reference * was made so far or such an attempt is still in progress. * - * See also {@linkcode errorCondition}. + * See also {@link errorCondition}. */ readonly resolveFailed: boolean | undefined; @@ -108,7 +134,7 @@ interface IPromptReferenceBase extends IDisposable { * If failed to resolve the reference this property contains * an error object that describes the failure reason. * - * See also {@linkcode resolveFailed}. + * See also {@link resolveFailed}. */ readonly errorCondition: ResolveError | undefined; @@ -132,13 +158,13 @@ interface IPromptReferenceBase extends IDisposable { /** * Direct references of the current reference. */ - references: readonly IPromptReference[]; + readonly references: readonly IPromptReference[]; /** * All references that the current reference may have, * including all possible nested child references. */ - allReferences: readonly IPromptReference[]; + readonly allReferences: readonly IPromptReference[]; /** * All *valid* references that the current reference may have, @@ -148,7 +174,18 @@ interface IPromptReferenceBase extends IDisposable { * without creating a circular reference loop or having any other * issues that would make the reference resolve logic to fail. */ - allValidReferences: readonly IPromptReference[]; + readonly allValidReferences: readonly IPromptReference[]; + + /** + * Entire associated `tools` metadata for this reference and + * all possible nested child references. + */ + readonly allToolsMetadata: readonly string[] | null; + + /** + * Metadata defined in the prompt header. + */ + readonly metadata: IPromptMetadata; /** * Returns a promise that resolves when the reference contents @@ -161,14 +198,14 @@ interface IPromptReferenceBase extends IDisposable { * and contents for all possible nested child references are * completely parsed and entire tree of references is built. * - * The same as {@linkcode settled} but for all prompts in + * The same as {@link settled} but for all prompts in * the reference tree. */ allSettled(): Promise; } /** - * The special case of the {@linkcode IPromptReferenceBase} that pertains + * The special case of the {@link IPromptReferenceBase} that pertains * to a file resource on the disk. */ export interface IPromptFileReference extends IPromptReferenceBase { 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 97297a4d720..ed14dc52fc1 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -3,16 +3,28 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IPromptPath, IPromptsService } from './types.js'; +import { ChatMode } from '../../constants.js'; +import { localize } from '../../../../../../nls.js'; +import { PROMPT_LANGUAGE_ID } from '../constants.js'; +import { flatten, forEach } from '../utils/treeUtils.js'; +import { PromptParser } from '../parsers/promptParser.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { IPromptFileReference } from '../parsers/types.js'; +import { match } from '../../../../../../base/common/glob.js'; +import { pick } from '../../../../../../base/common/arrays.js'; import { assert } from '../../../../../../base/common/assert.js'; +import { basename } from '../../../../../../base/common/path.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { ObjectCache } from '../../../../../../base/common/objectCache.js'; import { TextModelPromptParser } from '../parsers/textModelPromptParser.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { PROMPT_FILE_EXTENSION } from '../../../../../../platform/prompts/common/constants.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; +import { IChatPromptSlashCommand, TCombinedToolsMetadata, IMetadata, IPromptPath, IPromptsService, TPromptsStorage, TPromptsType } from './types.js'; /** * Provides prompt services. @@ -28,18 +40,27 @@ export class PromptsService extends Disposable implements IPromptsService { /** * Prompt files locator utility. */ - private readonly fileLocator = this.initService.createInstance(PromptFilesLocator); + private readonly fileLocator: PromptFilesLocator; constructor( + @ILabelService private readonly labelService: ILabelService, + @IModelService private readonly modelService: IModelService, @IInstantiationService private readonly initService: IInstantiationService, @IUserDataProfileService private readonly userDataService: IUserDataProfileService, ) { super(); + this.fileLocator = this.initService.createInstance(PromptFilesLocator); + // the factory function below creates a new prompt parser object // for the provided model, if no active non-disposed parser exists this.cache = this._register( new ObjectCache((model) => { + assert( + model.isDisposed() === false, + 'Text model must not be disposed.', + ); + /** * Note! When/if shared with "file" prompts, the `seenReferences` array below must be taken into account. * Otherwise consumers will either see incorrect failing or incorrect successful results, based on their @@ -48,10 +69,8 @@ export class PromptsService extends Disposable implements IPromptsService { const parser: TextModelPromptParser = initService.createInstance( TextModelPromptParser, model, - [], - ); - - parser.start(); + { seenReferences: [] }, + ).start(); // this is a sanity check and the contract of the object cache, // we must return a non-disposed object from this factory function @@ -74,52 +93,327 @@ export class PromptsService extends Disposable implements IPromptsService { model: ITextModel, ): TextModelPromptParser & { disposed: false } { assert( - !model.isDisposed(), + model.isDisposed() === false, 'Cannot create a prompt syntax parser for a disposed model.', ); return this.cache.get(model); } - public async listPromptFiles(): Promise { + public async listPromptFiles(type: TPromptsType): Promise { const userLocations = [this.userDataService.currentProfile.promptsHome]; const prompts = await Promise.all([ - this.fileLocator.listFilesIn(userLocations) - .then(withType('user')), - this.fileLocator.listFiles() - .then(withType('local')), + this.fileLocator.listFilesIn(userLocations, type) + .then(withType('user', type)), + this.fileLocator.listFiles(type) + .then(withType('local', type)), ]); return prompts.flat(); } - public getSourceFolders( - type: IPromptPath['type'], - ): readonly IPromptPath[] { + public getSourceFolders(type: TPromptsType): readonly IPromptPath[] { // sanity check to make sure we don't miss a new // prompt type that could be added in the future assert( - type === 'local' || type === 'user', + type === 'prompt' || type === 'instructions', `Unknown prompt type '${type}'.`, ); - const prompts = (type === 'user') - ? [this.userDataService.currentProfile.promptsHome] - : this.fileLocator.getConfigBasedSourceFolders(); + const result: IPromptPath[] = []; - return prompts.map(addType(type)); + for (const uri of this.fileLocator.getConfigBasedSourceFolders(type)) { + result.push({ uri, storage: 'local', type }); + } + const userHome = this.userDataService.currentProfile.promptsHome; + result.push({ uri: userHome, storage: 'user', type }); + + return result; + } + + public asPromptSlashCommand(command: string): IChatPromptSlashCommand | undefined { + if (command.match(/^[\w_\-\.]+/)) { + return { command, detail: localize('prompt.file.detail', 'Prompt file: {0}', command) }; + } + return undefined; + } + + public async resolvePromptSlashCommand(data: IChatPromptSlashCommand): Promise { + if (data.promptPath) { + return data.promptPath; + } + const files = await this.listPromptFiles('prompt'); + const command = data.command; + const result = files.find(file => getPromptCommandName(file.uri.path) === command); + if (result) { + return result; + } + const model = this.modelService.getModels().find(model => model.getLanguageId() === PROMPT_LANGUAGE_ID && getPromptCommandName(model.uri.path) === command); + if (model) { + return { uri: model.uri, storage: 'local', type: 'prompt' }; + } + return undefined; + } + + public async findPromptSlashCommands(): Promise { + const promptFiles = await this.listPromptFiles('prompt'); + return promptFiles.map(promptPath => { + const command = getPromptCommandName(promptPath.uri.path); + return { + command, + detail: localize('prompt.file.detail', 'Prompt file: {0}', this.labelService.getUriLabel(promptPath.uri, { relative: true })), + promptPath + }; + }); + } + + public async findInstructionFilesFor( + files: readonly URI[], + ): Promise { + const result: URI[] = []; + + const instructionFiles = await this.listPromptFiles('instructions'); + if (instructionFiles.length === 0) { + return result; + } + + const instructions = await this.getAllMetadata( + instructionFiles.map(pick('uri')), + ); + + for (const instruction of instructions.flatMap(flatten)) { + const { metadata, uri } = instruction; + const { applyTo } = metadata; + + if (applyTo === undefined) { + continue; + } + + // if glob pattern is one of the special wildcard values, + // add the instructions file event if no files are attached + if ((applyTo === '**') || (applyTo === '**/*')) { + result.push(uri); + + continue; + } + + // match each attached file with each glob pattern and + // add the instructions file its rule matches the file + for (const file of files) { + if (match(applyTo, file.fsPath)) { + result.push(uri); + + continue; + } + } + } + + return result; + } + + public async getAllMetadata( + promptUris: readonly URI[], + ): Promise { + const metadata = await Promise.all( + promptUris.map(async (uri) => { + let parser: PromptParser | undefined; + try { + parser = this.initService.createInstance( + PromptParser, + uri, + { allowNonPromptFiles: true }, + ).start(); + + await parser.allSettled(); + + return collectMetadata(parser); + } finally { + parser?.dispose(); + } + }), + ); + + return metadata; + } + + public async getCombinedToolsMetadata( + promptUris: readonly URI[], + ): Promise { + if (promptUris.length === 0) { + return null; + } + + const filesMetadata = await this.getAllMetadata(promptUris); + + const allTools = filesMetadata + .map((fileMetadata) => { + const result: string[] = []; + + let isFirst = true; + let isRootInAgentMode = false; + let hasTools = false; + + let chatMode: ChatMode | undefined; + + forEach((node) => { + const { metadata } = node; + const { mode, tools } = metadata; + + if (isFirst === true) { + isFirst = false; + + if ((mode === ChatMode.Agent) || (tools !== undefined)) { + isRootInAgentMode = true; + + chatMode = ChatMode.Agent; + } + } + + chatMode ??= mode; + + // if both chat modes are set, pick the more privileged one + if (chatMode && mode) { + chatMode = morePrivilegedChatMode( + chatMode, + mode, + ); + } + + if (isRootInAgentMode && tools !== undefined) { + result.push(...tools); + hasTools = true; + } + + return false; + }, fileMetadata); + + if ((chatMode) === ChatMode.Agent) { + return { + tools: (hasTools) + ? [...new Set(result)] + : undefined, + mode: ChatMode.Agent, + }; + } + + return { + mode: chatMode, + }; + }); + + let hasAnyTools = false; + let resultingChatMode: ChatMode | undefined; + + const result: string[] = []; + for (const { tools, mode } of allTools) { + resultingChatMode ??= mode; + + // if both chat modes are set, pick the more privileged one + if (resultingChatMode && mode) { + resultingChatMode = morePrivilegedChatMode( + resultingChatMode, + mode, + ); + } + + if (tools) { + result.push(...tools); + hasAnyTools = true; + } + } + + if (resultingChatMode === ChatMode.Agent) { + return { + tools: (hasAnyTools) + ? [...new Set(result)] + : undefined, + mode: resultingChatMode, + }; + } + + return { + tools: undefined, + mode: resultingChatMode, + }; } } /** - * Utility to add a provided prompt `type` to a prompt URI. + * Pick a more privileged chat mode between two provided ones. + */ +const morePrivilegedChatMode = ( + chatMode1: ChatMode, + chatMode2: ChatMode, +): ChatMode => { + // when modes equal, return one of them + if (chatMode1 === chatMode2) { + return chatMode1; + } + + // when modes are different but one of them is 'agent', use 'agent' + if ((chatMode1 === ChatMode.Agent) || (chatMode2 === ChatMode.Agent)) { + return ChatMode.Agent; + } + + // when modes are different, none of them is 'agent', but one of them + // is 'edit', use 'edit' + if ((chatMode1 === ChatMode.Edit) || (chatMode2 === ChatMode.Edit)) { + return ChatMode.Edit; + } + + throw new Error( + [ + 'Invalid logic encountered: ', + `at this point modes '${chatMode1}' and '${chatMode2}' are different, but`, + `both must have be equal to '${ChatMode.Ask}' at the same time.`, + ].join(' '), + ); +}; + +/** + * Collect all metadata from prompt file references + * into a single hierarchical tree structure. + */ +const collectMetadata = ( + reference: Pick, +): IMetadata => { + const childMetadata = []; + for (const child of reference.references) { + if (child.errorCondition !== undefined) { + continue; + } + + childMetadata.push(collectMetadata(child)); + } + + const children = (childMetadata.length > 0) + ? childMetadata + : undefined; + + return { + uri: reference.uri, + metadata: reference.metadata, + children, + }; +}; + + +export function getPromptCommandName(path: string) { + const name = basename(path, PROMPT_FILE_EXTENSION); + return name; +} + +/** + * Utility to add a provided prompt `storage` and + * `type` attributes to a prompt URI. */ const addType = ( - type: 'local' | 'user', + storage: TPromptsStorage, + type: TPromptsType, ): (uri: URI) => IPromptPath => { return (uri) => { - return { uri, type: type }; + return { uri, storage, type }; }; }; @@ -127,10 +421,11 @@ const addType = ( * Utility to add a provided prompt `type` to a list of prompt URIs. */ const withType = ( - type: 'local' | 'user', + storage: TPromptsStorage, + type: TPromptsType, ): (uris: readonly URI[]) => (readonly IPromptPath[]) => { return (uris) => { return uris - .map(addType(type)); + .map(addType(storage, type)); }; }; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/types.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/types.ts index 1f29a5811b5..e9faf6a2f40 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/types.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/types.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { TTree } from '../utils/treeUtils.js'; +import { ChatMode } from '../../constants.js'; +import { IPromptMetadata } from '../parsers/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -15,11 +18,14 @@ import { createDecorator } from '../../../../../../platform/instantiation/common export const IPromptsService = createDecorator('IPromptsService'); /** -* Supported prompt types. -* - `local` means the prompt is a local file. -* - `user` means a "roamble" prompt file (similar to snippets). -*/ -type TPromptsType = 'local' | 'user'; + * Where the prompt is stored. + */ +export type TPromptsStorage = 'local' | 'user'; + +/** + * What the prompt is used for. + */ +export type TPromptsType = 'instructions' | 'prompt'; /** * Represents a prompt path with its type. @@ -32,11 +38,84 @@ export interface IPromptPath { readonly uri: URI; /** - * Type of the prompt. + * Storage of the prompt. + */ + readonly storage: TPromptsStorage; + + /** + * Type of the prompt (e.g. 'prompt' or 'instructions'). */ readonly type: TPromptsType; } +/** + * Type for a shared prompt parser instance returned by the {@link IPromptsService}. + * Because the parser is shared, we omit the `dispose` method from + * the original type so the caller cannot dispose it prematurely + */ +export type TSharedPrompt = Omit; + +/** + * Metadata node object in a hierarchical tree of prompt references. + */ +export interface IMetadata { + /** + * URI of a prompt file. + */ + readonly uri: URI; + + /** + * Metadata of the prompt file. + */ + readonly metadata: IPromptMetadata; + + /** + * List of metadata for each valid child prompt reference. + */ + readonly children?: readonly TTree[]; +} + +/** + * Type of combined tools metadata for the case + * when the prompt is in the agent mode. + */ +interface ICombinedAgentToolsMetadata { + /** + * List of combined tools metadata for + * the entire tree of prompt references. + */ + readonly tools: readonly string[] | undefined; + + /** + * Resulting chat mode of a prompt, based on modes + * used in the entire tree of prompt references. + */ + readonly mode: ChatMode.Agent; +} + +/** + * Type of combined tools metadata for the case + * when the prompt is in non-agent mode. + */ +interface ICombinedNonAgentToolsMetadata { + /** + * List of combined tools metadata is empty + * when the prompt is in non-agent mode. + */ + readonly tools: undefined; + + /** + * Resulting chat mode of a prompt, based on modes + * used in the entire tree of prompt references. + */ + readonly mode?: ChatMode.Ask | ChatMode.Edit; +} + +/** + * General type of the combined tools metadata. + */ +export type TCombinedToolsMetadata = ICombinedAgentToolsMetadata | ICombinedNonAgentToolsMetadata; + /** * Provides prompt services. */ @@ -49,15 +128,74 @@ export interface IPromptsService extends IDisposable { */ getSyntaxParserFor( model: ITextModel, - ): TextModelPromptParser & { disposed: false }; + ): TSharedPrompt & { disposed: false }; /** * List all available prompt files. */ - listPromptFiles(): Promise; + listPromptFiles(type: TPromptsType): Promise; /** * Get a list of prompt source folders based on the provided prompt type. */ getSourceFolders(type: TPromptsType): readonly IPromptPath[]; + + /** + * Returns a prompt command if the command name. + * Undefined is returned if the name does not look like a file name of a prompt file. + */ + asPromptSlashCommand(name: string): IChatPromptSlashCommand | undefined; + + /** + * Gets the prompt file for a slash command. + */ + resolvePromptSlashCommand(data: IChatPromptSlashCommand): Promise; + + /** + * Returns a prompt command if the command name is valid. + */ + findPromptSlashCommands(): Promise; + + /** + * Find all instruction files which have a glob pattern in their + * 'applyTo' metadata record that match the provided list of files. + */ + findInstructionFilesFor( + fileUris: readonly URI[], + ): Promise; + + /** + * Get all metadata for entire prompt references tree + * that spans out of each of the provided files. + * + * In other words, the metadata tree is built starting from + * each of the provided files, therefore the result is a number + * of metadata trees, one for each file. + */ + getAllMetadata( + promptUris: readonly URI[], + ): Promise; + + /** + * Computes "combined" tools and chat mode metadata based on + * all provided files and their respective child references + * at the same time. + * + * For instance, the resulting {@link TCombinedToolsMetadata.mode} + * is computed as the least-privileged chat mode that can satisfy + * all the prompt files and their child references. + * + * On the other hand the resulting {@link TCombinedToolsMetadata.tools} + * metadata is computed as a union of all tools metadata that all + * prompt files and their child references specify. + */ + getCombinedToolsMetadata( + promptUris: readonly URI[], + ): Promise; +} + +export interface IChatPromptSlashCommand { + readonly command: string; + readonly detail: string; + readonly promptPath?: IPromptPath; } 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 975d45f56e2..179312fb361 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { TPromptsType } from '../service/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { match } from '../../../../../../base/common/glob.js'; import { assert } from '../../../../../../base/common/assert.js'; @@ -13,7 +14,7 @@ import { PromptsConfig } from '../../../../../../platform/prompts/common/config. import { basename, dirname, extUri } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { isPromptFile, PROMPT_FILE_EXTENSION } from '../../../../../../platform/prompts/common/constants.js'; +import { getPromptFileType, PROMPT_FILE_EXTENSION } from '../../../../../../platform/prompts/common/constants.js'; /** * Utility class to locate prompt files. @@ -30,11 +31,11 @@ export class PromptFilesLocator { * * @returns List of prompt files found in the workspace. */ - public async listFiles(): Promise { - const configuredLocations = PromptsConfig.promptSourceFolders(this.configService); + public async listFiles(type: TPromptsType): Promise { + const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); const absoluteLocations = toAbsoluteLocations(configuredLocations, this.workspaceService); - return await this.listFilesIn(absoluteLocations); + return await this.listFilesIn(absoluteLocations, type); } /** @@ -47,8 +48,9 @@ export class PromptFilesLocator { */ public async listFilesIn( folders: readonly URI[], + type: TPromptsType, ): Promise { - return await this.findInstructionFiles(folders); + return await this.findFilesInLocations(folders, type); } /** @@ -63,8 +65,8 @@ export class PromptFilesLocator { * * @returns List of possible unambiguous prompt file folders. */ - public getConfigBasedSourceFolders(): readonly URI[] { - const configuredLocations = PromptsConfig.promptSourceFolders(this.configService); + public getConfigBasedSourceFolders(type: TPromptsType): readonly URI[] { + const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); const absoluteLocations = toAbsoluteLocations(configuredLocations, this.workspaceService); // locations in the settings can contain glob patterns so we need @@ -112,9 +114,11 @@ export class PromptFilesLocator { * @param absoluteLocations List of prompt file source folders to search for prompt files in. Must be absolute paths. * @returns List of prompt files found in the provided source folders. */ - private async findInstructionFiles( + private async findFilesInLocations( absoluteLocations: readonly URI[], + type: TPromptsType, ): Promise { + // find all prompt files in the provided locations, then match // the found file paths against (possible) glob patterns const paths = new ResourceSet(); @@ -124,27 +128,34 @@ export class PromptFilesLocator { `Provided location must be an absolute path, got '${absoluteLocation.path}'.`, ); - // normalize the glob pattern to always end with "any prompt file" pattern - // unless the last part of the path is already a glob pattern itself; this is - // to handle the case when a user specifies a file glob pattern at the end, e.g., - // "my-folder/*.md" or "my-folder/*" already include the prompt files - const location = (isValidGlob(basename(absoluteLocation)) || absoluteLocation.path.endsWith(PROMPT_FILE_EXTENSION)) - ? absoluteLocation - : extUri.joinPath(absoluteLocation, `*${PROMPT_FILE_EXTENSION}`); - - // find all prompt files in entire file tree, starting from - // a first parent folder that does not contain a glob pattern - const promptFiles = await findAllPromptFiles( - firstNonGlobParent(location), - this.fileService, - ); - - // filter out found prompt files to only include those that match - // the original glob pattern specified in the settings (if any) - for (const file of promptFiles) { - if (match(location.path, file.path)) { + const nonGlobParent = firstNonGlobParent(absoluteLocation); + if (nonGlobParent === absoluteLocation) { + // the path does not contain a glob pattern, so we can + // just find all prompt files in the provided location + const promptFiles = await findFilesInLocation( + absoluteLocation, + type, + this.fileService, + ); + for (const file of promptFiles) { paths.add(file); } + } else { + // the path contains a glob pattern + // need to discuss whether to keep it or how to limit it (not documented yet) + const promptFiles = await findFilesInLocation( + nonGlobParent, + type, + this.fileService, + ); + + // filter out found prompt files to only include those that match + // the original glob pattern specified in the settings (if any) + for (const file of promptFiles) { + if (match(absoluteLocation.path, file.path)) { + paths.add(file); + } + } } } @@ -266,8 +277,9 @@ export const firstNonGlobParent = ( /** * Finds all `prompt files` in the provided location and all of its subfolders. */ -const findAllPromptFiles = async ( +const findFilesInLocation = async ( location: URI, + type: TPromptsType, fileService: IFileService, ): Promise => { const result: URI[] = []; @@ -275,7 +287,7 @@ const findAllPromptFiles = async ( try { const info = await fileService.resolve(location); - if (info.isFile && isPromptFile(info.resource)) { + if (info.isFile && getPromptFileType(info.resource) === type) { result.push(info.resource); return result; @@ -283,14 +295,14 @@ const findAllPromptFiles = async ( if (info.isDirectory && info.children) { for (const child of info.children) { - if (child.isFile && isPromptFile(child.resource)) { + if (child.isFile && getPromptFileType(child.resource) === type) { result.push(child.resource); continue; } if (child.isDirectory) { - const promptFiles = await findAllPromptFiles(child.resource, fileService); + const promptFiles = await findFilesInLocation(child.resource, type, fileService); result.push(...promptFiles); continue; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/treeUtils.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/treeUtils.ts new file mode 100644 index 00000000000..bfbc36075e0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/treeUtils.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Type for a generic tree node. + */ +export type TTree = { children?: readonly TTree[] } & TTreenNode; + +/** + * Flatter a tree structure into a single flat array. + */ +export const flatten = ( + treeRoot: TTree, +): Omit[] => { + const result: Omit[] = []; + + result.push(treeRoot); + + for (const child of treeRoot.children ?? []) { + result.push(...flatten(child)); + } + + return result; +}; + +/** + * Traverse a tree structure and execute a callback for each node. + */ +export const forEach = ( + callback: (node: TTreeNode) => boolean, + treeRoot: TTree, +): ReturnType => { + const shouldStop = callback(treeRoot); + + if (shouldStop === true) { + return true; + } + + for (const child of treeRoot.children ?? []) { + const shouldStop = forEach(callback, child); + + if (shouldStop === true) { + return true; + } + } + + return false; +}; + +/** + * Maps nodes of a tree to a new type preserving the original tree structure by invoking + * the provided callback function for each node. + * + * @param callback Function to map each of the nodes in the tree. The callback receives the original + * readonly tree node and a list of its already-mapped readonly children and expected + * to return a new tree node object. If the new object does not have an explicit + * `children` property set (e.g., set to `undefined` or an array), the utility will + * automatically set the `children` property to the `new mapped children` for you, + * otherwise the set `children` property is preserved. Likewise, if the callback + * modifies the `newChildren` array directly, but doesn't explicitly set the `children` + * property on the returned object, the modification to the `newChildren` array are + * preserved in the resulting object. + * + * @param treeRoot The root node of the tree to be mapped. + * + * ### Examples + * + * ```typescript + * const tree = { + * id: '1', + * children: [ + * { id: '1.1' }, + * { id: '1.2' }, + * }; + * + * const newTree = map((node, _newChildren) => { + * return { + * name: `name-of-${node.id}`, + * }; + * }, tree); + * + * assert.deepStrictEqual(newTree, { + * name: 'name-of-1', + * children: [ + * { name: 'name-of-1.1' }, + * { name: 'name-of-1.2' }, + * }); + * ``` + */ +export const map = < + TTreeNode extends object, + TNewTreeNode extends object, +>( + callback: ( + originalNode: Readonly>, + newChildren: Readonly[] | undefined, + ) => TTree, + treeRoot: TTree, +): TTree => { + // if the node does not have children, just call the callback + if (treeRoot.children === undefined) { + return callback(treeRoot, undefined); + } + + // otherwise process all the children recursively first + const newChildren = treeRoot.children + .map(curry(map, callback)); + + // then run the callback with the new children + const newNode = callback(treeRoot, newChildren); + + // if user explicitly set the children, preserve the value + if ('children' in newNode) { + return newNode; + } + + // otherwise if no children is explicitly set, + // use the new children array instead + newNode.children = newChildren; + + return newNode; +}; + +/** + * Type for a rest parameters of function, excluding + * the first argument. + */ +type TRestParameters any> = + T extends (first: any, ...rest: infer R) => any ? R : never; + +/** + * Type for a curried function. + * See {@link curry} for more info. + */ +type TCurriedFunction any> = ((...args: TRestParameters) => ReturnType); + +/** + * Curry a provided function with the first argument. + */ +export const curry = ( + callback: (arg1: T, ...args: any[]) => K, + arg1: T, +): TCurriedFunction => { + return (...args) => { + return callback(arg1, ...args); + }; +}; diff --git a/src/vs/workbench/contrib/chat/common/tools/editFileTool.ts b/src/vs/workbench/contrib/chat/common/tools/editFileTool.ts index b6384cf0ad5..ac60d09b425 100644 --- a/src/vs/workbench/contrib/chat/common/tools/editFileTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/editFileTool.ts @@ -9,92 +9,47 @@ import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../base/common/observable.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; -import { localize } from '../../../../../nls.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { SaveReason } from '../../../../common/editor.js'; import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { CellUri } from '../../../notebook/common/notebookCommon.js'; import { INotebookService } from '../../../notebook/common/notebookService.js'; import { ICodeMapperService } from '../../common/chatCodeMapperService.js'; -import { IChatEditingService } from '../../common/chatEditingService.js'; import { ChatModel } from '../../common/chatModel.js'; import { IChatService } from '../../common/chatService.js'; -import { ILanguageModelIgnoredFilesService } from '../../common/ignoredFiles.js'; -import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolResult } from '../../common/languageModelToolsService.js'; -import { IToolInputProcessor } from './tools.js'; - -const codeInstructions = ` -The user is very smart and can understand how to apply your edits to their files, you just need to provide minimal hints. -Avoid repeating existing code, instead use comments to represent regions of unchanged code. The user prefers that you are as concise as possible. For example: -// ...existing code... -{ changed code } -// ...existing code... -{ changed code } -// ...existing code... - -Here is an example of how you should use format an edit to an existing Person class: -class Person { - // ...existing code... - age: number; - // ...existing code... - getAge() { - return this.age; - } -} -`; +import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolResult, ToolProgress } from '../../common/languageModelToolsService.js'; export const ExtensionEditToolId = 'vscode_editFile'; export const InternalEditToolId = 'vscode_editFile_internal'; export const EditToolData: IToolData = { id: InternalEditToolId, - displayName: localize('chat.tools.editFile', "Edit File"), - modelDescription: `Edit a file in the workspace. Use this tool once per file that needs to be modified, even if there are multiple changes for a file. Generate the "explanation" property first. ${codeInstructions}`, - inputSchema: { - type: 'object', - properties: { - explanation: { - type: 'string', - description: 'A short explanation of the edit being made. Can be the same as the explanation you showed to the user.', - }, - filePath: { - type: 'string', - description: 'An absolute path to the file to edit, or the URI of a untitled, not yet named, file, such as `untitled:Untitled-1.', - }, - code: { - type: 'string', - description: 'The code change to apply to the file. ' + codeInstructions - } - }, - required: ['explanation', 'filePath', 'code'] - } + displayName: '', // not used + modelDescription: '', // Not used + source: { type: 'internal' }, }; +export interface EditToolParams { + uri: UriComponents; + explanation: string; + code: string; +} + export class EditTool implements IToolImpl { constructor( @IChatService private readonly chatService: IChatService, - @IChatEditingService private readonly chatEditingService: IChatEditingService, @ICodeMapperService private readonly codeMapperService: ICodeMapperService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @ILanguageModelIgnoredFilesService private readonly ignoredFilesService: ILanguageModelIgnoredFilesService, @ITextFileService private readonly textFileService: ITextFileService, @INotebookService private readonly notebookService: INotebookService, ) { } - async invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { + async invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { if (!invocation.context) { throw new Error('toolInvocationToken is required for this tool'); } const parameters = invocation.parameters as EditToolParams; - const uri = URI.revive(parameters.file); // TODO@roblourens do revive in MainThreadLanguageModelTools - if (!this.workspaceContextService.isInsideWorkspace(uri)) { - throw new Error(`File ${uri.fsPath} can't be edited because it's not inside the current workspace`); - } - - if (await this.ignoredFilesService.fileIsIgnored(uri, token)) { - throw new Error(`File ${uri.fsPath} can't be edited because it is configured to be ignored by Copilot`); - } + const fileUri = URI.revive(parameters.uri); + const uri = CellUri.parse(fileUri)?.notebook || fileUri; const model = this.chatService.getSession(invocation.context?.sessionId) as ChatModel; const request = model.getRequests().at(-1)!; @@ -115,19 +70,19 @@ export class EditTool implements IToolImpl { }); model.acceptResponseProgress(request, { kind: 'codeblockUri', - uri + uri, + isEdit: true }); model.acceptResponseProgress(request, { kind: 'markdownContent', - content: new MarkdownString(parameters.code + '\n````\n') + content: new MarkdownString('\n````\n') }); - const notebookUri = CellUri.parse(uri)?.notebook || uri; // Signal start. - if (this.notebookService.hasSupportedNotebooks(notebookUri) && (this.notebookService.getNotebookTextModel(notebookUri))) { + if (this.notebookService.hasSupportedNotebooks(uri) && (this.notebookService.getNotebookTextModel(uri))) { model.acceptResponseProgress(request, { kind: 'notebookEdit', edits: [], - uri: notebookUri + uri }); } else { model.acceptResponseProgress(request, { @@ -137,7 +92,7 @@ export class EditTool implements IToolImpl { }); } - const editSession = this.chatEditingService.getEditingSession(model.sessionId); + const editSession = model.editingSession; if (!editSession) { throw new Error('This tool must be called from within an editing session'); } @@ -145,7 +100,9 @@ export class EditTool implements IToolImpl { const result = await this.codeMapperService.mapCode({ codeBlocks: [{ code: parameters.code, resource: uri, markdownBeforeBlock: parameters.explanation }], location: 'tool', - chatRequestId: invocation.chatRequestId + chatRequestId: invocation.chatRequestId, + chatRequestModel: invocation.modelId, + chatSessionId: invocation.context.sessionId, }, { textEdit: (target, edits) => { model.acceptResponseProgress(request, { kind: 'textEdit', uri: target, edits }); @@ -156,8 +113,8 @@ export class EditTool implements IToolImpl { }, token); // Signal end. - if (this.notebookService.hasSupportedNotebooks(notebookUri) && (this.notebookService.getNotebookTextModel(notebookUri))) { - model.acceptResponseProgress(request, { kind: 'notebookEdit', uri: notebookUri, edits: [], done: true }); + if (this.notebookService.hasSupportedNotebooks(uri) && (this.notebookService.getNotebookTextModel(uri))) { + model.acceptResponseProgress(request, { kind: 'notebookEdit', uri, edits: [], done: true }); } else { model.acceptResponseProgress(request, { kind: 'textEdit', uri, edits: [], done: true }); } @@ -204,31 +161,3 @@ export class EditTool implements IToolImpl { }; } } - -export interface EditToolParams { - file: UriComponents; - explanation: string; - code: string; -} - -export interface EditToolRawParams { - filePath: string; - explanation: string; - code: string; -} - -export class EditToolInputProcessor implements IToolInputProcessor { - processInput(input: EditToolRawParams): EditToolParams { - if (!input.filePath) { - // Tool name collision, or input wasn't properly validated upstream - return input as any; - } - const filePath = input.filePath; - // Runs in EH, will be mapped - return { - file: filePath.startsWith('untitled:') ? URI.parse(filePath) : URI.file(filePath), - explanation: input.explanation, - code: input.code, - }; - } -} diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts index d375686f553..d3acab61bdc 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts @@ -13,6 +13,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { ExtensionIdentifier, IExtensionManifest } from '../../../../../platform/extensions/common/extensions.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../../services/extensionManagement/common/extensionFeatures.js'; @@ -135,8 +136,6 @@ function toToolKey(extensionIdentifier: ExtensionIdentifier, toolName: string) { return `${extensionIdentifier.value}/${toolName}`; } -const CopilotAgentModeTag = 'vscode_editing'; - export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.toolsExtensionPointHandler'; @@ -145,6 +144,7 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri constructor( @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, @ILogService logService: ILogService, + @IProductService productService: IProductService ) { languageModelToolsExtensionPoint.setHandler((extensions, delta) => { for (const extension of delta.added) { @@ -169,16 +169,8 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri continue; } - if (rawTool.tags?.includes(CopilotAgentModeTag)) { - if (!isProposedApiEnabled(extension.description, 'languageModelToolsForAgent') && !isProposedApiEnabled(extension.description, 'chatParticipantPrivate')) { - logService.error(`Extension '${extension.description.identifier.value}' CANNOT register tool with tag "${CopilotAgentModeTag}" without enabling 'languageModelToolsForAgent' proposal`); - continue; - } - } - - if (rawTool.tags?.some(tag => tag !== CopilotAgentModeTag && (tag.startsWith('copilot_') || tag.startsWith('vscode_'))) && !isProposedApiEnabled(extension.description, 'chatParticipantPrivate')) { + if (rawTool.tags?.some(tag => tag.startsWith('copilot_') || tag.startsWith('vscode_')) && !isProposedApiEnabled(extension.description, 'chatParticipantPrivate')) { logService.error(`Extension '${extension.description.identifier.value}' CANNOT register tool with tags starting with "vscode_" or "copilot_"`); - continue; } const rawIcon = rawTool.icon; @@ -195,13 +187,19 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri }; } + // If OSS and the product.json is not set up, fall back to checking api proposal + const isBuiltinTool = productService.defaultChatAgent?.chatExtensionId ? + ExtensionIdentifier.equals(extension.description.identifier, productService.defaultChatAgent.chatExtensionId) : + isProposedApiEnabled(extension.description, 'chatParticipantPrivate'); const tool: IToolData = { ...rawTool, - extensionId: extension.description.identifier, + source: { type: 'extension', label: extension.description.displayName ?? extension.description.name, extensionId: extension.description.identifier, isExternalTool: !isBuiltinTool }, inputSchema: rawTool.inputSchema, id: rawTool.name, icon, when: rawTool.when ? ContextKeyExpr.deserialize(rawTool.when) : undefined, + alwaysDisplayInputOutput: !isBuiltinTool, + supportsToolPicker: rawTool.canBeReferencedInPrompt }; const disposable = languageModelToolsService.registerToolData(tool); this._registrationDisposables.set(toToolKey(extension.description.identifier, rawTool.name), disposable); diff --git a/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts b/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts new file mode 100644 index 00000000000..20de711820c --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * This is a subset of the types export from jsonTypes.d.ts in @vscode/prompt-tsx. + * It's just the types needed to stringify prompt-tsx tool results. + * It should be kept in sync with the types in that file. + */ + +export declare const enum PromptNodeType { + Piece = 1, + Text = 2 +} +export interface TextJSON { + type: PromptNodeType.Text; + text: string; + lineBreakBefore: boolean | undefined; +} +/** + * Constructor kind of the node represented by {@link PieceJSON}. This is + * less descriptive than the actual constructor, as we only care to preserve + * the element data that the renderer cares about. + */ +export declare const enum PieceCtorKind { + BaseChatMessage = 1, + Other = 2, + ImageChatMessage = 3 +} +export interface BasePieceJSON { + type: PromptNodeType.Piece; + ctor: PieceCtorKind.BaseChatMessage | PieceCtorKind.Other; + children: PromptNodeJSON[]; +} +export interface ImageChatMessagePieceJSON { + type: PromptNodeType.Piece; + ctor: PieceCtorKind.ImageChatMessage; + children: PromptNodeJSON[]; + props: { + src: string; + detail?: 'low' | 'high'; + }; +} +export type PieceJSON = BasePieceJSON | ImageChatMessagePieceJSON; +export type PromptNodeJSON = PieceJSON | TextJSON; +export interface PromptElementJSON { + node: PieceJSON; +} + +export function stringifyPromptElementJSON(element: PromptElementJSON): string { + const strs: string[] = []; + stringifyPromptNodeJSON(element.node, strs); + return strs.join(''); +} + +function stringifyPromptNodeJSON(node: PromptNodeJSON, strs: string[]): void { + if (node.type === PromptNodeType.Text) { + if (node.lineBreakBefore) { + strs.push('\n'); + } + + if (typeof node.text === 'string') { + strs.push(node.text); + } + } else if (node.ctor === PieceCtorKind.ImageChatMessage) { + // This case currently can't be hit by prompt-tsx + strs.push(''); + } else if (node.ctor === PieceCtorKind.BaseChatMessage || node.ctor === PieceCtorKind.Other) { + for (const child of node.children) { + stringifyPromptNodeJSON(child, strs); + } + } +} diff --git a/src/vs/workbench/contrib/chat/common/tools/tools.ts b/src/vs/workbench/contrib/chat/common/tools/tools.ts index cfdcf85f99e..1ca0556cc94 100644 --- a/src/vs/workbench/contrib/chat/common/tools/tools.ts +++ b/src/vs/workbench/contrib/chat/common/tools/tools.ts @@ -25,6 +25,4 @@ export class BuiltinToolsContribution extends Disposable implements IWorkbenchCo } } -export interface IToolInputProcessor { - processInput(input: any): any; -} +export const InternalFetchWebPageToolId = 'vscode_fetchWebPage_internal'; diff --git a/src/vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions.ts b/src/vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions.ts index a55199d9769..6dd2e47985a 100644 --- a/src/vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions.ts +++ b/src/vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions.ts @@ -55,6 +55,11 @@ import { IChatResponseModel } from '../../common/chatModel.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { renderStringAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; import { ChatAgentLocation } from '../../common/constants.js'; +import { SearchContext } from '../../../search/common/constants.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import Severity from '../../../../../base/common/severity.js'; +import { isCancellationError } from '../../../../../base/common/errors.js'; +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; //#region Speech to Text @@ -458,7 +463,8 @@ export class HoldToVoiceChatInChatViewAction extends Action2 { ChatContextKeys.requestInProgress.negate(), // disable when a chat request is in progress FocusInChatInput?.negate(), // when already in chat input, disable this action and prefer to start voice chat directly EditorContextKeys.focus.negate(), // do not steal the inline-chat keybinding - NOTEBOOK_EDITOR_FOCUSED.negate() // do not steal the notebook keybinding + NOTEBOOK_EDITOR_FOCUSED.negate(), // do not steal the notebook keybinding + SearchContext.SearchViewFocusedKey.negate() // do not steal the search keybinding ), primary: KeyMod.CtrlCmd | KeyCode.KeyI } @@ -538,13 +544,13 @@ const primaryVoiceActionMenu = (when: ContextKeyExpression | undefined) => { return [ { id: MenuId.ChatInput, - when: ContextKeyExpr.and(ContextKeyExpr.or(ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession)), when), + when: ContextKeyExpr.and(ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), when), group: 'navigation', order: 3 }, { id: MenuId.ChatExecute, - when: ContextKeyExpr.and(ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel).negate(), ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession).negate(), when), + when: ContextKeyExpr.and(ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel).negate(), when), group: 'navigation', order: 2 } @@ -730,7 +736,7 @@ class ChatSynthesizerSessions { const activeSession = this.activeSession = new CancellationTokenSource(); const disposables = new DisposableStore(); - activeSession.token.onCancellationRequested(() => disposables.dispose()); + disposables.add(activeSession.token.onCancellationRequested(() => disposables.dispose())); const session = await this.speechService.createTextToSpeechSession(activeSession.token, 'chat'); @@ -1260,14 +1266,31 @@ abstract class BaseInstallSpeechProviderAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const contextKeyService = accessor.get(IContextKeyService); const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService); + const dialogService = accessor.get(IDialogService); try { InstallingSpeechProvider.bindTo(contextKeyService).set(true); + await this.installExtension(extensionsWorkbenchService, dialogService); + } finally { + InstallingSpeechProvider.bindTo(contextKeyService).reset(); + } + } + + private async installExtension(extensionsWorkbenchService: IExtensionsWorkbenchService, dialogService: IDialogService): Promise { + try { await extensionsWorkbenchService.install(BaseInstallSpeechProviderAction.SPEECH_EXTENSION_ID, { justification: this.getJustification(), enable: true }, ProgressLocation.Notification); - } finally { - InstallingSpeechProvider.bindTo(contextKeyService).reset(); + } catch (error) { + const { confirmed } = await dialogService.confirm({ + type: Severity.Error, + message: localize('unknownSetupError', "An error occurred while setting up voice chat. Would you like to try again?"), + detail: error && !isCancellationError(error) ? toErrorMessage(error) : undefined, + primaryButton: localize('retry', "Retry") + }); + if (confirmed) { + return this.installExtension(extensionsWorkbenchService, dialogService); + } } } diff --git a/src/vs/workbench/contrib/chat/electron-sandbox/tools/fetchPageTool.ts b/src/vs/workbench/contrib/chat/electron-sandbox/tools/fetchPageTool.ts index 30418d3b03d..632a9b9bbc3 100644 --- a/src/vs/workbench/contrib/chat/electron-sandbox/tools/fetchPageTool.ts +++ b/src/vs/workbench/contrib/chat/electron-sandbox/tools/fetchPageTool.ts @@ -3,21 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../../nls.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; import { IWebContentExtractorService } from '../../../../../platform/webContentExtractor/common/webContentExtractor.js'; import { ITrustedDomainService } from '../../../url/browser/trustedDomainService.js'; -import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolResult } from '../../common/languageModelToolsService.js'; -import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../common/languageModelToolsService.js'; +import { InternalFetchWebPageToolId } from '../../common/tools/tools.js'; -export const InternalFetchWebPageToolId = 'vscode_fetchWebPage_internal'; export const FetchWebPageToolData: IToolData = { id: InternalFetchWebPageToolId, displayName: 'Fetch Web Page', - tags: ['vscode_editing'], + canBeReferencedInPrompt: false, modelDescription: localize('fetchWebPage.modelDescription', 'Fetches the main content from a web page. This tool is useful for summarizing or analyzing the content of a webpage.'), - userDescription: localize('fetchWebPage.userDescription', 'Fetch the main content from a web page. This tool is useful for summarizing or analyzing the content of a webpage.'), + source: { type: 'internal' }, inputSchema: { type: 'object', properties: { @@ -41,61 +41,102 @@ export class FetchWebPageTool implements IToolImpl { @ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService, ) { } - async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _token: CancellationToken): Promise { - const { valid } = this._parseUris((invocation.parameters as { urls?: string[] }).urls); - if (!valid.length) { + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const parsedUriResults = this._parseUris((invocation.parameters as { urls?: string[] }).urls); + const validUris = Array.from(parsedUriResults.values()).filter((uri): uri is URI => !!uri); + if (!validUris.length) { return { content: [{ kind: 'text', value: localize('fetchWebPage.noValidUrls', 'No valid URLs provided.') }] }; } - for (const uri of valid) { + // We approved these via confirmation, so mark them as "approved" in this session + // if they are not approved via the trusted domain service. + for (const uri of validUris) { if (!this._trustedDomainService.isValid(uri)) { this._alreadyApprovedDomains.add(uri.toString(true)); } } - const result = await this._readerModeService.extract(valid); - // Right now there's a bug when returning multiple text content parts so we're merging into one. - // When that's fixed we can use the helper function _getPromptPartForWebPageContents. - const value = result.map((content, index) => localize( - 'fetchWebPage.promptPart', - 'Below is the main content extracted from the webpage ({0}). Please read and analyze this content to assist with any follow-up questions:\n\n{1}', - valid[index].toString(), - content - )).join('\n\n---\n\n'); - return { content: [{ kind: 'text', value }] }; + const contents = await this._readerModeService.extract(validUris); + // Make an array that contains either the content or undefined for invalid URLs + const contentsWithUndefined: (string | undefined)[] = []; + let indexInContents = 0; + parsedUriResults.forEach((uri) => { + if (uri) { + contentsWithUndefined.push(contents[indexInContents]); + indexInContents++; + } else { + contentsWithUndefined.push(undefined); + } + }); + + return { + content: this._getPromptPartsForResults(contentsWithUndefined), + // Have multiple results show in the dropdown + toolResultDetails: validUris.length > 1 ? validUris : undefined + }; } async prepareToolInvocation(parameters: any, token: CancellationToken): Promise { - const { invalid, valid } = this._parseUris(parameters.urls); + const map = this._parseUris(parameters.urls); + const invalid = new Array(); + const valid = new Array(); + map.forEach((uri, url) => { + if (!uri) { + invalid.push(url); + } else { + valid.push(uri); + } + }); const urlsNeedingConfirmation = valid.filter(url => !this._trustedDomainService.isValid(url) && !this._alreadyApprovedDomains.has(url.toString(true))); const pastTenseMessage = invalid.length ? invalid.length > 1 + // If there are multiple invalid URLs, show them all ? new MarkdownString( localize( 'fetchWebPage.pastTenseMessage.plural', 'Fetched {0} web pages, but the following were invalid URLs:\n\n{1}\n\n', valid.length, invalid.map(url => `- ${url}`).join('\n') )) + // If there is only one invalid URL, show it : new MarkdownString( localize( 'fetchWebPage.pastTenseMessage.singular', 'Fetched web page, but the following was an invalid URL:\n\n{0}\n\n', invalid[0] )) + // No invalid URLs : new MarkdownString(); - pastTenseMessage.appendMarkdown(valid.length > 1 - ? localize('fetchWebPage.pastTenseMessageResult.plural', 'Fetched {0} web pages', valid.length) - : localize('fetchWebPage.pastTenseMessageResult.singular', 'Fetched [web page]({0})', valid[0].toString()) - ); - const result: IPreparedToolInvocation = { - invocationMessage: valid.length > 1 - ? new MarkdownString(localize('fetchWebPage.invocationMessage.plural', 'Fetching {0} web pages', valid.length)) - : new MarkdownString(localize('fetchWebPage.invocationMessage.singular', 'Fetching [web page]({0})', valid[0].toString())), - pastTenseMessage - }; + const invocationMessage = new MarkdownString(); + if (valid.length > 1) { + pastTenseMessage.appendMarkdown(localize('fetchWebPage.pastTenseMessageResult.plural', 'Fetched {0} web pages', valid.length)); + invocationMessage.appendMarkdown(localize('fetchWebPage.invocationMessage.plural', 'Fetching {0} web pages', valid.length)); + } else { + const url = valid[0].toString(); + // If the URL is too long, show it as a link... otherwise, show it as plain text + if (url.length > 400) { + pastTenseMessage.appendMarkdown(localize({ + key: 'fetchWebPage.pastTenseMessageResult.singularAsLink', + comment: [ + // Make sure the link syntax is correct + '{Locked="]({0})"}', + ] + }, 'Fetched [web page]({0})', url)); + invocationMessage.appendMarkdown(localize({ + key: 'fetchWebPage.invocationMessage.singularAsLink', + comment: [ + // Make sure the link syntax is correct + '{Locked="]({0})"}', + ] + }, 'Fetching [web page]({0})', url)); + } else { + pastTenseMessage.appendMarkdown(localize('fetchWebPage.pastTenseMessageResult.singular', 'Fetched {0}', url)); + invocationMessage.appendMarkdown(localize('fetchWebPage.invocationMessage.singular', 'Fetching {0}', url)); + } + } + const result: IPreparedToolInvocation = { invocationMessage, pastTenseMessage }; if (urlsNeedingConfirmation.length) { const confirmationTitle = urlsNeedingConfirmation.length > 1 ? localize('fetchWebPage.confirmationTitle.plural', 'Fetch untrusted web pages?') @@ -113,43 +154,36 @@ export class FetchWebPageTool implements IToolImpl { ); confirmationMessage.appendMarkdown( - '\n\n$(info)' + localize( + '\n\n$(info) ' + localize( 'fetchWebPage.confirmationMessageManageTrustedDomains', 'You can [manage your trusted domains]({0}) to skip this confirmation in the future.', `command:${managedTrustedDomainsCommand}` ) ); - result.confirmationMessages = { title: confirmationTitle, message: confirmationMessage }; + result.confirmationMessages = { title: confirmationTitle, message: confirmationMessage, allowAutoConfirm: false }; } return result; } - private _parseUris(urls?: string[]): { invalid: string[]; valid: URI[] } { - const invalidUrls: string[] = []; - const validUrls: URI[] = []; + private _parseUris(urls?: string[]): Map { + const results = new Map(); urls?.forEach(uri => { try { const uriObj = URI.parse(uri); - validUrls.push(uriObj); + results.set(uri, uriObj); } catch (e) { - invalidUrls.push(uri); + results.set(uri, undefined); } }); - - return { invalid: invalidUrls, valid: validUrls }; + return results; } - // private _getPromptPartForWebPageContents(webPageContents: string, uri: URI): IToolResultTextPart { - // return { - // kind: 'text', - // value: localize( - // 'fetchWebPage.promptPart', - // 'Below is the main content extracted from the webpage ({0}). Please read and analyze this content to assist with any follow-up questions:\n\n{1}', - // uri.toString(), - // webPageContents - // ) - // }; - // } + private _getPromptPartsForResults(results: (string | undefined)[]): IToolResultTextPart[] { + return results.map(value => ({ + kind: 'text', + value: value || localize('fetchWebPage.invalidUrl', 'Invalid URL') + })); + } } diff --git a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_CDATA.0.snap b/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_CDATA.0.snap index 67f63f14b70..7c307009368 100644 --- a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_CDATA.0.snap +++ b/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_CDATA.0.snap @@ -1 +1 @@ -

<!--[CDATA[<div-->content]]>
\ No newline at end of file +

<!--[CDATA[<div-->content]]>

\ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_html_comments.0.snap b/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_html_comments.0.snap index 9def37d5acb..10d47923cbd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_html_comments.0.snap +++ b/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_html_comments.0.snap @@ -1 +1 @@ -
<!-- comment1 <div></div> -->
content
<!-- comment2 -->
\ No newline at end of file +
<!-- comment1 <div></div> -->
content

<!-- comment2 -->

\ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_mixed_valid_and_invalid_HTML.0.snap b/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_mixed_valid_and_invalid_HTML.0.snap index ba72307e533..fa56efb26d5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_mixed_valid_and_invalid_HTML.0.snap +++ b/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownRenderer_mixed_valid_and_invalid_HTML.0.snap @@ -5,4 +5,4 @@
  • hi
  • </details> -
    <canvas>canvas here</canvas>
    <details></details> \ No newline at end of file +
    <canvas>canvas here</canvas>

    <details></details>

    \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditingModifiedNotebookEntry.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditingModifiedNotebookEntry.test.ts index 0ac36cbdb45..42270fb0be7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditingModifiedNotebookEntry.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditingModifiedNotebookEntry.test.ts @@ -807,7 +807,7 @@ suite('ChatEditingModifiedNotebookEntry', function () { const cell = createICell(CellKind.Code, 'print("Hello World")'); const result = adjustCellDiffAndOriginalModelBasedOnCellAddDelete([0, 0, [cell]], - cellsDiffInfo, 2, 2, applyEdits, createModifiedCellDiffInfo); + cellsDiffInfo, 3, 2, applyEdits, createModifiedCellDiffInfo); assert.deepStrictEqual(appliedEdits, [ { editType: CellEditType.Replace, @@ -838,7 +838,7 @@ suite('ChatEditingModifiedNotebookEntry', function () { }, ]); }); - test('Insert a new cell into an notebook with 3 cells deleted', async function () { + test('Insert a new cell into a notebook with 3 cells deleted', async function () { const cellsDiffInfo: ICellDiffInfo[] = [ { diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('0'), originalCellIndex: 0, @@ -875,7 +875,7 @@ suite('ChatEditingModifiedNotebookEntry', function () { ]; const cell = createICell(CellKind.Code, 'print("Hello World")'); const result = adjustCellDiffAndOriginalModelBasedOnCellAddDelete([2, 0, [cell]], - cellsDiffInfo, 5, 7, applyEdits, createModifiedCellDiffInfo); + cellsDiffInfo, 6, 7, applyEdits, createModifiedCellDiffInfo); assert.deepStrictEqual(appliedEdits, [ { @@ -1090,6 +1090,53 @@ suite('ChatEditingModifiedNotebookEntry', function () { }, ]); }); + test('Delete the first cell, then insert a new cell at the top', async function () { + const cellsDiffInfo: ICellDiffInfo[] = [ + { + diff, keep, undo, type: 'delete', originalModel: createOriginalModel('0'), originalCellIndex: 0, + modifiedCellIndex: undefined, modifiedModel: createModifiedModel('null'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('1'), originalCellIndex: 1, + modifiedCellIndex: 0, modifiedModel: createModifiedModel('1'), + }, + ]; + + const cell1 = createICell(CellKind.Code, 'print("Hello World")'); + const result = adjustCellDiffAndOriginalModelBasedOnCellAddDelete([0, 0, [cell1]], + cellsDiffInfo, 2, 2, applyEdits, createModifiedCellDiffInfo); + + assert.deepStrictEqual(appliedEdits, [ + { + editType: CellEditType.Replace, + index: 1, + cells: [{ + cellKind: CellKind.Code, + language: 'python', + outputs: [], + mime: undefined, + metadata: {}, + internalMetadata: {}, + source: cell1.getValue(), + }], count: 0 + } + ]); + + assert.deepStrictEqual(result, [ + { + diff, keep, undo, type: 'delete', originalModel: createOriginalModel('0'), originalCellIndex: 0, + modifiedCellIndex: undefined, modifiedModel: createModifiedModel('null'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('InsertedOriginal:1'), originalCellIndex: 1, + modifiedCellIndex: 0, modifiedModel: createModifiedModel('InsertedModified:0'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('1'), originalCellIndex: 2, + modifiedCellIndex: 1, modifiedModel: createModifiedModel('1'), + }, + ]); + }); test('Delete a new cell from a notebook with 3 cells deleted', async function () { const cellsDiffInfo: ICellDiffInfo[] = [ { @@ -1300,6 +1347,161 @@ suite('ChatEditingModifiedNotebookEntry', function () { }, ]); }); + + test('Insert 1 cell at the bottom via chat, then user creats a new cell just below that', async function () { + const cellsDiffInfo: ICellDiffInfo[] = [ + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('0'), originalCellIndex: 0, + modifiedCellIndex: 0, modifiedModel: createModifiedModel('0'), + }, + { + diff, keep, undo, type: 'insert', originalModel: createOriginalModel('null'), originalCellIndex: undefined, + modifiedCellIndex: 1, modifiedModel: createModifiedModel('New1'), + }, + ]; + const cell1 = createICell(CellKind.Code, 'print("Hello World")'); + const result = adjustCellDiffAndOriginalModelBasedOnCellAddDelete([2, 0, [cell1]], + cellsDiffInfo, 3, 1, applyEdits, createModifiedCellDiffInfo); + + assert.deepStrictEqual(appliedEdits, [ + { + editType: CellEditType.Replace, + index: 1, + cells: [{ + cellKind: CellKind.Code, + language: 'python', + outputs: [], + mime: undefined, + metadata: {}, + internalMetadata: {}, + source: cell1.getValue(), + }], count: 0 + } + ]); + + assert.deepStrictEqual(result, [ + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('0'), originalCellIndex: 0, + modifiedCellIndex: 0, modifiedModel: createModifiedModel('0'), + }, + { + diff, keep, undo, type: 'insert', originalModel: createOriginalModel('null'), originalCellIndex: undefined, + modifiedCellIndex: 1, modifiedModel: createModifiedModel('New1'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('InsertedOriginal:1'), originalCellIndex: 1, + modifiedCellIndex: 2, modifiedModel: createModifiedModel('InsertedModified:2'), + }, + ]); + }); + test('Insert 1 cell at the bottom via chat, then user creats anew cells above the previous new cell', async function () { + const cellsDiffInfo: ICellDiffInfo[] = [ + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('0'), originalCellIndex: 0, + modifiedCellIndex: 0, modifiedModel: createModifiedModel('0'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('1'), originalCellIndex: 1, + modifiedCellIndex: 1, modifiedModel: createModifiedModel('1'), + }, + { + diff, keep, undo, type: 'insert', originalModel: createOriginalModel('null'), originalCellIndex: undefined, + modifiedCellIndex: 2, modifiedModel: createModifiedModel('New1'), + }, + ]; + const cell1 = createICell(CellKind.Code, 'print("Hello World")'); + const result = adjustCellDiffAndOriginalModelBasedOnCellAddDelete([2, 0, [cell1]], + cellsDiffInfo, 3, 2, applyEdits, createModifiedCellDiffInfo); + + assert.deepStrictEqual(appliedEdits, [ + { + editType: CellEditType.Replace, + index: 2, + cells: [{ + cellKind: CellKind.Code, + language: 'python', + outputs: [], + mime: undefined, + metadata: {}, + internalMetadata: {}, + source: cell1.getValue(), + }], count: 0 + } + ]); + + assert.deepStrictEqual(result, [ + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('0'), originalCellIndex: 0, + modifiedCellIndex: 0, modifiedModel: createModifiedModel('0'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('1'), originalCellIndex: 1, + modifiedCellIndex: 1, modifiedModel: createModifiedModel('1'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('InsertedOriginal:2'), originalCellIndex: 2, + modifiedCellIndex: 2, modifiedModel: createModifiedModel('InsertedModified:2'), + }, + { + diff, keep, undo, type: 'insert', originalModel: createOriginalModel('null'), originalCellIndex: undefined, + modifiedCellIndex: 3, modifiedModel: createModifiedModel('New1'), + }, + ]); + }); + test('Insert 1 cell at the bottom via chat, then user inserts a new cells below the previous new cell', async function () { + const cellsDiffInfo: ICellDiffInfo[] = [ + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('0'), originalCellIndex: 0, + modifiedCellIndex: 0, modifiedModel: createModifiedModel('0'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('1'), originalCellIndex: 1, + modifiedCellIndex: 1, modifiedModel: createModifiedModel('1'), + }, + { + diff, keep, undo, type: 'insert', originalModel: createOriginalModel('null'), originalCellIndex: undefined, + modifiedCellIndex: 2, modifiedModel: createModifiedModel('New1'), + }, + ]; + const cell1 = createICell(CellKind.Code, 'print("Hello World")'); + const result = adjustCellDiffAndOriginalModelBasedOnCellAddDelete([3, 0, [cell1]], + cellsDiffInfo, 3, 2, applyEdits, createModifiedCellDiffInfo); + + assert.deepStrictEqual(appliedEdits, [ + { + editType: CellEditType.Replace, + index: 2, + cells: [{ + cellKind: CellKind.Code, + language: 'python', + outputs: [], + mime: undefined, + metadata: {}, + internalMetadata: {}, + source: cell1.getValue(), + }], count: 0 + } + ]); + + assert.deepStrictEqual(result, [ + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('0'), originalCellIndex: 0, + modifiedCellIndex: 0, modifiedModel: createModifiedModel('0'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('1'), originalCellIndex: 1, + modifiedCellIndex: 1, modifiedModel: createModifiedModel('1'), + }, + { + diff, keep, undo, type: 'insert', originalModel: createOriginalModel('null'), originalCellIndex: undefined, + modifiedCellIndex: 2, modifiedModel: createModifiedModel('New1'), + }, + { + diff, keep, undo, type: 'unchanged', originalModel: createOriginalModel('InsertedOriginal:2'), originalCellIndex: 2, + modifiedCellIndex: 3, modifiedModel: createModifiedModel('InsertedModified:3'), + }, + ]); + }); }); suite('Cell Movements', function () { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditingService.test.ts index 5fed1eb581a..17a7eec607b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditingService.test.ts @@ -17,7 +17,7 @@ import { IChatEditingService } from '../../common/chatEditingService.js'; import { assertThrowsAsync, ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IChatVariablesService } from '../../common/chatVariables.js'; import { MockChatVariablesService } from '../common/mockChatVariables.js'; -import { ChatAgentService, IChatAgentImplementation, IChatAgentService } from '../../common/chatAgents.js'; +import { ChatAgentService, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../common/chatAgents.js'; import { IChatSlashCommandService } from '../../common/chatSlashCommands.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { NullWorkbenchAssignmentService } from '../../../../services/assignment/test/common/nullAssignmentService.js'; @@ -31,9 +31,11 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { waitForState } from '../../../../../base/common/observable.js'; import { INotebookService } from '../../../notebook/common/notebookService.js'; import { Range } from '../../../../../editor/common/core/range.js'; -import { ChatAgentLocation } from '../../common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../../common/constants.js'; +import { NotebookTextModel } from '../../../notebook/common/model/notebookTextModel.js'; +import { ChatTransferService, IChatTransferService } from '../../common/chatTransferService.js'; -function getAgentData(id: string) { +function getAgentData(id: string): IChatAgentData { return { name: id, id: id, @@ -42,6 +44,7 @@ function getAgentData(id: string) { publisherDisplayName: '', extensionDisplayName: '', locations: [ChatAgentLocation.Panel], + modes: [ChatMode.Ask], metadata: {}, slashCommands: [], disambiguation: [], @@ -61,6 +64,7 @@ suite('ChatEditingService', function () { collection.set(IChatAgentService, new SyncDescriptor(ChatAgentService)); collection.set(IChatVariablesService, new MockChatVariablesService()); collection.set(IChatSlashCommandService, new class extends mock() { }); + collection.set(IChatTransferService, new SyncDescriptor(ChatTransferService)); collection.set(IChatEditingService, new SyncDescriptor(ChatEditingService)); collection.set(IChatService, new SyncDescriptor(ChatService)); collection.set(IMultiDiffSourceResolverService, new class extends mock() { @@ -69,7 +73,10 @@ suite('ChatEditingService', function () { } }); collection.set(INotebookService, new class extends mock() { - override hasSupportedNotebooks(resource: URI): boolean { + override getNotebookTextModel(_uri: URI): NotebookTextModel | undefined { + return undefined; + } + override hasSupportedNotebooks(_resource: URI): boolean { return false; } }); @@ -110,15 +117,15 @@ suite('ChatEditingService', function () { test('create session', async function () { assert.ok(editingService); - const model = chatService.startSession(ChatAgentLocation.EditingSession, CancellationToken.None); - const session = await editingService.createEditingSession(model.sessionId, true); + const model = chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None); + const session = await editingService.createEditingSession(model, true); assert.strictEqual(session.chatSessionId, model.sessionId); assert.strictEqual(session.isGlobalEditingSession, true); await assertThrowsAsync(async () => { // DUPE not allowed - await editingService.createEditingSession(model.sessionId); + await editingService.createEditingSession(model); }); session.dispose(); @@ -131,8 +138,11 @@ suite('ChatEditingService', function () { const uri = URI.from({ scheme: 'test', path: 'HelloWorld' }); - const model = chatService.startSession(ChatAgentLocation.EditingSession, CancellationToken.None); - const session = await editingService.createEditingSession(model.sessionId, true); + const model = chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None); + const session = await model.editingSessionObs?.promise; + if (!session) { + assert.fail('session not created'); + } const chatRequest = model?.addRequest({ text: '', parts: [] }, { variables: [] }, 0); assertType(chatRequest.response); @@ -155,7 +165,6 @@ suite('ChatEditingService', function () { await entry.reject(undefined); - session.dispose(); model.dispose(); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditingSessionStorage.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditingSessionStorage.test.ts new file mode 100644 index 00000000000..cc036e2f6e4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditingSessionStorage.test.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { cloneAndChange } from '../../../../../base/common/objects.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { OffsetEdit } from '../../../../../editor/common/core/offsetEdit.js'; +import { OffsetRange } from '../../../../../editor/common/core/offsetRange.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { TestEnvironmentService } from '../../../../test/browser/workbenchTestServices.js'; +import { ISnapshotEntry } from '../../browser/chatEditing/chatEditingModifiedFileEntry.js'; +import { ChatEditingSessionStorage, IChatEditingSessionStop, StoredSessionState } from '../../browser/chatEditing/chatEditingSessionStorage.js'; +import { ChatEditingSnapshotTextModelContentProvider } from '../../browser/chatEditing/chatEditingTextModelContentProviders.js'; +import { ModifiedFileEntryState } from '../../common/chatEditingService.js'; + +suite('ChatEditingSessionStorage', () => { + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + const sessionId = generateUuid(); + let fs: FileService; + let storage: TestChatEditingSessionStorage; + + class TestChatEditingSessionStorage extends ChatEditingSessionStorage { + public get storageLocation() { + return super._getStorageLocation(); + } + } + + setup(() => { + fs = ds.add(new FileService(new NullLogService())); + ds.add(fs.registerProvider(TestEnvironmentService.workspaceStorageHome.scheme, ds.add(new InMemoryFileSystemProvider()))); + + storage = new TestChatEditingSessionStorage( + sessionId, + fs, + TestEnvironmentService, + new NullLogService(), + { getWorkspace: () => ({ id: 'workspaceId' }) } as any, + ); + }); + + function makeStop(requestId: string | undefined, before: string, after: string): IChatEditingSessionStop { + const stopId = generateUuid(); + const resource = URI.file('/foo.js'); + return { + stopId, + entries: new ResourceMap([ + [resource, { resource, languageId: 'javascript', snapshotUri: ChatEditingSnapshotTextModelContentProvider.getSnapshotFileURI(sessionId, requestId, stopId, resource.path), original: `contents${before}}`, current: `contents${after}`, originalToCurrentEdit: OffsetEdit.replace(OffsetRange.ofLength(42), 'newtext'), state: ModifiedFileEntryState.Modified, telemetryInfo: { agentId: 'agentId', command: 'cmd', requestId: generateUuid(), result: undefined, sessionId } } satisfies ISnapshotEntry], + ]), + }; + } + + function generateState(): StoredSessionState { + const initialFileContents = new ResourceMap(); + for (let i = 0; i < 10; i++) { initialFileContents.set(URI.file(`/foo${i}.js`), `fileContents${Math.floor(i / 2)}`); } + + const r1 = generateUuid(); + const r2 = generateUuid(); + return { + initialFileContents, + pendingSnapshot: makeStop(undefined, 'd', 'e'), + recentSnapshot: makeStop(undefined, 'd', 'e'), + linearHistoryIndex: 3, + linearHistory: [ + { startIndex: 0, requestId: r1, stops: [makeStop(r1, 'a', 'b')], postEdit: makeStop(r1, 'b', 'c').entries }, + { startIndex: 1, requestId: r2, stops: [makeStop(r2, 'c', 'd'), makeStop(r2, 'd', 'd')], postEdit: makeStop(r2, 'd', 'd').entries }, + ] + }; + } + + test('state is empty initially', async () => { + const s = await storage.restoreState(); + assert.strictEqual(s, undefined); + }); + + test('round trips state', async () => { + const original = generateState(); + await storage.storeState(original); + + const changer = (x: any) => { + return URI.isUri(x) ? x.toString() : x instanceof Map ? cloneAndChange([...x.values()], changer) : undefined; + }; + + const restored = await storage.restoreState(); + assert.deepStrictEqual(cloneAndChange(restored, changer), cloneAndChange(original, changer)); + }); + + test('clears state', async () => { + await storage.storeState(generateState()); + await storage.clearState(); + const s = await storage.restoreState(); + assert.strictEqual(s, undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/languageModelToolsService.test.ts index fb866bbfade..5454f7f7ca3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/languageModelToolsService.test.ts @@ -39,7 +39,8 @@ suite('LanguageModelToolsService', () => { const toolData: IToolData = { id: 'testTool', modelDescription: 'Test Tool', - displayName: 'Test Tool' + displayName: 'Test Tool', + source: { type: 'internal' }, }; const disposable = service.registerToolData(toolData); @@ -52,7 +53,8 @@ suite('LanguageModelToolsService', () => { const toolData: IToolData = { id: 'testTool', modelDescription: 'Test Tool', - displayName: 'Test Tool' + displayName: 'Test Tool', + source: { type: 'internal' }, }; store.add(service.registerToolData(toolData)); @@ -71,20 +73,23 @@ suite('LanguageModelToolsService', () => { id: 'testTool1', modelDescription: 'Test Tool 1', when: ContextKeyEqualsExpr.create('testKey', false), - displayName: 'Test Tool' + displayName: 'Test Tool', + source: { type: 'internal' }, }; const toolData2: IToolData = { id: 'testTool2', modelDescription: 'Test Tool 2', when: ContextKeyEqualsExpr.create('testKey', true), - displayName: 'Test Tool' + displayName: 'Test Tool', + source: { type: 'internal' }, }; const toolData3: IToolData = { id: 'testTool3', modelDescription: 'Test Tool 3', - displayName: 'Test Tool' + displayName: 'Test Tool', + source: { type: 'internal' }, }; store.add(service.registerToolData(toolData1)); @@ -101,7 +106,8 @@ suite('LanguageModelToolsService', () => { const toolData: IToolData = { id: 'testTool', modelDescription: 'Test Tool', - displayName: 'Test Tool' + displayName: 'Test Tool', + source: { type: 'internal' }, }; store.add(service.registerToolData(toolData)); @@ -135,14 +141,15 @@ suite('LanguageModelToolsService', () => { const toolData: IToolData = { id: 'testTool', modelDescription: 'Test Tool', - displayName: 'Test Tool' + displayName: 'Test Tool', + source: { type: 'internal' }, }; store.add(service.registerToolData(toolData)); const toolBarrier = new Barrier(); const toolImpl: IToolImpl = { - invoke: async (invocation, countTokens, cancelToken) => { + invoke: async (invocation, countTokens, progress, cancelToken) => { assert.strictEqual(invocation.callId, '1'); assert.strictEqual(invocation.toolId, 'testTool'); assert.deepStrictEqual(invocation.parameters, { a: 1 }); diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_after_newline.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_after_newline.0.snap index a71a6928c26..c91b499d014 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_after_newline.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_after_newline.0.snap @@ -36,6 +36,7 @@ extensionDisplayName: "", extensionPublisherId: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_with_leading_whitespace.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_with_leading_whitespace.0.snap index 4ca12379473..4229538081b 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_with_leading_whitespace.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_with_leading_whitespace.0.snap @@ -36,6 +36,7 @@ extensionDisplayName: "", extensionPublisherId: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_question_mark.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_question_mark.0.snap index ca95c7e1cfa..062362603e4 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_question_mark.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_question_mark.0.snap @@ -22,6 +22,7 @@ extensionDisplayName: "", extensionPublisherId: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_subcommand_after_text.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_subcommand_after_text.0.snap index c062f9c138a..15b0d547d18 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_subcommand_after_text.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_subcommand_after_text.0.snap @@ -22,6 +22,7 @@ extensionDisplayName: "", extensionPublisherId: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents__subCommand.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents__subCommand.0.snap index 22098b4e0de..eb55c494113 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents__subCommand.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents__subCommand.0.snap @@ -22,6 +22,7 @@ extensionDisplayName: "", extensionPublisherId: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_tools_and_multiline.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_tools_and_multiline.0.snap index 27552be87ba..3f0b98575dd 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_tools_and_multiline.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_tools_and_multiline.0.snap @@ -22,6 +22,7 @@ extensionDisplayName: "", extensionPublisherId: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_tools_and_multiline__part2.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_tools_and_multiline__part2.0.snap index 5617c8a0680..9d8283d8a07 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_tools_and_multiline__part2.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_tools_and_multiline__part2.0.snap @@ -22,6 +22,7 @@ extensionDisplayName: "", extensionPublisherId: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize.0.snap index 2de45db43d1..954291a13fb 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize.0.snap @@ -32,6 +32,7 @@ publisherDisplayName: "", extensionDisplayName: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ ], disambiguation: [ ] @@ -74,6 +75,7 @@ publisherDisplayName: "", extensionDisplayName: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ ], disambiguation: [ ] @@ -98,7 +100,9 @@ }, contentReferences: [ ], codeCitations: [ ], - timestamp: undefined + timestamp: undefined, + confirmation: undefined, + editedFileEvents: undefined } ] } \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize_with_response.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize_with_response.0.snap index 0a9e4d67229..eac347edaac 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize_with_response.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize_with_response.0.snap @@ -32,6 +32,7 @@ publisherDisplayName: "", extensionDisplayName: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ ], disambiguation: [ ] @@ -74,6 +75,7 @@ publisherDisplayName: "", extensionDisplayName: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ ], disambiguation: [ ] @@ -82,7 +84,9 @@ usedContext: undefined, contentReferences: [ ], codeCitations: [ ], - timestamp: undefined + timestamp: undefined, + confirmation: undefined, + editedFileEvents: undefined } ] } \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.1.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.1.snap index 36fd2784d41..6c53d43dcbf 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.1.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.1.snap @@ -31,6 +31,7 @@ publisherDisplayName: "", extensionDisplayName: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { requester: { name: "test" } }, slashCommands: [ ], disambiguation: [ ] @@ -81,6 +82,7 @@ publisherDisplayName: "", extensionDisplayName: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { requester: { name: "test" } }, slashCommands: [ ], disambiguation: [ ] @@ -105,7 +107,9 @@ }, contentReferences: [ ], codeCitations: [ ], - timestamp: undefined + timestamp: undefined, + confirmation: undefined, + editedFileEvents: undefined }, { requestId: undefined, @@ -148,6 +152,7 @@ publisherDisplayName: "", extensionDisplayName: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { requester: { name: "test" } }, slashCommands: [ ], disambiguation: [ ], @@ -157,7 +162,9 @@ usedContext: undefined, contentReferences: [ ], codeCitations: [ ], - timestamp: undefined + timestamp: undefined, + confirmation: undefined, + editedFileEvents: undefined } ] } \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_sendRequest_fails.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_sendRequest_fails.0.snap index 8e5fd37cea2..f6a351338de 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_sendRequest_fails.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_sendRequest_fails.0.snap @@ -31,6 +31,7 @@ publisherDisplayName: "", extensionDisplayName: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ ], disambiguation: [ ] @@ -74,6 +75,7 @@ publisherDisplayName: "", extensionDisplayName: "", locations: [ "panel" ], + modes: [ "ask" ], metadata: { }, slashCommands: [ ], disambiguation: [ ] @@ -82,7 +84,9 @@ usedContext: undefined, contentReferences: [ ], codeCitations: [ ], - timestamp: undefined + timestamp: undefined, + confirmation: undefined, + editedFileEvents: undefined } ] } \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts b/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts index cec803118e1..41c00776359 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts @@ -18,6 +18,7 @@ const testAgentData: IChatAgentData = { extensionId: new ExtensionIdentifier(''), extensionPublisherId: '', locations: [], + modes: [], metadata: {}, slashCommands: [], disambiguation: [], diff --git a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts index 9f5444ca5cd..768f7892ded 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts @@ -22,6 +22,8 @@ import { ChatRequestTextPart } from '../../common/chatParserTypes.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { TestExtensionService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; import { ChatAgentLocation } from '../../common/constants.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; suite('ChatModel', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -35,6 +37,7 @@ suite('ChatModel', () => { instantiationService.stub(IExtensionService, new TestExtensionService()); instantiationService.stub(IContextKeyService, new MockContextKeyService()); instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService))); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); }); test('Waits for initialization', async () => { @@ -160,7 +163,7 @@ suite('ChatModel', () => { model1.initialize(undefined); const text = 'hello'; - const request1 = model1.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0, undefined, undefined, undefined, undefined, undefined, undefined, true); + const request1 = model1.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0, undefined, undefined, undefined, undefined, undefined, true); assert.strictEqual(request1.isCompleteAddedRequest, true); assert.strictEqual(request1.response!.isCompleteAddedRequest, true); diff --git a/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts b/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts index b9dd31e234a..6d8091a23fa 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts @@ -19,9 +19,10 @@ import { IChatService } from '../../common/chatService.js'; import { IChatSlashCommandService } from '../../common/chatSlashCommands.js'; import { IChatVariablesService } from '../../common/chatVariables.js'; import { ChatMode, ChatAgentLocation } from '../../common/constants.js'; -import { ILanguageModelToolsService, IToolData } from '../../common/languageModelToolsService.js'; +import { IToolData } from '../../common/languageModelToolsService.js'; +import { IPromptsService } from '../../common/promptSyntax/service/types.js'; import { MockChatService } from './mockChatService.js'; -import { MockChatVariablesService } from './mockChatVariables.js'; +import { MockPromptsService } from './mockPromptsService.js'; suite('ChatRequestParser', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -29,7 +30,7 @@ suite('ChatRequestParser', () => { let instantiationService: TestInstantiationService; let parser: ChatRequestParser; - let toolsService: MockObject; + let variableService: MockObject; setup(async () => { instantiationService = testDisposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); @@ -37,11 +38,14 @@ suite('ChatRequestParser', () => { instantiationService.stub(IExtensionService, new TestExtensionService()); instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(IContextKeyService, new MockContextKeyService()); - instantiationService.stub(IChatVariablesService, new MockChatVariablesService()); instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService))); + instantiationService.stub(IPromptsService, testDisposables.add(new MockPromptsService())); - toolsService = mockObject()({}); - instantiationService.stub(ILanguageModelToolsService, toolsService as any); + variableService = mockObject()(); + variableService.getDynamicVariables.returns([]); + variableService.getSelectedTools.returns([]); + + instantiationService.stub(IChatVariablesService, variableService as any); }); test('plain text', async () => { @@ -120,7 +124,7 @@ suite('ChatRequestParser', () => { // }); const getAgentWithSlashCommands = (slashCommands: IChatAgentCommand[]) => { - return { id: 'agent', name: 'agent', extensionId: nullExtensionDescription.identifier, publisherDisplayName: '', extensionDisplayName: '', extensionPublisherId: '', locations: [ChatAgentLocation.Panel], metadata: {}, slashCommands, disambiguation: [] } satisfies IChatAgentData; + return { id: 'agent', name: 'agent', extensionId: nullExtensionDescription.identifier, publisherDisplayName: '', extensionDisplayName: '', extensionPublisherId: '', locations: [ChatAgentLocation.Panel], modes: [ChatMode.Ask], metadata: {}, slashCommands, disambiguation: [] } satisfies IChatAgentData; }; test('agent with subcommand after text', async () => { @@ -198,8 +202,10 @@ suite('ChatRequestParser', () => { agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); - toolsService.getToolByName.onCall(0).returns({ id: 'get_selection', canBeReferencedInPrompt: true, displayName: '', modelDescription: '' } satisfies IToolData); - toolsService.getToolByName.onCall(1).returns({ id: 'get_debugConsole', canBeReferencedInPrompt: true, displayName: '', modelDescription: '' } satisfies IToolData); + variableService.getSelectedTools.returns([ + { id: 'get_selection', toolReferenceName: 'selection', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: { type: 'internal' } }, + { id: 'get_debugConsole', toolReferenceName: 'debugConsole', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: { type: 'internal' } } + ] satisfies IToolData[]); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest('1', '@agent /subCommand \nPlease do with #selection\nand #debugConsole'); @@ -211,8 +217,10 @@ suite('ChatRequestParser', () => { agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); - toolsService.getToolByName.onCall(0).returns({ id: 'get_selection', canBeReferencedInPrompt: true, displayName: '', modelDescription: '' } satisfies IToolData); - toolsService.getToolByName.onCall(1).returns({ id: 'get_debugConsole', canBeReferencedInPrompt: true, displayName: '', modelDescription: '' } satisfies IToolData); + variableService.getSelectedTools.returns([ + { id: 'get_selection', toolReferenceName: 'selection', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: { type: 'internal' } }, + { id: 'get_debugConsole', toolReferenceName: 'debugConsole', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: { type: 'internal' } } + ] satisfies IToolData[]); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest('1', '@agent Please \ndo /subCommand with #selection\nand #debugConsole'); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService.test.ts index be6d91e9c26..0d06d63cdfb 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService.test.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Event } from '../../../../../base/common/event.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { assertSnapshot } from '../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -26,18 +27,19 @@ import { IWorkspaceContextService } from '../../../../../platform/workspace/comm import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { NullWorkbenchAssignmentService } from '../../../../services/assignment/test/common/nullAssignmentService.js'; import { IExtensionService, nullExtensionDescription } from '../../../../services/extensions/common/extensions.js'; +import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; -import { TestContextService, TestExtensionService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; -import { ChatAgentService, IChatAgent, IChatAgentImplementation, IChatAgentService } from '../../common/chatAgents.js'; +import { mock, TestContextService, TestExtensionService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../common/chatAgents.js'; +import { IChatEditingService, IChatEditingSession } from '../../common/chatEditingService.js'; import { IChatModel, ISerializableChatData } from '../../common/chatModel.js'; import { IChatFollowup, IChatService } from '../../common/chatService.js'; import { ChatService } from '../../common/chatServiceImpl.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../common/chatSlashCommands.js'; import { IChatVariablesService } from '../../common/chatVariables.js'; -import { ChatAgentLocation } from '../../common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../../common/constants.js'; import { MockChatService } from './mockChatService.js'; import { MockChatVariablesService } from './mockChatVariables.js'; -import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; const chatAgentWithUsedContext: IChatAgent = { @@ -48,6 +50,7 @@ const chatAgentWithUsedContext: IChatAgent = { extensionPublisherId: '', extensionDisplayName: '', locations: [ChatAgentLocation.Panel], + modes: [ChatMode.Ask], metadata: {}, slashCommands: [], disambiguation: [], @@ -81,6 +84,7 @@ const chatAgentWithMarkdown: IChatAgent = { extensionPublisherId: '', extensionDisplayName: '', locations: [ChatAgentLocation.Panel], + modes: [ChatMode.Ask], metadata: {}, slashCommands: [], disambiguation: [], @@ -93,7 +97,7 @@ const chatAgentWithMarkdown: IChatAgent = { }, }; -function getAgentData(id: string) { +function getAgentData(id: string): IChatAgentData { return { name: id, id: id, @@ -102,6 +106,7 @@ function getAgentData(id: string) { publisherDisplayName: '', extensionDisplayName: '', locations: [ChatAgentLocation.Panel], + modes: [ChatMode.Ask], metadata: {}, slashCommands: [], disambiguation: [], @@ -133,6 +138,11 @@ suite('ChatService', () => { instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/path/to/workspaceStorage') }); instantiationService.stub(ILifecycleService, { onWillShutdown: Event.None }); + instantiationService.stub(IChatEditingService, new class extends mock() { + override startOrContinueGlobalEditingSession(): Promise { + return Promise.resolve(Disposable.None as IChatEditingSession); + } + }); chatAgentService = testDisposables.add(instantiationService.createInstance(ChatAgentService)); instantiationService.stub(IChatAgentService, chatAgentService); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 06560d694d6..138d176f95b 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -14,6 +14,7 @@ import { ChatMessageRole, IChatResponseFragment, languageModelExtensionPoint, La import { IExtensionService, nullExtensionDescription } from '../../../../services/extensions/common/extensions.js'; import { ExtensionsRegistry } from '../../../../services/extensions/common/extensionsRegistry.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../common/modelPicker/modelPickerWidget.js'; suite('LanguageModels', function () { @@ -50,6 +51,7 @@ suite('LanguageModels', function () { name: 'Pretty Name', vendor: 'test-vendor', family: 'test-family', + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, version: 'test-version', id: 'test-id', maxInputTokens: 100, @@ -70,6 +72,7 @@ suite('LanguageModels', function () { vendor: 'test-vendor', family: 'test2-family', version: 'test2-version', + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, id: 'test-id', maxInputTokens: 100, maxOutputTokens: 100, @@ -119,6 +122,7 @@ suite('LanguageModels', function () { id: 'actual-lm', maxInputTokens: 100, maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, }, sendChatRequest: async (messages, _from, _options, token) => { // const message = messages.at(-1); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts index 2b645e0c906..346e678252d 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts @@ -12,6 +12,7 @@ import { IChatCompleteResponse, IChatDetail, IChatProviderInfo, IChatSendRequest import { ChatAgentLocation } from '../../common/constants.js'; export class MockChatService implements IChatService { + edits2Enabled: boolean = false; _serviceBrand: undefined; transferredSessionData: IChatTransferredSessionData | undefined; onDidSubmitRequest: Event<{ chatSessionId: string }> = Event.None; @@ -91,7 +92,6 @@ export class MockChatService implements IChatService { throw new Error('Method not implemented.'); } - unifiedViewEnabled = false; isEditingLocation(location: ChatAgentLocation): boolean { throw new Error('Method not implemented.'); } @@ -103,4 +103,12 @@ export class MockChatService implements IChatService { logChatIndex(): void { throw new Error('Method not implemented.'); } + + isPersistedSessionEmpty(sessionId: string): boolean { + throw new Error('Method not implemented.'); + } + + activateDefaultAgent(location: ChatAgentLocation): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts b/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts index 267af76a9f1..796050f1a23 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts @@ -3,10 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IChatRequestVariableData, IChatRequestVariableEntry } from '../../common/chatModel.js'; -import { IParsedChatRequest } from '../../common/chatParserTypes.js'; import { IChatVariablesService, IDynamicVariable } from '../../common/chatVariables.js'; -import { ChatAgentLocation } from '../../common/constants.js'; +import { IToolData } from '../../common/languageModelToolsService.js'; export class MockChatVariablesService implements IChatVariablesService { _serviceBrand: undefined; @@ -15,13 +13,7 @@ export class MockChatVariablesService implements IChatVariablesService { return []; } - resolveVariables(prompt: IParsedChatRequest, attachedContextVariables: IChatRequestVariableEntry[] | undefined): IChatRequestVariableData { - return { - variables: [] - }; - } - - attachContext(name: string, value: unknown, location: ChatAgentLocation): void { - throw new Error('Method not implemented.'); + getSelectedTools(sessionId: string): readonly IToolData[] { + return []; } } diff --git a/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts index d854a0beb5e..2cf7956aa5a 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { IProgressStep } from '../../../../../platform/progress/common/progress.js'; import { CountTokensCallback, ILanguageModelToolsService, IToolData, IToolImpl, IToolInvocation, IToolResult } from '../../common/languageModelToolsService.js'; export class MockLanguageModelToolsService implements ILanguageModelToolsService { @@ -22,6 +23,14 @@ export class MockLanguageModelToolsService implements ILanguageModelToolsService return Disposable.None; } + resetToolAutoConfirmation(): void { + + } + + setToolAutoConfirmation(toolId: string, scope: 'workspace' | 'profile', autoConfirm?: boolean): void { + + } + registerToolImplementation(name: string, tool: IToolImpl): IDisposable { return Disposable.None; } @@ -38,6 +47,10 @@ export class MockLanguageModelToolsService implements ILanguageModelToolsService return undefined; } + acceptProgress(sessionId: string | undefined, callId: string, progress: IProgressStep): void { + + } + async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { return { content: [{ kind: 'text', value: 'result' }] diff --git a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts new file mode 100644 index 00000000000..0683ce71f9e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ITextModel } from '../../../../../editor/common/model.js'; +import { PROMPT_FILE_EXTENSION } from '../../../../../platform/prompts/common/constants.js'; +import { TextModelPromptParser } from '../../common/promptSyntax/parsers/textModelPromptParser.js'; +import { IChatPromptSlashCommand, IMetadata, IPromptPath, IPromptsService, TCombinedToolsMetadata, TPromptsType } from '../../common/promptSyntax/service/types.js'; + +export class MockPromptsService implements IPromptsService { + getCombinedToolsMetadata(files: readonly URI[]): Promise { + throw new Error('Method not implemented.'); + } + getAllMetadata(files: readonly URI[]): Promise { + throw new Error('Method not implemented.'); + } + _serviceBrand: undefined; + getSyntaxParserFor(model: ITextModel): TextModelPromptParser & { disposed: false } { + throw new Error('Method not implemented.'); + } + listPromptFiles(type: TPromptsType): Promise { + throw new Error('Method not implemented.'); + } + getSourceFolders(type: TPromptsType): readonly IPromptPath[] { + throw new Error('Method not implemented.'); + } + public asPromptSlashCommand(name: string): IChatPromptSlashCommand | undefined { + if (name.endsWith(PROMPT_FILE_EXTENSION)) { + const command = `prompt:${name.substring(0, -PROMPT_FILE_EXTENSION.length)}`; + return { + command, detail: name, + }; + } + return undefined; + } + resolvePromptSlashCommand(data: IChatPromptSlashCommand): Promise { + throw new Error('Method not implemented.'); + } + findPromptSlashCommands(): Promise { + throw new Error('Method not implemented.'); + } + findInstructionFilesFor(files: readonly URI[]): Promise { + throw new Error('Method not implemented.'); + } + dispose(): void { } +} diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/chatPromptCodec.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/chatPromptCodec.test.ts index b063215ec7d..83b44281913 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/chatPromptCodec.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/chatPromptCodec.test.ts @@ -9,6 +9,8 @@ import { newWriteableStream } from '../../../../../../../base/common/stream.js'; import { TestDecoder } from '../../../../../../../editor/test/common/utils/testDecoder.js'; import { ChatPromptCodec } from '../../../../common/promptSyntax/codecs/chatPromptCodec.js'; import { FileReference } from '../../../../common/promptSyntax/codecs/tokens/fileReference.js'; +import { NewLine } from '../../../../../../../editor/common/codecs/linesCodec/tokens/newLine.js'; +import { Space, Tab, Word } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/index.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { ChatPromptDecoder, TChatPromptToken } from '../../../../common/promptSyntax/codecs/chatPromptDecoder.js'; @@ -51,30 +53,76 @@ suite('ChatPromptCodec', () => { await test.run( '#file:/etc/hosts some text\t\n for #file:./README.md\t testing\n ✔ purposes\n#file:LICENSE.md ✌ \t#file:.gitignore\n\n\n\t #file:/Users/legomushroom/repos/vscode \n\nsomething #file:\tsomewhere\n', [ + // reference new FileReference( new Range(1, 1, 1, 1 + 16), '/etc/hosts', ), + new Space(new Range(1, 17, 1, 17 + 1)), + new Word(new Range(1, 18, 1, 18 + 4), 'some'), + new Space(new Range(1, 22, 1, 22 + 1)), + new Word(new Range(1, 23, 1, 23 + 4), 'text'), + new Tab(new Range(1, 27, 1, 27 + 1)), + new NewLine(new Range(1, 28, 1, 28 + 1)), + new Space(new Range(2, 1, 2, 1 + 1)), + new Space(new Range(2, 2, 2, 2 + 1)), + new Word(new Range(2, 3, 2, 3 + 3), 'for'), + new Space(new Range(2, 6, 2, 6 + 1)), + // reference new FileReference( new Range(2, 7, 2, 7 + 17), './README.md', ), + new Tab(new Range(2, 24, 2, 24 + 1)), + new Space(new Range(2, 25, 2, 25 + 1)), + new Word(new Range(2, 26, 2, 26 + 7), 'testing'), + new NewLine(new Range(2, 33, 2, 33 + 1)), + new Space(new Range(3, 1, 3, 1 + 1)), + new Word(new Range(3, 2, 3, 2 + 1), '✔'), + new Space(new Range(3, 3, 3, 3 + 1)), + new Word(new Range(3, 4, 3, 4 + 8), 'purposes'), + new NewLine(new Range(3, 12, 3, 12 + 1)), + // reference new FileReference( new Range(4, 1, 4, 1 + 16), 'LICENSE.md', ), + new Space(new Range(4, 17, 4, 17 + 1)), + new Word(new Range(4, 18, 4, 18 + 1), '✌'), + new Space(new Range(4, 19, 4, 19 + 1)), + new Tab(new Range(4, 20, 4, 20 + 1)), + // reference new FileReference( new Range(4, 21, 4, 21 + 16), '.gitignore', ), + new NewLine(new Range(4, 37, 4, 37 + 1)), + new NewLine(new Range(5, 1, 5, 1 + 1)), + new NewLine(new Range(6, 1, 6, 1 + 1)), + new Tab(new Range(7, 1, 7, 1 + 1)), + new Space(new Range(7, 2, 7, 2 + 1)), + new Space(new Range(7, 3, 7, 3 + 1)), + new Space(new Range(7, 4, 7, 4 + 1)), + // reference new FileReference( new Range(7, 5, 7, 5 + 38), '/Users/legomushroom/repos/vscode', ), + new Space(new Range(7, 43, 7, 43 + 1)), + new Space(new Range(7, 44, 7, 44 + 1)), + new Space(new Range(7, 45, 7, 45 + 1)), + new NewLine(new Range(7, 46, 7, 46 + 1)), + new NewLine(new Range(8, 1, 8, 1 + 1)), + new Word(new Range(9, 1, 9, 1 + 9), 'something'), + new Space(new Range(9, 10, 9, 10 + 1)), + // reference new FileReference( new Range(9, 11, 9, 11 + 6), '', ), + new Tab(new Range(9, 17, 9, 17 + 1)), + new Word(new Range(9, 18, 9, 18 + 9), 'somewhere'), + new NewLine(new Range(9, 27, 9, 27 + 1)), ], ); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/chatPromptDecoder.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/chatPromptDecoder.test.ts index 6f009d57468..9a36c891bb0 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/chatPromptDecoder.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/chatPromptDecoder.test.ts @@ -8,9 +8,15 @@ import { Range } from '../../../../../../../editor/common/core/range.js'; import { newWriteableStream } from '../../../../../../../base/common/stream.js'; import { TestDecoder } from '../../../../../../../editor/test/common/utils/testDecoder.js'; import { FileReference } from '../../../../common/promptSyntax/codecs/tokens/fileReference.js'; +import { NewLine } from '../../../../../../../editor/common/codecs/linesCodec/tokens/newLine.js'; +import { PromptAtMention } from '../../../../common/promptSyntax/codecs/tokens/promptAtMention.js'; +import { PromptSlashCommand } from '../../../../common/promptSyntax/codecs/tokens/promptSlashCommand.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { MarkdownLink } from '../../../../../../../editor/common/codecs/markdownCodec/tokens/markdownLink.js'; +import { PromptTemplateVariable } from '../../../../common/promptSyntax/codecs/tokens/promptTemplateVariable.js'; import { ChatPromptDecoder, TChatPromptToken } from '../../../../common/promptSyntax/codecs/chatPromptDecoder.js'; +import { PromptVariable, PromptVariableWithData } from '../../../../common/promptSyntax/codecs/tokens/promptVariable.js'; +import { At, Dash, ExclamationMark, FormFeed, Hash, Space, Tab, VerticalTab, Word } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/index.js'; /** * A reusable test utility that asserts that a `ChatPromptDecoder` instance @@ -53,45 +59,365 @@ suite('ChatPromptDecoder', () => { const contents = [ '', - 'haalo!', + 'haalo! @workspace', ' message 👾 message #file:./path/to/file1.md', - '', + '\f', '## Heading Title', ' \t#file:a/b/c/filename2.md\t🖖\t#file:other-file.md', ' [#file:reference.md](./reference.md)some text #file:/some/file/with/absolute/path.md', - 'text text #file: another text', + 'text /run text #file: another @github text #selection even more text', + '\t\v#my-name:metadata:1:20 \t\t/command\t\v${inputs:id}\t', ]; await test.run( contents, [ + // first line + new NewLine(new Range(1, 1, 1, 2)), + // second line + new Word(new Range(2, 1, 2, 6), 'haalo'), + new ExclamationMark(new Range(2, 6, 2, 7)), + new Space(new Range(2, 7, 2, 8)), + new PromptAtMention( + new Range(2, 8, 2, 18), + 'workspace', + ), + new NewLine(new Range(2, 18, 2, 19)), + // third line + new Space(new Range(3, 1, 3, 2)), + new Word(new Range(3, 2, 3, 9), 'message'), + new Space(new Range(3, 9, 3, 10)), + new Word(new Range(3, 10, 3, 12), '👾'), + new Space(new Range(3, 12, 3, 13)), + new Word(new Range(3, 13, 3, 20), 'message'), + new Space(new Range(3, 20, 3, 21)), new FileReference( new Range(3, 21, 3, 21 + 24), './path/to/file1.md', ), + new NewLine(new Range(3, 45, 3, 46)), + // fourth line + new FormFeed(new Range(4, 1, 4, 2)), + new NewLine(new Range(4, 2, 4, 3)), + // fifth line + new Hash(new Range(5, 1, 5, 2)), + new Hash(new Range(5, 2, 5, 3)), + new Space(new Range(5, 3, 5, 4)), + new Word(new Range(5, 4, 5, 11), 'Heading'), + new Space(new Range(5, 11, 5, 12)), + new Word(new Range(5, 12, 5, 17), 'Title'), + new NewLine(new Range(5, 17, 5, 18)), + // sixth line + new Space(new Range(6, 1, 6, 2)), + new Tab(new Range(6, 2, 6, 3)), new FileReference( new Range(6, 3, 6, 3 + 24), 'a/b/c/filename2.md', ), + new Tab(new Range(6, 27, 6, 28)), + new Word(new Range(6, 28, 6, 30), '🖖'), + new Tab(new Range(6, 30, 6, 31)), new FileReference( new Range(6, 31, 6, 31 + 19), 'other-file.md', ), + new NewLine(new Range(6, 50, 6, 51)), + // seventh line + new Space(new Range(7, 1, 7, 2)), new MarkdownLink( 7, 2, '[#file:reference.md]', '(./reference.md)', ), + new Word(new Range(7, 38, 7, 38 + 4), 'some'), + new Space(new Range(7, 42, 7, 43)), + new Word(new Range(7, 43, 7, 43 + 4), 'text'), + new Space(new Range(7, 47, 7, 48)), new FileReference( new Range(7, 48, 7, 48 + 38), '/some/file/with/absolute/path.md', ), + new NewLine(new Range(7, 86, 7, 87)), + // eighth line + new Word(new Range(8, 1, 8, 5), 'text'), + new Space(new Range(8, 5, 8, 6)), + new PromptSlashCommand( + new Range(8, 6, 8, 6 + 4), + 'run', + ), + new Space(new Range(8, 10, 8, 11)), + new Word(new Range(8, 11, 8, 11 + 4), 'text'), + new Space(new Range(8, 15, 8, 16)), new FileReference( - new Range(8, 11, 8, 11 + 6), + new Range(8, 16, 8, 16 + 6), '', ), + new Space(new Range(8, 22, 8, 23)), + new Word(new Range(8, 23, 8, 23 + 7), 'another'), + new Space(new Range(8, 30, 8, 31)), + new PromptAtMention( + new Range(8, 31, 8, 32 + 6), + 'github', + ), + new Space(new Range(8, 38, 8, 39)), + new Word(new Range(8, 39, 8, 39 + 4), 'text'), + new Space(new Range(8, 43, 8, 44)), + new PromptVariable( + new Range(8, 44, 8, 44 + 10), + 'selection', + ), + new Space(new Range(8, 54, 8, 55)), + new Word(new Range(8, 55, 8, 55 + 4), 'even'), + new Space(new Range(8, 59, 8, 60)), + new Word(new Range(8, 60, 8, 60 + 4), 'more'), + new Space(new Range(8, 64, 8, 65)), + new Word(new Range(8, 65, 8, 65 + 4), 'text'), + new NewLine(new Range(8, 69, 8, 70)), + // ninth line + new Tab(new Range(9, 1, 9, 2)), + new VerticalTab(new Range(9, 2, 9, 3)), + new PromptVariableWithData( + new Range(9, 3, 9, 3 + 22), + 'my-name', + 'metadata:1:20', + ), + new Space(new Range(9, 25, 9, 26)), + new Tab(new Range(9, 26, 9, 27)), + new Tab(new Range(9, 27, 9, 28)), + new PromptSlashCommand( + new Range(9, 28, 9, 28 + 8), + 'command', + ), + new Tab(new Range(9, 36, 9, 37)), + new VerticalTab(new Range(9, 37, 9, 38)), + new PromptTemplateVariable( + new Range(9, 38, 9, 38 + 12), + 'inputs:id', + ), + new Tab(new Range(9, 50, 9, 51)), ], ); }); + + suite('• variables', () => { + test('• produces expected tokens', async () => { + const test = testDisposables.add( + new TestChatPromptDecoder(), + ); + + const contents = [ + '', + '\t\v#variable@', + ' #selection#your-variable', + 'some-text #var:12-67# some text', + ]; + + await test.run( + contents, + [ + // first line + new NewLine(new Range(1, 1, 1, 2)), + // second line + new Tab(new Range(2, 1, 2, 2)), + new VerticalTab(new Range(2, 2, 2, 3)), + new PromptVariable( + new Range(2, 3, 2, 3 + 9), + 'variable', + ), + new At(new Range(2, 12, 2, 13)), + new NewLine(new Range(2, 13, 2, 14)), + // third line + new Space(new Range(3, 1, 3, 2)), + new PromptVariable( + new Range(3, 2, 3, 2 + 10), + 'selection', + ), + new PromptVariable( + new Range(3, 12, 3, 12 + 14), + 'your-variable', + ), + new NewLine(new Range(3, 26, 3, 27)), + // forth line + new Word(new Range(4, 1, 4, 5), 'some'), + new Dash(new Range(4, 5, 4, 6)), + new Word(new Range(4, 6, 4, 6 + 4), 'text'), + new Space(new Range(4, 10, 4, 11)), + new PromptVariableWithData( + new Range(4, 11, 4, 11 + 10), + 'var', + '12-67', + ), + new Hash(new Range(4, 21, 4, 22)), + new Space(new Range(4, 22, 4, 23)), + new Word(new Range(4, 23, 4, 23 + 4), 'some'), + new Space(new Range(4, 27, 4, 28)), + new Word(new Range(4, 28, 4, 28 + 4), 'text'), + ], + ); + }); + }); + + suite('• commands', () => { + test('• produces expected tokens', async () => { + const test = testDisposables.add( + new TestChatPromptDecoder(), + ); + + const contents = [ + 'my command is \t/run', + 'your /command\v is done', + '/their#command is a pun', + 'and the /none@cmd was made by a nun', + ]; + + await test.run( + contents, + [ + // first line + new Word(new Range(1, 1, 1, 3), 'my'), + new Space(new Range(1, 3, 1, 4)), + new Word(new Range(1, 4, 1, 11), 'command'), + new Space(new Range(1, 11, 1, 12)), + new Word(new Range(1, 12, 1, 12 + 2), 'is'), + new Space(new Range(1, 14, 1, 15)), + new Tab(new Range(1, 15, 1, 16)), + new PromptSlashCommand( + new Range(1, 16, 1, 16 + 4), + 'run', + ), + new NewLine(new Range(1, 20, 1, 21)), + // second line + new Word(new Range(2, 1, 2, 5), 'your'), + new Space(new Range(2, 5, 2, 6)), + new PromptSlashCommand( + new Range(2, 6, 2, 6 + 8), + 'command', + ), + new VerticalTab(new Range(2, 14, 2, 15)), + new Space(new Range(2, 15, 2, 16)), + new Word(new Range(2, 16, 2, 16 + 2), 'is'), + new Space(new Range(2, 18, 2, 19)), + new Word(new Range(2, 19, 2, 19 + 4), 'done'), + new NewLine(new Range(2, 23, 2, 24)), + // third line + new PromptSlashCommand( + new Range(3, 1, 3, 1 + 6), + 'their', + ), + new PromptVariable( + new Range(3, 7, 3, 7 + 8), + 'command', + ), + new Space(new Range(3, 15, 3, 16)), + new Word(new Range(3, 16, 3, 16 + 2), 'is'), + new Space(new Range(3, 18, 3, 19)), + new Space(new Range(3, 19, 3, 20)), + new Word(new Range(3, 20, 3, 20 + 1), 'a'), + new Space(new Range(3, 21, 3, 22)), + new Word(new Range(3, 22, 3, 22 + 3), 'pun'), + new NewLine(new Range(3, 25, 3, 26)), + // forth line + new Word(new Range(4, 1, 4, 4), 'and'), + new Space(new Range(4, 4, 4, 5)), + new Word(new Range(4, 5, 4, 5 + 3), 'the'), + new Space(new Range(4, 8, 4, 9)), + new PromptSlashCommand( + new Range(4, 9, 4, 9 + 5), + 'none', + ), + new PromptAtMention( + new Range(4, 14, 4, 14 + 4), + 'cmd', + ), + new Space(new Range(4, 18, 4, 19)), + new Word(new Range(4, 19, 4, 19 + 3), 'was'), + new Space(new Range(4, 22, 4, 23)), + new Word(new Range(4, 23, 4, 23 + 4), 'made'), + new Space(new Range(4, 27, 4, 28)), + new Word(new Range(4, 28, 4, 28 + 2), 'by'), + new Space(new Range(4, 30, 4, 31)), + new Word(new Range(4, 31, 4, 31 + 1), 'a'), + new Space(new Range(4, 32, 4, 33)), + new Word(new Range(4, 33, 4, 33 + 3), 'nun'), + ], + ); + }); + }); + + suite('• template variables', () => { + test('• produces expected tokens', async () => { + const test = testDisposables.add( + new TestChatPromptDecoder(), + ); + + const contents = [ + 'my command is \t${run}', + 'your ${variable}\v is done', + '${their:variable} is a pun', + 'and the ${none:var} is made for fun', + ]; + + await test.run( + contents, + [ + // first line + new Word(new Range(1, 1, 1, 3), 'my'), + new Space(new Range(1, 3, 1, 4)), + new Word(new Range(1, 4, 1, 11), 'command'), + new Space(new Range(1, 11, 1, 12)), + new Word(new Range(1, 12, 1, 12 + 2), 'is'), + new Space(new Range(1, 14, 1, 15)), + new Tab(new Range(1, 15, 1, 16)), + new PromptTemplateVariable( + new Range(1, 16, 1, 16 + 6), + 'run', + ), + new NewLine(new Range(1, 22, 1, 23)), + // second line + new Word(new Range(2, 1, 2, 5), 'your'), + new Space(new Range(2, 5, 2, 6)), + new PromptTemplateVariable( + new Range(2, 6, 2, 6 + 11), + 'variable', + ), + new VerticalTab(new Range(2, 17, 2, 18)), + new Space(new Range(2, 18, 2, 19)), + new Word(new Range(2, 19, 2, 19 + 2), 'is'), + new Space(new Range(2, 21, 2, 22)), + new Word(new Range(2, 22, 2, 22 + 4), 'done'), + new NewLine(new Range(2, 26, 2, 27)), + // third line + new PromptTemplateVariable( + new Range(3, 1, 3, 1 + 17), + 'their:variable', + ), + new Space(new Range(3, 18, 3, 19)), + new Word(new Range(3, 19, 3, 19 + 2), 'is'), + new Space(new Range(3, 21, 3, 22)), + new Word(new Range(3, 22, 3, 22 + 1), 'a'), + new Space(new Range(3, 23, 3, 24)), + new Word(new Range(3, 24, 3, 24 + 3), 'pun'), + new NewLine(new Range(3, 27, 3, 28)), + // forth line + new Word(new Range(4, 1, 4, 4), 'and'), + new Space(new Range(4, 4, 4, 5)), + new Word(new Range(4, 5, 4, 5 + 3), 'the'), + new Space(new Range(4, 8, 4, 9)), + new PromptTemplateVariable( + new Range(4, 9, 4, 9 + 11), + 'none:var', + ), + new Space(new Range(4, 20, 4, 21)), + new Word(new Range(4, 21, 4, 21 + 2), 'is'), + new Space(new Range(4, 23, 4, 24)), + new Word(new Range(4, 24, 4, 24 + 4), 'made'), + new Space(new Range(4, 28, 4, 29)), + new Word(new Range(4, 29, 4, 29 + 3), 'for'), + new Space(new Range(4, 32, 4, 33)), + new Word(new Range(4, 33, 4, 33 + 3), 'fun'), + ], + ); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/markdownExtensionsDecoder.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/markdownExtensionsDecoder.test.ts new file mode 100644 index 00000000000..46573d18f65 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/codecs/markdownExtensionsDecoder.test.ts @@ -0,0 +1,394 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../../../../../../../base/common/assert.js'; +import { VSBuffer } from '../../../../../../../base/common/buffer.js'; +import { randomInt } from '../../../../../../../base/common/numbers.js'; +import { Range } from '../../../../../../../editor/common/core/range.js'; +import { Text } from '../../../../../../../editor/common/codecs/baseToken.js'; +import { newWriteableStream } from '../../../../../../../base/common/stream.js'; +import { randomBoolean } from '../../../../../../../base/test/common/testUtils.js'; +import { TestDecoder } from '../../../../../../../editor/test/common/utils/testDecoder.js'; +import { Word } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/word.js'; +import { TChatPromptToken } from '../../../../common/promptSyntax/codecs/chatPromptDecoder.js'; +import { NewLine } from '../../../../../../../editor/common/codecs/linesCodec/tokens/newLine.js'; +import { TestSimpleDecoder } from '../../../../../../../editor/test/common/codecs/simpleDecoder.test.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { CarriageReturn } from '../../../../../../../editor/common/codecs/linesCodec/tokens/carriageReturn.js'; +import { Colon, Dash, Space, Tab, VerticalTab } from '../../../../../../../editor/common/codecs/simpleCodec/tokens/index.js'; +import { FrontMatterHeader } from '../../../../../../../editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterHeader.js'; +import { MarkdownExtensionsDecoder } from '../../../../../../../editor/common/codecs/markdownExtensionsCodec/markdownExtensionsDecoder.js'; +import { FrontMatterMarker, TMarkerToken } from '../../../../../../../editor/common/codecs/markdownExtensionsCodec/tokens/frontMatterMarker.js'; + +/** + * Type for supported end-of-line tokens. + */ +type TEndOfLine = '\n' | '\r\n'; + +/** + * End-of-line utility class for convenience. + */ +class TestEndOfLine extends Text { + /** + * Create a new instance with provided end-of line type and + * a starting position. + */ + public static create( + type: TEndOfLine, + lineNumber: number, + startColumn: number, + ): TestEndOfLine { + // sanity checks + assert( + lineNumber >= 1, + `Line number must be greater than or equal to 1, got '${lineNumber}'.`, + ); + assert( + startColumn >= 1, + `Start column must be greater than or equal to 1, got '${startColumn}'.`, + ); + + const tokens = []; + + if (type === '\r\n') { + tokens.push(new CarriageReturn( + new Range( + lineNumber, + startColumn, + lineNumber, + startColumn + 1, + ), + )); + + startColumn += 1; + } + + tokens.push(new NewLine( + new Range( + lineNumber, + startColumn, + lineNumber, + startColumn + 1, + ), + )); + + return TestEndOfLine.fromTokens(tokens); + } +} + +/** + * Test decoder for the `MarkdownExtensionsDecoder` class. + */ +export class TestMarkdownExtensionsDecoder extends TestDecoder { + constructor( + ) { + const stream = newWriteableStream(null); + const decoder = new MarkdownExtensionsDecoder(stream); + + super(stream, decoder); + } +} + +/** + * Front Matter marker utility class for testing purposes. + */ +class TestFrontMatterMarker extends FrontMatterMarker { + /** + * Create a new instance with provided dashes count, + * line number, and an end-of-line type. + */ + public static create( + dashCount: number, + lineNumber: number, + endOfLine: TEndOfLine, + ): TestFrontMatterMarker { + const tokens: TMarkerToken[] = []; + + let columnNumber = 1; + while (columnNumber <= dashCount) { + tokens.push(new Dash( + new Range( + lineNumber, + columnNumber, + lineNumber, + columnNumber + 1, + ), + )); + + columnNumber++; + } + + const endOfLineTokens = TestEndOfLine.create( + endOfLine, + lineNumber, + columnNumber, + ); + tokens.push(...endOfLineTokens.tokens); + + return TestFrontMatterMarker.fromTokens(tokens); + } +} + +suite('MarkdownExtensionsDecoder', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + /** + * Create a Front Matter header start/end marker with a random length. + */ + const randomMarker = ( + maxDashCount: number = 10, + minDashCount: number = 1, + ): string => { + const dashCount = randomInt(maxDashCount, minDashCount); + + return new Array(dashCount).fill('-').join(''); + }; + + suite('• Front Matter header', () => { + suite('• successful cases', () => { + test('• produces expected tokens', async () => { + const test = disposables.add( + new TestMarkdownExtensionsDecoder(), + ); + + // both line endings should result in the same result + const newLine = (randomBoolean()) + ? '\n' + : '\r\n'; + + const markerLength = randomInt(10, 3); + + const promptContents = [ + new Array(markerLength).fill('-').join(''), + 'variables: ', + ' - name: value\v', + new Array(markerLength).fill('-').join(''), + 'some text', + ]; + + const startMarker = TestFrontMatterMarker.create(markerLength, 1, newLine); + const endMarker = TestFrontMatterMarker.create(markerLength, 4, newLine); + + await test.run( + promptContents.join(newLine), + [ + // header + new FrontMatterHeader( + new Range(1, 1, 4, 1 + markerLength + newLine.length), + startMarker, + Text.fromTokens([ + new Word(new Range(2, 1, 2, 1 + 9), 'variables'), + new Colon(new Range(2, 10, 2, 11)), + new Space(new Range(2, 11, 2, 12)), + ...TestEndOfLine.create(newLine, 2, 12).tokens, + new Space(new Range(3, 1, 3, 2)), + new Space(new Range(3, 2, 3, 3)), + new Dash(new Range(3, 3, 3, 4)), + new Space(new Range(3, 4, 3, 5)), + new Word(new Range(3, 5, 3, 5 + 4), 'name'), + new Colon(new Range(3, 9, 3, 10)), + new Space(new Range(3, 10, 3, 11)), + new Word(new Range(3, 11, 3, 11 + 5), 'value'), + new VerticalTab(new Range(3, 16, 3, 17)), + ...TestEndOfLine.create(newLine, 3, 17).tokens, + ]), + endMarker, + ), + // content after the header + new Word(new Range(5, 1, 5, 1 + 4), 'some'), + new Space(new Range(5, 5, 5, 6)), + new Word(new Range(5, 6, 5, 6 + 4), 'text'), + ], + ); + }); + + test('• can contain dashes in the header contents', async () => { + const test = disposables.add( + new TestMarkdownExtensionsDecoder(), + ); + + // both line endings should result in the same result + const newLine = (randomBoolean()) + ? '\n' + : '\r\n'; + + const markerLength = randomInt(10, 4); + + // number of dashes inside the header contents it should not matter how many + // dashes are there, but the count should not be equal to `markerLength` + const dashesLength = (randomBoolean()) + ? randomInt(markerLength - 1, 1) + : randomInt(2 * markerLength, markerLength + 1); + + const promptContents = [ + // start marker + new Array(markerLength).fill('-').join(''), + // contents + 'variables: ', + new Array(dashesLength).fill('-').join(''), // dashes inside the contents + ' - name: value\t', + // end marker + new Array(markerLength).fill('-').join(''), + 'some text', + ]; + + const startMarker = TestFrontMatterMarker.create(markerLength, 1, newLine); + const endMarker = TestFrontMatterMarker.create(markerLength, 4, newLine); + + await test.run( + promptContents.join(newLine), + [ + // header + new FrontMatterHeader( + new Range(1, 1, 5, 1 + markerLength + newLine.length), + startMarker, + Text.fromTokens([ + new Word(new Range(2, 1, 2, 1 + 9), 'variables'), + new Colon(new Range(2, 10, 2, 11)), + new Space(new Range(2, 11, 2, 12)), + ...TestEndOfLine.create(newLine, 2, 12).tokens, + // dashes inside the header + ...TestFrontMatterMarker.create(dashesLength, 3, newLine).dashTokens, + ...TestEndOfLine.create(newLine, 3, dashesLength + 1).tokens, + // - + new Space(new Range(4, 1, 4, 2)), + new Space(new Range(4, 2, 4, 3)), + new Dash(new Range(4, 3, 4, 4)), + new Space(new Range(4, 4, 4, 5)), + new Word(new Range(4, 5, 4, 5 + 4), 'name'), + new Colon(new Range(4, 9, 4, 10)), + new Space(new Range(4, 10, 4, 11)), + new Word(new Range(4, 11, 4, 11 + 5), 'value'), + new Tab(new Range(4, 16, 4, 17)), + ...TestEndOfLine.create(newLine, 4, 17).tokens, + ]), + endMarker, + ), + // content after the header + new Word(new Range(6, 1, 6, 1 + 4), 'some'), + new Space(new Range(6, 5, 6, 6)), + new Word(new Range(6, 6, 6, 6 + 4), 'text'), + ], + ); + }); + }); + + suite('• failure cases', () => { + test('• fails if header starts not on the first line', async () => { + const test = disposables.add( + new TestMarkdownExtensionsDecoder(), + ); + + const simpleDecoder = disposables.add( + new TestSimpleDecoder(), + ); + + const marker = randomMarker(5); + + // prompt contents + const contents = [ + '', + marker, + 'variables:', + ' - name: value', + marker, + 'some text', + ]; + + // both line ending should result in the same result + const newLine = (randomBoolean()) + ? '\n' + : '\r\n'; + + const stringContents = contents.join(newLine); + + // send the same contents to the simple decoder + simpleDecoder.sendData(stringContents); + + // in the failure case we expect tokens to be re-emitted, therefore + // the list of tokens produced must be equal to the one of SimpleDecoder + await test.run( + stringContents, + (await simpleDecoder.receiveTokens()), + ); + }); + + test('• fails if header markers do not match (start marker is longer)', async () => { + const test = disposables.add( + new TestMarkdownExtensionsDecoder(), + ); + + const simpleDecoder = disposables.add( + new TestSimpleDecoder(), + ); + + const marker = randomMarker(5); + + // prompt contents + const contents = [ + `${marker}${marker}`, + 'variables:', + ' - name: value', + marker, + 'some text', + ]; + + // both line ending should result in the same result + const newLine = (randomBoolean()) + ? '\n' + : '\r\n'; + + const stringContents = contents.join(newLine); + + // send the same contents to the simple decoder + simpleDecoder.sendData(stringContents); + + // in the failure case we expect tokens to be re-emitted, therefore + // the list of tokens produced must be equal to the one of SimpleDecoder + await test.run( + stringContents, + (await simpleDecoder.receiveTokens()), + ); + }); + + test('• fails if header markers do not match (end marker is longer)', async () => { + const test = disposables.add( + new TestMarkdownExtensionsDecoder(), + ); + + const simpleDecoder = disposables.add( + new TestSimpleDecoder(), + ); + + const marker = randomMarker(5); + + const promptContents = [ + marker, + 'variables:', + ' - name: value', + `${marker}${marker}`, + 'some text', + ]; + + // both line ending should result in the same result + const newLine = (randomBoolean()) + ? '\n' + : '\r\n'; + + const stringContents = promptContents.join(newLine); + + // send the same contents to the simple decoder + simpleDecoder.sendData(stringContents); + + // in the failure case we expect tokens to be re-emitted, therefore + // the list of tokens produced must be equal to the one of SimpleDecoder + await test.run( + stringContents, + (await simpleDecoder.receiveTokens()), + ); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/contentProviders/filePromptContentsProvider.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/contentProviders/filePromptContentsProvider.test.ts index 42b2933defc..1ac567cf581 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/contentProviders/filePromptContentsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/contentProviders/filePromptContentsProvider.test.ts @@ -9,7 +9,7 @@ import { VSBuffer } from '../../../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../../../base/common/network.js'; import { randomInt } from '../../../../../../../base/common/numbers.js'; import { assertDefined } from '../../../../../../../base/common/types.js'; -import { wait } from '../../../../../../../base/test/common/testUtils.js'; +import { randomBoolean, wait } from '../../../../../../../base/test/common/testUtils.js'; import { ReadableStream } from '../../../../../../../base/common/stream.js'; import { IFileService } from '../../../../../../../platform/files/common/files.js'; import { FileService } from '../../../../../../../platform/files/common/fileService.js'; @@ -23,8 +23,9 @@ import { ConfigurationService } from '../../../../../../../platform/configuratio import { InMemoryFileSystemProvider } from '../../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { FilePromptContentProvider } from '../../../../common/promptSyntax/contentProviders/filePromptContentsProvider.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { NotPromptFile } from '../../../../common/promptFileReferenceErrors.js'; -suite('FilePromptContentsProvider', function () { +suite('FilePromptContentsProvider', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); let instantiationService: TestInstantiationService; @@ -48,7 +49,7 @@ suite('FilePromptContentsProvider', function () { instantiationService.stub(IConfigurationService, nullConfigService); }); - test('provides contents of a file', async function () { + test('• provides contents of a file', async () => { const fileService = instantiationService.get(IFileService); const fileName = `file-${randomInt(10000)}.prompt.md`; @@ -63,6 +64,7 @@ suite('FilePromptContentsProvider', function () { const contentsProvider = testDisposables.add(instantiationService.createInstance( FilePromptContentProvider, fileUri, + {}, )); let streamOrError: ReadableStream | Error | undefined; @@ -99,4 +101,144 @@ suite('FilePromptContentsProvider', function () { `Expected to receive '${expectedLine}', got '${receivedLine}'.`, ); }); + + suite('• options', () => { + suite('• allowNonPromptFiles', () => { + test('• true', async () => { + const fileService = instantiationService.get(IFileService); + + const fileName = (randomBoolean() === true) + ? `file-${randomInt(10_000)}.md` + : `file-${randomInt(10_000)}.txt`; + + const fileUri = URI.file(`/${fileName}`); + + if (await fileService.exists(fileUri)) { + await fileService.del(fileUri); + } + await fileService.writeFile(fileUri, VSBuffer.fromString('Hello, world!')); + await wait(5); + + const contentsProvider = testDisposables.add(instantiationService.createInstance( + FilePromptContentProvider, + fileUri, + { allowNonPromptFiles: true }, + )); + + let streamOrError: ReadableStream | Error | undefined; + testDisposables.add(contentsProvider.onContentChanged((event) => { + streamOrError = event; + })); + contentsProvider.start(); + + await wait(25); + + assertDefined( + streamOrError, + 'The `streamOrError` must be defined.', + ); + + assert( + !(streamOrError instanceof Error), + `Provider must produce a byte stream, got '${streamOrError}'.`, + ); + + const stream = new LinesDecoder(streamOrError); + + const receivedLines = await stream.consumeAll(); + assert.strictEqual( + receivedLines.length, + 1, + 'Must read the correct number of lines from the provider.', + ); + + const expectedLine = new Line(1, 'Hello, world!'); + const receivedLine = receivedLines[0]; + assert( + receivedLine.equals(expectedLine), + `Expected to receive '${expectedLine}', got '${receivedLine}'.`, + ); + }); + + test('• false', async () => { + const fileService = instantiationService.get(IFileService); + + const fileName = (randomBoolean() === true) + ? `file-${randomInt(10_000)}.md` + : `file-${randomInt(10_000)}.txt`; + + const fileUri = URI.file(`/${fileName}`); + + if (await fileService.exists(fileUri)) { + await fileService.del(fileUri); + } + await fileService.writeFile(fileUri, VSBuffer.fromString('Hello, world!')); + await wait(5); + + const contentsProvider = testDisposables.add(instantiationService.createInstance( + FilePromptContentProvider, + fileUri, + { allowNonPromptFiles: false }, + )); + + let streamOrError: ReadableStream | Error | undefined; + testDisposables.add(contentsProvider.onContentChanged((event) => { + streamOrError = event; + })); + contentsProvider.start(); + + await wait(25); + + assertDefined( + streamOrError, + 'The `streamOrError` must be defined.', + ); + + assert( + streamOrError instanceof NotPromptFile, + `Provider must produce an 'NotPromptFile' error, got '${streamOrError}'.`, + ); + }); + + test('• undefined', async () => { + const fileService = instantiationService.get(IFileService); + + const fileName = (randomBoolean() === true) + ? `file-${randomInt(10_000)}.md` + : `file-${randomInt(10_000)}.txt`; + + const fileUri = URI.file(`/${fileName}`); + + if (await fileService.exists(fileUri)) { + await fileService.del(fileUri); + } + await fileService.writeFile(fileUri, VSBuffer.fromString('Hello, world!')); + await wait(5); + + const contentsProvider = testDisposables.add(instantiationService.createInstance( + FilePromptContentProvider, + fileUri, + {}, + )); + + let streamOrError: ReadableStream | Error | undefined; + testDisposables.add(contentsProvider.onContentChanged((event) => { + streamOrError = event; + })); + contentsProvider.start(); + + await wait(25); + + assertDefined( + streamOrError, + 'The `streamOrError` must be defined.', + ); + + assert( + streamOrError instanceof NotPromptFile, + `Provider must produce an 'NotPromptFile' error, got '${streamOrError}'.`, + ); + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/parsers/textModelPromptParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/parsers/textModelPromptParser.test.ts index c13322ea1a2..aa3d01d2978 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/parsers/textModelPromptParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/parsers/textModelPromptParser.test.ts @@ -5,10 +5,13 @@ import assert from 'assert'; import { createURI } from '../testUtils/createUri.js'; +import { ChatMode } from '../../../../common/constants.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { Schemas } from '../../../../../../../base/common/network.js'; import { ExpectedReference } from '../testUtils/expectedReference.js'; +import { Range } from '../../../../../../../editor/common/core/range.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; +import { assertDefined } from '../../../../../../../base/common/types.js'; import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { OpenFailed } from '../../../../common/promptFileReferenceErrors.js'; import { IFileService } from '../../../../../../../platform/files/common/files.js'; @@ -19,7 +22,9 @@ import { ILogService, NullLogService } from '../../../../../../../platform/log/c import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { TextModelPromptParser } from '../../../../common/promptSyntax/parsers/textModelPromptParser.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../../common/promptSyntax/constants.js'; import { InMemoryFileSystemProvider } from '../../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { ExpectedDiagnosticError, ExpectedDiagnosticWarning, TExpectedDiagnostic } from '../testUtils/expectedDiagnostic.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; /** @@ -40,6 +45,7 @@ class TextModelPromptParserTest extends Disposable { constructor( uri: URI, initialContents: string[], + languageId: string = PROMPT_LANGUAGE_ID, @IFileService fileService: IFileService, @IInstantiationService initService: IInstantiationService, ) { @@ -56,7 +62,7 @@ class TextModelPromptParserTest extends Disposable { this.model = this._register( createTextModel( initialContents.join(lineEnding), - 'fooLang', + languageId, undefined, uri, ), @@ -64,10 +70,17 @@ class TextModelPromptParserTest extends Disposable { // create the parser instance this.parser = this._register( - initService.createInstance(TextModelPromptParser, this.model, []), + initService.createInstance(TextModelPromptParser, this.model, {}), ).start(); } + /** + * Wait for the prompt parsing/resolve process to finish. + */ + public allSettled(): Promise { + return this.parser.allSettled(); + } + /** * Validate the current state of the parser. */ @@ -78,7 +91,14 @@ class TextModelPromptParserTest extends Disposable { const { references } = this.parser; for (let i = 0; i < expectedReferences.length; i++) { - expectedReferences[i].validateEqual(references[i]); + const reference = references[i]; + + assertDefined( + reference, + `Expected reference #${i} be ${expectedReferences[i]}, got 'undefined'.`, + ); + + expectedReferences[i].validateEqual(reference); } assert.strictEqual( @@ -87,6 +107,45 @@ class TextModelPromptParserTest extends Disposable { `[${this.model.uri}] Unexpected number of references.`, ); } + + /** + * Validate list of diagnostic objects of the prompt header. + */ + public async validateHeaderDiagnostics( + expectedDiagnostics: readonly TExpectedDiagnostic[], + ) { + await this.parser.allSettled(); + + const { header } = this.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + const { diagnostics } = header; + + for (let i = 0; i < expectedDiagnostics.length; i++) { + const diagnostic = diagnostics[i]; + + assertDefined( + diagnostic, + `Expected diagnostic #${i} be ${expectedDiagnostics[i]}, got 'undefined'.`, + ); + + try { + expectedDiagnostics[i].validateEqual(diagnostic); + } catch (_error) { + throw new Error( + `Expected diagnostic #${i} to be ${expectedDiagnostics[i]}, got '${diagnostic}'.`, + ); + } + } + + assert.strictEqual( + expectedDiagnostics.length, + diagnostics.length, + `Expected '${expectedDiagnostics.length}' diagnostic objects, got '${diagnostics.length}'.`, + ); + } } suite('TextModelPromptParser', () => { @@ -106,17 +165,19 @@ suite('TextModelPromptParser', () => { const createTest = ( uri: URI, initialContents: string[], + languageId: string = PROMPT_LANGUAGE_ID, ): TextModelPromptParserTest => { return disposables.add( instantiationService.createInstance( TextModelPromptParserTest, uri, initialContents, + languageId, ), ); }; - test('core logic #1', async () => { + test('• core logic #1', async () => { const test = createTest( createURI('/foo/bar.md'), [ @@ -127,11 +188,11 @@ suite('TextModelPromptParser', () => { /* 05 */"Sometimes, the best code is the one you never have to write.", /* 06 */"A lone kangaroo once hopped into the local cafe, seeking free Wi-Fi.", /* 07 */"Critical #file:./folder/binary.file thinking is like coffee; best served strong [md link](/etc/hosts/random-file.txt) and without sugar.", - /* 08 */"Music is the mind’s way of doodling in the air.", + /* 08 */"Music is the mind's way of doodling in the air.", /* 09 */"Stargazing is just turning your eyes into cosmic explorers.", /* 10 */"Never trust a balloon salesman who hates birthdays.", /* 11 */"Running backward can be surprisingly enlightening.", - /* 12 */"There’s an art to whispering loudly.", + /* 12 */"There's an art to whispering loudly.", ], ); @@ -166,7 +227,7 @@ suite('TextModelPromptParser', () => { ]); }); - test('core logic #2', async () => { + test('• core logic #2', async () => { const test = createTest( createURI('/absolute/folder/and/a/filename.txt'), [ @@ -228,7 +289,675 @@ suite('TextModelPromptParser', () => { ]); }); - test('gets disposed with the model', async () => { + suite('• header', () => { + suite(' • metadata', () => { + test('• has correct \'prompt\' metadata', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"description: 'My prompt.'\t\t", + /* 03 */" something: true", /* unknown metadata record */ + /* 04 */" tools: [ 'tool_name1', \"tool_name2\", 'tool_name1', true, false, '', 'tool_name2' ]\t\t", + /* 05 */" tools: [ 'tool_name3', \"tool_name4\" ]", /* duplicate `tools` record is ignored */ + /* 06 */" tools: 'tool_name5'", /* duplicate `tools` record with invalid value is ignored */ + /* 07 */" mode: 'agent'", + /* 07 */" applyTo: 'frontend/**/*spec.ts'", + /* 08 */"---", + /* 09 */"The cactus on my desk has a thriving Instagram account.", + /* 10 */"Midnight snacks are the secret to eternal [text](./foo-bar-baz/another-file.ts) happiness.", + /* 11 */"In an alternate universe, pigeons deliver sushi by drone.", + /* 12 */"Lunar rainbows only appear when you sing in falsetto.", + /* 13 */"Carrots have secret telepathic abilities, but only on Tuesdays.", + ], + ); + + await test.validateReferences([ + new ExpectedReference({ + uri: createURI('/absolute/folder/and/a/foo-bar-baz/another-file.ts'), + text: '[text](./foo-bar-baz/another-file.ts)', + path: './foo-bar-baz/another-file.ts', + startLine: 11, + startColumn: 43, + pathStartColumn: 50, + childrenOrError: new OpenFailed(createURI('/absolute/folder/and/a/foo-bar-baz/another-file.ts'), 'File not found.'), + }), + ]); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode, description, applyTo } = metadata; + assert.deepStrictEqual( + tools, + ['tool_name1', 'tool_name2'], + `Prompt header must have correct tools metadata.`, + ); + + assert.strictEqual( + mode, + 'agent', + `Prompt header must have correct 'mode' metadata.`, + ); + + assert.strictEqual( + description, + 'My prompt.', + `Prompt header must have correct 'description' metadata.`, + ); + + assert.strictEqual( + applyTo, + undefined, + `Prompt header must have no 'applyTo' metadata.`, + ); + }); + + test('• has correct \'instructions\' metadata', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.instructions.md'), + [ + /* 01 */"---", + /* 02 */"description: 'My prompt.'\t\t", + /* 03 */" something: true", /* unknown metadata record */ + /* 04 */" tools: [ 'tool_name1', \"tool_name2\", 'tool_name1', true, false, '', 'tool_name2' ]\t\t", + /* 05 */" tools: [ 'tool_name3', \"tool_name4\" ]", /* duplicate `tools` record is ignored */ + /* 06 */" tools: 'tool_name5'", /* duplicate `tools` record with invalid value is ignored */ + /* 07 */" mode: 'agent'", + /* 07 */" applyTo: 'frontend/**/*spec.ts'", + /* 08 */"---", + /* 09 */"The cactus on my desk has a thriving Instagram account.", + /* 10 */"Midnight snacks are the secret to eternal [text](./foo-bar-baz/another-file.ts) happiness.", + /* 11 */"In an alternate universe, pigeons deliver sushi by drone.", + /* 12 */"Lunar rainbows only appear when you sing in falsetto.", + /* 13 */"Carrots have secret telepathic abilities, but only on Tuesdays.", + ], + INSTRUCTIONS_LANGUAGE_ID, + ); + + await test.validateReferences([ + new ExpectedReference({ + uri: createURI('/absolute/folder/and/a/foo-bar-baz/another-file.ts'), + text: '[text](./foo-bar-baz/another-file.ts)', + path: './foo-bar-baz/another-file.ts', + startLine: 11, + startColumn: 43, + pathStartColumn: 50, + childrenOrError: new OpenFailed(createURI('/absolute/folder/and/a/foo-bar-baz/another-file.ts'), 'File not found.'), + }), + ]); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode, description, applyTo } = metadata; + assert.deepStrictEqual( + tools, + ['tool_name1', 'tool_name2'], + `Prompt header must have correct tools metadata.`, + ); + + assert.strictEqual( + mode, + 'agent', + `Prompt header must have correct 'mode' metadata.`, + ); + + assert.strictEqual( + description, + 'My prompt.', + `Prompt header must have correct 'description' metadata.`, + ); + + assert.strictEqual( + applyTo, + 'frontend/**/*spec.ts', + `Prompt header must have no 'applyTo' metadata.`, + ); + }); + }); + + suite('• diagnostics', () => { + test('• core logic', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */" description: true \t ", + /* 03 */" mode: \"ask\"", + /* 04 */" something: true", /* unknown metadata record */ + /* 05 */"tools: [ 'tool_name1', \"tool_name2\", 'tool_name1', true, false, '', ,'tool_name2' ] ", + /* 06 */" tools: [ 'tool_name3', \"tool_name4\" ] \t\t ", /* duplicate `tools` record is ignored */ + /* 07 */"tools: 'tool_name5'", /* duplicate `tools` record with invalid value is ignored */ + /* 08 */"---", + /* 09 */"The cactus on my desk has a thriving Instagram account.", + /* 10 */"Midnight snacks are the secret to eternal [text](./foo-bar-baz/another-file.ts) happiness.", + /* 11 */"In an alternate universe, pigeons deliver sushi by drone.", + /* 12 */"Lunar rainbows only appear when you sing in falsetto.", + /* 13 */"Carrots have secret telepathic abilities, but only on Tuesdays.", + ], + ); + + await test.validateReferences([ + new ExpectedReference({ + uri: createURI('/absolute/folder/and/a/foo-bar-baz/another-file.ts'), + text: '[text](./foo-bar-baz/another-file.ts)', + path: './foo-bar-baz/another-file.ts', + startLine: 10, + startColumn: 43, + pathStartColumn: 50, + childrenOrError: new OpenFailed(createURI('/absolute/folder/and/a/foo-bar-baz/another-file.ts'), 'File not found.'), + }), + ]); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools } = metadata; + assertDefined( + tools, + 'Tools metadata must be defined.', + ); + + await test.validateHeaderDiagnostics([ + new ExpectedDiagnosticError( + new Range(2, 15, 2, 15 + 4), + 'Value of the \'description\' metadata must be \'string\', got \'boolean\'.', + ), + new ExpectedDiagnosticWarning( + new Range(4, 2, 4, 2 + 15), + 'Unknown metadata record \'something\' will be ignored.', + ), + new ExpectedDiagnosticWarning( + new Range(5, 38, 5, 38 + 12), + 'Duplicate tool name \'tool_name1\'.', + ), + new ExpectedDiagnosticWarning( + new Range(5, 52, 5, 52 + 4), + 'Expected a tool name (string), got \'true\'.', + ), + new ExpectedDiagnosticWarning( + new Range(5, 58, 5, 58 + 5), + 'Expected a tool name (string), got \'false\'.', + ), + new ExpectedDiagnosticWarning( + new Range(5, 65, 5, 65 + 2), + 'Tool name cannot be empty.', + ), + new ExpectedDiagnosticWarning( + new Range(5, 70, 5, 70 + 12), + 'Duplicate tool name \'tool_name2\'.', + ), + new ExpectedDiagnosticWarning( + new Range(3, 2, 3, 2 + 11), + 'Record \'mode\' is implied to have the \'agent\' value if \'tools\' record is present so the specified value will be ignored.', + ), + new ExpectedDiagnosticWarning( + new Range(6, 3, 6, 3 + 37), + 'Duplicate metadata record \'tools\' will be ignored.', + ), + new ExpectedDiagnosticWarning( + new Range(7, 1, 7, 1 + 19), + 'Duplicate metadata record \'tools\' will be ignored.', + ), + ]); + }); + + suite('• applyTo metadata', () => { + suite('• language', () => { + test('• prompt', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/my.prompt.md'), + [ + /* 01 */"---", + /* 02 */"applyTo: '**/*'", + /* 03 */"mode: \"ask\"", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + PROMPT_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { applyTo, mode } = metadata; + assert.strictEqual( + mode, + ChatMode.Ask, + 'Mode metadata must have correct value.', + ); + + assert( + applyTo === undefined, + 'ApplyTo metadata must not be defined.', + ); + + await test.validateHeaderDiagnostics([ + new ExpectedDiagnosticError( + new Range(2, 1, 2, 1 + 15), + 'The \'applyTo\' metadata record is only valid in instruction files.', + ), + ]); + }); + + test('• instructions', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/my.prompt.md'), + [ + /* 01 */"---", + /* 02 */"applyTo: '**/*'", + /* 03 */"mode: \"edit\"", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + INSTRUCTIONS_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { applyTo, mode } = metadata; + assert.strictEqual( + mode, + ChatMode.Edit, + 'Mode metadata must have correct value.', + ); + + assert.strictEqual( + applyTo, + '**/*', + 'ApplyTo metadata must have correct value.', + ); + + await test.validateHeaderDiagnostics([]); + }); + }); + }); + + test('• invalid glob pattern', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/my.prompt.md'), + [ + /* 01 */"---", + /* 02 */"mode: \"agent\"", + /* 03 */"applyTo: ''", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + INSTRUCTIONS_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { applyTo, mode } = metadata; + assert.strictEqual( + mode, + ChatMode.Agent, + 'Mode metadata must have correct value.', + ); + + assert.strictEqual( + applyTo, + undefined, + 'ApplyTo metadata must not be defined.', + ); + + await test.validateHeaderDiagnostics([ + new ExpectedDiagnosticWarning( + new Range(3, 10, 3, 10 + 2), + 'Invalid glob pattern \'\'.', + ), + ]); + }); + + suite('• tools and mode compatibility', () => { + suite('• tools is set', () => { + test('• ask mode', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"tools: [ 'tool_name3', \"tool_name4\" ] \t\t ", /* duplicate `tools` record is ignored */ + /* 03 */"mode: \"ask\"", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode } = metadata; + assertDefined( + tools, + 'Tools metadata must be defined.', + ); + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Mode metadata must have correct value.', + ); + + await test.validateHeaderDiagnostics([ + new ExpectedDiagnosticWarning( + new Range(3, 1, 3, 1 + 11), + 'Record \'mode\' is implied to have the \'agent\' value if \'tools\' record is present so the specified value will be ignored.', + ), + ]); + }); + + test('• edit mode', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"tools: [ 'tool_name3', \"tool_name4\" ] \t\t ", /* duplicate `tools` record is ignored */ + /* 03 */"mode: \"edit\"", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode } = metadata; + assertDefined( + tools, + 'Tools metadata must be defined.', + ); + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Mode metadata must have correct value.', + ); + + await test.validateHeaderDiagnostics([ + new ExpectedDiagnosticWarning( + new Range(3, 1, 3, 1 + 12), + 'Record \'mode\' is implied to have the \'agent\' value if \'tools\' record is present so the specified value will be ignored.', + ), + ]); + }); + + test('• agent mode', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"tools: [ 'tool_name3', \"tool_name4\" ] \t\t ", /* duplicate `tools` record is ignored */ + /* 03 */"mode: \"agent\"", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode } = metadata; + assertDefined( + tools, + 'Tools metadata must be defined.', + ); + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Mode metadata must have correct value.', + ); + + await test.validateHeaderDiagnostics([]); + }); + + test('• no mode', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"tools: [ 'tool_name3', \"tool_name4\" ] \t\t ", /* duplicate `tools` record is ignored */ + /* 03 */"---", + /* 04 */"The cactus on my desk has a thriving Instagram account.", + ], + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode } = metadata; + assertDefined( + tools, + 'Tools metadata must be defined.', + ); + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Mode metadata must have correct value.', + ); + + await test.validateHeaderDiagnostics([]); + }); + }); + + suite('• tools is not set', () => { + test('• ask mode', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"description: ['my prompt', 'description.']", + /* 03 */"mode: \"ask\"", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode } = metadata; + assert( + tools === undefined, + 'Tools metadata must not be defined.', + ); + + assert.strictEqual( + mode, + ChatMode.Ask, + 'Mode metadata must have correct value.', + ); + + await test.validateHeaderDiagnostics([ + new ExpectedDiagnosticError( + new Range(2, 14, 2, 14 + 29), + 'Value of the \'description\' metadata must be \'string\', got \'array\'.', + ), + ]); + }); + + test('• edit mode', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"description: my prompt description. \t\t \t\t ", + /* 03 */"mode: \"edit\"", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode } = metadata; + assert( + tools === undefined, + 'Tools metadata must not be defined.', + ); + + assert.strictEqual( + mode, + ChatMode.Edit, + 'Mode metadata must have correct value.', + ); + + await test.validateHeaderDiagnostics([ + new ExpectedDiagnosticError( + new Range(2, 1, 2, 1 + 11), + 'Unexpected token \'description\'.', + ), + new ExpectedDiagnosticError( + new Range(2, 12, 2, 12 + 2), + 'Unexpected token \': \'.', + ), + new ExpectedDiagnosticError( + new Range(2, 14, 2, 14 + 2), + 'Unexpected token \'my\'.', + ), + new ExpectedDiagnosticError( + new Range(2, 17, 2, 17 + 6), + 'Unexpected token \'prompt\'.', + ), + new ExpectedDiagnosticError( + new Range(2, 24, 2, 24 + 12), + 'Unexpected token \'description.\'.', + ), + ]); + }); + + test('• agent mode', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"mode: \"agent\"", + /* 03 */"---", + /* 04 */"The cactus on my desk has a thriving Instagram account.", + ], + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode } = metadata; + assert( + tools === undefined, + 'Tools metadata must not be defined.', + ); + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Mode metadata must have correct value.', + ); + + await test.validateHeaderDiagnostics([]); + }); + + test('• no mode', async () => { + const test = createTest( + createURI('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"description: 'My prompt.'", + /* 03 */"---", + /* 04 */"The cactus on my desk has a thriving Instagram account.", + ], + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + const { tools, mode } = metadata; + assert( + tools === undefined, + 'Tools metadata must not be defined.', + ); + + assert.strictEqual( + mode, + undefined, + 'Mode metadata must have correct value.', + ); + + await test.validateHeaderDiagnostics([]); + }); + }); + }); + }); + }); + + test('• gets disposed with the model', async () => { const test = createTest( createURI('/some/path/file.prompt.md'), [ @@ -249,7 +978,7 @@ suite('TextModelPromptParser', () => { ); }); - test('toString() implementation', async () => { + test('• toString() implementation', async () => { const modelUri = createURI('/Users/legomushroom/repos/prompt-snippets/README.md'); const test = createTest( modelUri, diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts index f9c6214af67..014aa1b857f 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts @@ -4,27 +4,34 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { ChatMode } from '../../../common/constants.js'; import { URI } from '../../../../../../base/common/uri.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { extUri } from '../../../../../../base/common/resources.js'; import { isWindows } from '../../../../../../base/common/platform.js'; import { Range } from '../../../../../../editor/common/core/range.js'; +import { assertDefined } from '../../../../../../base/common/types.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { IMockFolder, MockFilesystem } from './testUtils/mockFilesystem.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { IPromptFileReference } from '../../../common/promptSyntax/parsers/types.js'; +import { IPromptReference } from '../../../common/promptSyntax/parsers/types.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; import { FileService } from '../../../../../../platform/files/common/fileService.js'; import { NullPolicyService } from '../../../../../../platform/policy/common/policy.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; -import { TErrorCondition } from '../../../common/promptSyntax/parsers/basePromptParser.js'; import { FileReference } from '../../../common/promptSyntax/codecs/tokens/fileReference.js'; import { FilePromptParser } from '../../../common/promptSyntax/parsers/filePromptParser.js'; import { waitRandom, randomBoolean } from '../../../../../../base/test/common/testUtils.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/constants.js'; +import { MarkdownLink } from '../../../../../../editor/common/codecs/markdownCodec/tokens/markdownLink.js'; import { ConfigurationService } from '../../../../../../platform/configuration/common/configurationService.js'; +import { IPromptParserOptions, TErrorCondition } from '../../../common/promptSyntax/parsers/basePromptParser.js'; import { InMemoryFileSystemProvider } from '../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { INSTRUCTION_FILE_EXTENSION, PROMPT_FILE_EXTENSION } from '../../../../../../platform/prompts/common/constants.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { NotPromptFile, RecursiveReference, OpenFailed, FolderReference } from '../../../common/promptFileReferenceErrors.js'; @@ -40,10 +47,17 @@ class ExpectedReference { constructor( dirname: URI, - public readonly lineToken: FileReference, + public readonly linkToken: FileReference | MarkdownLink, public readonly errorCondition?: TErrorCondition, ) { - this.uri = extUri.resolvePath(dirname, lineToken.path); + this.uri = extUri.resolvePath(dirname, linkToken.path); + } + + /** + * Range of the underlying file reference token. + */ + public get range(): Range { + return this.linkToken.range; } /** @@ -75,7 +89,9 @@ class TestPromptFileReference extends Disposable { /** * Run the test. */ - public async run() { + public async run( + options: Partial = {}, + ): Promise { // create the files structure on the disk await (this.initService.createInstance(MockFilesystem, this.fileStructure)).mock(); @@ -90,7 +106,7 @@ class TestPromptFileReference extends Disposable { this.initService.createInstance( FilePromptParser, this.rootFileUri, - [], + options, ), ).start(); @@ -98,12 +114,32 @@ class TestPromptFileReference extends Disposable { await rootReference.allSettled(); // resolve the root file reference including all nested references - const resolvedReferences: readonly (IPromptFileReference | undefined)[] = rootReference.allReferences; + const resolvedReferences: readonly (IPromptReference | undefined)[] = rootReference.allReferences; for (let i = 0; i < this.expectedReferences.length; i++) { const expectedReference = this.expectedReferences[i]; const resolvedReference = resolvedReferences[i]; + if (expectedReference.linkToken instanceof MarkdownLink) { + assert( + resolvedReference?.subtype === 'markdown', + [ + `Expected ${i}th resolved reference to be a markdown link`, + `got '${resolvedReference}'.`, + ].join(', '), + ); + } + + if (expectedReference.linkToken instanceof FileReference) { + assert( + resolvedReference?.subtype === 'prompt', + [ + `Expected ${i}th resolved reference to be a #file: link`, + `got '${resolvedReference}'.`, + ].join(', '), + ); + } + assert( (resolvedReference) && (resolvedReference.uri.toString() === expectedReference.uri.toString()), @@ -113,6 +149,15 @@ class TestPromptFileReference extends Disposable { ].join(', '), ); + assert( + (resolvedReference) && + (resolvedReference.range.equalsRange(expectedReference.range)), + [ + `Expected ${i}th resolved reference range to be '${expectedReference.range}'`, + `got '${resolvedReference?.range}'.`, + ].join(', '), + ); + if (expectedReference.errorCondition === undefined) { assert( resolvedReference.errorCondition === undefined, @@ -139,8 +184,10 @@ class TestPromptFileReference extends Disposable { [ `\nExpected(${this.expectedReferences.length}): [\n ${this.expectedReferences.join('\n ')}\n]`, `Received(${resolvedReferences.length}): [\n ${resolvedReferences.join('\n ')}\n]`, - ].join('\n') + ].join('\n'), ); + + return rootReference; } } @@ -187,6 +234,20 @@ suite('PromptFileReference (Unix)', function () { instantiationService.stub(IFileService, nullFileService); instantiationService.stub(ILogService, nullLogService); instantiationService.stub(IConfigurationService, nullConfigService); + instantiationService.stub(IModelService, { getModel() { return null; } }); + instantiationService.stub(ILanguageService, { + guessLanguageIdByFilepathOrFirstLine(uri: URI) { + if (uri.path.endsWith(PROMPT_FILE_EXTENSION)) { + return PROMPT_LANGUAGE_ID; + } + + if (uri.path.endsWith(INSTRUCTION_FILE_EXTENSION)) { + return INSTRUCTIONS_LANGUAGE_ID; + } + + return null; + } + }); }); test('• resolves nested file references', async function () { @@ -236,7 +297,7 @@ suite('PromptFileReference (Unix)', function () { children: [ { name: 'another-file.prompt.md', - contents: `[](${rootFolder}/folder1/some-other-folder)\nanother-file.prompt.md contents\t [#file:file.txt](../file.txt)`, + contents: `[caption](${rootFolder}/folder1/some-other-folder)\nanother-file.prompt.md contents\t [#file:file.txt](../file.txt)`, }, { name: 'one_more_file_just_in_case.prompt.md', @@ -264,10 +325,9 @@ suite('PromptFileReference (Unix)', function () { ), new ExpectedReference( URI.joinPath(rootUri, './folder1'), - createTestFileReference( - `./some-other-folder/non-existing-folder`, - 2, - 1, + new MarkdownLink( + 2, 1, + '[]', '(./some-other-folder/non-existing-folder)', ), new OpenFailed( URI.joinPath(rootUri, './folder1/some-other-folder/non-existing-folder'), @@ -284,7 +344,10 @@ suite('PromptFileReference (Unix)', function () { ), new ExpectedReference( URI.joinPath(rootUri, './folder1/some-other-folder'), - createTestFileReference('.', 1, 1), + new MarkdownLink( + 1, 1, + '[caption]', `(/${rootFolderName}/folder1/some-other-folder)`, + ), new FolderReference( URI.joinPath(rootUri, './folder1/some-other-folder'), 'This folder is not a prompt file!', @@ -292,7 +355,10 @@ suite('PromptFileReference (Unix)', function () { ), new ExpectedReference( URI.joinPath(rootUri, './folder1/some-other-folder/yetAnotherFolder🤭'), - createTestFileReference('../file.txt', 2, 35), + new MarkdownLink( + 2, 34, + '[#file:file.txt]', '(../file.txt)', + ), new NotPromptFile( URI.joinPath(rootUri, './folder1/some-other-folder/file.txt'), 'Ughh oh, that is not a prompt file!', @@ -300,14 +366,17 @@ suite('PromptFileReference (Unix)', function () { ), new ExpectedReference( rootUri, - createTestFileReference('./folder1/some-other-folder/file4.prompt.md', 3, 14), + new MarkdownLink( + 3, 14, + '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', + ), ), new ExpectedReference( URI.joinPath(rootUri, './folder1/some-other-folder'), createTestFileReference('./some-non-existing/file.prompt.md', 1, 30), new OpenFailed( URI.joinPath(rootUri, './folder1/some-other-folder/some-non-existing/file.prompt.md'), - 'Failed to open non-existring prompt snippets file', + 'Failed to open non-existing prompt snippets file', ), ), new ExpectedReference( @@ -320,7 +389,11 @@ suite('PromptFileReference (Unix)', function () { ), new ExpectedReference( URI.joinPath(rootUri, './some-other-folder/folder1'), - createTestFileReference('../../folder1', 5, 48), + // createTestFileReference('../../folder1', 5, 48), + new MarkdownLink( + 5, 48, + '[]', '(../../folder1/)', + ), new FolderReference( URI.joinPath(rootUri, './folder1'), 'Uggh ohh!', @@ -404,14 +477,13 @@ suite('PromptFileReference (Unix)', function () { [ new ExpectedReference( rootUri, - createTestFileReference('folder1/file3.prompt.md', 2, 9), + createTestFileReference('folder1/file3.prompt.md', 2, 14), ), new ExpectedReference( URI.joinPath(rootUri, './folder1'), - createTestFileReference( - `${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md`, - 3, - 23, + new MarkdownLink( + 3, 26, + '[another-file.prompt.md]', `(${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md)`, ), ), /** @@ -471,7 +543,10 @@ suite('PromptFileReference (Unix)', function () { ), new ExpectedReference( rootUri, - createTestFileReference('./file1.md', 6, 2), + new MarkdownLink( + 6, 2, + '[some (snippet!) #name))]', '(./file1.md)', + ), new NotPromptFile( URI.joinPath(rootUri, './file1.md'), 'Uggh oh!', @@ -482,4 +557,1156 @@ suite('PromptFileReference (Unix)', function () { await test.run(); }); + + suite('• options', () => { + test('• allowNonPromptFiles', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: '## Some Header\nsome contents\n ', + }, + { + name: 'file2.md', + contents: '## Files\n\t- this file #file:folder1/file3.prompt.md \n\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!\n ', + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: `\n[](./some-other-folder/non-existing-folder)\n\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents\n some more\t content`, + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference\n\n\nand some\n non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + }, + { + name: 'file.txt', + contents: 'contents of a non-prompt-snippet file', + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: `[](${rootFolder}/folder1/some-other-folder)\nanother-file.prompt.md contents\t [#file:file.txt](../file.txt)`, + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }], + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.md`), + /** + * The expected references to be resolved. + */ + [ + new ExpectedReference( + rootUri, + createTestFileReference('folder1/file3.prompt.md', 2, 14), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1'), + new MarkdownLink( + 2, 1, + '[]', '(./some-other-folder/non-existing-folder)', + ), + new OpenFailed( + URI.joinPath(rootUri, './folder1/some-other-folder/non-existing-folder'), + 'Reference to non-existing file cannot be opened.', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1'), + createTestFileReference( + `/${rootFolderName}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md`, + 3, + 26, + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder'), + new MarkdownLink( + 1, 1, + '[]', `(/${rootFolderName}/folder1/some-other-folder)`, + ), + new FolderReference( + URI.joinPath(rootUri, './folder1/some-other-folder'), + 'This folder is not a prompt file!', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/yetAnotherFolder🤭'), + new MarkdownLink( + 2, 34, + '[#file:file.txt]', '(../file.txt)', + ), + new NotPromptFile( + URI.joinPath(rootUri, './folder1/some-other-folder/file.txt'), + 'Ughh oh, that is not a prompt file!', + ), + ), + new ExpectedReference( + rootUri, + new MarkdownLink( + 3, 14, + '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder'), + createTestFileReference('./some-non-existing/file.prompt.md', 1, 30), + new OpenFailed( + URI.joinPath(rootUri, './folder1/some-other-folder/some-non-existing/file.prompt.md'), + 'Failed to open non-existing prompt snippets file', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder'), + createTestFileReference('./some-non-prompt-file.md', 5, 13), + new OpenFailed( + URI.joinPath(rootUri, './folder1/some-other-folder/some-non-prompt-file.md'), + 'Oh no!', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './some-other-folder/folder1'), + new MarkdownLink( + 5, 48, + '[]', '(../../folder1/)', + ), + new FolderReference( + URI.joinPath(rootUri, './folder1'), + 'Uggh ohh!', + ), + ), + ] + )); + + await test.run({ allowNonPromptFiles: true }); + }); + }); + + suite('• metadata', () => { + test('• tools', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: 'file2.prompt.md', + contents: [ + '---', + 'description: \'Root prompt description.\'', + 'tools: [\'my-tool1\']', + 'mode: "agent" ', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + '---', + '', + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , ]', + 'something: true', + 'mode: \'ask\'\t', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: 'contents of a non-prompt-snippet file', + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool3\', false, "my-tool2" ]', + '---', + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }], + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.prompt.md`), + /** + * The expected references to be resolved. + */ + [ + new ExpectedReference( + rootUri, + createTestFileReference('folder1/file3.prompt.md', 7, 14), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1'), + new MarkdownLink( + 5, 1, + '[]', '(./some-other-folder/non-existing-folder)', + ), + new OpenFailed( + URI.joinPath(rootUri, './folder1/some-other-folder/non-existing-folder'), + 'Reference to non-existing file cannot be opened.', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1'), + createTestFileReference( + `/${rootFolderName}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md`, + 6, 26, + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder'), + new MarkdownLink( + 4, 1, + '[]', `(/${rootFolderName}/folder1/some-other-folder)`, + ), + new FolderReference( + URI.joinPath(rootUri, './folder1/some-other-folder'), + 'This folder is not a prompt file!', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder/yetAnotherFolder🤭'), + new MarkdownLink( + 5, 34, + '[#file:file.txt]', '(../file.txt)', + ), + new NotPromptFile( + URI.joinPath(rootUri, './folder1/some-other-folder/file.txt'), + 'Ughh oh, that is not a prompt file!', + ), + ), + new ExpectedReference( + rootUri, + new MarkdownLink( + 8, 14, + '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder'), + createTestFileReference('./some-non-existing/file.prompt.md', 6, 30), + new OpenFailed( + URI.joinPath(rootUri, './folder1/some-other-folder/some-non-existing/file.prompt.md'), + 'Failed to open non-existing prompt snippets file', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './folder1/some-other-folder'), + createTestFileReference('./some-non-prompt-file.md', 10, 13), + new OpenFailed( + URI.joinPath(rootUri, './folder1/some-other-folder/some-non-prompt-file.md'), + 'Oh no!', + ), + ), + new ExpectedReference( + URI.joinPath(rootUri, './some-other-folder/folder1'), + new MarkdownLink( + 10, 48, + '[]', '(../../folder1/)', + ), + new FolderReference( + URI.joinPath(rootUri, './folder1'), + 'Uggh ohh!', + ), + ), + ] + )); + + const rootReference = await test.run(); + + const { metadata, allToolsMetadata } = rootReference; + const { tools, description } = metadata; + + assert.deepStrictEqual( + tools, + ['my-tool1'], + 'Must have correct tools metadata.', + ); + + assert.deepStrictEqual( + description, + 'Root prompt description.', + 'Must have correct description metadata.', + ); + + assertDefined( + allToolsMetadata, + 'All tools metadata must to be defined.', + ); + assert.deepStrictEqual( + allToolsMetadata, + ['my-tool1', 'my-tool3', 'my-tool2'], + 'Must have correct all tools metadata.', + ); + }); + + suite('• applyTo', () => { + test('• prompt language', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: 'file2.prompt.md', + contents: [ + '---', + 'applyTo: \'**/*\'', + 'tools: [ false, \'my-tool12\' , ]', + 'description: \'Description of my prompt.\'', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + '---', + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , \'my-tool3\' , ]', + 'something: true', + 'mode: \'agent\'\t', + '---', + '', + '', + 'and some more content', + ], + }, + ], + }, + ], + }, + ], + }], + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.prompt.md`), + /** + * The expected references to be resolved. + */ + [ + new ExpectedReference( + rootUri, + createTestFileReference('folder1/file3.prompt.md', 7, 14), + ), + new ExpectedReference( + rootUri, + new MarkdownLink( + 8, 14, + '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', + ), + ), + ] + )); + + const rootReference = await test.run(); + + const { metadata, allToolsMetadata } = rootReference; + const { tools, mode, description, applyTo } = metadata; + + assert.deepStrictEqual( + tools, + ['my-tool12'], + 'Must have correct \'tools\' metadata.', + ); + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Must have correct \'mode\' metadata.', + ); + + assert.strictEqual( + description, + 'Description of my prompt.', + 'Must have correct \'description\' metadata.', + ); + + assert.deepStrictEqual( + allToolsMetadata, + [ + 'my-tool12', + 'my-tool1', + 'my-tool2', + 'my-tool3', + ], + 'Must have correct all tools metadata.', + ); + + assert.strictEqual( + applyTo, + undefined, + 'Must have no \'applyTo\' metadata.', + ); + }); + + + test('• instructions language', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: 'file2.instructions.md', + contents: [ + '---', + 'applyTo: \'**/*\'', + 'tools: [ false, \'my-tool12\' , ]', + 'description: \'Description of my prompt.\'', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + '---', + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , \'my-tool3\' , ]', + 'something: true', + 'mode: \'agent\'\t', + '---', + '', + '', + 'and some more content', + ], + }, + ], + }, + ], + }, + ], + }], + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.instructions.md`), + /** + * The expected references to be resolved. + */ + [ + new ExpectedReference( + rootUri, + createTestFileReference('folder1/file3.prompt.md', 7, 14), + ), + new ExpectedReference( + rootUri, + new MarkdownLink( + 8, 14, + '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', + ), + ), + ] + )); + + const rootReference = await test.run(); + + const { metadata, allToolsMetadata } = rootReference; + const { tools, mode, description, applyTo } = metadata; + + assert.deepStrictEqual( + tools, + ['my-tool12'], + 'Must have correct \'tools\' metadata.', + ); + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Must have correct \'mode\' metadata.', + ); + + assert.strictEqual( + description, + 'Description of my prompt.', + 'Must have correct \'description\' metadata.', + ); + + assert.deepStrictEqual( + allToolsMetadata, + [ + 'my-tool12', + 'my-tool1', + 'my-tool2', + 'my-tool3', + ], + 'Must have correct all tools metadata.', + ); + + assert.strictEqual( + applyTo, + '**/*', + 'Must have no \'applyTo\' metadata.', + ); + }); + }); + + suite('• tools and mode compatibility', () => { + test('• tools are ignored if root prompt in the ask mode', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: 'file2.prompt.md', + contents: [ + '---', + 'description: \'Description of my prompt.\'', + 'mode: "ask" ', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + 'mode: \'agent\'\t', + '---', + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , ]', + 'something: true', + 'mode: \'ask\'\t', + '---', + '', + '', + 'and some more content', + ], + }, + ], + }, + ], + }, + ], + }], + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.prompt.md`), + /** + * The expected references to be resolved. + */ + [ + new ExpectedReference( + rootUri, + createTestFileReference('folder1/file3.prompt.md', 6, 14), + ), + new ExpectedReference( + rootUri, + new MarkdownLink( + 7, 14, + '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', + ), + ), + ] + )); + + const rootReference = await test.run(); + + const { metadata, allToolsMetadata } = rootReference; + const { tools, mode, description } = metadata; + + assert.deepStrictEqual( + tools, + undefined, + 'Must have correct \'tools\' metadata.', + ); + + assert.deepStrictEqual( + mode, + ChatMode.Ask, + 'Must have correct \'mode\' metadata.', + ); + + assert.deepStrictEqual( + description, + 'Description of my prompt.', + 'Must have correct \'description\' metadata.', + ); + + assert.deepStrictEqual( + allToolsMetadata, + null, + 'Must have correct all tools metadata.', + ); + }); + + test('• tools are ignored if root prompt in the edit mode', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: 'file2.prompt.md', + contents: [ + '---', + 'description: \'Description of my prompt.\'', + 'mode:\t\t"edit"\t\t', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + '---', + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , ]', + 'something: true', + 'mode: \'agent\'\t', + '---', + '', + '', + 'and some more content', + ], + }, + ], + }, + ], + }, + ], + }], + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.prompt.md`), + /** + * The expected references to be resolved. + */ + [ + new ExpectedReference( + rootUri, + createTestFileReference('folder1/file3.prompt.md', 6, 14), + ), + new ExpectedReference( + rootUri, + new MarkdownLink( + 7, 14, + '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', + ), + ), + ] + )); + + const rootReference = await test.run(); + + const { metadata, allToolsMetadata } = rootReference; + const { tools, mode, description } = metadata; + + assert.deepStrictEqual( + tools, + undefined, + 'Must have correct \'tools\' metadata.', + ); + + assert.deepStrictEqual( + mode, + ChatMode.Edit, + 'Must have correct tools metadata.', + ); + + assert.deepStrictEqual( + description, + 'Description of my prompt.', + 'Must have correct \'description\' metadata.', + ); + + assert.deepStrictEqual( + allToolsMetadata, + null, + 'Must have correct all tools metadata.', + ); + }); + + test('• tools are not ignored if root prompt in the agent mode', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: 'file2.prompt.md', + contents: [ + '---', + 'description: \'Description of my prompt.\'', + 'mode: \t\t "agent" \t\t ', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + '---', + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , \'my-tool3\' , ]', + 'something: true', + 'mode: \'agent\'\t', + '---', + '', + '', + 'and some more content', + ], + }, + ], + }, + ], + }, + ], + }], + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.prompt.md`), + /** + * The expected references to be resolved. + */ + [ + new ExpectedReference( + rootUri, + createTestFileReference('folder1/file3.prompt.md', 6, 14), + ), + new ExpectedReference( + rootUri, + new MarkdownLink( + 7, 14, + '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', + ), + ), + ] + )); + + const rootReference = await test.run(); + + const { metadata, allToolsMetadata } = rootReference; + const { tools, mode, description } = metadata; + + assert.deepStrictEqual( + tools, + undefined, + 'Must have correct \'tools\' metadata.', + ); + + assert.deepStrictEqual( + mode, + ChatMode.Agent, + 'Must have correct \'mode\' metadata.', + ); + + assert.deepStrictEqual( + description, + 'Description of my prompt.', + 'Must have correct \'description\' metadata.', + ); + + assert.deepStrictEqual( + allToolsMetadata, + [ + 'my-tool1', + 'my-tool2', + 'my-tool3', + ], + 'Must have correct all tools metadata.', + ); + }); + + test('• tools are not ignored if root prompt implicitly in the agent mode', async function () { + if (isWindows) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + const rootUri = URI.file(rootFolder); + + const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference, + /** + * The file structure to be created on the disk for the test. + */ + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: 'file2.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool12\' , ]', + 'description: \'Description of my prompt.\'', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + '---', + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , \'my-tool3\' , ]', + 'something: true', + 'mode: \'agent\'\t', + '---', + '', + '', + 'and some more content', + ], + }, + ], + }, + ], + }, + ], + }], + /** + * The root file path to start the resolve process from. + */ + URI.file(`/${rootFolderName}/file2.prompt.md`), + /** + * The expected references to be resolved. + */ + [ + new ExpectedReference( + rootUri, + createTestFileReference('folder1/file3.prompt.md', 6, 14), + ), + new ExpectedReference( + rootUri, + new MarkdownLink( + 7, 14, + '[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)', + ), + ), + ] + )); + + const rootReference = await test.run(); + + const { metadata, allToolsMetadata } = rootReference; + const { tools, mode, description } = metadata; + + assert.deepStrictEqual( + tools, + ['my-tool12'], + 'Must have correct \'tools\' metadata.', + ); + + assert.deepStrictEqual( + mode, + ChatMode.Agent, + 'Must have correct \'mode\' metadata.', + ); + + assert.deepStrictEqual( + description, + 'Description of my prompt.', + 'Must have correct \'description\' metadata.', + ); + + assert.deepStrictEqual( + allToolsMetadata, + [ + 'my-tool12', + 'my-tool1', + 'my-tool2', + 'my-tool3', + ], + 'Must have correct all tools metadata.', + ); + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index bced4a5e45e..013ce456125 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -4,21 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import * as sinon from 'sinon'; import { createURI } from '../testUtils/createUri.js'; +import { ChatMode } from '../../../../common/constants.js'; import { URI } from '../../../../../../../base/common/uri.js'; +import { MockFilesystem } from '../testUtils/mockFilesystem.js'; +import { Schemas } from '../../../../../../../base/common/network.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; import { assertDefined } from '../../../../../../../base/common/types.js'; -import { waitRandom } from '../../../../../../../base/test/common/testUtils.js'; import { IPromptsService } from '../../../../common/promptSyntax/service/types.js'; import { IFileService } from '../../../../../../../platform/files/common/files.js'; +import { IModelService } from '../../../../../../../editor/common/services/model.js'; import { IPromptFileReference } from '../../../../common/promptSyntax/parsers/types.js'; import { FileService } from '../../../../../../../platform/files/common/fileService.js'; import { createTextModel } from '../../../../../../../editor/test/common/testTextModel.js'; -import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ILanguageService } from '../../../../../../../editor/common/languages/language.js'; +import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { randomBoolean, waitRandom } from '../../../../../../../base/test/common/testUtils.js'; +import { isWindows, isNative, isElectron } from '../../../../../../../base/common/platform.js'; import { TextModelPromptParser } from '../../../../common/promptSyntax/parsers/textModelPromptParser.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../../common/promptSyntax/constants.js'; +import { InMemoryFileSystemProvider } from '../../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { INSTRUCTION_FILE_EXTENSION, PROMPT_FILE_EXTENSION } from '../../../../../../../platform/prompts/common/constants.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -93,20 +103,42 @@ suite('PromptsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); let service: IPromptsService; - let instantiationService: TestInstantiationService; + let instaService: TestInstantiationService; setup(async () => { - instantiationService = disposables.add(new TestInstantiationService()); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IConfigurationService, new TestConfigurationService()); - instantiationService.stub(IFileService, disposables.add(instantiationService.createInstance(FileService))); + instaService = disposables.add(new TestInstantiationService()); + instaService.stub(ILogService, new NullLogService()); + instaService.stub(IConfigurationService, new TestConfigurationService()); - service = disposables.add(instantiationService.createInstance(PromptsService)); + const fileService = disposables.add(instaService.createInstance(FileService)); + instaService.stub(IFileService, fileService); + instaService.stub(IModelService, { getModel() { return null; } }); + instaService.stub(ILanguageService, { + guessLanguageIdByFilepathOrFirstLine(uri: URI) { + if (uri.path.endsWith(PROMPT_FILE_EXTENSION)) { + return PROMPT_LANGUAGE_ID; + } + + if (uri.path.endsWith(INSTRUCTION_FILE_EXTENSION)) { + return INSTRUCTIONS_LANGUAGE_ID; + } + + return 'plaintext'; + } + }); + + const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); + + service = disposables.add(instaService.createInstance(PromptsService)); }); suite('• getParserFor', () => { test('• provides cached parser instance', async () => { - const langId = 'fooLang'; + // both languages must yield the same result + const languageId = (randomBoolean()) + ? PROMPT_LANGUAGE_ID + : INSTRUCTIONS_LANGUAGE_ID; /** * Create a text model, get a parser for it, and perform basic assertions. @@ -114,7 +146,7 @@ suite('PromptsService', () => { const model1 = disposables.add(createTextModel( 'test1\n\t#file:./file.md\n\n\n [bin file](/root/tmp.bin)\t\n', - langId, + languageId, undefined, createURI('/Users/vscode/repos/test/file1.txt'), )); @@ -185,7 +217,7 @@ suite('PromptsService', () => { const model2 = disposables.add(createTextModel( 'some text #file:/absolute/path.txt \t\ntest-text2', - langId, + languageId, undefined, createURI('/Users/vscode/repos/test/some-folder/file.md'), )); @@ -378,7 +410,7 @@ suite('PromptsService', () => { // we cannot use the same model since it was already disposed const model2_1 = disposables.add(createTextModel( 'some text #file:/absolute/path.txt \n [caption](.copilot/prompts/test.prompt.md)\t\n\t\n more text', - langId, + languageId, undefined, createURI('/Users/vscode/repos/test/some-folder/file.md'), )); @@ -495,7 +527,7 @@ suite('PromptsService', () => { ); }); - test('• throws if disposed model provided', async function () { + test('• throws if a disposed model provided', async function () { const model = disposables.add(createTextModel( 'test1\ntest2\n\ntest3\t\n', 'barLang', @@ -511,4 +543,1516 @@ suite('PromptsService', () => { }, 'Cannot create a prompt parser for a disposed model.'); }); }); + + suite('• getCombinedToolsMetadata', () => { + suite('• agent mode', () => { + test('• explicit', async function () { + // temporary disable the tests on for electron/nodejs on windows + if (isWindows && (isNative || isElectron)) { + this.skip(); + } + + const rootFolderName = 'gets-combined-tools-metadata'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + const rootFileUri = URI.file(`${rootFolder}/${rootFileName}`); + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + 'tools: [\'my-tool1\']', + 'mode: "agent" ', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + '---', + '', + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , ]', + 'something: true', + 'mode: \'ask\'\t', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool3\', false, "my-tool2" ]', + '---', + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getCombinedToolsMetadata([rootFileUri]); + + assertDefined( + metadata, + 'Combined metadata must be defined.', + ); + + const { tools, mode } = metadata; + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Combined metadata \'mode\' must have correct value.', + ); + + assert.deepStrictEqual( + tools, + [ + 'my-tool1', + 'my-tool3', + 'my-tool2', + ], + 'Combined metadata \'tools\' must have correct value.', + ); + }); + + test('• implicit', async function () { + // temporary disable the tests on for electron/nodejs on windows + if (isWindows && (isNative || isElectron)) { + this.skip(); + } + + const rootFolderName = 'gets-combined-tools-metadata'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + const rootFileUri = URI.file(`${rootFolder}/${rootFileName}`); + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + 'tools: [\'my-tool1\']', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + '---', + '', + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , ]', + 'something: true', + 'mode: \'ask\'\t', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool3\', false, "my-tool2" ]', + '---', + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getCombinedToolsMetadata([rootFileUri]); + + assertDefined( + metadata, + 'Combined metadata must be defined.', + ); + + const { tools, mode } = metadata; + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Combined metadata \'mode\' must have correct value.', + ); + + assert.deepStrictEqual( + tools, + [ + 'my-tool1', + 'my-tool3', + 'my-tool2', + ], + 'Combined metadata \'tools\' must have correct value.', + ); + }); + + test('• implicit (incorrect value)', async function () { + // temporary disable the tests on for electron/nodejs on windows + if (isWindows && (isNative || isElectron)) { + this.skip(); + } + + const rootFolderName = 'gets-combined-tools-metadata'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + const rootFileUri = URI.file(`${rootFolder}/${rootFileName}`); + + // both modes must yield the same result + const incorrectMode = (randomBoolean()) + ? ChatMode.Ask + : ChatMode.Edit; + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + 'tools: [\'my-tool1\']', + `mode: '${incorrectMode}'`, + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + '---', + '', + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , ]', + 'something: true', + 'mode: \'ask\'\t', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool3\', false, "my-tool2" ]', + '---', + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getCombinedToolsMetadata([rootFileUri]); + + assertDefined( + metadata, + 'Combined metadata must be defined.', + ); + + const { tools, mode } = metadata; + + assert.strictEqual( + mode, + ChatMode.Agent, + 'Combined metadata \'mode\' must have correct value.', + ); + + assert.deepStrictEqual( + tools, + [ + 'my-tool1', + 'my-tool3', + 'my-tool2', + ], + 'Combined metadata \'tools\' must have correct value.', + ); + }); + }); + + suite('• edit mode', () => { + test('• explicit', async () => { + const rootFolderName = 'gets-combined-tools-metadata'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + const rootFileUri = URI.file(`${rootFolder}/${rootFileName}`); + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + 'mode: "edit"', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'something: true', + 'mode: \'ask\'\t', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + '---', + 'mode: \'ask\'\t', + '---', + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getCombinedToolsMetadata([rootFileUri]); + + assertDefined( + metadata, + 'Combined metadata must be defined.', + ); + + const { tools, mode } = metadata; + + assert.strictEqual( + mode, + ChatMode.Edit, + 'Combined metadata \'mode\' must have correct value.', + ); + + assert.strictEqual( + tools, + undefined, + 'Combined metadata \'tools\' must have correct value.', + ); + }); + + test('• implicit', async function () { + // temporary disable the tests on for electron/nodejs on windows + if (isWindows && (isNative || isElectron)) { + this.skip(); + } + + const rootFolderName = 'gets-combined-tools-metadata'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + const rootFileUri = URI.file(`${rootFolder}/${rootFileName}`); + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'mode: \'ask\'', + '---', + '', + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'something: true', + 'mode: \'ask\'\t', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + '---', + 'description: "My prompt."', + 'mode: "edit"\t\t', + '---', + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getCombinedToolsMetadata([rootFileUri]); + + assertDefined( + metadata, + 'Combined metadata must be defined.', + ); + + const { tools, mode } = metadata; + + assert.strictEqual( + mode, + ChatMode.Edit, + 'Combined metadata \'mode\' must have correct value.', + ); + + assert.strictEqual( + tools, + undefined, + 'Combined metadata \'tools\' must have correct value.', + ); + }); + + test('• implicit (incorrect value)', async function () { + // temporary disable the tests on for electron/nodejs on windows + if (isWindows && (isNative || isElectron)) { + this.skip(); + } + + const rootFolderName = 'gets-combined-tools-metadata'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + const rootFileUri = URI.file(`${rootFolder}/${rootFileName}`); + + // both modes must yield the same result + const incorrectMode = (randomBoolean()) + ? 'unknown-mode-1' + : 'unknown-mode-2'; + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + `mode: '${incorrectMode}'`, + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'mode: \'ask\'', + '---', + '', + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'something: true', + 'mode: \'edit\'\t', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getCombinedToolsMetadata([rootFileUri]); + + assertDefined( + metadata, + 'Combined metadata must be defined.', + ); + + const { tools, mode } = metadata; + + assert.strictEqual( + mode, + ChatMode.Edit, + 'Combined metadata \'mode\' must have correct value.', + ); + + assert.strictEqual( + tools, + undefined, + 'Combined metadata \'tools\' must have correct value.', + ); + }); + }); + + suite('• ask mode', () => { + test('• explicit', async () => { + const rootFolderName = 'gets-combined-tools-metadata'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + const rootFileUri = URI.file(`${rootFolder}/${rootFileName}`); + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + 'mode:\t\t"ask"\t', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'something: true', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + '---', + 'description: "some text"', + '---', + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getCombinedToolsMetadata([rootFileUri]); + + assertDefined( + metadata, + 'Combined metadata must be defined.', + ); + + const { tools, mode } = metadata; + + assert.strictEqual( + mode, + ChatMode.Ask, + 'Combined metadata \'mode\' must have correct value.', + ); + + assert.strictEqual( + tools, + undefined, + 'Combined metadata \'tools\' must have correct value.', + ); + }); + + test('• implicit', async function () { + // temporary disable the tests on for electron/nodejs on windows + if (isWindows && (isNative || isElectron)) { + this.skip(); + } + + const rootFolderName = 'gets-combined-tools-metadata'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + const rootFileUri = URI.file(`${rootFolder}/${rootFileName}`); + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'description: "Another prompt description."', + '---', + '', + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'something: true', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + '---', + 'description: "My prompt."', + 'mode: "ask"\t\t', + '---', + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getCombinedToolsMetadata([rootFileUri]); + + assertDefined( + metadata, + 'Combined metadata must be defined.', + ); + + const { tools, mode } = metadata; + + assert.strictEqual( + mode, + ChatMode.Ask, + 'Combined metadata \'mode\' must have correct value.', + ); + + assert.strictEqual( + tools, + undefined, + 'Combined metadata \'tools\' must have correct value.', + ); + }); + + test('• implicit (incorrect value)', async function () { + // temporary disable the tests on for electron/nodejs on windows + if (isWindows && (isNative || isElectron)) { + this.skip(); + } + + const rootFolderName = 'gets-combined-tools-metadata'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + const rootFileUri = URI.file(`${rootFolder}/${rootFileName}`); + + // both modes must yield the same result + const incorrectMode = (randomBoolean()) + ? 'unknown-mode-1' + : 'unknown-mode-2'; + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + `mode: '${incorrectMode}'`, + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.prompt.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'something: true', + 'mode: \'ask\'\t', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.prompt.md', + contents: [ + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getCombinedToolsMetadata([rootFileUri]); + + assertDefined( + metadata, + 'Combined metadata must be defined.', + ); + + const { tools, mode } = metadata; + + assert.strictEqual( + mode, + ChatMode.Ask, + 'Combined metadata \'mode\' must have correct value.', + ); + + assert.strictEqual( + tools, + undefined, + 'Combined metadata \'tools\' must have correct value.', + ); + }); + }); + }); + + suite('• getAllMetadata', () => { + test('• explicit', async function () { + // temporary disable the tests on for electron/nodejs on windows + if (isWindows && (isNative || isElectron)) { + this.skip(); + } + + const rootFolderName = 'resolves-nested-file-references'; + const rootFolder = `/${rootFolderName}`; + + const rootFileName = 'file2.prompt.md'; + + const rootFolderUri = URI.file(rootFolder); + const rootFileUri = URI.joinPath(rootFolderUri, rootFileName); + + await (instaService.createInstance(MockFilesystem, + // the file structure to be created on the disk for the test + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: rootFileName, + contents: [ + '---', + 'description: \'Root prompt description.\'', + 'tools: [\'my-tool1\', , true]', + 'mode: "agent" ', + '---', + '## Files', + '\t- this file #file:folder1/file3.prompt.md ', + '\t- also this [file4.prompt.md](./folder1/some-other-folder/file4.prompt.md) please!', + ' ', + ], + }, + { + name: 'folder1', + children: [ + { + name: 'file3.prompt.md', + contents: [ + '---', + 'tools: [ false, \'my-tool1\' , ]', + 'mode: \'edit\'', + '---', + '', + '[](./some-other-folder/non-existing-folder)', + `\t- some seemingly random #file:${rootFolder}/folder1/some-other-folder/yetAnotherFolder🤭/another-file.instructions.md contents`, + ' some more\t content', + ], + }, + { + name: 'some-other-folder', + children: [ + { + name: 'file4.prompt.md', + contents: [ + '---', + 'tools: [\'my-tool1\', "my-tool2", true, , ]', + 'something: true', + 'mode: \'ask\'\t', + 'description: "File 4 splendid description."', + '---', + 'this file has a non-existing #file:./some-non-existing/file.prompt.md\t\treference', + '', + '', + 'and some', + ' non-prompt #file:./some-non-prompt-file.md\t\t \t[](../../folder1/)\t', + ], + }, + { + name: 'file.txt', + contents: [ + '---', + 'description: "Non-prompt file description".', + 'tools: ["my-tool-24"]', + '---', + ], + }, + { + name: 'yetAnotherFolder🤭', + children: [ + { + name: 'another-file.instructions.md', + contents: [ + '---', + 'description: "Another file description."', + 'tools: [\'my-tool3\', false, "my-tool2" ]', + 'applyTo: "**/*.tsx"', + '---', + `[](${rootFolder}/folder1/some-other-folder)`, + 'another-file.instructions.md contents\t [#file:file.txt](../file.txt)', + ], + }, + { + name: 'one_more_file_just_in_case.prompt.md', + contents: 'one_more_file_just_in_case.prompt.md contents', + }, + ], + }, + ], + }, + ], + }, + ], + }])).mock(); + + const metadata = await service + .getAllMetadata([rootFileUri]); + + assert.deepStrictEqual( + metadata, + [{ + uri: rootFileUri, + metadata: { + description: 'Root prompt description.', + tools: ['my-tool1'], + mode: 'agent', + applyTo: undefined, + }, + children: [ + { + uri: URI.joinPath(rootFolderUri, 'folder1/file3.prompt.md'), + metadata: { + description: undefined, + applyTo: undefined, + tools: ['my-tool1'], + mode: 'agent', + }, + children: [ + { + uri: URI.joinPath(rootFolderUri, 'folder1/some-other-folder/yetAnotherFolder🤭/another-file.instructions.md'), + metadata: { + description: 'Another file description.', + tools: ['my-tool3', 'my-tool2'], + mode: 'agent', + applyTo: '**/*.tsx', + }, + children: undefined, + }, + ], + }, + { + uri: URI.joinPath(rootFolderUri, 'folder1/some-other-folder/file4.prompt.md'), + metadata: { + tools: ['my-tool1', 'my-tool2'], + description: 'File 4 splendid description.', + applyTo: undefined, + mode: 'agent', + }, + children: undefined, + } + ], + }], + ); + }); + }); + + suite('• findInstructionFilesFor', () => { + teardown(() => { + sinon.restore(); + }); + + test('• finds correct instruction files', async () => { + const rootFolderName = 'finds-instruction-files'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + const userPromptsFolderName = '/tmp/user-data/prompts'; + const userPromptsFolderUri = URI.file(userPromptsFolderName); + + sinon.stub(service, 'listPromptFiles') + .returns(Promise.resolve([ + // local instructions + { + uri: URI.joinPath(rootFolderUri, '.github/prompts/file1.instructions.md'), + storage: 'local', + type: 'instructions', + }, + { + uri: URI.joinPath(rootFolderUri, '.github/prompts/file2.instructions.md'), + storage: 'local', + type: 'instructions', + }, + { + uri: URI.joinPath(rootFolderUri, '.github/prompts/file3.instructions.md'), + storage: 'local', + type: 'instructions', + }, + { + uri: URI.joinPath(rootFolderUri, '.github/prompts/file4.instructions.md'), + storage: 'local', + type: 'instructions', + }, + // user instructions + { + uri: URI.joinPath(userPromptsFolderUri, 'file10.instructions.md'), + storage: 'user', + type: 'instructions', + }, + { + uri: URI.joinPath(userPromptsFolderUri, 'file11.instructions.md'), + storage: 'user', + type: 'instructions', + }, + ])); + + // mock current workspace file structure + await (instaService.createInstance(MockFilesystem, + [{ + name: rootFolderName, + children: [ + { + name: 'file1.prompt.md', + contents: [ + '## Some Header', + 'some contents', + ' ', + ], + }, + { + name: '.github/prompts', + children: [ + { + name: 'file1.instructions.md', + contents: [ + '---', + 'description: \'Instructions file 1.\'', + 'applyTo: "**/*.tsx"', + '---', + 'Some instructions 1 contents.', + ], + }, + { + name: 'file2.instructions.md', + contents: [ + '---', + 'description: \'Instructions file 2.\'', + 'applyTo: "**/folder1/*.tsx"', + '---', + 'Some instructions 2 contents.', + ], + }, + { + name: 'file3.instructions.md', + contents: [ + '---', + 'description: \'Instructions file 3.\'', + 'applyTo: "**/folder2/*.tsx"', + '---', + 'Some instructions 3 contents.', + ], + }, + { + name: 'file4.instructions.md', + contents: [ + '---', + 'description: \'Instructions file 4.\'', + 'applyTo: "src/build/*.tsx"', + '---', + 'Some instructions 4 contents.', + ], + }, + { + name: 'file5.prompt.md', + contents: [ + '---', + 'description: \'Prompt file 5.\'', + '---', + 'Some prompt 5 contents.', + ], + }, + ], + }, + { + name: 'folder1', + children: [ + { + name: 'main.tsx', + contents: 'console.log("Haalou!")', + }, + ], + }, + ], + }])).mock(); + + // mock user data instructions + await (instaService.createInstance(MockFilesystem, [ + { + name: userPromptsFolderName, + children: [ + { + name: 'file10.instructions.md', + contents: [ + '---', + 'description: \'Instructions file 10.\'', + 'applyTo: "**/folder1/*.tsx"', + '---', + 'Some instructions 10 contents.', + ], + }, + { + name: 'file11.instructions.md', + contents: [ + '---', + 'description: \'Instructions file 11.\'', + 'applyTo: "**/folder1/*.py"', + '---', + 'Some instructions 11 contents.', + ], + }, + { + name: 'file12.prompt.md', + contents: [ + '---', + 'description: \'Prompt file 12.\'', + '---', + 'Some prompt 12 contents.', + ], + }, + ], + } + ])).mock(); + + const instructions = await service + .findInstructionFilesFor([ + URI.joinPath(rootFolderUri, 'folder1/main.tsx'), + ]); + + assert.deepStrictEqual( + instructions, + [ + // local instructions + URI.joinPath(rootFolderUri, '.github/prompts/file1.instructions.md'), + URI.joinPath(rootFolderUri, '.github/prompts/file2.instructions.md'), + // user instructions + URI.joinPath(userPromptsFolderUri, 'file10.instructions.md'), + ], + 'Must find correct instruction files.', + ); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/expectedDiagnostic.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/expectedDiagnostic.ts new file mode 100644 index 00000000000..5c39b6059dd --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/expectedDiagnostic.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { assertNever } from '../../../../../../../base/common/assert.js'; +import { PromptMetadataDiagnostic, PromptMetadataError, PromptMetadataWarning, TDiagnostic } from '../../../../common/promptSyntax/parsers/promptHeader/diagnostics.js'; + +/** + * Base class for all expected diagnostics used in the unit tests. + */ +abstract class ExpectedDiagnostic extends PromptMetadataDiagnostic { + /** + * Validate that the provided diagnostic is equal to this object. + */ + public validateEqual(other: TDiagnostic) { + this.validateTypesEqual(other); + + assert.strictEqual( + this.message, + other.message, + `Expected message '${this.message}', got '${other.message}'.`, + ); + + assert( + this.range + .equalsRange(other.range), + `Expected range '${this.range}', got '${other.range}'.`, + ); + } + + /** + * Validate that the provided diagnostic is of the same + * diagnostic type as this object. + */ + private validateTypesEqual(other: TDiagnostic) { + if (other instanceof PromptMetadataWarning) { + assert( + this instanceof ExpectedDiagnosticWarning, + `Expected a warning diagnostic object, got '${other}'.`, + ); + + return; + } + + if (other instanceof PromptMetadataError) { + assert( + this instanceof ExpectedDiagnosticError, + `Expected a error diagnostic object, got '${other}'.`, + ); + + return; + } + + assertNever( + other, + `Unknown diagnostic type '${other}'.`, + ); + } +} + +/** + * Expected warning diagnostic object for testing purposes. + */ +export class ExpectedDiagnosticWarning extends ExpectedDiagnostic { + /** + * Returns a string representation of this object. + */ + public override toString(): string { + return `expected-diagnostic/warning(${this.message})${this.range}`; + } +} + +/** + * Expected error diagnostic object for testing purposes. + */ +export class ExpectedDiagnosticError extends ExpectedDiagnostic { + /** + * Returns a string representation of this object. + */ + public override toString(): string { + return `expected-diagnostic/error(${this.message})${this.range}`; + } +} + +/** + * Type for any expected diagnostic object. + */ +export type TExpectedDiagnostic = ExpectedDiagnosticWarning | ExpectedDiagnosticError; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/expectedReference.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/expectedReference.ts index d43123799e1..2c91a7f730d 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/expectedReference.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/expectedReference.ts @@ -155,7 +155,14 @@ export class ExpectedReference { const { references } = other; for (let i = 0; i < children.length; i++) { - children[i].validateEqual(references[i]); + const reference = references[i]; + + assertDefined( + reference, + `${errorPrefix} Expected reference #${i} be ${children[i]}, got 'undefined'.`, + ); + + children[i].validateEqual(reference); } if (references.length > children.length) { @@ -182,4 +189,11 @@ export class ExpectedReference { throw new Error(`${errorPrefix} Expected another reference '${expectedReference.options.text}', got 'undefined'.`); } } + + /** + * Returns a string representation of the reference. + */ + public toString(): string { + return `expected-reference/${this.options.text}`; + } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts index 80db5124e43..09d00b4c1dc 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts @@ -6,6 +6,7 @@ import { URI } from '../../../../../../../base/common/uri.js'; import { assert } from '../../../../../../../base/common/assert.js'; import { VSBuffer } from '../../../../../../../base/common/buffer.js'; +import { wait } from '../../../../../../../base/test/common/testUtils.js'; import { IFileService } from '../../../../../../../platform/files/common/files.js'; /** @@ -19,7 +20,7 @@ interface IMockFilesystemNode { * Represents a `file` node. */ export interface IMockFile extends IMockFilesystemNode { - contents: string; + contents: string | readonly string[]; } /** @@ -47,12 +48,19 @@ export class MockFilesystem { * Starts the mock process. */ public async mock(): Promise[]> { - return await Promise.all( + const result = await Promise.all( this.folders .map((folder) => { return this.mockFolder(folder); }), ); + + // wait for the filesystem event to settle before proceeding + // this is temporary workaround and should be fixed once we + // improve behavior of the `settled()` / `allSettled()` methods + await wait(25); + + return result; } /** @@ -90,7 +98,11 @@ export class MockFilesystem { `File '${folderUri.path}' already exists.`, ); - await this.fileService.writeFile(childUri, VSBuffer.fromString(child.contents)); + const contents: string = (typeof child.contents === 'string') + ? child.contents + : child.contents.join('\n'); + + await this.fileService.writeFile(childUri, VSBuffer.fromString(contents)); resolvedChildren.push({ ...child, diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index f84f3e91e36..670cf234a7b 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -33,7 +33,7 @@ const mockConfigService = (value: T): IConfigurationService => { ); assert( - [PromptsConfig.CONFIG_KEY, PromptsConfig.LOCATIONS_CONFIG_KEY].includes(key), + [PromptsConfig.KEY, PromptsConfig.PROMPT_LOCATIONS_KEY].includes(key), `Unsupported configuration key '${key}'.`, ); @@ -109,7 +109,7 @@ suite('PromptFilesLocator', () => { const locator = await createPromptsLocator(undefined, EMPTY_WORKSPACE, []); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [], 'No prompts must be found.', @@ -123,7 +123,7 @@ suite('PromptFilesLocator', () => { }, EMPTY_WORKSPACE, []); assert.deepStrictEqual( - await locator.listFiles(), + await locator.listFiles('prompt'), [], 'No prompts must be found.', ); @@ -136,7 +136,7 @@ suite('PromptFilesLocator', () => { ], EMPTY_WORKSPACE, []); assert.deepStrictEqual( - await locator.listFiles(), + await locator.listFiles('prompt'), [], 'No prompts must be found.', ); @@ -146,7 +146,7 @@ suite('PromptFilesLocator', () => { const locator = await createPromptsLocator(null, EMPTY_WORKSPACE, []); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [], 'No prompts must be found.', @@ -157,7 +157,7 @@ suite('PromptFilesLocator', () => { const locator = await createPromptsLocator('/etc/hosts/prompts', EMPTY_WORKSPACE, []); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [], 'No prompts must be found.', @@ -210,7 +210,7 @@ suite('PromptFilesLocator', () => { ]); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/prompts/test.prompt.md').path, @@ -287,7 +287,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/deps/text/my.prompt.md').fsPath, @@ -447,7 +447,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/deps/text/nested/specific.prompt.md').fsPath, @@ -531,7 +531,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/deps/text/my.prompt.md').fsPath, @@ -691,7 +691,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/deps/text/nested/specific.prompt.md').fsPath, @@ -771,7 +771,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/deps/text/my.prompt.md').fsPath, @@ -931,7 +931,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/deps/text/nested/specific.prompt.md').fsPath, @@ -1016,7 +1016,7 @@ suite('PromptFilesLocator', () => { ]); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/.github/prompts/my.prompt.md').fsPath, @@ -1103,7 +1103,7 @@ suite('PromptFilesLocator', () => { ]); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/prompts/test.prompt.md').path, @@ -1224,7 +1224,7 @@ suite('PromptFilesLocator', () => { ]); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/.github/prompts/default.prompt.md').path, @@ -1345,7 +1345,7 @@ suite('PromptFilesLocator', () => { ]); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/.github/prompts/default.prompt.md').fsPath, @@ -1469,7 +1469,7 @@ suite('PromptFilesLocator', () => { ]); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/prompts/test.prompt.md').path, @@ -1592,7 +1592,7 @@ suite('PromptFilesLocator', () => { ]); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ // all of these are due to the `.github/prompts` setting @@ -1707,7 +1707,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/gen/text/my.prompt.md').fsPath, @@ -1913,7 +1913,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/gen/text/my.prompt.md').fsPath, @@ -2038,7 +2038,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/gen/text/my.prompt.md').fsPath, @@ -2274,7 +2274,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - (await locator.listFiles()) + (await locator.listFiles('prompt')) .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/gen/text/my.prompt.md').fsPath, @@ -2390,7 +2390,7 @@ suite('PromptFilesLocator', () => { ); assert.deepStrictEqual( - locator.getConfigBasedSourceFolders() + locator.getConfigBasedSourceFolders('prompt') .map((file) => file.fsPath), [ createURI('/Users/legomushroom/repos/vscode/.github/prompts').fsPath, diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/treeUrils.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/treeUrils.test.ts new file mode 100644 index 00000000000..d1d3dfa7c82 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/treeUrils.test.ts @@ -0,0 +1,482 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { randomInt } from '../../../../../../../base/common/numbers.js'; +import { curry, flatten, forEach, map } from '../../../../common/promptSyntax/utils/treeUtils.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; + +suite('tree utilities', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('• flatten', () => { + const tree = { + id: '1', + children: [ + { + id: '1.1', + }, + { + id: '1.2', + children: [ + { + id: '1.2.1', + children: [ + { + id: '1.2.1.1', + }, + { + id: '1.2.1.2', + }, + { + id: '1.2.1.3', + } + ], + }, + { + id: '1.2.2', + }, + ] + }, + ], + }; + + assert.deepStrictEqual(flatten(tree), [ + tree, + tree.children[0], + tree.children[1], + tree.children[1].children![0], + tree.children[1].children![0].children![0], + tree.children[1].children![0].children![1], + tree.children[1].children![0].children![2], + tree.children[1].children![1], + ]); + + assert.deepStrictEqual(flatten({}), [{}]); + }); + + suite('• forEach', () => { + test('• iterates though all nodes', () => { + const tree = { + id: '1', + children: [ + { + id: '1.1', + }, + { + id: '1.2', + children: [ + { + id: '1.2.1', + children: [ + { + id: '1.2.1.1', + }, + { + id: '1.2.1.2', + }, + { + id: '1.2.1.3', + } + ], + }, + { + id: '1.2.2', + }, + ] + }, + ], + }; + + const treeCopy = JSON.parse(JSON.stringify(tree)); + + const seenIds: string[] = []; + forEach((node) => { + seenIds.push(node.id); + return false; + }, tree); + + assert.deepStrictEqual(seenIds, [ + '1', + '1.1', + '1.2', + '1.2.1', + '1.2.1.1', + '1.2.1.2', + '1.2.1.3', + '1.2.2', + ]); + + assert.deepStrictEqual( + treeCopy, + tree, + 'forEach should not modify the tree', + ); + }); + + test('• can be stopped prematurely', () => { + const tree = { + id: '1', + children: [ + { + id: '1.1', + }, + { + id: '1.2', + children: [ + { + id: '1.2.1', + children: [ + { + id: '1.2.1.1', + }, + { + id: '1.2.1.2', + }, + { + id: '1.2.1.3', + children: [ + { + id: '1.2.1.3.1', + }, + ], + } + ], + }, + { + id: '1.2.2', + }, + ] + }, + ], + }; + + const treeCopy = JSON.parse(JSON.stringify(tree)); + + const seenIds: string[] = []; + forEach((node) => { + seenIds.push(node.id); + + if (node.id === '1.2.1') { + return true; // stop traversing + } + + return false; + }, tree); + + assert.deepStrictEqual(seenIds, [ + '1', + '1.1', + '1.2', + '1.2.1', + ]); + + assert.deepStrictEqual( + treeCopy, + tree, + 'forEach should not modify the tree', + ); + }); + }); + + suite('• map', () => { + test('• maps a tree', () => { + interface ITree { + id: string; + children?: ITree[]; + } + + const tree: ITree = { + id: '1', + children: [ + { + id: '1.1', + }, + { + id: '1.2', + children: [ + { + id: '1.2.1', + children: [ + { + id: '1.2.1.1', + }, + { + id: '1.2.1.2', + }, + { + id: '1.2.1.3', + } + ], + }, + { + id: '1.2.2', + }, + ] + }, + ], + }; + + const treeCopy = JSON.parse(JSON.stringify(tree)); + + const newRootNode = { + newId: '__1__', + }; + + const newChildNode = { + newId: '__1.2.1.3__', + }; + + const newTree = map((node) => { + if (node.id === '1') { + return newRootNode; + } + + if (node.id === '1.2.1.3') { + return newChildNode; + } + + return { + newId: `__${node.id}__`, + }; + }, tree); + + assert.deepStrictEqual(newTree, { + newId: '__1__', + children: [ + { + newId: '__1.1__', + }, + { + newId: '__1.2__', + children: [ + { + newId: '__1.2.1__', + children: [ + { + newId: '__1.2.1.1__', + }, + { + newId: '__1.2.1.2__', + }, + { + newId: '__1.2.1.3__', + }, + ], + }, + { + newId: '__1.2.2__', + }, + ] + }, + ], + }); + + assert( + newRootNode === newTree, + 'Map should not replace return node reference (root node).', + ); + + assert( + newChildNode === newTree.children![1].children![0].children![2], + 'Map should not replace return node reference (child node).', + ); + + assert.deepStrictEqual( + treeCopy, + tree, + 'forEach should not modify the tree', + ); + }); + + test('• callback can control resulting children', () => { + interface ITree { + id: string; + children?: ITree[]; + } + + const tree: ITree = { + id: '1', + children: [ + { id: '1.1' }, + { + id: '1.2', + children: [ + { + id: '1.2.1', + children: [ + { id: '1.2.1.1' }, + { id: '1.2.1.2' }, + { + id: '1.2.1.3', + children: [ + { + id: '1.2.1.3.1', + }, + { + id: '1.2.1.3.2', + }, + ], + } + ], + }, + { + id: '1.2.2', + children: [ + { id: '1.2.2.1' }, + { id: '1.2.2.2' }, + { id: '1.2.2.3' }, + ], + }, + { + id: '1.2.3', + children: [ + { id: '1.2.3.1' }, + { id: '1.2.3.2' }, + { id: '1.2.3.3' }, + { id: '1.2.3.4' }, + ], + }, + ] + }, + ], + }; + + const treeCopy = JSON.parse(JSON.stringify(tree)); + + const newNodeWithoutChildren = { + newId: '__1.2.1.3__', + children: undefined, + }; + + const newTree = map((node, newChildren) => { + // validates that explicitly setting `children` to + // `undefined` will be preserved on the resulting new node + if (node.id === '1.2.1.3') { + return newNodeWithoutChildren; + } + + // validates that setting `children` to a new array + // will be preserved on the resulting new node + if (node.id === '1.2.2') { + assert.deepStrictEqual( + newChildren, + [ + { newId: '__1.2.2.1__' }, + { newId: '__1.2.2.2__' }, + { newId: '__1.2.2.3__' }, + ], + `Node '${node.id}' must have correct new children.`, + ); + + return { + newId: `__${node.id}__`, + children: [newChildren[2]], + }; + } + + // validates that modifying `newChildren` directly + // will be preserved on the resulting new node + if (node.id === '1.2.3') { + assert.deepStrictEqual( + newChildren, + [ + { newId: '__1.2.3.1__' }, + { newId: '__1.2.3.2__' }, + { newId: '__1.2.3.3__' }, + { newId: '__1.2.3.4__' }, + ], + `Node '${node.id}' must have correct new children.`, + ); + + newChildren.length = 2; + + return { + newId: `__${node.id}__`, + }; + } + + // convert to a new node in all other cases + return { + newId: `__${node.id}__`, + }; + }, tree); + + assert.deepStrictEqual(newTree, { + newId: '__1__', + children: [ + { newId: '__1.1__' }, + { + newId: '__1.2__', + children: [ + { + newId: '__1.2.1__', + children: [ + { newId: '__1.2.1.1__' }, + { newId: '__1.2.1.2__' }, + { + newId: '__1.2.1.3__', + children: undefined, + }, + ], + }, + { + newId: '__1.2.2__', + children: [ + { newId: '__1.2.2.3__' }, + ], + }, + { + newId: '__1.2.3__', + children: [ + { newId: '__1.2.3.1__' }, + { newId: '__1.2.3.2__' }, + ], + }, + ] + }, + ], + }); + + assert( + newNodeWithoutChildren === newTree.children![1].children![0].children![2], + 'Map should not replace return node reference (node without children).', + ); + + assert.deepStrictEqual( + treeCopy, + tree, + 'forEach should not modify the tree', + ); + }); + }); + + test('• curry', () => { + const originalFunction = (a: number, b: number, c: number) => { + return a + b + c; + }; + + const firstArgument = randomInt(100, -100); + const curriedFunction = curry(originalFunction, firstArgument); + + let iterations = 10; + while (iterations-- > 0) { + const secondArgument = randomInt(100, -100); + const thirdArgument = randomInt(100, -100); + + assert.strictEqual( + curriedFunction(secondArgument, thirdArgument), + originalFunction(firstArgument, secondArgument, thirdArgument), + 'Curried and original functions must yield the same result.', + ); + + // a sanity check to ensure we don't compare ambiguous infinities + assert( + isFinite(originalFunction(firstArgument, secondArgument, thirdArgument)), + 'Function results must be finite.', + ); + } + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts index 16e504d20ac..c8f8acd1fab 100644 --- a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts @@ -13,11 +13,11 @@ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/e import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { nullExtensionDescription } from '../../../../services/extensions/common/extensions.js'; import { ISpeechProvider, ISpeechService, ISpeechToTextEvent, ISpeechToTextSession, ITextToSpeechSession, KeywordRecognitionStatus, SpeechToTextStatus } from '../../../speech/common/speechService.js'; -import { IChatAgent, IChatAgentCommand, IChatAgentCompletionItem, IChatAgentData, IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentMetadata, IChatAgentRequest, IChatAgentResult, IChatAgentService, IChatParticipantDetectionProvider, IChatWelcomeMessageContent } from '../../common/chatAgents.js'; +import { IChatAgent, IChatAgentCommand, IChatAgentCompletionItem, IChatAgentData, IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentMetadata, IChatAgentRequest, IChatAgentResult, IChatAgentService, IChatParticipantDetectionProvider } from '../../common/chatAgents.js'; import { IChatModel } from '../../common/chatModel.js'; import { IChatFollowup, IChatProgress } from '../../common/chatService.js'; +import { ChatAgentLocation, ChatMode } from '../../common/constants.js'; import { IVoiceChatSessionOptions, IVoiceChatTextEvent, VoiceChatService } from '../../common/voiceChatService.js'; -import { ChatAgentLocation } from '../../common/constants.js'; suite('VoiceChat', () => { @@ -32,6 +32,7 @@ suite('VoiceChat', () => { extensionDisplayName = ''; extensionPublisherId = ''; locations: ChatAgentLocation[] = [ChatAgentLocation.Panel]; + modes = [ChatMode.Ask]; public readonly name: string; constructor(readonly id: string, readonly slashCommands: IChatAgentCommand[]) { this.name = id; @@ -50,7 +51,6 @@ suite('VoiceChat', () => { throw new Error('Method not implemented.'); } invoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error('Method not implemented.'); } - provideWelcomeMessage?(token: CancellationToken): ProviderResult { throw new Error('Method not implemented.'); } metadata = {}; } @@ -76,7 +76,6 @@ suite('VoiceChat', () => { getAgents(): IChatAgent[] { return agents; } getDefaultAgent(): IChatAgent | undefined { throw new Error(); } getContributedDefaultAgent(): IChatAgentData | undefined { throw new Error(); } - getSecondaryAgent(): IChatAgent | undefined { throw new Error(); } registerAgent(id: string, data: IChatAgentData): IDisposable { throw new Error('Method not implemented.'); } getAgent(id: string): IChatAgentData | undefined { throw new Error('Method not implemented.'); } getAgentsByName(name: string): IChatAgentData[] { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts b/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts index e8aedfe3cc5..3801ee2391e 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import './emptyTextEditorHint.css'; -import * as dom from '../../../../../base/browser/dom.js'; -import { DisposableStore, dispose, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { $, addDisposableListener, getActiveWindow } from '../../../../../base/browser/dom.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from '../../../../../editor/browser/editorBrowser.js'; import { localize } from '../../../../../nls.js'; import { ChangeLanguageAction } from '../../../../browser/parts/editor/editorStatus.js'; @@ -24,80 +24,60 @@ import { ApplyFileSnippetAction } from '../../../snippets/browser/commands/fileT import { IInlineChatSessionService } from '../../../inlineChat/browser/inlineChatSessionService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { KeybindingLabel } from '../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; -import { OS } from '../../../../../base/common/platform.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { LOG_MODE_ID, OUTPUT_MODE_ID } from '../../../../services/output/common/output.js'; import { SEARCH_RESULT_LANGUAGE_ID } from '../../../../services/search/common/search.js'; -import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; -import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; -import { IChatAgent, IChatAgentService } from '../../../chat/common/chatAgents.js'; +import { IChatAgentService } from '../../../chat/common/chatAgents.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { ChatAgentLocation } from '../../../chat/common/constants.js'; - -const $ = dom.$; - -export interface IEmptyTextEditorHintOptions { - readonly clickable?: boolean; -} +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; export const emptyTextEditorHintSetting = 'workbench.editor.empty.hint'; -export class EmptyTextEditorHintContribution implements IEditorContribution { +export class EmptyTextEditorHintContribution extends Disposable implements IEditorContribution { - public static readonly ID = 'editor.contrib.emptyTextEditorHint'; + static readonly ID = 'editor.contrib.emptyTextEditorHint'; - protected toDispose: IDisposable[]; private textHintContentWidget: EmptyTextEditorHintContentWidget | undefined; constructor( protected readonly editor: ICodeEditor, - @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, - @ICommandService private readonly commandService: ICommandService, - @IConfigurationService protected readonly configurationService: IConfigurationService, - @IHoverService protected readonly hoverService: IHoverService, - @IKeybindingService private readonly keybindingService: IKeybindingService, + @IConfigurationService private readonly configurationService: IConfigurationService, @IInlineChatSessionService private readonly inlineChatSessionService: IInlineChatSessionService, @IChatAgentService private readonly chatAgentService: IChatAgentService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - @IProductService protected readonly productService: IProductService, - @IContextMenuService private readonly contextMenuService: IContextMenuService + @IInstantiationService private readonly instantiationService: IInstantiationService ) { - this.toDispose = []; - this.toDispose.push(this.editor.onDidChangeModel(() => this.update())); - this.toDispose.push(this.editor.onDidChangeModelLanguage(() => this.update())); - this.toDispose.push(this.editor.onDidChangeModelContent(() => this.update())); - this.toDispose.push(this.chatAgentService.onDidChangeAgents(() => this.update())); - this.toDispose.push(this.editor.onDidChangeModelDecorations(() => this.update())); - this.toDispose.push(this.editor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => { + super(); + + this._register(this.editor.onDidChangeModel(() => this.update())); + this._register(this.editor.onDidChangeModelLanguage(() => this.update())); + this._register(this.editor.onDidChangeModelContent(() => this.update())); + this._register(this.chatAgentService.onDidChangeAgents(() => this.update())); + this._register(this.editor.onDidChangeModelDecorations(() => this.update())); + this._register(this.editor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => { if (e.hasChanged(EditorOption.readOnly)) { this.update(); } })); - this.toDispose.push(this.configurationService.onDidChangeConfiguration(e => { + this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(emptyTextEditorHintSetting)) { this.update(); } })); - this.toDispose.push(inlineChatSessionService.onWillStartSession(editor => { + this._register(inlineChatSessionService.onWillStartSession(editor => { if (this.editor === editor) { this.textHintContentWidget?.dispose(); } })); - this.toDispose.push(inlineChatSessionService.onDidEndSession(e => { + this._register(inlineChatSessionService.onDidEndSession(e => { if (this.editor === e.editor) { this.update(); } })); } - protected _getOptions(): IEmptyTextEditorHintOptions { - return { clickable: true }; - } - - protected _shouldRenderHint() { + protected shouldRenderHint() { const configValue = this.configurationService.getValue(emptyTextEditorHintSetting); if (configValue === 'hidden') { return false; @@ -137,63 +117,49 @@ export class EmptyTextEditorHintContribution implements IEditorContribution { } protected update(): void { - const shouldRenderHint = this._shouldRenderHint(); + const shouldRenderHint = this.shouldRenderHint(); if (shouldRenderHint && !this.textHintContentWidget) { - this.textHintContentWidget = new EmptyTextEditorHintContentWidget( - this.editor, - this._getOptions(), - this.editorGroupsService, - this.commandService, - this.configurationService, - this.hoverService, - this.keybindingService, - this.chatAgentService, - this.telemetryService, - this.productService, - this.contextMenuService - ); + this.textHintContentWidget = this.instantiationService.createInstance(EmptyTextEditorHintContentWidget, this.editor); } else if (!shouldRenderHint && this.textHintContentWidget) { this.textHintContentWidget.dispose(); this.textHintContentWidget = undefined; } } - dispose(): void { - dispose(this.toDispose); + override dispose(): void { + super.dispose(); + this.textHintContentWidget?.dispose(); } } -class EmptyTextEditorHintContentWidget implements IContentWidget { +class EmptyTextEditorHintContentWidget extends Disposable implements IContentWidget { private static readonly ID = 'editor.widget.emptyHint'; private domNode: HTMLElement | undefined; - private readonly toDispose: DisposableStore; private isVisible = false; private ariaLabel: string = ''; constructor( private readonly editor: ICodeEditor, - private readonly options: IEmptyTextEditorHintOptions, - private readonly editorGroupsService: IEditorGroupsService, - private readonly commandService: ICommandService, - private readonly configurationService: IConfigurationService, - private readonly hoverService: IHoverService, - private readonly keybindingService: IKeybindingService, - private readonly chatAgentService: IChatAgentService, - private readonly telemetryService: ITelemetryService, - private readonly productService: IProductService, - private readonly contextMenuService: IContextMenuService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @ICommandService private readonly commandService: ICommandService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IChatAgentService private readonly chatAgentService: IChatAgentService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { - this.toDispose = new DisposableStore(); - this.toDispose.add(this.editor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => { + super(); + + this._register(this.editor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => { if (this.domNode && e.hasChanged(EditorOption.fontInfo)) { this.editor.applyFontInfo(this.domNode); } })); const onDidFocusEditorText = Event.debounce(this.editor.onDidFocusEditorText, () => undefined, 500); - this.toDispose.add(onDidFocusEditorText(() => { + this._register(onDidFocusEditorText(() => { if (this.editor.hasTextFocus() && this.isVisible && this.ariaLabel && this.configurationService.getValue(AccessibilityVerbositySettingId.EmptyEditorHint)) { status(this.ariaLabel); } @@ -205,7 +171,7 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { return EmptyTextEditorHintContentWidget.ID; } - private _disableHint(e?: MouseEvent) { + private disableHint(e?: MouseEvent) { const disableHint = () => { this.configurationService.updateValue(emptyTextEditorHintSetting, 'hidden'); this.dispose(); @@ -218,7 +184,7 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { } this.contextMenuService.showContextMenu({ - getAnchor: () => { return new StandardMouseEvent(dom.getActiveWindow(), e); }, + getAnchor: () => { return new StandardMouseEvent(getActiveWindow(), e); }, getActions: () => { return [{ id: 'workench.action.disableEmptyEditorHint', @@ -235,112 +201,39 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { }); } - private _getHintInlineChat(providers: IChatAgent[]) { - const providerName = (providers.length === 1 ? providers[0].fullName : undefined) ?? this.productService.nameShort; - - const inlineChatId = 'inlineChat.start'; - let ariaLabel = `Ask ${providerName} something or start typing to dismiss.`; - - const handleClick = () => { - this.telemetryService.publicLog2('workbenchActionExecuted', { - id: 'inlineChat.hintAction', - from: 'hint' - }); - this.commandService.executeCommand(inlineChatId, { from: 'hint' }); - }; + private getHint() { + const hasInlineChatProvider = this.chatAgentService.getActivatedAgents().filter(candidate => candidate.locations.includes(ChatAgentLocation.Editor)).length > 0; const hintHandler: IContentActionHandler = { - disposables: this.toDispose, - callback: (index, _event) => { - switch (index) { - case '0': - handleClick(); - break; - } - } - }; - - const hintElement = $('empty-hint-text'); - hintElement.style.display = 'block'; - - const keybindingHint = this.keybindingService.lookupKeybinding(inlineChatId); - const keybindingHintLabel = keybindingHint?.getLabel(); - - if (keybindingHint && keybindingHintLabel) { - const actionPart = localize('emptyHintText', 'Press {0} to ask {1} to do something. ', keybindingHintLabel, providerName); - - const [before, after] = actionPart.split(keybindingHintLabel).map((fragment) => { - if (this.options.clickable) { - const hintPart = $('a', undefined, fragment); - hintPart.style.fontStyle = 'italic'; - hintPart.style.cursor = 'pointer'; - this.toDispose.add(dom.addDisposableListener(hintPart, dom.EventType.CONTEXT_MENU, (e) => this._disableHint(e))); - this.toDispose.add(dom.addDisposableListener(hintPart, dom.EventType.CLICK, handleClick)); - return hintPart; - } else { - const hintPart = $('span', undefined, fragment); - hintPart.style.fontStyle = 'italic'; - return hintPart; - } - }); - - hintElement.appendChild(before); - - const label = hintHandler.disposables.add(new KeybindingLabel(hintElement, OS)); - label.set(keybindingHint); - label.element.style.width = 'min-content'; - label.element.style.display = 'inline'; - - if (this.options.clickable) { - label.element.style.cursor = 'pointer'; - this.toDispose.add(dom.addDisposableListener(label.element, dom.EventType.CONTEXT_MENU, (e) => this._disableHint(e))); - this.toDispose.add(dom.addDisposableListener(label.element, dom.EventType.CLICK, handleClick)); - } - - hintElement.appendChild(after); - - const typeToDismiss = localize('emptyHintTextDismiss', 'Start typing to dismiss.'); - const textHint2 = $('span', undefined, typeToDismiss); - textHint2.style.fontStyle = 'italic'; - hintElement.appendChild(textHint2); - - ariaLabel = actionPart.concat(typeToDismiss); - } else { - const hintMsg = localize({ - key: 'inlineChatHint', - comment: [ - 'Preserve double-square brackets and their order', - ] - }, '[[Ask {0} to do something]] or start typing to dismiss.', providerName); - const rendered = renderFormattedText(hintMsg, { actionHandler: hintHandler }); - hintElement.appendChild(rendered); - } - - return { ariaLabel, hintElement }; - } - - private _getHintDefault() { - const hintHandler: IContentActionHandler = { - disposables: this.toDispose, + disposables: this._store, callback: (index, event) => { switch (index) { case '0': - languageOnClickOrTap(event.browserEvent); + hasInlineChatProvider ? askSomething(event.browserEvent) : languageOnClickOrTap(event.browserEvent); break; case '1': - snippetOnClickOrTap(event.browserEvent); + hasInlineChatProvider ? languageOnClickOrTap(event.browserEvent) : snippetOnClickOrTap(event.browserEvent); break; case '2': - chooseEditorOnClickOrTap(event.browserEvent); + hasInlineChatProvider ? snippetOnClickOrTap(event.browserEvent) : chooseEditorOnClickOrTap(event.browserEvent); break; case '3': - this._disableHint(); + this.disableHint(); break; } } }; // the actual command handlers... + const askSomethingCommandId = 'inlineChat.start'; + const askSomething = async (e: UIEvent) => { + e.stopPropagation(); + this.telemetryService.publicLog2('workbenchActionExecuted', { + id: askSomethingCommandId, + from: 'hint' + }); + await this.commandService.executeCommand(askSomethingCommandId, { from: 'hint' }); + }; const languageOnClickOrTap = async (e: UIEvent) => { e.stopPropagation(); // Need to focus editor before so current editor becomes active and the command is properly executed @@ -379,28 +272,33 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { } }; - const hintMsg = localize({ - key: 'message', + const keybindingsLookup = hasInlineChatProvider ? [askSomethingCommandId, ChangeLanguageAction.ID, ApplyFileSnippetAction.Id] : [ChangeLanguageAction.ID, ApplyFileSnippetAction.Id, 'welcome.showNewFileEntries']; + const keybindingLabels = keybindingsLookup.map(id => this.keybindingService.lookupKeybinding(id)?.getLabel()); + + const hintMsg = (hasInlineChatProvider ? localize({ + key: 'emptyTextEditorHintWithInlineChat', comment: [ 'Preserve double-square brackets and their order', 'language refers to a programming language' ] - }, '[[Select a language]], or [[fill with template]], or [[open a different editor]] to get started.\nStart typing to dismiss or [[don\'t show]] this again.'); + }, '[[Open chat]] ({0}), or [[select a language]] ({1}), or [[fill with template]] ({2}) to get started.\nStart typing to dismiss or [[don\'t show]] this again.', keybindingLabels.at(0) ?? '', keybindingLabels.at(1) ?? '', keybindingLabels.at(2) ?? '') : localize({ + key: 'emptyTextEditorHintWithoutInlineChat', + comment: [ + 'Preserve double-square brackets and their order', + 'language refers to a programming language' + ] + }, '[[Select a language]] ({0}), or [[fill with template]] ({1}), or [[open a different editor]] ({2}) to get started.\nStart typing to dismiss or [[don\'t show]] this again.', keybindingLabels.at(0) ?? '', keybindingLabels.at(1) ?? '', keybindingLabels.at(2) ?? '')).replaceAll('()', ''); const hintElement = renderFormattedText(hintMsg, { actionHandler: hintHandler, renderCodeSegments: false, }); hintElement.style.fontStyle = 'italic'; - // ugly way to associate keybindings... - const keybindingsLookup = [ChangeLanguageAction.ID, ApplyFileSnippetAction.Id, 'welcome.showNewFileEntries']; - const keybindingLabels = keybindingsLookup.map((id) => this.keybindingService.lookupKeybinding(id)?.getLabel() ?? id); - const ariaLabel = localize('defaultHintAriaLabel', 'Execute {0} to select a language, execute {1} to fill with template, or execute {2} to open a different editor and get started. Start typing to dismiss.', ...keybindingLabels); + const ariaLabel = hasInlineChatProvider ? + localize('defaultHintAriaLabelWithInlineChat', 'Execute {0} to ask a question, execute {1} to select a language, or execute {2} to fill with template and get started. Start typing to dismiss.', ...keybindingLabels) : + localize('defaultHintAriaLabelWithoutInlineChat', 'Execute {0} to select a language, execute {1} to fill with template, or execute {2} to open a different editor and get started. Start typing to dismiss.', ...keybindingLabels); for (const anchor of hintElement.querySelectorAll('a')) { anchor.style.cursor = 'pointer'; - const id = keybindingsLookup.shift(); - const title = id && this.keybindingService.lookupKeybinding(id)?.getLabel(); - hintHandler.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), anchor, title ?? '')); } return { hintElement, ariaLabel }; @@ -412,12 +310,11 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { this.domNode.style.width = 'max-content'; this.domNode.style.paddingLeft = '4px'; - const inlineChatProviders = this.chatAgentService.getActivatedAgents().filter(candidate => candidate.locations.includes(ChatAgentLocation.Editor)); - const { hintElement, ariaLabel } = !inlineChatProviders.length ? this._getHintDefault() : this._getHintInlineChat(inlineChatProviders); + const { hintElement, ariaLabel } = this.getHint(); this.domNode.append(hintElement); this.ariaLabel = ariaLabel.concat(localize('disableHint', ' Toggle {0} in settings to disable this hint.', AccessibilityVerbositySettingId.EmptyEditorHint)); - this.toDispose.add(dom.addDisposableListener(this.domNode, 'click', () => { + this._register(addDisposableListener(this.domNode, 'click', () => { this.editor.focus(); })); @@ -434,9 +331,10 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { }; } - dispose(): void { + override dispose(): void { + super.dispose(); + this.editor.removeContentWidget(this); - dispose(this.toDispose); } } diff --git a/src/vs/workbench/contrib/codeEditor/browser/inspectEditorTokens/inspectEditorTokens.ts b/src/vs/workbench/contrib/codeEditor/browser/inspectEditorTokens/inspectEditorTokens.ts index a896a8019fd..4bfaeb312fb 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/inspectEditorTokens/inspectEditorTokens.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/inspectEditorTokens/inspectEditorTokens.ts @@ -31,7 +31,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { SEMANTIC_HIGHLIGHTING_SETTING_ID, IEditorSemanticHighlightingOptions } from '../../../../../editor/contrib/semanticTokens/common/semanticTokensConfig.js'; import { Schemas } from '../../../../../base/common/network.js'; import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; -import { ITreeSitterParserService } from '../../../../../editor/common/services/treeSitterParserService.js'; +import { ITextModelTreeSitter, ITreeSitterParserService } from '../../../../../editor/common/services/treeSitterParserService.js'; import type * as Parser from '@vscode/tree-sitter-wasm'; const $ = dom.$; @@ -254,7 +254,7 @@ class InspectEditorTokensWidget extends Disposable implements IContentWidget { if (this._isDisposed) { return; } - this._compute(grammar, semanticTokens, tree?.tree, position); + this._compute(grammar, semanticTokens, tree, position); this._domNode.style.maxWidth = `${Math.max(this._editor.getLayoutInfo().width * 0.66, 500)}px`; this._editor.layoutContentWidget(this); }, (err) => { @@ -275,7 +275,7 @@ class InspectEditorTokensWidget extends Disposable implements IContentWidget { return this._themeService.getColorTheme().semanticHighlighting; } - private _compute(grammar: IGrammar | null, semanticTokens: SemanticTokensResult | null, tree: Parser.Tree | undefined, position: Position) { + private _compute(grammar: IGrammar | null, semanticTokens: SemanticTokensResult | null, tree: ITextModelTreeSitter | undefined, position: Position) { const textMateTokenInfo = grammar && this._getTokensAtPosition(grammar, position); const semanticTokenInfo = semanticTokens && this._getSemanticTokenAtPosition(semanticTokens, position); const treeSitterTokenInfo = tree && this._getTreeSitterTokenAtPosition(tree, position); @@ -400,19 +400,21 @@ class InspectEditorTokensWidget extends Disposable implements IContentWidget { } if (treeSitterTokenInfo) { + const lastTokenInfo = treeSitterTokenInfo[treeSitterTokenInfo.length - 1]; dom.append(this._domNode, $('hr.tiw-metadata-separator')); const table = dom.append(this._domNode, $('table.tiw-metadata-table')); const tbody = dom.append(table, $('tbody')); dom.append(tbody, $('tr', undefined, - $('td.tiw-metadata-key', undefined, `tree-sitter token ${treeSitterTokenInfo.id}` as string), - $('td.tiw-metadata-value', undefined, `${treeSitterTokenInfo.text}`) + $('td.tiw-metadata-key', undefined, `tree-sitter token ${lastTokenInfo.id}` as string), + $('td.tiw-metadata-value', undefined, `${lastTokenInfo.text}`) )); const scopes = new Array(); - let node = treeSitterTokenInfo; - while (node.parent) { + let i = treeSitterTokenInfo.length - 1; + let node = treeSitterTokenInfo[i]; + while (node.parent || i > 0) { scopes.push(node.type); - node = node.parent; + node = node.parent ?? treeSitterTokenInfo[--i]; if (node) { scopes.push($('br')); } @@ -662,10 +664,23 @@ class InspectEditorTokensWidget extends Disposable implements IContentWidget { return lastGoodNode; } - private _getTreeSitterTokenAtPosition(tree: Parser.Tree, pos: Position): Parser.Node | null { - const cursor = tree.walk(); - - return this._walkTreeforPosition(cursor, pos); + private _getTreeSitterTokenAtPosition(textModelTreeSitter: ITextModelTreeSitter, pos: Position): Parser.Node[] | null { + let tree = textModelTreeSitter.parseResult; + if (!tree?.tree) { + return null; + } + const nodes: Parser.Node[] = []; + do { + const cursor = tree.tree.walk(); + const node = this._walkTreeforPosition(cursor, pos); + if (node) { + nodes.push(node); + tree = textModelTreeSitter.getInjection(node.startIndex, tree.languageId); + } else { + tree = undefined; + } + } while (tree?.tree); + return nodes.length > 0 ? nodes : null; } private _renderTokenStyleDefinition(definition: TokenStyleDefinition | undefined, property: keyof TokenStyleData): Array { diff --git a/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoLineQuickAccess.ts b/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoLineQuickAccess.ts index 972fd363b90..37963ed5fba 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoLineQuickAccess.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoLineQuickAccess.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../../base/common/event.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IKeyMods, IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -22,7 +23,7 @@ import { IEditorGroupsService } from '../../../../services/editor/common/editorG export class GotoLineQuickAccessProvider extends AbstractGotoLineQuickAccessProvider { - protected readonly onDidActiveTextEditorControlChange = this.editorService.onDidActiveEditorChange; + protected readonly onDidActiveTextEditorControlChange: Event; constructor( @IEditorService private readonly editorService: IEditorService, @@ -30,6 +31,7 @@ export class GotoLineQuickAccessProvider extends AbstractGotoLineQuickAccessProv @IConfigurationService private readonly configurationService: IConfigurationService ) { super(); + this.onDidActiveTextEditorControlChange = this.editorService.onDidActiveEditorChange; } private get configuration() { diff --git a/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts b/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts index 037908fa8db..d8dea5ca723 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../../base/common/event.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IKeyMods, IQuickPickSeparator, IQuickInputService, IQuickPick, ItemActivation } from '../../../../../platform/quickinput/common/quickInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -37,7 +38,7 @@ import { matchesFuzzyIconAware, parseLabelWithIcons } from '../../../../../base/ export class GotoSymbolQuickAccessProvider extends AbstractGotoSymbolQuickAccessProvider { - protected readonly onDidActiveTextEditorControlChange = this.editorService.onDidActiveEditorChange; + protected readonly onDidActiveTextEditorControlChange: Event; constructor( @IEditorService private readonly editorService: IEditorService, @@ -50,6 +51,7 @@ export class GotoSymbolQuickAccessProvider extends AbstractGotoSymbolQuickAccess super(languageFeaturesService, outlineModelService, { openSideBySideDirection: () => this.configuration.openSideBySideDirection }); + this.onDidActiveTextEditorControlChange = this.editorService.onDidActiveEditorChange; } //#region DocumentSymbols (text editor required) diff --git a/src/vs/workbench/contrib/codeEditor/browser/workbenchEditorWorkerService.ts b/src/vs/workbench/contrib/codeEditor/browser/workbenchEditorWorkerService.ts index 3579b82c48d..b74d233147b 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/workbenchEditorWorkerService.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/workbenchEditorWorkerService.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { WorkerDescriptor } from '../../../../base/browser/defaultWorkerFactory.js'; +import { WebWorkerDescriptor } from '../../../../base/browser/webWorkerFactory.js'; +import { FileAccess } from '../../../../base/common/network.js'; import { EditorWorkerService } from '../../../../editor/browser/services/editorWorkerService.js'; import { ILanguageConfigurationService } from '../../../../editor/common/languages/languageConfigurationRegistry.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; @@ -19,7 +20,7 @@ export class WorkbenchEditorWorkerService extends EditorWorkerService { @ILanguageConfigurationService languageConfigurationService: ILanguageConfigurationService, @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, ) { - const workerDescriptor = new WorkerDescriptor('vs/editor/common/services/editorSimpleWorker', 'TextEditorWorker'); + const workerDescriptor = new WebWorkerDescriptor(FileAccess.asBrowserUri('vs/editor/common/services/editorWebWorkerMain.js'), 'TextEditorWorker'); super(workerDescriptor, modelService, configurationService, logService, languageConfigurationService, languageFeaturesService); } } diff --git a/src/vs/workbench/contrib/comments/browser/commentNode.ts b/src/vs/workbench/contrib/comments/browser/commentNode.ts index 77f5e4117a2..fbd9e81aa39 100644 --- a/src/vs/workbench/contrib/comments/browser/commentNode.ts +++ b/src/vs/workbench/contrib/comments/browser/commentNode.ts @@ -44,7 +44,6 @@ import { CommentContextKeys } from '../common/commentContextKeys.js'; import { FileAccess, Schemas } from '../../../../base/common/network.js'; import { COMMENTS_SECTION, ICommentsConfiguration } from '../common/commentsConfiguration.js'; import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; -import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { MarshalledCommentThread } from '../../../common/comments.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -116,7 +115,6 @@ export class CommentNode extends Disposable { @IContextKeyService contextKeyService: IContextKeyService, @IConfigurationService private configurationService: IConfigurationService, @IHoverService private hoverService: IHoverService, - @IAccessibilityService private accessibilityService: IAccessibilityService, @IKeybindingService private keybindingService: IKeybindingService, @ITextModelService private readonly textModelService: ITextModelService, ) { @@ -160,9 +158,6 @@ export class CommentNode extends Disposable { if (pendingEdit) { this.switchToEditMode(); } - this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => { - this.toggleToolbarHidden(true); - })); this.activeCommentListeners(); } @@ -223,7 +218,7 @@ export class CommentNode extends Disposable { private updateCommentUserIcon(userIconPath: UriComponents | undefined) { this._avatar.textContent = ''; if (userIconPath) { - const img = dom.append(this._avatar, dom.$('img.avatar')); + const img = dom.append(this._avatar, dom.$('img.avatar')) as HTMLImageElement; img.src = FileAccess.uriToBrowserUri(URI.revive(userIconPath)).toString(true); img.onerror = _ => img.remove(); } @@ -271,18 +266,9 @@ export class CommentNode extends Disposable { } this._actionsToolbarContainer = dom.append(header, dom.$('.comment-actions')); - this.toggleToolbarHidden(true); this.createActionsToolbar(); } - private toggleToolbarHidden(hidden: boolean) { - if (hidden && !this.accessibilityService.isScreenReaderOptimized()) { - this._actionsToolbarContainer.classList.add('hidden'); - } else { - this._actionsToolbarContainer.classList.remove('hidden'); - } - } - private getToolbarActions(menu: IMenu): { primary: IAction[]; secondary: IAction[] } { const contributedActions = menu.getActions({ shouldForwardArgs: true }); const primary: IAction[] = []; @@ -328,19 +314,11 @@ export class CommentNode extends Disposable { this.toolbar.value.context = this.commentNodeContext; this.toolbar.value.actionRunner = this._actionRunner; - - this.registerActionBarListeners(this._actionsToolbarContainer); } private createActionsToolbar() { const actions: IAction[] = []; - const hasReactionHandler = this.commentService.hasReactionHandler(this.owner); - const toggleReactionAction = hasReactionHandler ? this.createReactionPicker(this.comment.commentReactions || []) : undefined; - if (toggleReactionAction) { - actions.push(toggleReactionAction); - } - const menu = this._commentMenus.getCommentTitleActions(this.comment, this._contextKeyService); this._register(menu); this._register(menu.onDidChange(e => { @@ -348,9 +326,6 @@ export class CommentNode extends Disposable { if (!this.toolbar && (primary.length || secondary.length)) { this.createToolbar(); } - if (toggleReactionAction) { - primary.unshift(toggleReactionAction); - } this.toolbar.value!.setActions(primary, secondary); })); @@ -680,7 +655,6 @@ export class CommentNode extends Disposable { setFocus(focused: boolean, visible: boolean = false) { if (focused) { this._domNode.focus(); - this.toggleToolbarHidden(false); this._actionsToolbarContainer.classList.add('tabfocused'); this._domNode.tabIndex = 0; if (this.comment.mode === languages.CommentMode.Editing) { @@ -688,26 +662,12 @@ export class CommentNode extends Disposable { } } else { if (this._actionsToolbarContainer.classList.contains('tabfocused') && !this._actionsToolbarContainer.classList.contains('mouseover')) { - this.toggleToolbarHidden(true); this._domNode.tabIndex = -1; } this._actionsToolbarContainer.classList.remove('tabfocused'); } } - private registerActionBarListeners(actionsContainer: HTMLElement): void { - this._register(dom.addDisposableListener(this._domNode, 'mouseenter', () => { - this.toggleToolbarHidden(false); - actionsContainer.classList.add('mouseover'); - })); - this._register(dom.addDisposableListener(this._domNode, 'mouseleave', () => { - if (actionsContainer.classList.contains('mouseover') && !actionsContainer.classList.contains('tabfocused')) { - this.toggleToolbarHidden(true); - } - actionsContainer.classList.remove('mouseover'); - })); - } - async update(newComment: languages.Comment) { if (newComment.body !== this.comment.body) { diff --git a/src/vs/workbench/contrib/comments/browser/commentReply.ts b/src/vs/workbench/contrib/comments/browser/commentReply.ts index 917b6a64239..b181a9cb5c1 100644 --- a/src/vs/workbench/contrib/comments/browser/commentReply.ts +++ b/src/vs/workbench/contrib/comments/browser/commentReply.ts @@ -9,7 +9,7 @@ import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from '../../../../base/browser/ui/mo import { IAction } from '../../../../base/common/actions.js'; import { Disposable, IDisposable, dispose } from '../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../base/common/marshallingIds.js'; -import { Schemas } from '../../../../base/common/network.js'; +import { FileAccess, Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; @@ -38,8 +38,10 @@ export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration'; export class CommentReply extends Disposable { commentEditor: ICodeEditor; - form: HTMLElement; + private _container: HTMLElement; + private _form: HTMLElement; commentEditorIsEmpty: IContextKey; + private avatar!: HTMLElement; private _error!: HTMLElement; private _formActions!: HTMLElement; private _editorActions!: HTMLElement; @@ -70,16 +72,18 @@ export class CommentReply extends Disposable { @ITextModelService private readonly textModelService: ITextModelService ) { super(); - - this.form = dom.append(container, dom.$('.comment-form')); - this.commentEditor = this._register(this._scopedInstatiationService.createInstance(SimpleCommentEditor, this.form, SimpleCommentEditor.getEditorOptions(configurationService), _contextKeyService, this._parentThread)); + this._container = dom.append(container, dom.$('.comment-form-container')); + this._form = dom.append(this._container, dom.$('.comment-form')); + this.commentEditor = this._register(this._scopedInstatiationService.createInstance(SimpleCommentEditor, this._form, SimpleCommentEditor.getEditorOptions(configurationService), _contextKeyService, this._parentThread)); this.commentEditorIsEmpty = CommentContextKeys.commentIsEmpty.bindTo(this._contextKeyService); this.commentEditorIsEmpty.set(!this._pendingComment); this.initialize(focus); } - async initialize(focus: boolean) { + private async initialize(focus: boolean) { + this.avatar = dom.append(this._form, dom.$('.avatar-container')); + this.updateAuthorInfo(); const hasExistingComments = this._commentThread.comments && this._commentThread.comments.length > 0; const modeId = generateUuid() + '-' + (hasExistingComments ? this._commentThread.threadId : ++INMEM_MODEL_ID); const params = JSON.stringify({ @@ -115,7 +119,7 @@ export class CommentReply extends Disposable { } })); - this.createTextModelListener(this.commentEditor, this.form); + this.createTextModelListener(this.commentEditor, this._form); this.setCommentEditorDecorations(); @@ -123,12 +127,12 @@ export class CommentReply extends Disposable { if (this._pendingComment) { this.expandReplyArea(); } else if (hasExistingComments) { - this.createReplyButton(this.commentEditor, this.form); + this.createReplyButton(this.commentEditor, this._form); } else if (focus && (this._commentThread.comments && this._commentThread.comments.length === 0)) { this.expandReplyArea(); } - this._error = dom.append(this.form, dom.$('.validation-error.hidden')); - const formActions = dom.append(this.form, dom.$('.form-actions')); + this._error = dom.append(this._container, dom.$('.validation-error.hidden')); + const formActions = dom.append(this._container, dom.$('.form-actions')); this._formActions = dom.append(formActions, dom.$('.other-actions')); this.createCommentWidgetFormActions(this._formActions, model.object.textEditorModel); this._editorActions = dom.append(formActions, dom.$('.editor-actions')); @@ -149,7 +153,7 @@ export class CommentReply extends Disposable { const oldAndNewBothEmpty = !this._commentThread.comments?.length && !commentThread.comments?.length; if (!this._reviewThreadReplyButton) { - this.createReplyButton(this.commentEditor, this.form); + this.createReplyButton(this.commentEditor, this._form); } if (this._commentThread.comments && this._commentThread.comments.length === 0 && !oldAndNewBothEmpty) { @@ -203,11 +207,23 @@ export class CommentReply extends Disposable { return this.commentEditor.hasWidgetFocus(); } - public updateCanReply() { - if (!this._commentThread.canReply) { - this.form.style.display = 'none'; + private updateAuthorInfo() { + this.avatar.textContent = ''; + if (typeof this._commentThread.canReply !== 'boolean' && this._commentThread.canReply.iconPath) { + this.avatar.style.display = 'block'; + const img = dom.append(this.avatar, dom.$('img.avatar')) as HTMLImageElement; + img.src = FileAccess.uriToBrowserUri(URI.revive(this._commentThread.canReply.iconPath)).toString(true); } else { - this.form.style.display = 'block'; + this.avatar.style.display = 'none'; + } + } + + public updateCanReply() { + this.updateAuthorInfo(); + if (!this._commentThread.canReply) { + this._container.style.display = 'none'; + } else { + this._container.style.display = 'block'; } } @@ -320,12 +336,12 @@ export class CommentReply extends Disposable { } private get isReplyExpanded(): boolean { - return this.form.classList.contains('expand'); + return this._container.classList.contains('expand'); } private expandReplyArea() { if (!this.isReplyExpanded) { - this.form.classList.add('expand'); + this._container.classList.add('expand'); this.commentEditor.focus(); this.commentEditor.layout(); } @@ -345,7 +361,7 @@ export class CommentReply extends Disposable { } this.commentEditor.setValue(''); this._pendingComment = { body: '', cursor: new Position(1, 1) }; - this.form.classList.remove('expand'); + this._container.classList.remove('expand'); this._error.textContent = ''; this._error.classList.add('hidden'); } diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts index e664efbc245..0039a4030ea 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts @@ -157,7 +157,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget this._commentThreadDisposables = []; this.create(); - this._globalToDispose.add(this.themeService.onDidColorThemeChange(e => this._applyTheme(e.theme))); + this._globalToDispose.add(this.themeService.onDidColorThemeChange(this._applyTheme, this)); this._globalToDispose.add(this.editor.onDidChangeConfiguration(e => { if (e.hasChanged(EditorOption.fontInfo)) { this._applyTheme(this.themeService.getColorTheme()); diff --git a/src/vs/workbench/contrib/comments/browser/media/review.css b/src/vs/workbench/contrib/comments/browser/media/review.css index e062349f9de..f0b9a064d8d 100644 --- a/src/vs/workbench/contrib/comments/browser/media/review.css +++ b/src/vs/workbench/contrib/comments/browser/media/review.css @@ -78,7 +78,7 @@ margin-top: 4px !important; } -.review-widget .body .review-comment .avatar-container img.avatar { +.review-widget .body .avatar-container img.avatar { height: 28px; width: 28px; display: inline-block; @@ -172,7 +172,6 @@ } .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label.toolbar-toggle-pickReactions { - display: none; background-size: 16px; font-size: 16px; width: 26px; @@ -183,11 +182,6 @@ border: none; } -.review-widget .body .review-comment .review-comment-contents:hover .comment-reactions .action-item a.action-label.toolbar-toggle-pickReactions { - display: inline-block; - background-size: 16px; -} - .review-widget .body .review-comment .comment-title .action-label { display: block; height: 16px; @@ -252,7 +246,7 @@ max-width: 100%; } -.review-widget .body .comment-form { +.review-widget .body .comment-form-container { margin: 8px 20px; } @@ -302,7 +296,6 @@ padding: 4px 10px; } - .review-widget .body .comment-additional-actions .codicon-drop-down-button { align-items: center; } @@ -310,17 +303,27 @@ .review-widget .body .monaco-editor { color: var(--vscode-editor-foreground); } -.review-widget .body .comment-form.expand .review-thread-reply-button { + +.review-widget .body .comment-form-container .comment-form { + display: flex; + flex-direction: row; +} + +.review-widget .body .comment-form-container .comment-form .avatar-container { + padding-right: 20px; +} + +.review-widget .body .comment-form-container.expand .review-thread-reply-button { display: none; } -.review-widget .body .comment-form.expand .monaco-editor, -.review-widget .body .comment-form.expand .form-actions { +.review-widget .body .comment-form-container.expand .monaco-editor, +.review-widget .body .comment-form-container.expand .form-actions { display: block; box-sizing: content-box; } -.review-widget .body .comment-form .review-thread-reply-button { +.review-widget .body .comment-form-container .review-thread-reply-button { text-align: left; display: block; width: 100%; @@ -339,18 +342,18 @@ font-family: var(--monaco-monospace-font); } -.review-widget .body .comment-form .review-thread-reply-button:focus { +.review-widget .body .comment-form-container .review-thread-reply-button:focus { outline-style: solid; outline-width: 1px; } -.review-widget .body .comment-form .monaco-editor, -.review-widget .body .comment-form .monaco-editor .monaco-editor-background, +.review-widget .body .comment-form-container .monaco-editor, +.review-widget .body .comment-form-container .monaco-editor .monaco-editor-background, .review-widget .body .edit-container .monaco-editor .monaco-editor-background { background-color: var(--vscode-editorCommentsWidget-replyInputBackground); } -.review-widget .body .comment-form .monaco-editor, +.review-widget .body .comment-form-container .monaco-editor, .review-widget .body .edit-container .monaco-editor { width: 100%; min-height: 90px; @@ -361,12 +364,12 @@ padding: 6px 0 6px 12px; } -.review-widget .body .comment-form .monaco-editor, -.review-widget .body .comment-form .form-actions { +.review-widget .body .comment-form-container .monaco-editor, +.review-widget .body .comment-form-container .form-actions { display: none; } -.review-widget .body .comment-form .form-actions, +.review-widget .body .comment-form-container .form-actions, .review-widget .body .edit-container .form-actions { overflow: auto; margin: 10px 0; @@ -381,7 +384,7 @@ margin-right: 12px; } -.review-widget .body .comment-form .form-actions .monaco-text-button, +.review-widget .body .comment-form-container .form-actions .monaco-text-button, .review-widget .body .edit-container .monaco-text-button { width: auto; padding: 4px 10px; diff --git a/src/vs/workbench/contrib/configExporter/electron-sandbox/configurationExportHelper.contribution.ts b/src/vs/workbench/contrib/configExporter/electron-sandbox/configurationExportHelper.contribution.ts deleted file mode 100644 index 678bf198d59..00000000000 --- a/src/vs/workbench/contrib/configExporter/electron-sandbox/configurationExportHelper.contribution.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../common/contributions.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; -import { INativeWorkbenchEnvironmentService } from '../../../services/environment/electron-sandbox/environmentService.js'; -import { DefaultConfigurationExportHelper } from './configurationExportHelper.js'; - -export class ExtensionPoints implements IWorkbenchContribution { - - constructor( - @IInstantiationService instantiationService: IInstantiationService, - @INativeWorkbenchEnvironmentService environmentService: INativeWorkbenchEnvironmentService - ) { - // Config Exporter - if (environmentService.args['export-default-configuration']) { - instantiationService.createInstance(DefaultConfigurationExportHelper); - } - } -} - -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(ExtensionPoints, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/configExporter/electron-sandbox/configurationExportHelper.ts b/src/vs/workbench/contrib/configExporter/electron-sandbox/configurationExportHelper.ts deleted file mode 100644 index ae0bcfcaf80..00000000000 --- a/src/vs/workbench/contrib/configExporter/electron-sandbox/configurationExportHelper.ts +++ /dev/null @@ -1,120 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { INativeWorkbenchEnvironmentService } from '../../../services/environment/electron-sandbox/environmentService.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { IConfigurationNode, IConfigurationRegistry, Extensions, IConfigurationPropertySchema } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { IExtensionService } from '../../../services/extensions/common/extensions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; - -interface IExportedConfigurationNode { - name: string; - description: string; - default: any; - type?: string | string[]; - enum?: any[]; - enumDescriptions?: string[]; -} - -interface IConfigurationExport { - settings: IExportedConfigurationNode[]; - buildTime: number; - commit?: string; - buildNumber?: number; -} - -export class DefaultConfigurationExportHelper { - - constructor( - @INativeWorkbenchEnvironmentService environmentService: INativeWorkbenchEnvironmentService, - @IExtensionService private readonly extensionService: IExtensionService, - @ICommandService private readonly commandService: ICommandService, - @IFileService private readonly fileService: IFileService, - @IProductService private readonly productService: IProductService - ) { - const exportDefaultConfigurationPath = environmentService.args['export-default-configuration']; - if (exportDefaultConfigurationPath) { - this.writeConfigModelAndQuit(URI.file(exportDefaultConfigurationPath)); - } - } - - private async writeConfigModelAndQuit(target: URI): Promise { - try { - await this.extensionService.whenInstalledExtensionsRegistered(); - await this.writeConfigModel(target); - } finally { - this.commandService.executeCommand('workbench.action.quit'); - } - } - - private async writeConfigModel(target: URI): Promise { - const config = this.getConfigModel(); - - const resultString = JSON.stringify(config, undefined, ' '); - await this.fileService.writeFile(target, VSBuffer.fromString(resultString)); - } - - private getConfigModel(): IConfigurationExport { - const configRegistry = Registry.as(Extensions.Configuration); - const configurations = configRegistry.getConfigurations().slice(); - const settings: IExportedConfigurationNode[] = []; - const processedNames = new Set(); - - const processProperty = (name: string, prop: IConfigurationPropertySchema) => { - if (processedNames.has(name)) { - console.warn('Setting is registered twice: ' + name); - return; - } - - processedNames.add(name); - const propDetails: IExportedConfigurationNode = { - name, - description: prop.description || prop.markdownDescription || '', - default: prop.default, - type: prop.type - }; - - if (prop.enum) { - propDetails.enum = prop.enum; - } - - if (prop.enumDescriptions || prop.markdownEnumDescriptions) { - propDetails.enumDescriptions = prop.enumDescriptions || prop.markdownEnumDescriptions; - } - - settings.push(propDetails); - }; - - const processConfig = (config: IConfigurationNode) => { - if (config.properties) { - for (const name in config.properties) { - processProperty(name, config.properties[name]); - } - } - - config.allOf?.forEach(processConfig); - }; - - configurations.forEach(processConfig); - - const excludedProps = configRegistry.getExcludedConfigurationProperties(); - for (const name in excludedProps) { - processProperty(name, excludedProps[name]); - } - - const result: IConfigurationExport = { - settings: settings.sort((a, b) => a.name.localeCompare(b.name)), - buildTime: Date.now(), - commit: this.productService.commit, - buildNumber: this.productService.settingsSearchBuildId - }; - - return result; - } -} diff --git a/src/vs/workbench/contrib/debug/browser/callStackView.ts b/src/vs/workbench/contrib/debug/browser/callStackView.ts index 28d99d7646f..176b0aa7006 100644 --- a/src/vs/workbench/contrib/debug/browser/callStackView.ts +++ b/src/vs/workbench/contrib/debug/browser/callStackView.ts @@ -189,7 +189,6 @@ export class CallStackView extends ViewPane { this.updateActions(); this.needsRefresh = false; - this.dataSource.deemphasizedStackFramesToShow = []; await this.tree.updateChildren(); try { const toExpand = new Set(); @@ -326,7 +325,7 @@ export class CallStackView extends ViewPane { } } if (element instanceof Array) { - this.dataSource.deemphasizedStackFramesToShow.push(...element); + element.forEach(sf => this.dataSource.deemphasizedStackFramesToShow.add(sf)); this.tree.updateChildren(); } })); @@ -509,6 +508,7 @@ interface IStackFrameTemplateData { label: HighlightedLabel; actionBar: ActionBar; templateDisposable: DisposableStore; + elementDisposables: DisposableStore; } function getSessionContextOverlay(session: IDebugSession): [string, any][] { @@ -726,11 +726,12 @@ class StackFramesRenderer implements ICompressibleTreeRenderer, index: number, data: IStackFrameTemplateData): void { @@ -744,7 +745,7 @@ class StackFramesRenderer implements ICompressibleTreeRenderer { + const action = data.elementDisposables.add(new Action('debug.callStack.restartFrame', localize('restartFrame', "Restart Frame"), ThemeIcon.asClassName(icons.debugRestartFrame), true, async () => { try { await stackFrame.restart(); } catch (e) { this.notificationService.error(e); } - }); + })); data.actionBar.push(action, { icon: true, label: false }); } } @@ -774,9 +775,12 @@ class StackFramesRenderer implements ICompressibleTreeRenderer, FuzzyScore>, index: number, templateData: IStackFrameTemplateData, height: number | undefined): void { throw new Error('Method not implemented.'); } + disposeElement(element: ITreeNode, index: number, templateData: IStackFrameTemplateData, height: number | undefined): void { + templateData.elementDisposables.clear(); + } disposeTemplate(templateData: IStackFrameTemplateData): void { - templateData.actionBar.dispose(); + templateData.templateDisposable.dispose(); } } @@ -929,7 +933,7 @@ function isDebugSession(obj: any): obj is IDebugSession { } class CallStackDataSource implements IAsyncDataSource { - deemphasizedStackFramesToShow: IStackFrame[] = []; + deemphasizedStackFramesToShow = new WeakSet(); constructor(private debugService: IDebugService) { } @@ -977,7 +981,7 @@ class CallStackDataSource implements IAsyncDataSource { if (child instanceof StackFrame && child.source && isFrameDeemphasized(child)) { // Check if the user clicked to show the deemphasized source - if (this.deemphasizedStackFramesToShow.indexOf(child) === -1) { + if (!this.deemphasizedStackFramesToShow.has(child)) { if (result.length) { const last = result[result.length - 1]; if (last instanceof Array) { diff --git a/src/vs/workbench/contrib/debug/browser/debug.contribution.ts b/src/vs/workbench/contrib/debug/browser/debug.contribution.ts index 1c522165c23..fd9120ac2d7 100644 --- a/src/vs/workbench/contrib/debug/browser/debug.contribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debug.contribution.ts @@ -40,7 +40,7 @@ import { BreakpointsView } from './breakpointsView.js'; import { CallStackEditorContribution } from './callStackEditorContribution.js'; import { CallStackView } from './callStackView.js'; import { registerColors } from './debugColors.js'; -import { ADD_CONFIGURATION_ID, ADD_TO_WATCH_ID, ADD_TO_WATCH_LABEL, CALLSTACK_BOTTOM_ID, CALLSTACK_BOTTOM_LABEL, CALLSTACK_DOWN_ID, CALLSTACK_DOWN_LABEL, CALLSTACK_TOP_ID, CALLSTACK_TOP_LABEL, CALLSTACK_UP_ID, CALLSTACK_UP_LABEL, CONTINUE_ID, CONTINUE_LABEL, COPY_EVALUATE_PATH_ID, COPY_EVALUATE_PATH_LABEL, COPY_STACK_TRACE_ID, COPY_VALUE_ID, COPY_VALUE_LABEL, DEBUG_COMMAND_CATEGORY, DEBUG_CONSOLE_QUICK_ACCESS_PREFIX, DEBUG_QUICK_ACCESS_PREFIX, DEBUG_RUN_COMMAND_ID, DEBUG_RUN_LABEL, DEBUG_START_COMMAND_ID, DEBUG_START_LABEL, DISCONNECT_AND_SUSPEND_ID, DISCONNECT_AND_SUSPEND_LABEL, DISCONNECT_ID, DISCONNECT_LABEL, EDIT_EXPRESSION_COMMAND_ID, JUMP_TO_CURSOR_ID, NEXT_DEBUG_CONSOLE_ID, NEXT_DEBUG_CONSOLE_LABEL, OPEN_LOADED_SCRIPTS_LABEL, PAUSE_ID, PAUSE_LABEL, PREV_DEBUG_CONSOLE_ID, PREV_DEBUG_CONSOLE_LABEL, REMOVE_EXPRESSION_COMMAND_ID, RESTART_FRAME_ID, RESTART_LABEL, RESTART_SESSION_ID, SELECT_AND_START_ID, SELECT_AND_START_LABEL, SELECT_DEBUG_CONSOLE_ID, SELECT_DEBUG_CONSOLE_LABEL, SELECT_DEBUG_SESSION_ID, SELECT_DEBUG_SESSION_LABEL, SET_EXPRESSION_COMMAND_ID, SHOW_LOADED_SCRIPTS_ID, STEP_INTO_ID, STEP_INTO_LABEL, STEP_INTO_TARGET_ID, STEP_INTO_TARGET_LABEL, STEP_OUT_ID, STEP_OUT_LABEL, STEP_OVER_ID, STEP_OVER_LABEL, STOP_ID, STOP_LABEL, TERMINATE_THREAD_ID, TOGGLE_INLINE_BREAKPOINT_ID } from './debugCommands.js'; +import { ADD_CONFIGURATION_ID, ADD_TO_WATCH_ID, ADD_TO_WATCH_LABEL, CALLSTACK_BOTTOM_ID, CALLSTACK_BOTTOM_LABEL, CALLSTACK_DOWN_ID, CALLSTACK_DOWN_LABEL, CALLSTACK_TOP_ID, CALLSTACK_TOP_LABEL, CALLSTACK_UP_ID, CALLSTACK_UP_LABEL, CONTINUE_ID, CONTINUE_LABEL, COPY_EVALUATE_PATH_ID, COPY_EVALUATE_PATH_LABEL, COPY_STACK_TRACE_ID, COPY_VALUE_ID, COPY_VALUE_LABEL, DEBUG_COMMAND_CATEGORY, DEBUG_CONSOLE_QUICK_ACCESS_PREFIX, DEBUG_QUICK_ACCESS_PREFIX, DEBUG_RUN_COMMAND_ID, DEBUG_RUN_LABEL, DEBUG_START_COMMAND_ID, DEBUG_START_LABEL, DISCONNECT_AND_SUSPEND_ID, DISCONNECT_AND_SUSPEND_LABEL, DISCONNECT_ID, DISCONNECT_LABEL, EDIT_EXPRESSION_COMMAND_ID, JUMP_TO_CURSOR_ID, NEXT_DEBUG_CONSOLE_ID, NEXT_DEBUG_CONSOLE_LABEL, OPEN_LOADED_SCRIPTS_LABEL, PAUSE_ID, PAUSE_LABEL, PREV_DEBUG_CONSOLE_ID, PREV_DEBUG_CONSOLE_LABEL, REMOVE_EXPRESSION_COMMAND_ID, RESTART_FRAME_ID, RESTART_LABEL, RESTART_SESSION_ID, SELECT_AND_START_ID, SELECT_AND_START_LABEL, SELECT_DEBUG_CONSOLE_ID, SELECT_DEBUG_CONSOLE_LABEL, SELECT_DEBUG_SESSION_ID, SELECT_DEBUG_SESSION_LABEL, SET_EXPRESSION_COMMAND_ID, SHOW_LOADED_SCRIPTS_ID, STEP_INTO_ID, STEP_INTO_LABEL, STEP_INTO_TARGET_ID, STEP_INTO_TARGET_LABEL, STEP_OUT_ID, STEP_OUT_LABEL, STEP_OVER_ID, STEP_OVER_LABEL, STOP_ID, STOP_LABEL, TERMINATE_THREAD_ID, TOGGLE_INLINE_BREAKPOINT_ID, COPY_ADDRESS_ID, COPY_ADDRESS_LABEL, TOGGLE_BREAKPOINT_ID } from './debugCommands.js'; import { DebugConsoleQuickAccess } from './debugConsoleQuickAccess.js'; import { RunToCursorAction, SelectionToReplAction, SelectionToWatchExpressionsAction } from './debugEditorActions.js'; import { DebugEditorContribution } from './debugEditorContribution.js'; @@ -381,6 +381,28 @@ MenuRegistry.appendMenuItem(MenuId.MenubarDebugMenu, { when: CONTEXT_DEBUGGERS_AVAILABLE }); +// Disassembly + +MenuRegistry.appendMenuItem(MenuId.DebugDisassemblyContext, { + group: '1_edit', + command: { + id: COPY_ADDRESS_ID, + title: COPY_ADDRESS_LABEL, + }, + order: 2, + when: CONTEXT_DEBUGGERS_AVAILABLE +}); + +MenuRegistry.appendMenuItem(MenuId.DebugDisassemblyContext, { + group: '3_breakpoints', + command: { + id: TOGGLE_BREAKPOINT_ID, + title: nls.localize({ key: 'miToggleBreakpoint', comment: ['&& denotes a mnemonic'] }, "Toggle Breakpoint"), + }, + order: 2, + when: CONTEXT_DEBUGGERS_AVAILABLE +}); + // Breakpoint actions are registered from breakpointsView.ts // Install Debuggers diff --git a/src/vs/workbench/contrib/debug/browser/debugCommands.ts b/src/vs/workbench/contrib/debug/browser/debugCommands.ts index 92b79ee572b..fe5bb95a0a5 100644 --- a/src/vs/workbench/contrib/debug/browser/debugCommands.ts +++ b/src/vs/workbench/contrib/debug/browser/debugCommands.ts @@ -40,6 +40,8 @@ import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; export const ADD_CONFIGURATION_ID = 'debug.addConfiguration'; +export const COPY_ADDRESS_ID = 'editor.debug.action.copyAddress'; +export const TOGGLE_BREAKPOINT_ID = 'editor.debug.action.toggleBreakpoint'; export const TOGGLE_INLINE_BREAKPOINT_ID = 'editor.debug.action.toggleInlineBreakpoint'; export const COPY_STACK_TRACE_ID = 'debug.copyStackTrace'; export const REVERSE_CONTINUE_ID = 'workbench.action.debug.reverseContinue'; @@ -105,6 +107,7 @@ export const CALLSTACK_UP_LABEL = nls.localize2('callStackUp', "Navigate Up Call export const CALLSTACK_DOWN_LABEL = nls.localize2('callStackDown', "Navigate Down Call Stack"); export const COPY_EVALUATE_PATH_LABEL = nls.localize2('copyAsExpression', "Copy as Expression"); export const COPY_VALUE_LABEL = nls.localize2('copyValue', "Copy Value"); +export const COPY_ADDRESS_LABEL = nls.localize2('copyAddress', "Copy Address"); export const ADD_TO_WATCH_LABEL = nls.localize2('addToWatchExpressions', "Add to Watch"); export const SELECT_DEBUG_CONSOLE_LABEL = nls.localize2('selectDebugConsole', "Select Debug Console"); diff --git a/src/vs/workbench/contrib/debug/browser/debugEditorActions.ts b/src/vs/workbench/contrib/debug/browser/debugEditorActions.ts index f6a5af3713a..81b57c8b357 100644 --- a/src/vs/workbench/contrib/debug/browser/debugEditorActions.ts +++ b/src/vs/workbench/contrib/debug/browser/debugEditorActions.ts @@ -25,18 +25,19 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { PanelFocusContext } from '../../../common/contextkeys.js'; import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; import { openBreakpointSource } from './breakpointsView.js'; -import { DisassemblyView } from './disassemblyView.js'; +import { DisassemblyView, IDisassembledInstructionEntry } from './disassemblyView.js'; import { Repl } from './repl.js'; import { BREAKPOINT_EDITOR_CONTRIBUTION_ID, BreakpointWidgetContext, CONTEXT_CALLSTACK_ITEM_TYPE, CONTEXT_DEBUG_STATE, CONTEXT_DEBUGGERS_AVAILABLE, CONTEXT_DISASSEMBLE_REQUEST_SUPPORTED, CONTEXT_DISASSEMBLY_VIEW_FOCUS, CONTEXT_EXCEPTION_WIDGET_VISIBLE, CONTEXT_FOCUSED_STACK_FRAME_HAS_INSTRUCTION_POINTER_REFERENCE, CONTEXT_IN_DEBUG_MODE, CONTEXT_LANGUAGE_SUPPORTS_DISASSEMBLE_REQUEST, CONTEXT_STEP_INTO_TARGETS_SUPPORTED, EDITOR_CONTRIBUTION_ID, IBreakpointEditorContribution, IDebugConfiguration, IDebugEditorContribution, IDebugService, REPL_VIEW_ID, WATCH_VIEW_ID } from '../common/debug.js'; import { getEvaluatableExpressionAtPosition } from '../common/debugUtils.js'; import { DisassemblyViewInput } from '../common/disassemblyViewInput.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; +import { TOGGLE_BREAKPOINT_ID } from '../../../../workbench/contrib/debug/browser/debugCommands.js'; class ToggleBreakpointAction extends Action2 { constructor() { super({ - id: 'editor.debug.action.toggleBreakpoint', + id: TOGGLE_BREAKPOINT_ID, title: { ...nls.localize2('toggleBreakpointAction', "Debug: Toggle Breakpoint"), mnemonicTitle: nls.localize({ key: 'miToggleBreakpoint', comment: ['&& denotes a mnemonic'] }, "Toggle &&Breakpoint"), @@ -57,13 +58,13 @@ class ToggleBreakpointAction extends Action2 { }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, entry?: IDisassembledInstructionEntry): Promise { const editorService = accessor.get(IEditorService); const debugService = accessor.get(IDebugService); const activePane = editorService.activeEditorPane; if (activePane instanceof DisassemblyView) { - const location = activePane.focusedAddressAndOffset; + const location = entry ? activePane.getAddressAndOffset(entry) : activePane.focusedAddressAndOffset; if (location) { const bps = debugService.getModel().getInstructionBreakpoints(); const toRemove = bps.find(bp => bp.address === location.address); diff --git a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts index 285e99c61f9..8f67f94b32e 100644 --- a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts @@ -170,7 +170,7 @@ export function createInlineValueDecoration(lineNumber: number, contentText: str } function replaceWsWithNoBreakWs(str: string): string { - return str.replace(/[ \t]/g, strings.noBreakWhitespace); + return str.replace(/[ \t\n]/g, strings.noBreakWhitespace); } function createInlineValueDecorationsInsideRange(expressions: ReadonlyArray, ranges: Range[], model: ITextModel, wordToLineNumbersMap: Map) { diff --git a/src/vs/workbench/contrib/debug/browser/debugMemory.ts b/src/vs/workbench/contrib/debug/browser/debugMemory.ts index 8fcd11962b3..9eabf24fed7 100644 --- a/src/vs/workbench/contrib/debug/browser/debugMemory.ts +++ b/src/vs/workbench/contrib/debug/browser/debugMemory.ts @@ -235,11 +235,12 @@ class MemoryRegionView extends Disposable implements IMemoryRegion { public readonly onDidInvalidate = this.invalidateEmitter.event; public readonly writable: boolean; - private readonly width = this.range.toOffset - this.range.fromOffset; + private readonly width: number; constructor(private readonly parent: IMemoryRegion, public readonly range: { fromOffset: number; toOffset: number }) { super(); this.writable = parent.writable; + this.width = range.toOffset - range.fromOffset; this._register(parent); this._register(parent.onDidInvalidate(e => { diff --git a/src/vs/workbench/contrib/debug/browser/debugSession.ts b/src/vs/workbench/contrib/debug/browser/debugSession.ts index 3e63e489167..7054f7ad505 100644 --- a/src/vs/workbench/contrib/debug/browser/debugSession.ts +++ b/src/vs/workbench/contrib/debug/browser/debugSession.ts @@ -3,22 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { getActiveWindow } from '../../../../base/browser/dom.js'; import * as aria from '../../../../base/browser/ui/aria/aria.js'; +import { mainWindow } from '../../../../base/browser/window.js'; import { distinct } from '../../../../base/common/arrays.js'; import { Queue, RunOnceScheduler, raceTimeout } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { canceled } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { normalizeDriveLetter } from '../../../../base/common/labels.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, dispose } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, MutableDisposable, dispose } from '../../../../base/common/lifecycle.js'; import { mixin } from '../../../../base/common/objects.js'; import * as platform from '../../../../base/common/platform.js'; import * as resources from '../../../../base/common/resources.js'; import Severity from '../../../../base/common/severity.js'; +import { isDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IPosition, Position } from '../../../../editor/common/core/position.js'; import { localize } from '../../../../nls.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -28,28 +32,24 @@ import { ICustomEndpointTelemetryService, ITelemetryService, TelemetryLevel } fr import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; import { ViewContainerLocation } from '../../../common/views.js'; -import { RawDebugSession } from './rawDebugSession.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; +import { IPaneCompositePartService } from '../../../services/panecomposite/browser/panecomposite.js'; +import { LiveTestResult } from '../../testing/common/testResult.js'; +import { ITestResultService } from '../../testing/common/testResultService.js'; +import { ITestService } from '../../testing/common/testService.js'; import { AdapterEndEvent, IBreakpoint, IConfig, IDataBreakpoint, IDataBreakpointInfoResponse, IDebugConfiguration, IDebugLocationReferenced, IDebugService, IDebugSession, IDebugSessionOptions, IDebugger, IExceptionBreakpoint, IExceptionInfo, IFunctionBreakpoint, IInstructionBreakpoint, IMemoryRegion, IRawModelUpdate, IRawStoppedDetails, IReplElement, IStackFrame, IThread, LoadedSourceEvent, State, VIEWLET_ID, isFrameDeemphasized } from '../common/debug.js'; import { DebugCompoundRoot } from '../common/debugCompoundRoot.js'; import { DebugModel, ExpressionContainer, MemoryRegion, Thread } from '../common/debugModel.js'; import { Source } from '../common/debugSource.js'; import { filterExceptionsFromTelemetry } from '../common/debugUtils.js'; import { INewReplElementData, ReplModel } from '../common/replModel.js'; -import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { IHostService } from '../../../services/host/browser/host.js'; -import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; -import { IPaneCompositePartService } from '../../../services/panecomposite/browser/panecomposite.js'; -import { getActiveWindow } from '../../../../base/browser/dom.js'; -import { mainWindow } from '../../../../base/browser/window.js'; -import { isDefined } from '../../../../base/common/types.js'; -import { ITestService } from '../../testing/common/testService.js'; -import { ITestResultService } from '../../testing/common/testResultService.js'; -import { LiveTestResult } from '../../testing/common/testResult.js'; -import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { RawDebugSession } from './rawDebugSession.js'; const TRIGGERED_BREAKPOINT_MAX_DELAY = 1500; -export class DebugSession implements IDebugSession, IDisposable { +export class DebugSession implements IDebugSession { parentSession: IDebugSession | undefined; rememberedCapabilities?: DebugProtocol.Capabilities; diff --git a/src/vs/workbench/contrib/debug/browser/disassemblyView.ts b/src/vs/workbench/contrib/debug/browser/disassemblyView.ts index 95c740dcaa6..0f15bf5d2e7 100644 --- a/src/vs/workbench/contrib/debug/browser/disassemblyView.ts +++ b/src/vs/workbench/contrib/debug/browser/disassemblyView.ts @@ -6,7 +6,7 @@ import { PixelRatio } from '../../../../base/browser/pixelRatio.js'; import { $, Dimension, addStandardDisposableListener, append } from '../../../../base/browser/dom.js'; import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; -import { ITableRenderer, ITableVirtualDelegate } from '../../../../base/browser/ui/table/table.js'; +import { ITableContextMenuEvent, ITableRenderer, ITableVirtualDelegate } from '../../../../base/browser/ui/table/table.js'; import { binarySearch2 } from '../../../../base/common/arrays.js'; import { Color } from '../../../../base/common/color.js'; import { Emitter } from '../../../../base/common/event.js'; @@ -25,7 +25,7 @@ import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { TextEditorSelectionRevealType } from '../../../../platform/editor/common/editor.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchTable } from '../../../../platform/list/browser/listService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; @@ -43,8 +43,14 @@ import { getUriFromSource } from '../common/debugSource.js'; import { isUri, sourcesEqual } from '../common/debugUtils.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IMenu, IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { COPY_ADDRESS_ID, COPY_ADDRESS_LABEL } from '../../../../workbench/contrib/debug/browser/debugCommands.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { getFlatContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -interface IDisassembledInstructionEntry { +export interface IDisassembledInstructionEntry { allowBreakpoint: boolean; isBreakpointSet: boolean; isBreakpointEnabled: boolean; @@ -62,7 +68,6 @@ interface IDisassembledInstructionEntry { address: bigint; } - // Special entry as a placeholer when disassembly is not available const disassemblyNotAvailable: IDisassembledInstructionEntry = { allowBreakpoint: false, @@ -91,6 +96,7 @@ export class DisassemblyView extends EditorPane { private _enableSourceCodeRender: boolean = true; private _loadingLock: boolean = false; private readonly _referenceToMemoryAddress = new Map(); + private menu: IMenu; constructor( group: IEditorGroup, @@ -100,9 +106,14 @@ export class DisassemblyView extends EditorPane { @IConfigurationService private readonly _configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IDebugService private readonly _debugService: IDebugService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IMenuService menuService: IMenuService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(DISASSEMBLY_VIEW_ID, group, telemetryService, themeService, storageService); + this.menu = menuService.createMenu(MenuId.DebugDisassemblyContext, contextKeyService); + this._register(this.menu); this._disassembledInstructions = undefined; this._onDidChangeStackFrame = this._register(new Emitter({ leakWarningThreshold: 1000 })); this._previousDebuggingState = _debugService.state; @@ -180,6 +191,10 @@ export class DisassemblyView extends EditorPane { return undefined; } + return this.getAddressAndOffset(element); + } + + getAddressAndOffset(element: IDisassembledInstructionEntry) { const reference = element.instructionReference; const offset = Number(element.address - this.getReferenceAddress(reference)!); return { reference, offset, address: element.address }; @@ -273,6 +288,8 @@ export class DisassemblyView extends EditorPane { } })); + this._register(this._disassembledInstructions.onContextMenu(e => this.onContextMenu(e))); + this._register(this._debugService.getViewModel().onDidFocusStackFrame(({ stackFrame }) => { if (this._disassembledInstructions && stackFrame?.instructionPointerReference) { this.goToInstructionAndOffset(stackFrame.instructionPointerReference, 0); @@ -633,6 +650,15 @@ export class DisassemblyView extends EditorPane { this._referenceToMemoryAddress.clear(); this._disassembledInstructions?.splice(0, this._disassembledInstructions.length, [disassemblyNotAvailable]); } + + private onContextMenu(e: ITableContextMenuEvent): void { + const actions = getFlatContextMenuActions(this.menu.getActions({ shouldForwardArgs: true })); + this._contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => actions, + getActionsContext: () => e.element + }); + } } interface IBreakpointColumnTemplateData { @@ -775,8 +801,8 @@ class InstructionRenderer extends Disposable implements ITableRenderer { - this._topStackFrameColor = e.theme.getColor(topStackFrameColor); - this._focusedStackFrameColor = e.theme.getColor(focusedStackFrameColor); + this._topStackFrameColor = e.getColor(topStackFrameColor); + this._focusedStackFrameColor = e.getColor(focusedStackFrameColor); })); } @@ -1006,3 +1032,16 @@ export class DisassemblyViewContribution implements IWorkbenchContribution { this._onDidChangeModelLanguage?.dispose(); } } + +CommandsRegistry.registerCommand({ + metadata: { + description: COPY_ADDRESS_LABEL, + }, + id: COPY_ADDRESS_ID, + handler: async (accessor: ServicesAccessor, entry?: IDisassembledInstructionEntry) => { + if (entry?.instruction?.address) { + const clipboardService = accessor.get(IClipboardService); + clipboardService.writeText(entry.instruction.address); + } + } +}); diff --git a/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts b/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts index 9f8d26570ed..35479f2cef7 100644 --- a/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts +++ b/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts @@ -10,7 +10,7 @@ import { ZoneWidget } from '../../../../editor/contrib/zoneWidget/browser/zoneWi import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { IExceptionInfo, IDebugSession, IDebugEditorContribution, EDITOR_CONTRIBUTION_ID } from '../common/debug.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { IThemeService, IColorTheme, IThemeChangeEvent } from '../../../../platform/theme/common/themeService.js'; +import { IThemeService, IColorTheme } from '../../../../platform/theme/common/themeService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Color } from '../../../../base/common/color.js'; import { registerColor } from '../../../../platform/theme/common/colorRegistry.js'; @@ -41,7 +41,7 @@ export class ExceptionWidget extends ZoneWidget { super(editor, { showFrame: true, showArrow: true, isAccessible: true, frameWidth: 1, className: 'exception-widget-container' }); this.applyTheme(themeService.getColorTheme()); - this._disposables.add(themeService.onDidColorThemeChange(this.onDidColorThemeChange.bind(this))); + this._disposables.add(themeService.onDidColorThemeChange(this.applyTheme.bind(this))); this.create(); const onDidLayoutChangeScheduler = new RunOnceScheduler(() => this._doLayout(undefined, undefined), 50); @@ -49,10 +49,6 @@ export class ExceptionWidget extends ZoneWidget { this._disposables.add(onDidLayoutChangeScheduler); } - private onDidColorThemeChange(e: IThemeChangeEvent): void { - this.applyTheme(e.theme); - } - private applyTheme(theme: IColorTheme): void { this.backgroundColor = theme.getColor(debugExceptionWidgetBackground); const frameColor = theme.getColor(debugExceptionWidgetBorder); diff --git a/src/vs/workbench/contrib/debug/common/debug.ts b/src/vs/workbench/contrib/debug/common/debug.ts index 6ce5e3e38a9..545504bfde0 100644 --- a/src/vs/workbench/contrib/debug/common/debug.ts +++ b/src/vs/workbench/contrib/debug/common/debug.ts @@ -368,7 +368,7 @@ export interface IDebugLocationReferenced { source: Source; } -export interface IDebugSession extends ITreeElement { +export interface IDebugSession extends ITreeElement, IDisposable { readonly configuration: IConfig; readonly unresolvedConfiguration: IConfig | undefined; diff --git a/src/vs/workbench/contrib/debug/common/debugModel.ts b/src/vs/workbench/contrib/debug/common/debugModel.ts index 75794f9dfd4..75062d56a1d 100644 --- a/src/vs/workbench/contrib/debug/common/debugModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugModel.ts @@ -744,10 +744,11 @@ export class MemoryRegion extends Disposable implements IMemoryRegion { public readonly onDidInvalidate = this.invalidateEmitter.event; /** @inheritdoc */ - public readonly writable = !!this.session.capabilities.supportsWriteMemoryRequest; + public readonly writable: boolean; constructor(private readonly memoryReference: string, private readonly session: IDebugSession) { super(); + this.writable = !!this.session.capabilities.supportsWriteMemoryRequest; this._register(session.onDidInvalidateMemory(e => { if (e.body.memoryReference === memoryReference) { this.invalidate(e.body.offset, e.body.count - e.body.offset); @@ -1489,6 +1490,7 @@ export class DebugModel extends Disposable implements IDebugModel { } if (s.state === State.Inactive && s.configuration.name === session.configuration.name) { // Make sure to remove all inactive sessions that are using the same configuration as the new session + s.dispose(); return false; } diff --git a/src/vs/workbench/contrib/debug/common/debugStorage.ts b/src/vs/workbench/contrib/debug/common/debugStorage.ts index 5c2a8f485ba..051b1bd0958 100644 --- a/src/vs/workbench/contrib/debug/common/debugStorage.ts +++ b/src/vs/workbench/contrib/debug/common/debugStorage.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../base/common/observable.js'; +import { ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -28,11 +28,11 @@ export interface IChosenEnvironment { } export class DebugStorage extends Disposable { - public readonly breakpoints = observableValue(this, this.loadBreakpoints()); - public readonly functionBreakpoints = observableValue(this, this.loadFunctionBreakpoints()); - public readonly exceptionBreakpoints = observableValue(this, this.loadExceptionBreakpoints()); - public readonly dataBreakpoints = observableValue(this, this.loadDataBreakpoints()); - public readonly watchExpressions = observableValue(this, this.loadWatchExpressions()); + public readonly breakpoints: ISettableObservable; + public readonly functionBreakpoints: ISettableObservable; + public readonly exceptionBreakpoints: ISettableObservable; + public readonly dataBreakpoints: ISettableObservable; + public readonly watchExpressions: ISettableObservable; constructor( @IStorageService private readonly storageService: IStorageService, @@ -41,6 +41,11 @@ export class DebugStorage extends Disposable { @ILogService private readonly logService: ILogService ) { super(); + this.breakpoints = observableValue(this, this.loadBreakpoints()); + this.functionBreakpoints = observableValue(this, this.loadFunctionBreakpoints()); + this.exceptionBreakpoints = observableValue(this, this.loadExceptionBreakpoints()); + this.dataBreakpoints = observableValue(this, this.loadDataBreakpoints()); + this.watchExpressions = observableValue(this, this.loadWatchExpressions()); this._register(storageService.onDidChangeValue(StorageScope.WORKSPACE, undefined, this._store)(e => { if (e.external) { diff --git a/src/vs/workbench/contrib/debug/test/common/mockDebug.ts b/src/vs/workbench/contrib/debug/test/common/mockDebug.ts index 8d3c433daac..4ce86561a40 100644 --- a/src/vs/workbench/contrib/debug/test/common/mockDebug.ts +++ b/src/vs/workbench/contrib/debug/test/common/mockDebug.ts @@ -179,6 +179,10 @@ export class MockSession implements IDebugSession { readonly suppressDebugView = false; readonly autoExpandLazyVariables = false; + dispose(): void { + + } + getMemory(memoryReference: string): IMemoryRegion { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/deprecatedExtensionMigrator/browser/deprecatedExtensionMigrator.contribution.ts b/src/vs/workbench/contrib/deprecatedExtensionMigrator/browser/deprecatedExtensionMigrator.contribution.ts deleted file mode 100644 index 6c1899f0180..00000000000 --- a/src/vs/workbench/contrib/deprecatedExtensionMigrator/browser/deprecatedExtensionMigrator.contribution.ts +++ /dev/null @@ -1,103 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Action } from '../../../../base/common/actions.js'; -import { onUnexpectedError } from '../../../../base/common/errors.js'; -import { isDefined } from '../../../../base/common/types.js'; -import { localize } from '../../../../nls.js'; -import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from '../../../common/contributions.js'; -import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; -import { EnablementState } from '../../../services/extensionManagement/common/extensionManagement.js'; -import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; - -class DeprecatedExtensionMigratorContribution { - constructor( - @IConfigurationService private readonly configurationService: IConfigurationService, - @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, - @IStorageService private readonly storageService: IStorageService, - @INotificationService private readonly notificationService: INotificationService, - @IOpenerService private readonly openerService: IOpenerService - ) { - this.init().catch(onUnexpectedError); - } - - private async init(): Promise { - const bracketPairColorizerId = 'coenraads.bracket-pair-colorizer'; - - await this.extensionsWorkbenchService.queryLocal(); - const extension = this.extensionsWorkbenchService.installed.find(e => e.identifier.id === bracketPairColorizerId); - if ( - !extension || - ((extension.enablementState !== EnablementState.EnabledGlobally) && - (extension.enablementState !== EnablementState.EnabledWorkspace)) - ) { - return; - } - - const state = await this.getState(); - const disablementLogEntry = state.disablementLog.some(d => d.extensionId === bracketPairColorizerId); - - if (disablementLogEntry) { - return; - } - - state.disablementLog.push({ extensionId: bracketPairColorizerId, disablementDateTime: new Date().getTime() }); - await this.setState(state); - - await this.extensionsWorkbenchService.setEnablement(extension, EnablementState.DisabledGlobally); - - const nativeBracketPairColorizationEnabledKey = 'editor.bracketPairColorization.enabled'; - const bracketPairColorizationEnabled = !!this.configurationService.inspect(nativeBracketPairColorizationEnabledKey).user; - - this.notificationService.notify({ - message: localize('bracketPairColorizer.notification', "The extension 'Bracket pair Colorizer' got disabled because it was deprecated."), - severity: Severity.Info, - actions: { - primary: [ - new Action('', localize('bracketPairColorizer.notification.action.uninstall', "Uninstall Extension"), undefined, undefined, () => { - this.extensionsWorkbenchService.uninstall(extension); - }), - ], - secondary: [ - !bracketPairColorizationEnabled ? new Action('', localize('bracketPairColorizer.notification.action.enableNative', "Enable Native Bracket Pair Colorization"), undefined, undefined, () => { - this.configurationService.updateValue(nativeBracketPairColorizationEnabledKey, true, ConfigurationTarget.USER); - }) : undefined, - new Action('', localize('bracketPairColorizer.notification.action.showMoreInfo', "More Info"), undefined, undefined, () => { - this.openerService.open('https://github.com/microsoft/vscode/issues/155179'); - }), - ].filter(isDefined), - } - }); - } - - private readonly storageKey = 'deprecatedExtensionMigrator.state'; - - private async getState(): Promise { - const jsonStr = await this.storageService.get(this.storageKey, StorageScope.APPLICATION, ''); - if (jsonStr === '') { - return { disablementLog: [] }; - } - return JSON.parse(jsonStr) as State; - } - - private async setState(state: State): Promise { - const json = JSON.stringify(state); - await this.storageService.store(this.storageKey, json, StorageScope.APPLICATION, StorageTarget.USER); - } -} - -interface State { - disablementLog: { - extensionId: string; - disablementDateTime: number; - }[]; -} - -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(DeprecatedExtensionMigratorContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts b/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts index cb2e6e81555..4deb48381eb 100644 --- a/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts +++ b/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts @@ -68,6 +68,7 @@ import { EditSessionsStoreClient } from '../common/editSessionsStorageClient.js' import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkspaceIdentityService } from '../../../services/workspaces/common/workspaceIdentityService.js'; import { hashAsync } from '../../../../base/common/hash.js'; +import { ResourceSet } from '../../../../base/common/map.js'; registerSingleton(IEditSessionsLogService, EditSessionsLogService, InstantiationType.Delayed); registerSingleton(IEditSessionsStorageService, EditSessionsWorkbenchService, InstantiationType.Delayed); @@ -685,6 +686,24 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo // Save all saveable editors before building edit session contents await this.editorService.saveAll(); + // Do a first pass over all repositories to ensure that the edit session identity is created for each. + // This may change the working changes that need to be stored later + const createdEditSessionIdentities = new ResourceSet(); + for (const repository of this.scmService.repositories) { + const changedResources = this.getChangedResources(repository); + if (!changedResources.size) { + continue; + } + for (const uri of changedResources) { + const workspaceFolder = this.contextService.getWorkspaceFolder(uri); + if (!workspaceFolder || createdEditSessionIdentities.has(uri)) { + continue; + } + createdEditSessionIdentities.add(uri); + await this.editSessionIdentityService.onWillCreateEditSessionIdentity(workspaceFolder, cancellationToken); + } + } + for (const repository of this.scmService.repositories) { // Look through all resource groups and compute which files were added/modified/deleted const trackedUris = this.getChangedResources(repository); // A URI might appear in more than one resource group @@ -703,8 +722,6 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo continue; } - await this.editSessionIdentityService.onWillCreateEditSessionIdentity(workspaceFolder, cancellationToken); - name = name ?? workspaceFolder.name; const relativeFilePath = relativePath(workspaceFolder.uri, uri) ?? uri.path; diff --git a/src/vs/workbench/contrib/editSessions/common/workspaceStateSync.ts b/src/vs/workbench/contrib/editSessions/common/workspaceStateSync.ts index 0068c223d2b..46acc992b99 100644 --- a/src/vs/workbench/contrib/editSessions/common/workspaceStateSync.ts +++ b/src/vs/workbench/contrib/editSessions/common/workspaceStateSync.ts @@ -48,6 +48,7 @@ class NullEnablementService implements IUserDataSyncEnablementService { canToggleEnablement(): boolean { return true; } setEnablement(_enabled: boolean): void { } isResourceEnabled(_resource: SyncResource): boolean { return true; } + isResourceEnablementConfigured(_resource: SyncResource): boolean { return false; } setResourceEnablement(_resource: SyncResource, _enabled: boolean): void { } getResourceSyncStateVersion(_resource: SyncResource): string | undefined { return undefined; } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts index 6c235d89393..aaaf4865076 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts @@ -30,7 +30,7 @@ import { localize } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, IScopedContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { computeSize, IExtensionGalleryService, IGalleryExtension, ILocalExtension } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { computeSize, FilterType, IExtensionGalleryService, IGalleryExtension, ILocalExtension } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { areSameExtensions } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; import { ExtensionType, IExtensionManifest } from '../../../../platform/extensions/common/extensions.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -86,6 +86,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { ByteSize, IFileService } from '../../../../platform/files/common/files.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; +import { IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; function toDateString(date: Date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}, ${date.toLocaleTimeString(language, { hourCycle: 'h23' })}`; @@ -1030,6 +1031,7 @@ class AdditionalDetailsWidget extends Disposable { @IFileService private readonly fileService: IFileService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, + @IExtensionGalleryManifestService private readonly extensionGalleryManifestService: IExtensionGalleryManifestService, ) { super(); this.render(extension); @@ -1059,10 +1061,17 @@ class AdditionalDetailsWidget extends Disposable { const categoriesContainer = append(container, $('.categories-container.additional-details-element')); append(categoriesContainer, $('.additional-details-title', undefined, localize('categories', "Categories"))); const categoriesElement = append(categoriesContainer, $('.categories')); - for (const category of extension.categories) { - this.disposables.add(onClick(append(categoriesElement, $('span.category', { tabindex: '0' }, category)), - () => this.extensionsWorkbenchService.openSearch(`@category:"${category}"`))); - } + this.extensionGalleryManifestService.getExtensionGalleryManifest() + .then(manifest => { + const hasCategoryFilter = manifest?.capabilities.extensionQuery.filtering?.some(({ name }) => name === FilterType.Category); + for (const category of extension.categories) { + const categoryElement = append(categoriesElement, $('span.category', { tabindex: '0' }, category)); + if (hasCategoryFilter) { + categoryElement.classList.add('clickable'); + this.disposables.add(onClick(categoryElement, () => this.extensionsWorkbenchService.openSearch(`@category:"${category}"`))); + } + } + }); } } @@ -1299,14 +1308,12 @@ registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) = const buttonHoverBackgroundColor = theme.getColor(buttonHoverBackground); if (buttonHoverBackgroundColor) { - collector.addRule(`.monaco-workbench .extension-editor .content > .details > .additional-details-container .categories-container > .categories > .category:hover { background-color: ${buttonHoverBackgroundColor}; border-color: ${buttonHoverBackgroundColor}; }`); - collector.addRule(`.monaco-workbench .extension-editor .content > .details > .additional-details-container .tags-container > .tags > .tag:hover { background-color: ${buttonHoverBackgroundColor}; border-color: ${buttonHoverBackgroundColor}; }`); + collector.addRule(`.monaco-workbench .extension-editor .content > .details > .additional-details-container .categories-container > .categories > .category.clickable:hover { background-color: ${buttonHoverBackgroundColor}; border-color: ${buttonHoverBackgroundColor}; }`); } const buttonForegroundColor = theme.getColor(buttonForeground); if (buttonForegroundColor) { - collector.addRule(`.monaco-workbench .extension-editor .content > .details > .additional-details-container .categories-container > .categories > .category:hover { color: ${buttonForegroundColor}; }`); - collector.addRule(`.monaco-workbench .extension-editor .content > .details > .additional-details-container .tags-container > .tags > .tag:hover { color: ${buttonForegroundColor}; }`); + collector.addRule(`.monaco-workbench .extension-editor .content > .details > .additional-details-container .categories-container > .categories > .category.clickable:hover { color: ${buttonForegroundColor}; }`); } }); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts index 323622a7ac4..b70a49c4016 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts @@ -74,7 +74,7 @@ class RecommendationsNotification extends Disposable { show(): void { if (!this.notificationHandle) { - this.updateNotificationHandle(this.notificationService.prompt(this.severity, this.message, this.choices, { sticky: true, onCancel: () => this.cancelled = true })); + this.updateNotificationHandle(this.notificationService.prompt(this.severity, this.message, this.choices, { sticky: true, priority: NotificationPriority.OPTIONAL, onCancel: () => this.cancelled = true })); } } diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 19926de3171..adc061690e0 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -8,10 +8,10 @@ import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { MenuRegistry, MenuId, registerAction2, Action2, IMenuItem, IAction2Options } from '../../../../platform/actions/common/actions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { ExtensionsLocalizedLabel, IExtensionManagementService, IExtensionGalleryService, PreferencesLocalizedLabel, EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, UseUnpkgResourceApiConfigKey, AllowedExtensionsConfigKey, SortBy, FilterType } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { ExtensionsLocalizedLabel, IExtensionManagementService, IExtensionGalleryService, PreferencesLocalizedLabel, EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, UseUnpkgResourceApiConfigKey, SortBy, FilterType, VerifyExtensionSignatureConfigKey } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { EnablementState, IExtensionManagementServerService, IPublisherInfo, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; -import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from '../../../common/contributions.js'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { VIEWLET_ID, IExtensionsWorkbenchService, IExtensionsViewPaneContainer, TOGGLE_IGNORE_EXTENSION_ACTION_ID, INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID, WORKSPACE_RECOMMENDATIONS_VIEW_ID, IWorkspaceRecommendedExtensionsView, AutoUpdateConfigurationKey, HasOutdatedExtensionsContext, SELECT_INSTALL_VSIX_EXTENSION_COMMAND_ID, LIST_WORKSPACE_UNSUPPORTED_EXTENSIONS_COMMAND_ID, ExtensionEditorTab, THEME_ACTIONS_GROUP, INSTALL_ACTIONS_GROUP, OUTDATED_EXTENSIONS_VIEW_ID, CONTEXT_HAS_GALLERY, extensionsSearchActionsMenu, UPDATE_ACTIONS_GROUP, IExtensionArg, ExtensionRuntimeActionType, EXTENSIONS_CATEGORY, AutoRestartConfigurationKey } from '../common/extensions.js'; import { InstallSpecificVersionOfExtensionAction, ConfigureWorkspaceRecommendedExtensionsAction, ConfigureWorkspaceFolderRecommendedExtensionsAction, SetColorThemeAction, SetFileIconThemeAction, SetProductIconThemeAction, ClearLanguageAction, ToggleAutoUpdateForExtensionAction, ToggleAutoUpdatesForPublisherAction, TogglePreReleaseExtensionAction, InstallAnotherVersionAction, InstallAction } from './extensionsActions.js'; @@ -80,7 +80,9 @@ import { IConfigurationMigrationRegistry, Extensions as ConfigurationMigrationEx import { IProductService } from '../../../../platform/product/common/productService.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import product from '../../../../platform/product/common/product.js'; -import { IExtensionGalleryManifest, IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { ExtensionGalleryResourceType, ExtensionGalleryServiceUrlConfigKey, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifest, IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { ILanguageModelToolsService } from '../../chat/common/languageModelToolsService.js'; +import { SearchExtensionsTool, SearchExtensionsToolData } from '../common/searchExtensionsTool.js'; // Singletons registerSingleton(IExtensionsWorkbenchService, ExtensionsWorkbenchService, InstantiationType.Eager /* Auto updates extensions */); @@ -260,7 +262,7 @@ Registry.as(ConfigurationExtensions.Configuration) description: localize('extensionsInQuickAccess', "When enabled, extensions can be searched for via Quick Access and report issues from there."), default: true }, - 'extensions.verifySignature': { + [VerifyExtensionSignatureConfigKey]: { type: 'boolean', description: localize('extensions.verifySignature', "When enabled, extensions are verified to be signed before getting installed."), default: true, @@ -280,69 +282,18 @@ Registry.as(ConfigurationExtensions.Configuration) scope: ConfigurationScope.APPLICATION, tags: ['onExp', 'usesOnlineServices'] }, - [AllowedExtensionsConfigKey]: { - // Note: Type is set only to object because to support policies generation during build time, where single type is expected. - type: 'object', - markdownDescription: localize('extensions.allowed', "Specify a list of extensions that are allowed to use. This helps maintain a secure and consistent development environment by restricting the use of unauthorized extensions. For more information on how to configure this setting, please visit the [Configure Allowed Extensions](https://code.visualstudio.com/docs/setup/enterprise#_configure-allowed-extensions) section."), - default: '*', - defaultSnippets: [{ - body: {}, - description: localize('extensions.allowed.none', "No extensions are allowed."), - }, { - body: { - '*': true - }, - description: localize('extensions.allowed.all', "All extensions are allowed."), - }], + [ExtensionGalleryServiceUrlConfigKey]: { + type: 'string', + description: localize('extensions.gallery.serviceUrl', "Configure the Marketplace service URL to connect to"), + default: '', scope: ConfigurationScope.APPLICATION, + tags: ['usesOnlineServices'], + included: false, policy: { - name: 'AllowedExtensions', - minimumVersion: '1.96', + name: 'ExtensionGalleryServiceUrl', + minimumVersion: '1.99', }, - additionalProperties: false, - patternProperties: { - '([a-z0-9A-Z][a-z0-9-A-Z]*)\\.([a-z0-9A-Z][a-z0-9-A-Z]*)$': { - anyOf: [ - { - type: ['boolean', 'string'], - enum: [true, false, 'stable'], - description: localize('extensions.allow.description', "Allow or disallow the extension."), - enumDescriptions: [ - localize('extensions.allowed.enable.desc', "Extension is allowed."), - localize('extensions.allowed.disable.desc', "Extension is not allowed."), - localize('extensions.allowed.disable.stable.desc', "Allow only stable versions of the extension."), - ], - }, - { - type: 'array', - items: { - type: 'string', - }, - description: localize('extensions.allow.version.description', "Allow or disallow specific versions of the extension. To specifcy a platform specific version, use the format `platform@1.2.3`, e.g. `win32-x64@1.2.3`. Supported platforms are `win32-x64`, `win32-arm64`, `linux-x64`, `linux-arm64`, `linux-armhf`, `alpine-x64`, `alpine-arm64`, `darwin-x64`, `darwin-arm64`"), - }, - ] - }, - '([a-z0-9A-Z][a-z0-9-A-Z]*)$': { - type: ['boolean', 'string'], - enum: [true, false, 'stable'], - description: localize('extension.publisher.allow.description', "Allow or disallow all extensions from the publisher."), - enumDescriptions: [ - localize('extensions.publisher.allowed.enable.desc', "All extensions from the publisher are allowed."), - localize('extensions.publisher.allowed.disable.desc', "All extensions from the publisher are not allowed."), - localize('extensions.publisher.allowed.disable.stable.desc', "Allow only stable versions of the extensions from the publisher."), - ], - }, - '\\*': { - type: 'boolean', - enum: [true, false], - description: localize('extensions.allow.all.description', "Allow or disallow all extensions."), - enumDescriptions: [ - localize('extensions.allow.all.enable', "Allow all extensions."), - localize('extensions.allow.all.disable', "Disallow all extensions.") - ], - } - } - } + }, } }); @@ -497,7 +448,7 @@ CommandsRegistry.registerCommand({ throw new Error(localize('notInstalled', "Extension '{0}' is not installed. Make sure you use the full extension ID, including the publisher, e.g.: ms-dotnettools.csharp.", id)); } if (extensionToUninstall.isBuiltin) { - throw new Error(localize('builtin', "Extension '{0}' is a Built-in extension and cannot be installed", id)); + throw new Error(localize('builtin', "Extension '{0}' is a Built-in extension and cannot be uninstalled", id)); } try { @@ -549,7 +500,9 @@ export const CONTEXT_HAS_REMOTE_SERVER = new RawContextKey('hasRemoteSe export const CONTEXT_HAS_WEB_SERVER = new RawContextKey('hasWebServer', false); const CONTEXT_GALLERY_SORT_CAPABILITIES = new RawContextKey('gallerySortCapabilities', ''); const CONTEXT_GALLERY_FILTER_CAPABILITIES = new RawContextKey('galleryFilterCapabilities', ''); -const CONTEXT_GALLERY_ALL_REPOSITORY_SIGNED = new RawContextKey('galleryAllRepositorySigned', false); +const CONTEXT_GALLERY_ALL_PUBLIC_REPOSITORY_SIGNED = new RawContextKey('galleryAllPublicRepositorySigned', false); +const CONTEXT_GALLERY_ALL_PRIVATE_REPOSITORY_SIGNED = new RawContextKey('galleryAllPrivateRepositorySigned', false); +const CONTEXT_GALLERY_HAS_EXTENSION_LINK = new RawContextKey('galleryHasExtensionLink', false); async function runAction(action: IAction): Promise { try { @@ -615,7 +568,9 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi private async registerGalleryCapabilitiesContexts(extensionGalleryManifest: IExtensionGalleryManifest | null): Promise { CONTEXT_GALLERY_SORT_CAPABILITIES.bindTo(this.contextKeyService).set(`_${extensionGalleryManifest?.capabilities.extensionQuery.sorting?.map(s => s.name)?.join('_')}_UpdateDate_`); CONTEXT_GALLERY_FILTER_CAPABILITIES.bindTo(this.contextKeyService).set(`_${extensionGalleryManifest?.capabilities.extensionQuery.filtering?.map(s => s.name)?.join('_')}_`); - CONTEXT_GALLERY_ALL_REPOSITORY_SIGNED.bindTo(this.contextKeyService).set(!!extensionGalleryManifest?.capabilities?.signing?.allRepositorySigned); + CONTEXT_GALLERY_ALL_PUBLIC_REPOSITORY_SIGNED.bindTo(this.contextKeyService).set(!!extensionGalleryManifest?.capabilities?.signing?.allPublicRepositorySigned); + CONTEXT_GALLERY_ALL_PRIVATE_REPOSITORY_SIGNED.bindTo(this.contextKeyService).set(!!extensionGalleryManifest?.capabilities?.signing?.allPrivateRepositorySigned); + CONTEXT_GALLERY_HAS_EXTENSION_LINK.bindTo(this.contextKeyService).set(!!(extensionGalleryManifest && getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionDetailsViewUri))); } private registerQuickAccessProvider(): void { @@ -1539,7 +1494,8 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi menu: { id: MenuId.ExtensionContext, group: '0_install', - when: ContextKeyExpr.and(ContextKeyExpr.equals('extensionStatus', 'uninstalled'), ContextKeyExpr.has('isGalleryExtension'), ContextKeyExpr.not('extensionDisallowInstall'), ContextKeyExpr.has('extensionIsUnsigned'), CONTEXT_GALLERY_ALL_REPOSITORY_SIGNED), + when: ContextKeyExpr.and(ContextKeyExpr.equals('extensionStatus', 'uninstalled'), ContextKeyExpr.has('isGalleryExtension'), ContextKeyExpr.not('extensionDisallowInstall'), ContextKeyExpr.has('extensionIsUnsigned'), + ContextKeyExpr.or(ContextKeyExpr.and(CONTEXT_GALLERY_ALL_PUBLIC_REPOSITORY_SIGNED, ContextKeyExpr.not('extensionIsPrivate')), ContextKeyExpr.and(CONTEXT_GALLERY_ALL_PRIVATE_REPOSITORY_SIGNED, ContextKeyExpr.has('extensionIsPrivate')))), order: 1 }, run: async (accessor: ServicesAccessor, extensionId: string) => { @@ -1661,7 +1617,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi menu: { id: MenuId.ExtensionContext, group: '1_copy', - when: ContextKeyExpr.has('isGalleryExtension'), + when: ContextKeyExpr.and(ContextKeyExpr.has('isGalleryExtension'), CONTEXT_GALLERY_HAS_EXTENSION_LINK), }, run: async (accessor: ServicesAccessor, _, extension: IExtensionArg) => { const clipboardService = accessor.get(IClipboardService); @@ -2012,6 +1968,21 @@ class TrustedPublishersInitializer implements IWorkbenchContribution { } } +class ExtensionToolsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'extensions.chat.toolsContribution'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + const searchExtensionsTool = instantiationService.createInstance(SearchExtensionsTool); + this._register(toolsService.registerToolData(SearchExtensionsToolData)); + this._register(toolsService.registerToolImplementation(SearchExtensionsToolData.id, searchExtensionsTool)); + } +} + const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); workbenchRegistry.registerWorkbenchContribution(ExtensionsContributions, LifecyclePhase.Restored); workbenchRegistry.registerWorkbenchContribution(StatusUpdater, LifecyclePhase.Eventually); @@ -2028,6 +1999,8 @@ if (isWeb) { workbenchRegistry.registerWorkbenchContribution(ExtensionStorageCleaner, LifecyclePhase.Eventually); } +registerWorkbenchContribution2(ExtensionToolsContribution.ID, ExtensionToolsContribution, WorkbenchPhase.AfterRestored); + // Running Extensions registerAction2(ShowRuntimeExtensionsAction); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 7b344ef9fb6..a9002dc941e 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -14,7 +14,7 @@ import { IContextMenuService } from '../../../../platform/contextview/browser/co import { disposeIfDisposable } from '../../../../base/common/lifecycle.js'; import { IExtension, ExtensionState, IExtensionsWorkbenchService, IExtensionContainer, TOGGLE_IGNORE_EXTENSION_ACTION_ID, SELECT_INSTALL_VSIX_EXTENSION_COMMAND_ID, THEME_ACTIONS_GROUP, INSTALL_ACTIONS_GROUP, UPDATE_ACTIONS_GROUP, ExtensionEditorTab, ExtensionRuntimeActionType, IExtensionArg, AutoUpdateConfigurationKey } from '../common/extensions.js'; import { ExtensionsConfigurationInitialContent } from '../common/extensionsFileTemplate.js'; -import { IGalleryExtension, IExtensionGalleryService, ILocalExtension, InstallOptions, InstallOperation, ExtensionManagementErrorCode, IAllowedExtensionsService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { IGalleryExtension, IExtensionGalleryService, ILocalExtension, InstallOptions, InstallOperation, ExtensionManagementErrorCode, IAllowedExtensionsService, shouldRequireRepositorySignatureFor } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { IWorkbenchExtensionEnablementService, EnablementState, IExtensionManagementServerService, IExtensionManagementServer, IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { ExtensionRecommendationReason, IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; import { areSameExtensions, getExtensionId } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; @@ -73,6 +73,7 @@ import { IUpdateService } from '../../../../platform/update/common/update.js'; import { ActionWithDropdownActionViewItem, IActionWithDropdownActionViewItemOptions } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { IAuthenticationUsageService } from '../../../services/authentication/browser/authenticationUsageService.js'; import { IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { IWorkbenchIssueService } from '../../issue/common/issue.js'; export class PromptExtensionInstallFailureAction extends Action { @@ -92,6 +93,7 @@ export class PromptExtensionInstallFailureAction extends Action { @IInstantiationService private readonly instantiationService: IInstantiationService, @IExtensionGalleryService private readonly galleryService: IExtensionGalleryService, @IExtensionManifestPropertiesService private readonly extensionManifestPropertiesService: IExtensionManifestPropertiesService, + @IWorkbenchIssueService private readonly workbenchIssueService: IWorkbenchIssueService, ) { super('extension.promptExtensionInstallFailure'); } @@ -158,7 +160,7 @@ export class PromptExtensionInstallFailureAction extends Action { return; } - if (ExtensionManagementErrorCode.SignatureVerificationFailed === (this.error.name) || ExtensionManagementErrorCode.SignatureVerificationInternal === (this.error.name)) { + if (ExtensionManagementErrorCode.SignatureVerificationFailed === (this.error.name)) { await this.dialogService.prompt({ type: 'error', message: localize('verification failed', "Cannot install '{0}' extension because {1} cannot verify the extension signature", this.extension.displayName, this.productService.nameLong), @@ -179,6 +181,33 @@ export class PromptExtensionInstallFailureAction extends Action { return; } + if (ExtensionManagementErrorCode.SignatureVerificationInternal === (this.error.name)) { + await this.dialogService.prompt({ + type: 'error', + message: localize('verification failed', "Cannot install '{0}' extension because {1} cannot verify the extension signature", this.extension.displayName, this.productService.nameLong), + detail: getErrorMessage(this.error), + buttons: [{ + label: localize('learn more', "Learn More"), + run: () => this.openerService.open('https://code.visualstudio.com/docs/editor/extension-marketplace#_the-extension-signature-cannot-be-verified-by-vs-code') + }, { + label: localize('report issue', "Report Issue"), + run: () => this.workbenchIssueService.openReporter({ + issueTitle: localize('report issue title', "Extension Signature Verification Failed: {0}", this.extension.displayName), + issueBody: localize('report issue body', "Please include following log `F1 > Open View... > Shared` below.\n\n") + }) + }, { + label: localize('install donot verify', "Install Anyway (Don't Verify Signature)"), + run: () => { + const installAction = this.instantiationService.createInstance(InstallAction, { ...this.options, donotVerifySignature: true, }); + installAction.extension = this.extension; + return installAction.run(); + } + }], + cancelButton: true + }); + return; + } + const operationMessage = this.installOperation === InstallOperation.Update ? localize('update operation', "Error while updating '{0}' extension.", this.extension.displayName || this.extension.identifier.id) : localize('install operation', "Error while installing '{0}' extension.", this.extension.displayName || this.extension.identifier.id); let additionalMessage; @@ -470,7 +499,7 @@ export class InstallAction extends ExtensionAction { return; } - if (this.extension.gallery && !this.extension.gallery.isSigned && (await this.extensionGalleryManifestService.getExtensionGalleryManifest())?.capabilities.signing?.allRepositorySigned) { + if (this.extension.gallery && !this.extension.gallery.isSigned && shouldRequireRepositorySignatureFor(this.extension.private, await this.extensionGalleryManifestService.getExtensionGalleryManifest())) { const { result } = await this.dialogService.prompt({ type: Severity.Warning, message: localize('not signed', "'{0}' is an extension from an unknown source. Are you sure you want to install?", this.extension.displayName), @@ -999,17 +1028,19 @@ export class UpdateAction extends ExtensionAction { } } - alert(localize('updateExtensionStart', "Updating extension {0} to version {1} started.", this.extension.displayName, this.extension.latestVersion)); - return this.install(this.extension); - } - - private async install(extension: IExtension): Promise { - const options = extension.local?.preRelease ? { installPreReleaseVersion: true } : undefined; + const installOptions: InstallOptions = {}; + if (this.extension.local?.source === 'vsix' && this.extension.local.pinned) { + installOptions.pinned = false; + } + if (this.extension.local?.preRelease) { + installOptions.installPreReleaseVersion = true; + } try { - await this.extensionsWorkbenchService.install(extension, options); - alert(localize('updateExtensionComplete', "Updating extension {0} to version {1} completed.", extension.displayName, extension.latestVersion)); + alert(localize('updateExtensionStart', "Updating extension {0} to version {1} started.", this.extension.displayName, this.extension.latestVersion)); + await this.extensionsWorkbenchService.install(this.extension, installOptions); + alert(localize('updateExtensionComplete', "Updating extension {0} to version {1} completed.", this.extension.displayName, this.extension.latestVersion)); } catch (err) { - this.instantiationService.createInstance(PromptExtensionInstallFailureAction, extension, options, extension.latestVersion, InstallOperation.Update, err).run(); + this.instantiationService.createInstance(PromptExtensionInstallFailureAction, this.extension, installOptions, this.extension.latestVersion, InstallOperation.Update, err).run(); } } } @@ -1264,6 +1295,7 @@ async function getContextMenuActionsGroups(extension: IExtension | undefined | n cksOverlay.push(['isExtensionAllowed', allowedExtensionsService.isAllowed({ id: extension.identifier.id, publisherDisplayName: extension.publisherDisplayName }) === true]); cksOverlay.push(['isPreReleaseExtensionAllowed', allowedExtensionsService.isAllowed({ id: extension.identifier.id, publisherDisplayName: extension.publisherDisplayName, prerelease: true }) === true]); cksOverlay.push(['extensionIsUnsigned', extension.gallery && !extension.gallery.isSigned]); + cksOverlay.push(['extensionIsPrivate', extension.gallery?.private]); const [colorThemes, fileIconThemes, productIconThemes, extensionUsesAuth] = await Promise.all([workbenchThemeService.getColorThemes(), workbenchThemeService.getFileIconThemes(), workbenchThemeService.getProductIconThemes(), authenticationUsageService.extensionUsesAuth(extension.identifier.id.toLowerCase())]); cksOverlay.push(['extensionHasColorThemes', colorThemes.some(theme => isThemeFromExtension(theme, extension))]); @@ -2552,7 +2584,7 @@ export class ExtensionStatusAction extends ExtensionAction { return; } - if (this.extension.state === ExtensionState.Uninstalled && this.extension.gallery && !this.extension.gallery.isSigned && (await this.extensionGalleryManifestService.getExtensionGalleryManifest())?.capabilities.signing?.allRepositorySigned) { + if (this.extension.state === ExtensionState.Uninstalled && this.extension.gallery && !this.extension.gallery.isSigned && shouldRequireRepositorySignatureFor(this.extension.private, await this.extensionGalleryManifestService.getExtensionGalleryManifest())) { this.updateStatus({ icon: warningIcon, message: new MarkdownString(localize('not signed tooltip', "This extension is not signed by the Extension Marketplace.")) }, true); return; } @@ -2578,7 +2610,7 @@ export class ExtensionStatusAction extends ExtensionAction { return; } - if (this.extension.outdated && this.extensionsWorkbenchService.isAutoUpdateEnabledFor(this.extension)) { + if (this.extension.outdated) { const message = await this.extensionsWorkbenchService.shouldRequireConsentToUpdate(this.extension); if (message) { const markdown = new MarkdownString(); @@ -2610,11 +2642,13 @@ export class ExtensionStatusAction extends ExtensionAction { return; } - // Extension is disabled by its dependency - const result = this.allowedExtensionsService.isAllowed(this.extension.local); - if (result !== true) { - this.updateStatus({ icon: warningIcon, message: new MarkdownString(localize('disabled - not allowed', "This extension is disabled because {0}", result.value)) }, true); - return; + // Extension is disabled by allowed list + if (this.extension.enablementState === EnablementState.DisabledByAllowlist) { + const result = this.allowedExtensionsService.isAllowed(this.extension.local); + if (result !== true) { + this.updateStatus({ icon: warningIcon, message: new MarkdownString(localize('disabled - not allowed', "This extension is disabled because {0}", result.value)) }, true); + return; + } } // Extension is disabled by environment diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts index 82f9f53d501..f327b08b0d5 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts @@ -5,17 +5,17 @@ import * as dom from '../../../../base/browser/dom.js'; import { localize } from '../../../../nls.js'; -import { IDisposable, dispose, Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Action } from '../../../../base/common/actions.js'; -import { IExtensionsWorkbenchService, IExtension } from '../common/extensions.js'; +import { IDisposable, dispose, Disposable, DisposableStore, toDisposable, isDisposable } from '../../../../base/common/lifecycle.js'; +import { Action, ActionRunner, IAction, Separator } from '../../../../base/common/actions.js'; +import { IExtensionsWorkbenchService, IExtension, IExtensionsViewState } from '../common/extensions.js'; import { Event } from '../../../../base/common/event.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IListService, WorkbenchAsyncDataTree } from '../../../../platform/list/browser/listService.js'; +import { IListService, IWorkbenchPagedListOptions, WorkbenchAsyncDataTree, WorkbenchPagedList } from '../../../../platform/list/browser/listService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { registerThemingParticipant, IColorTheme, ICssStyleCollector } from '../../../../platform/theme/common/themeService.js'; import { IAsyncDataSource, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; -import { IListVirtualDelegate, IListRenderer } from '../../../../base/browser/ui/list/list.js'; +import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { isNonEmptyArray } from '../../../../base/common/arrays.js'; import { Delegate, Renderer } from './extensionsList.js'; @@ -27,6 +27,125 @@ import { IListStyles } from '../../../../base/browser/ui/list/listWidget.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IStyleOverride } from '../../../../platform/theme/browser/defaultStyles.js'; import { getAriaLabelForExtension } from './extensionsViews.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../common/views.js'; +import { IWorkbenchLayoutService, Position } from '../../../services/layout/browser/layoutService.js'; +import { areSameExtensions } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; +import { ExtensionAction, getContextMenuActions, ManageExtensionAction } from './extensionsActions.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { getLocationBasedViewColors } from '../../../browser/parts/views/viewPane.js'; +import { DelayedPagedModel, IPagedModel } from '../../../../base/common/paging.js'; + +export class ExtensionsList extends Disposable { + + readonly list: WorkbenchPagedList; + private readonly contextMenuActionRunner = this._register(new ActionRunner()); + + constructor( + parent: HTMLElement, + viewId: string, + options: Partial>, + extensionsViewState: IExtensionsViewState, + @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @INotificationService notificationService: INotificationService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + this._register(this.contextMenuActionRunner.onDidRun(({ error }) => error && notificationService.error(error))); + const delegate = new Delegate(); + const renderer = instantiationService.createInstance(Renderer, extensionsViewState, { + hoverOptions: { + position: () => { + const viewLocation = viewDescriptorService.getViewLocationById(viewId); + if (viewLocation === ViewContainerLocation.Sidebar) { + return layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT; + } + if (viewLocation === ViewContainerLocation.AuxiliaryBar) { + return layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.LEFT : HoverPosition.RIGHT; + } + return HoverPosition.RIGHT; + } + } + }); + this.list = instantiationService.createInstance(WorkbenchPagedList, `${viewId}-Extensions`, parent, delegate, [renderer], { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel(extension: IExtension | null): string { + return getAriaLabelForExtension(extension); + }, + getWidgetAriaLabel(): string { + return localize('extensions', "Extensions"); + } + }, + overrideStyles: getLocationBasedViewColors(viewDescriptorService.getViewLocationById(viewId)).listOverrideStyles, + openOnSingleClick: true, + ...options + }) as WorkbenchPagedList; + this._register(this.list.onContextMenu(e => this.onContextMenu(e), this)); + this._register(this.list); + + this._register(Event.debounce(Event.filter(this.list.onDidOpen, e => e.element !== null), (_, event) => event, 75, true)(options => { + this.openExtension(options.element!, { sideByside: options.sideBySide, ...options.editorOptions }); + })); + } + + setModel(model: IPagedModel) { + this.list.model = new DelayedPagedModel(model); + } + + layout(height?: number, width?: number): void { + this.list.layout(height, width); + } + + private openExtension(extension: IExtension, options: { sideByside?: boolean; preserveFocus?: boolean; pinned?: boolean }): void { + extension = this.extensionsWorkbenchService.local.filter(e => areSameExtensions(e.identifier, extension.identifier))[0] || extension; + this.extensionsWorkbenchService.open(extension, options); + } + + private async onContextMenu(e: IListContextMenuEvent): Promise { + if (e.element) { + const disposables = new DisposableStore(); + const manageExtensionAction = disposables.add(this.instantiationService.createInstance(ManageExtensionAction)); + const extension = e.element ? this.extensionsWorkbenchService.local.find(local => areSameExtensions(local.identifier, e.element!.identifier) && (!e.element!.server || e.element!.server === local.server)) || e.element + : e.element; + manageExtensionAction.extension = extension; + let groups: IAction[][] = []; + if (manageExtensionAction.enabled) { + groups = await manageExtensionAction.getActionGroups(); + } else if (extension) { + groups = await getContextMenuActions(extension, this.contextKeyService, this.instantiationService); + groups.forEach(group => group.forEach(extensionAction => { + if (extensionAction instanceof ExtensionAction) { + extensionAction.extension = extension; + } + })); + } + const actions: IAction[] = []; + for (const menuActions of groups) { + for (const menuAction of menuActions) { + actions.push(menuAction); + if (isDisposable(menuAction)) { + disposables.add(menuAction); + } + } + actions.push(new Separator()); + } + actions.pop(); + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => actions, + actionRunner: this.contextMenuActionRunner, + onHide: () => disposables.dispose() + }); + } + } +} export class ExtensionsGridView extends Disposable { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts index 9b840de9a61..d7c6d3c70be 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts @@ -34,7 +34,7 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IContextKeyService, ContextKeyExpr, RawContextKey, IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { INotificationService, NotificationPriority } from '../../../../platform/notification/common/notification.js'; +import { INotificationService, IPromptChoice, NotificationPriority } from '../../../../platform/notification/common/notification.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; @@ -67,6 +67,8 @@ import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js import { KeyCode } from '../../../../base/common/keyCodes.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { IExtensionGalleryManifest, IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { URI } from '../../../../base/common/uri.js'; export const DefaultViewsContext = new RawContextKey('defaultExtensionViews', true); export const ExtensionsSortByContext = new RawContextKey('extensionsSortByValue', ''); @@ -503,6 +505,7 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE private searchBox: SuggestEnabledInput | undefined; private notificationContainer: HTMLElement | undefined; private readonly searchViewletState: MementoObject; + private extensionGalleryManifest: IExtensionGalleryManifest | null = null; constructor( @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @@ -510,6 +513,7 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE @IProgressService private readonly progressService: IProgressService, @IInstantiationService instantiationService: IInstantiationService, @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, + @IExtensionGalleryManifestService extensionGalleryManifestService: IExtensionGalleryManifestService, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @IExtensionManagementServerService private readonly extensionManagementServerService: IExtensionManagementServerService, @INotificationService private readonly notificationService: INotificationService, @@ -551,6 +555,15 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE this._register(this.paneCompositeService.onDidPaneCompositeOpen(e => { if (e.viewContainerLocation === ViewContainerLocation.Sidebar) { this.onViewletOpen(e.composite); } }, this)); this._register(extensionsWorkbenchService.onReset(() => this.refresh())); this.searchViewletState = this.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); + + extensionGalleryManifestService.getExtensionGalleryManifest() + .then(galleryManifest => { + this.extensionGalleryManifest = galleryManifest; + this._register(extensionGalleryManifestService.onDidChangeExtensionGalleryManifest(galleryManifest => { + this.extensionGalleryManifest = galleryManifest; + this.refresh(); + })); + }); } get searchValue(): string | undefined { @@ -581,7 +594,7 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE else if (/sort:/.test(item)) { return 'c'; } else { return 'd'; } }, - provideResults: (query: string) => Query.suggestions(query) + provideResults: (query: string) => Query.suggestions(query, this.extensionGalleryManifest) }, placeholder, 'extensions:searchinput', { placeholderText: placeholder, value: searchValue })); this.notificationContainer = append(this.header, $('.notification-container.hidden', { 'tabindex': '0' })); @@ -974,6 +987,7 @@ export class MaliciousExtensionChecker implements IWorkbenchContribution { @IHostService private readonly hostService: IHostService, @ILogService private readonly logService: ILogService, @INotificationService private readonly notificationService: INotificationService, + @ICommandService private readonly commandService: ICommandService, ) { this.loopCheckForMaliciousExtensions(); } @@ -986,30 +1000,42 @@ export class MaliciousExtensionChecker implements IWorkbenchContribution { private async checkForMaliciousExtensions(): Promise { try { - const maliciousExtensions: ILocalExtension[] = []; + const maliciousExtensions: [ILocalExtension, string | undefined][] = []; let shouldRestartExtensions = false; let shouldReloadWindow = false; for (const extension of this.extensionsWorkbenchService.installed) { if (extension.isMalicious && extension.local) { - maliciousExtensions.push(extension.local); + maliciousExtensions.push([extension.local, extension.maliciousInfoLink]); shouldRestartExtensions = shouldRestartExtensions || extension.runtimeState?.action === ExtensionRuntimeActionType.RestartExtensions; shouldReloadWindow = shouldReloadWindow || extension.runtimeState?.action === ExtensionRuntimeActionType.ReloadWindow; } } if (maliciousExtensions.length) { - await this.extensionsManagementService.uninstallExtensions(maliciousExtensions.map(e => ({ extension: e, options: { remove: true } }))); - this.notificationService.prompt( - Severity.Warning, - localize('malicious warning', "The following extensions were found to be problematic and have been uninstalled: {0}", maliciousExtensions.map(e => e.identifier.id).join(', ')), - shouldRestartExtensions || shouldReloadWindow ? [{ - label: shouldRestartExtensions ? localize('restartNow', "Restart Extensions") : localize('reloadNow', "Reload Now"), - run: () => shouldRestartExtensions ? this.extensionsWorkbenchService.updateRunningExtensions() : this.hostService.reload() - }] : [], - { - sticky: true, - priority: NotificationPriority.URGENT + await this.extensionsManagementService.uninstallExtensions(maliciousExtensions.map(e => ({ extension: e[0], options: { remove: true } }))); + for (const [extension, link] of maliciousExtensions) { + const buttons: IPromptChoice[] = []; + if (shouldRestartExtensions || shouldReloadWindow) { + buttons.push({ + label: shouldRestartExtensions ? localize('restartNow', "Restart Extensions") : localize('reloadNow', "Reload Now"), + run: () => shouldRestartExtensions ? this.extensionsWorkbenchService.updateRunningExtensions() : this.hostService.reload() + }); } - ); + if (link) { + buttons.push({ + label: localize('learnMore', "Learn More"), + run: () => this.commandService.executeCommand('vscode.open', URI.parse(link)) + }); + } + this.notificationService.prompt( + Severity.Warning, + localize('malicious warning', "The extension '{0}' was found to be problematic and has been uninstalled", extension.manifest.displayName || extension.identifier.id), + buttons, + { + sticky: true, + priority: NotificationPriority.URGENT + } + ); + } } } catch (err) { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts index dd378644f9c..f6ece8cd747 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts @@ -4,10 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; -import { Disposable, DisposableStore, isDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { isCancellationError, getErrorMessage, CancellationError } from '../../../../base/common/errors.js'; -import { createErrorWithActions } from '../../../../base/common/errorMessage.js'; import { PagedModel, IPagedModel, DelayedPagedModel, IPager } from '../../../../base/common/paging.js'; import { SortOrder, IQueryOptions as IGalleryQueryOptions, SortBy as GallerySortBy, InstallExtensionInfo, ExtensionGalleryErrorCode, ExtensionGalleryError } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { IExtensionManagementServer, IExtensionManagementServerService, EnablementState, IWorkbenchExtensionManagementService, IWorkbenchExtensionEnablementService } from '../../../services/extensionManagement/common/extensionManagement.js'; @@ -17,7 +16,6 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { append, $ } from '../../../../base/browser/dom.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { Delegate, Renderer } from './extensionsList.js'; import { ExtensionResultsListFocused, ExtensionState, IExtension, IExtensionsViewState, IExtensionsWorkbenchService, IWorkspaceRecommendedExtensionsView } from '../common/extensions.js'; import { Query } from '../common/extensionQuery.js'; import { IExtensionService, toExtension } from '../../../services/extensions/common/extensions.js'; @@ -25,7 +23,6 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { IViewletViewOptions } from '../../../browser/parts/views/viewsViewlet.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js'; -import { ManageExtensionAction, getContextMenuActions, ExtensionAction } from './extensionsActions.js'; import { WorkbenchPagedList } from '../../../../platform/list/browser/listService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; @@ -33,23 +30,19 @@ import { ViewPane, IViewPaneOptions, ViewPaneShowActions } from '../../../browse import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { coalesce, distinct, range } from '../../../../base/common/arrays.js'; import { alert } from '../../../../base/browser/ui/aria/aria.js'; -import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { IAction, Action, Separator, ActionRunner } from '../../../../base/common/actions.js'; +import { ActionRunner } from '../../../../base/common/actions.js'; import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionUntrustedWorkspaceSupportType, ExtensionVirtualWorkspaceSupportType, IExtensionDescription, IExtensionIdentifier, isLanguagePackExtension } from '../../../../platform/extensions/common/extensions.js'; import { CancelablePromise, createCancelablePromise, ThrottledDelayer } from '../../../../base/common/async.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../../common/views.js'; +import { IViewDescriptorService } from '../../../common/views.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IExtensionManifestPropertiesService } from '../../../services/extensions/common/extensionManifestPropertiesService.js'; import { isVirtualWorkspace } from '../../../../platform/workspace/common/virtualWorkspace.js'; import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; -import { IWorkbenchLayoutService, Position } from '../../../services/layout/browser/layoutService.js'; -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { isOfflineError } from '../../../../base/parts/request/common/request.js'; import { defaultCountBadgeStyles } from '../../../../platform/theme/browser/defaultStyles.js'; @@ -59,6 +52,7 @@ import { URI } from '../../../../base/common/uri.js'; import { isString } from '../../../../base/common/types.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { ExtensionsList } from './extensionsViewer.js'; export const NONE_CATEGORY = 'none'; @@ -156,11 +150,9 @@ export class ExtensionsListView extends ViewPane { @IContextKeyService contextKeyService: IContextKeyService, @IViewDescriptorService viewDescriptorService: IViewDescriptorService, @IOpenerService openerService: IOpenerService, - @IPreferencesService private readonly preferencesService: IPreferencesService, @IStorageService private readonly storageService: IStorageService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, - @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IExtensionFeaturesManagementService private readonly extensionFeaturesManagementService: IExtensionFeaturesManagementService, @IUriIdentityService protected readonly uriIdentityService: IUriIdentityService, @ILogService private readonly logService: ILogService @@ -196,46 +188,10 @@ export class ExtensionsListView extends ViewPane { const messageSeverityIcon = append(messageContainer, $('')); const messageBox = append(messageContainer, $('.message')); const extensionsList = append(container, $('.extensions-list')); - const delegate = new Delegate(); - this.extensionsViewState = new ExtensionsViewState(); - const renderer = this.instantiationService.createInstance(Renderer, this.extensionsViewState, { - hoverOptions: { - position: () => { - const viewLocation = this.viewDescriptorService.getViewLocationById(this.id); - if (viewLocation === ViewContainerLocation.Sidebar) { - return this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT; - } - if (viewLocation === ViewContainerLocation.AuxiliaryBar) { - return this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.LEFT : HoverPosition.RIGHT; - } - return HoverPosition.RIGHT; - } - } - }); - this.list = this.instantiationService.createInstance(WorkbenchPagedList, 'Extensions', extensionsList, delegate, [renderer], { - multipleSelectionSupport: false, - setRowLineHeight: false, - horizontalScrolling: false, - accessibilityProvider: { - getAriaLabel(extension: IExtension | null): string { - return getAriaLabelForExtension(extension); - }, - getWidgetAriaLabel(): string { - return localize('extensions', "Extensions"); - } - }, - overrideStyles: this.getLocationBasedColors().listOverrideStyles, - openOnSingleClick: true - }) as WorkbenchPagedList; + this.extensionsViewState = this._register(new ExtensionsViewState()); + this.list = this._register(this.instantiationService.createInstance(ExtensionsList, extensionsList, this.id, {}, this.extensionsViewState)).list; ExtensionResultsListFocused.bindTo(this.list.contextKeyService); - this._register(this.list.onContextMenu(e => this.onContextMenu(e), this)); this._register(this.list.onDidChangeFocus(e => this.extensionsViewState?.onFocusChange(coalesce(e.elements)), this)); - this._register(this.list); - this._register(this.extensionsViewState); - - this._register(Event.debounce(Event.filter(this.list.onDidOpen, e => e.element !== null), (_, event) => event, 75, true)(options => { - this.openExtension(options.element!, { sideByside: options.sideBySide, ...options.editorOptions }); - })); this.bodyTemplate = { extensionsList, @@ -327,44 +283,6 @@ export class ExtensionsListView extends ViewPane { return Promise.resolve(emptyModel); } - private async onContextMenu(e: IListContextMenuEvent): Promise { - if (e.element) { - const disposables = new DisposableStore(); - const manageExtensionAction = disposables.add(this.instantiationService.createInstance(ManageExtensionAction)); - const extension = e.element ? this.extensionsWorkbenchService.local.find(local => areSameExtensions(local.identifier, e.element!.identifier) && (!e.element!.server || e.element!.server === local.server)) || e.element - : e.element; - manageExtensionAction.extension = extension; - let groups: IAction[][] = []; - if (manageExtensionAction.enabled) { - groups = await manageExtensionAction.getActionGroups(); - } else if (extension) { - groups = await getContextMenuActions(extension, this.contextKeyService, this.instantiationService); - groups.forEach(group => group.forEach(extensionAction => { - if (extensionAction instanceof ExtensionAction) { - extensionAction.extension = extension; - } - })); - } - const actions: IAction[] = []; - for (const menuActions of groups) { - for (const menuAction of menuActions) { - actions.push(menuAction); - if (isDisposable(menuAction)) { - disposables.add(menuAction); - } - } - actions.push(new Separator()); - } - actions.pop(); - this.contextMenuService.showContextMenu({ - getAnchor: () => e.anchor, - getActions: () => actions, - actionRunner: this.contextMenuActionRunner, - onHide: () => disposables.dispose() - }); - } - } - private async query(query: Query, options: IQueryOptions, token: CancellationToken): Promise { const idRegex = /@id:(([a-z0-9A-Z][a-z0-9\-A-Z]*)\.([a-z0-9A-Z][a-z0-9\-A-Z]*))/g; const ids: string[] = []; @@ -1194,30 +1112,6 @@ export class ExtensionsListView extends ViewPane { } } - private openExtension(extension: IExtension, options: { sideByside?: boolean; preserveFocus?: boolean; pinned?: boolean }): void { - extension = this.extensionsWorkbenchService.local.filter(e => areSameExtensions(e.identifier, extension.identifier))[0] || extension; - this.extensionsWorkbenchService.open(extension, options).then(undefined, err => this.onError(err)); - } - - private onError(err: any): void { - if (isCancellationError(err)) { - return; - } - - const message = err && err.message || ''; - - if (/ECONNREFUSED/.test(message)) { - const error = createErrorWithActions(localize('suggestProxyError', "Marketplace returned 'ECONNREFUSED'. Please check the 'http.proxy' setting."), [ - new Action('open user settings', localize('open user settings', "Open User Settings"), undefined, true, () => this.preferencesService.openUserSettings()) - ]); - - this.notificationService.error(error); - return; - } - - this.notificationService.error(err); - } - override dispose(): void { super.dispose(); if (this.queryRequest) { @@ -1454,11 +1348,9 @@ export class StaticQueryExtensionsView extends ExtensionsListView { @IContextKeyService contextKeyService: IContextKeyService, @IViewDescriptorService viewDescriptorService: IViewDescriptorService, @IOpenerService openerService: IOpenerService, - @IPreferencesService preferencesService: IPreferencesService, @IStorageService storageService: IStorageService, @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, @IWorkbenchExtensionEnablementService extensionEnablementService: IWorkbenchExtensionEnablementService, - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IExtensionFeaturesManagementService extensionFeaturesManagementService: IExtensionFeaturesManagementService, @IUriIdentityService uriIdentityService: IUriIdentityService, @ILogService logService: ILogService @@ -1466,7 +1358,7 @@ export class StaticQueryExtensionsView extends ExtensionsListView { super(options, viewletViewOptions, notificationService, keybindingService, contextMenuService, instantiationService, themeService, extensionService, extensionsWorkbenchService, extensionRecommendationsService, telemetryService, hoverService, configurationService, contextService, extensionManagementServerService, extensionManifestPropertiesService, extensionManagementService, workspaceService, productService, contextKeyService, viewDescriptorService, openerService, - preferencesService, storageService, workspaceTrustManagementService, extensionEnablementService, layoutService, extensionFeaturesManagementService, + storageService, workspaceTrustManagementService, extensionEnablementService, extensionFeaturesManagementService, uriIdentityService, logService); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts index 20325ef00d3..b0cf41cffd3 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts @@ -109,10 +109,6 @@ export class InstallCountWidget extends ExtensionWidget { return; } - if (!this.small && !this.extension.url) { - return; - } - const parent = this.small ? this.container : append(this.container, $('span.install', { tabIndex: 0 })); append(parent, $('span' + ThemeIcon.asCSSSelector(installCountIcon))); const count = append(parent, $('span.count')); @@ -126,7 +122,7 @@ export class InstallCountWidget extends ExtensionWidget { static getInstallLabel(extension: IExtension, small: boolean): string | undefined { const installCount = extension.installCount; - if (installCount === undefined) { + if (!installCount) { return undefined; } @@ -279,7 +275,7 @@ export class PublisherWidget extends ExtensionWidget { append(verifiedPublisher, $('span.extension-verified-publisher.clickable'), renderIcon(verifiedPublisherIcon)); if (this.small) { - if (this.extension.publisherDomain) { + if (this.extension.publisherDomain?.verified) { append(this.element, verifiedPublisher); } append(this.element, publisherDisplayName); @@ -291,7 +287,7 @@ export class PublisherWidget extends ExtensionWidget { this.containerHover = this.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.element, localize('publisher', "Publisher ({0})", this.extension.publisherDisplayName))); append(this.element, publisherDisplayName); - if (this.extension.publisherDomain) { + if (this.extension.publisherDomain?.verified) { append(this.element, verifiedPublisher); const publisherDomainLink = URI.parse(this.extension.publisherDomain.link); verifiedPublisher.tabIndex = 0; diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index efd8a2732f1..ec2972994f0 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -21,10 +21,14 @@ import { TargetPlatformToString, IAllowedExtensionsService, AllowedExtensionsConfigKey, - EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT + EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT, + ExtensionManagementError, + ExtensionManagementErrorCode, + MaliciousExtensionInfo, + shouldRequireRepositorySignatureFor } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { IWorkbenchExtensionEnablementService, EnablementState, IExtensionManagementServerService, IExtensionManagementServer, IWorkbenchExtensionManagementService, DefaultIconPath, IResourceExtension } from '../../../services/extensionManagement/common/extensionManagement.js'; -import { getGalleryExtensionTelemetryData, getLocalExtensionTelemetryData, areSameExtensions, groupByExtension, getGalleryExtensionId, isMalicious } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; +import { getGalleryExtensionTelemetryData, getLocalExtensionTelemetryData, areSameExtensions, groupByExtension, getGalleryExtensionId, findMatchingMaliciousEntry } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IHostService } from '../../../services/host/browser/host.js'; @@ -66,7 +70,7 @@ import { ShowCurrentReleaseNotesActionId } from '../../update/common/update.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; -import { IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { ExtensionGalleryResourceType, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; interface IExtensionStateProvider { (extension: Extension): T; @@ -213,7 +217,7 @@ export class Extension implements IExtension { } get private(): boolean { - return this.local ? this.local.private : this.gallery ? this.gallery.private : false; + return this.gallery ? this.gallery.private : this.local ? this.local.private : false; } get pinned(): boolean { @@ -292,9 +296,13 @@ export class Extension implements IExtension { return this.stateProvider(this); } - private malicious: boolean = false; - public get isMalicious(): boolean { - return this.malicious || this.enablementState === EnablementState.DisabledByMalicious; + private malicious: MaliciousExtensionInfo | undefined; + public get isMalicious(): boolean | undefined { + return !!this.malicious || this.enablementState === EnablementState.DisabledByMalicious; + } + + public get maliciousInfoLink(): string | undefined { + return this.malicious?.learnMoreLink; } public deprecationInfo: IDeprecationInfo | undefined; @@ -378,9 +386,8 @@ export class Extension implements IExtension { return !!this.gallery?.properties.isPreReleaseVersion; } - private _extensionEnabledWithPreRelease: boolean | undefined; get hasPreReleaseVersion(): boolean { - return !!this.gallery?.hasPreReleaseVersion || !!this.local?.hasPreReleaseVersion || !!this._extensionEnabledWithPreRelease; + return this.gallery ? this.gallery.hasPreReleaseVersion : !!this.local?.hasPreReleaseVersion; } get hasReleaseVersion(): boolean { @@ -554,9 +561,8 @@ ${this.description} } setExtensionsControlManifest(extensionsControlManifest: IExtensionsControlManifest): void { - this.malicious = isMalicious(this.identifier, extensionsControlManifest.malicious); + this.malicious = findMatchingMaliciousEntry(this.identifier, extensionsControlManifest.malicious); this.deprecationInfo = extensionsControlManifest.deprecated ? extensionsControlManifest.deprecated[this.identifier.id.toLowerCase()] : undefined; - this._extensionEnabledWithPreRelease = extensionsControlManifest?.extensionsEnabledWithPreRelease?.includes(this.identifier.id.toLowerCase()); } private getManifestFromLocalOrResource(): IExtensionManifest | null { @@ -931,7 +937,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension private readonly _onReset = new Emitter(); get onReset() { return this._onReset.event; } - readonly preferPreReleases = this.productService.quality !== 'stable'; + readonly preferPreReleases: boolean; private installing: IExtension[] = []; private tasksInProgress: CancelablePromise[] = []; @@ -976,10 +982,8 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension @IAllowedExtensionsService private readonly allowedExtensionsService: IAllowedExtensionsService, ) { super(); - const preferPreReleasesValue = configurationService.getValue('_extensions.preferPreReleases'); - if (!isUndefined(preferPreReleasesValue)) { - this.preferPreReleases = !!preferPreReleasesValue; - } + this.preferPreReleases = productService.quality !== 'stable'; + this.hasOutdatedExtensionsContextKey = HasOutdatedExtensionsContext.bindTo(contextKeyService); if (extensionManagementServerService.localExtensionManagementServer) { this.localExtensions = this._register(instantiationService.createInstance(Extensions, @@ -1063,13 +1067,13 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } if (e.affectsConfiguration(AutoCheckUpdatesConfigurationKey)) { if (this.isAutoCheckUpdatesEnabled()) { - this.checkForUpdates(); + this.checkForUpdates(`Enabled auto check updates`); } } })); this._register(this.extensionEnablementService.onEnablementChanged(platformExtensions => { if (this.getAutoUpdateValue() === 'onlyEnabledExtensions' && platformExtensions.some(e => this.extensionEnablementService.isEnabled(e))) { - this.checkForUpdates(); + this.checkForUpdates('Extension enablement changed'); } })); this._register(Event.debounce(this.onChange, () => undefined, 100)(() => this.hasOutdatedExtensionsContextKey.set(this.outdated.length > 0))); @@ -1080,14 +1084,14 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension comment: 'Report when update check is triggered on product update'; }>('extensions:updatecheckonproductupdate'); if (this.isAutoCheckUpdatesEnabled()) { - this.checkForUpdates(); + this.checkForUpdates('Product update'); } } })); this._register(this.allowedExtensionsService.onDidChangeAllowedExtensionsConfigValue(() => { if (this.isAutoCheckUpdatesEnabled()) { - this.checkForUpdates(); + this.checkForUpdates('Allowed extensions changed'); } })); @@ -1825,7 +1829,12 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension return ExtensionState.Uninstalled; } - async checkForUpdates(onlyBuiltin?: boolean): Promise { + async checkForUpdates(reason?: string, onlyBuiltin?: boolean): Promise { + if (reason) { + this.logService.info(`[Extensions]: Checking for updates. Reason: ${reason}`); + } else { + this.logService.trace(`[Extensions]: Checking for updates`); + } if (!this.galleryService.isEnabled()) { return; } @@ -1870,6 +1879,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension this.telemetryService.publicLog2('galleryService:checkingForUpdates', { count: infos.length, }); + this.logService.trace(`Checking updates for extensions`, infos.map(e => e.id).join(', ')); const galleryExtensions = await this.galleryService.getExtensions(infos, { targetPlatform, compatible: true, productVersion: this.getProductVersion(), preferResourceApi: true }, CancellationToken.None); if (galleryExtensions.length) { await this.syncInstalledExtensionsWithGallery(galleryExtensions); @@ -1961,6 +1971,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } await Promise.allSettled(extensions.map(extensions => extensions.syncInstalledExtensionsWithGallery(gallery, this.getProductVersion()))); if (this.outdated.length) { + this.logService.info(`Auto updating outdated extensions.`, this.outdated.map(e => e.identifier.id).join(', ')); this.eventuallyAutoUpdateExtensions(); } } @@ -1992,7 +2003,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } private async autoUpdateBuiltinExtensions(): Promise { - await this.checkForUpdates(true); + await this.checkForUpdates(undefined, true); const toUpdate = this.outdated.filter(e => e.isBuiltin); await Promises.settled(toUpdate.map(e => this.install(e, e.local?.preRelease ? { installPreReleaseVersion: true } : undefined))); } @@ -2016,9 +2027,11 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension const toUpdate: IExtension[] = []; for (const extension of this.outdated) { if (!this.shouldAutoUpdateExtension(extension)) { + this.logService.info('Auto update disabled for extension', extension.identifier.id); continue; } if (await this.shouldRequireConsentToUpdate(extension)) { + this.logService.info('Auto update consent required for extension', extension.identifier.id); continue; } toUpdate.push(extension); @@ -2029,7 +2042,10 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } const productVersion = this.getProductVersion(); - await Promises.settled(toUpdate.map(e => this.install(e, e.local?.preRelease ? { installPreReleaseVersion: true, productVersion } : { productVersion }))); + await Promises.settled(toUpdate.map(e => { + this.logService.info('Auto updating extension', e.identifier.id); + return this.install(e, e.local?.preRelease ? { installPreReleaseVersion: true, productVersion } : { productVersion }); + })); } private getProductVersion(): IProductVersion { @@ -2099,11 +2115,15 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension return; } - if (extension.local?.manifest.main || extension.local?.manifest.browser) { + if (!extension.gallery || !extension.local) { return; } - if (!extension.gallery) { + if (extension.local.identifier.uuid && extension.local.identifier.uuid !== extension.gallery.identifier.uuid) { + return nls.localize('consentRequiredToUpdateRepublishedExtension', "The marketplace metadata of this extension changed, likely due to a re-publish."); + } + + if (!extension.local.manifest.engines.vscode || extension.local.manifest.main || extension.local.manifest.browser) { return; } @@ -2284,7 +2304,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } if (extension.gallery) { - if (!extension.gallery.isSigned && (await this.extensionGalleryManifestService.getExtensionGalleryManifest())?.capabilities.signing?.allRepositorySigned) { + if (!extension.gallery.isSigned && shouldRequireRepositorySignatureFor(extension.private, await this.extensionGalleryManifestService.getExtensionGalleryManifest())) { return new MarkdownString().appendText(nls.localize('not signed', "This extension is not signed.")); } @@ -2389,10 +2409,15 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension if (!installable) { if (!gallery) { const id = isString(arg) ? arg : (arg).identifier.id; + const manifest = await this.extensionGalleryManifestService.getExtensionGalleryManifest(); + const reportIssueUri = manifest ? getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ContactSupportUri) : undefined; + const reportIssueMessage = reportIssueUri ? nls.localize('report issue', "If this issue persists, please report it at {0}", reportIssueUri.toString()) : ''; if (installOptions.version) { - throw new Error(nls.localize('not found version', "Unable to install extension '{0}' because the requested version '{1}' is not found.", id, installOptions.version)); + const message = nls.localize('not found version', "The extension '{0}' cannot be installed because the requested version '{1}' was not found.", id, installOptions.version); + throw new ExtensionManagementError(reportIssueMessage ? `${message} ${reportIssueMessage}` : message, ExtensionManagementErrorCode.NotFound); } else { - throw new Error(nls.localize('not found', "Unable to install extension '{0}' because it is not found.", id)); + const message = nls.localize('not found', "The extension '{0}' cannot be installed because it was not found.", id); + throw new ExtensionManagementError(reportIssueMessage ? `${message} ${reportIssueMessage}` : message, ExtensionManagementErrorCode.NotFound); } } installable = gallery; @@ -2472,7 +2497,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension return extension; } - async installInServer(extension: IExtension, server: IExtensionManagementServer): Promise { + async installInServer(extension: IExtension, server: IExtensionManagementServer, installOptions?: InstallOptions): Promise { await this.doInstall(extension, async () => { const local = extension.local; if (!local) { @@ -2482,7 +2507,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension extension = (await this.getExtensions([{ ...extension.identifier, preRelease: local.preRelease }], CancellationToken.None))[0] ?? extension; } if (extension.gallery) { - return server.extensionManagementService.installFromGallery(extension.gallery, { installPreReleaseVersion: local.preRelease }); + return server.extensionManagementService.installFromGallery(extension.gallery, { installPreReleaseVersion: local.preRelease, ...installOptions }); } const targetPlatform = await server.extensionManagementService.getTargetPlatform(); @@ -2595,7 +2620,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension return this.withProgress({ location: ProgressLocation.Extensions, - title: nls.localize('uninstallingExtension', 'Uninstalling extension....'), + title: nls.localize('uninstallingExtension', 'Uninstalling extension...'), source: `${extension.identifier.id}` }, () => this.extensionManagementService.uninstallExtensions(extensionsToUninstall).then(() => undefined)); } @@ -2669,7 +2694,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension await Promise.all(this.getAllExtensions().map(async extensions => { const local = extensions.local.find(e => areSameExtensions(e.identifier, extension.identifier))?.local; if (local && local.isApplicationScoped === isApplicationScoped) { - await this.extensionManagementService.toggleAppliationScope(local, this.userDataProfileService.currentProfile.extensionsResource); + await this.extensionManagementService.toggleApplicationScope(local, this.userDataProfileService.currentProfile.extensionsResource); } })); } @@ -2710,7 +2735,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } private doInstall(extension: IExtension | undefined, installTask: () => Promise, progressLocation?: ProgressLocation | string): Promise { - const title = extension ? nls.localize('installing named extension', "Installing '{0}' extension....", extension.displayName) : nls.localize('installing extension', 'Installing extension....'); + const title = extension ? nls.localize('installing named extension', "Installing '{0}' extension...", extension.displayName) : nls.localize('installing extension', 'Installing extension...'); return this.withProgress({ location: progressLocation ?? ProgressLocation.Extensions, title @@ -2738,7 +2763,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension if (existingExtension) { installOptions = installOptions || {}; if (existingExtension.latestVersion === manifest.version) { - installOptions.pinned = existingExtension.local?.pinned || !this.shouldAutoUpdateExtension(existingExtension); + installOptions.pinned = installOptions.pinned ?? (existingExtension.local?.pinned || !this.shouldAutoUpdateExtension(existingExtension)); } else { installOptions.installGivenVersion = true; } @@ -2748,7 +2773,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension private installFromGallery(extension: IExtension, gallery: IGalleryExtension, installOptions: InstallExtensionOptions, servers: IExtensionManagementServer[] | undefined): Promise { installOptions = installOptions ?? {}; - installOptions.pinned = extension.local?.pinned || !this.shouldAutoUpdateExtension(extension); + installOptions.pinned = installOptions.pinned ?? (extension.local?.pinned || !this.shouldAutoUpdateExtension(extension)); if (extension.local && !servers) { installOptions.productVersion = this.getProductVersion(); installOptions.operation = InstallOperation.Update; diff --git a/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts index 6d98e545b6e..927e707f50f 100644 --- a/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts @@ -305,7 +305,7 @@ export class FileBasedRecommendations extends ExtensionRecommendations { const promptedRecommendations = language !== PLAINTEXT_LANGUAGE_ID ? this.getPromptedRecommendations()[language] : undefined; if (promptedRecommendations) { - recommendations = recommendations.filter(extensionId => promptedRecommendations.includes(extensionId)); + recommendations = recommendations.filter(extensionId => !promptedRecommendations.includes(extensionId)); } if (recommendations.length === 0) { diff --git a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css index 62be7a0b84a..12ea3c206aa 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css @@ -477,15 +477,13 @@ padding-bottom: 15px; } -.extension-editor > .body > .content > .details > .additional-details-container .categories-container > .categories > .category, -.extension-editor > .body > .content > .details > .additional-details-container .tags-container > .tags > .tag { +.extension-editor > .body > .content > .details > .additional-details-container .categories-container > .categories > .category { display: inline-block; border: 1px solid rgba(136, 136, 136, 0.45); padding: 2px 4px; border-radius: 2px; font-size: 90%; margin: 0px 6px 3px 0px; - cursor: pointer; } .extension-editor > .body > .content > .details > .additional-details-container .resources-container > .resources > .resource { diff --git a/src/vs/workbench/contrib/extensions/common/extensionQuery.ts b/src/vs/workbench/contrib/extensions/common/extensionQuery.ts index 68320b1455a..cd74a11bd4d 100644 --- a/src/vs/workbench/contrib/extensions/common/extensionQuery.ts +++ b/src/vs/workbench/contrib/extensions/common/extensionQuery.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IExtensionGalleryManifest } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { FilterType, SortBy } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { EXTENSION_CATEGORIES } from '../../../../platform/extensions/common/extensions.js'; export class Query { @@ -11,11 +13,32 @@ export class Query { this.value = value.trim(); } - static suggestions(query: string): string[] { - const commands = ['installed', 'updates', 'enabled', 'disabled', 'builtin', 'featured', 'popular', 'recommended', 'recentlyPublished', 'workspaceUnsupported', 'deprecated', 'sort', 'category', 'tag', 'ext', 'id', 'outdated', 'recentlyUpdated'] as const; + static suggestions(query: string, galleryManifest: IExtensionGalleryManifest | null): string[] { + + const commands = ['installed', 'updates', 'enabled', 'disabled', 'builtin']; + if (galleryManifest?.capabilities.extensionQuery?.filtering?.some(c => c.name === FilterType.Featured)) { + commands.push('featured'); + } + + commands.push(...['popular', 'recommended', 'recentlyPublished', 'workspaceUnsupported', 'deprecated', 'sort']); + const isCategoriesEnabled = galleryManifest?.capabilities.extensionQuery?.filtering?.some(c => c.name === FilterType.Category); + if (isCategoriesEnabled) { + commands.push('category'); + } + + commands.push(...['tag', 'ext', 'id', 'outdated', 'recentlyUpdated']); + const sortCommands = []; + if (galleryManifest?.capabilities.extensionQuery?.sorting?.some(c => c.name === SortBy.InstallCount)) { + sortCommands.push('installs'); + } + if (galleryManifest?.capabilities.extensionQuery?.sorting?.some(c => c.name === SortBy.WeightedRating)) { + sortCommands.push('rating'); + } + sortCommands.push('name', 'publishedDate', 'updateDate'); + const subcommands = { - 'sort': ['installs', 'rating', 'name', 'publishedDate', 'updateDate'], - 'category': EXTENSION_CATEGORIES.map(c => `"${c.toLowerCase()}"`), + 'sort': sortCommands, + 'category': isCategoriesEnabled ? EXTENSION_CATEGORIES.map(c => `"${c.toLowerCase()}"`) : [], 'tag': [''], 'ext': [''], 'id': [''] diff --git a/src/vs/workbench/contrib/extensions/common/extensions.ts b/src/vs/workbench/contrib/extensions/common/extensions.ts index 4a232d2d560..b86d948aa1a 100644 --- a/src/vs/workbench/contrib/extensions/common/extensions.ts +++ b/src/vs/workbench/contrib/extensions/common/extensions.ts @@ -104,7 +104,8 @@ export interface IExtension { readonly local?: ILocalExtension; gallery?: IGalleryExtension; readonly resourceExtension?: IResourceExtension; - readonly isMalicious: boolean; + readonly isMalicious: boolean | undefined; + readonly maliciousInfoLink: string | undefined; readonly deprecationInfo?: IDeprecationInfo; } @@ -143,7 +144,7 @@ export interface IExtensionsWorkbenchService { install(id: string, installOptions?: InstallExtensionOptions, progressLocation?: ProgressLocation | string): Promise; install(vsix: URI, installOptions?: InstallExtensionOptions, progressLocation?: ProgressLocation | string): Promise; install(extension: IExtension, installOptions?: InstallExtensionOptions, progressLocation?: ProgressLocation | string): Promise; - installInServer(extension: IExtension, server: IExtensionManagementServer): Promise; + installInServer(extension: IExtension, server: IExtensionManagementServer, installOptions?: InstallOptions): Promise; downloadVSIX(extension: string, prerelease: boolean): Promise; uninstall(extension: IExtension): Promise; togglePreRelease(extension: IExtension): Promise; diff --git a/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts b/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts new file mode 100644 index 00000000000..9668c594e98 --- /dev/null +++ b/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { SortBy } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { EXTENSION_CATEGORIES } from '../../../../platform/extensions/common/extensions.js'; +import { CountTokensCallback, IToolData, IToolImpl, IToolInvocation, IToolResult, ToolProgress } from '../../chat/common/languageModelToolsService.js'; +import { ExtensionState, IExtension, IExtensionsWorkbenchService } from '../common/extensions.js'; + +export const SearchExtensionsToolId = 'vscode_searchExtensions_internal'; + +export const SearchExtensionsToolData: IToolData = { + id: SearchExtensionsToolId, + toolReferenceName: 'extensions', + canBeReferencedInPrompt: true, + icon: ThemeIcon.fromId(Codicon.extensions.id), + supportsToolPicker: true, + displayName: localize('searchExtensionsTool.displayName', 'Search Extensions'), + modelDescription: localize('searchExtensionsTool.modelDescription', "This is a tool for browsing Visual Studio Code Extensions Marketplace. It allows the model to search for extensions and retrieve detailed information about them. The model should use this tool whenever it needs to discover extensions or resolve information about known ones. To use the tool, the model has to provide the category of the extensions, relevant search keywords, or known extension IDs. Note that search results may include false positives, so reviewing and filtering is recommended."), + source: { type: 'internal' }, + inputSchema: { + type: 'object', + properties: { + category: { + type: 'string', + description: 'The category of extensions to search for', + enum: EXTENSION_CATEGORIES, + }, + keywords: { + type: 'array', + items: { + type: 'string', + }, + description: 'The keywords to search for', + }, + ids: { + type: 'array', + items: { + type: 'string', + }, + description: 'The ids of the extensions to search for', + }, + }, + } +}; + +type InputParams = { + category?: string; + keywords?: string; + ids?: string[]; +}; + +type ExtensionData = { + id: string; + name: string; + description: string; + installed: boolean; + installCount: number; + rating: number; + categories: readonly string[]; + tags: readonly string[]; +}; + +export class SearchExtensionsTool implements IToolImpl { + + constructor( + @IExtensionsWorkbenchService private readonly extensionWorkbenchService: IExtensionsWorkbenchService, + ) { } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { + const params = invocation.parameters as InputParams; + if (!params.keywords?.length && !params.category && !params.ids?.length) { + return { + content: [{ + kind: 'text', + value: localize('searchExtensionsTool.noInput', 'Please provide a category or keywords or ids to search for.') + }] + }; + } + + const extensionsMap = new Map(); + + const addExtension = (extensions: IExtension[]) => { + for (const extension of extensions) { + if (extension.deprecationInfo || extension.isMalicious) { + continue; + } + extensionsMap.set(extension.identifier.id.toLowerCase(), { + id: extension.identifier.id, + name: extension.displayName, + description: extension.description, + installed: extension.state === ExtensionState.Installed, + installCount: extension.installCount ?? 0, + rating: extension.rating ?? 0, + categories: extension.categories ?? [], + tags: extension.gallery?.tags ?? [] + }); + } + }; + + const queryAndAddExtensions = async (text: string) => { + const extensions = await this.extensionWorkbenchService.queryGallery({ + text, + pageSize: 10, + sortBy: SortBy.InstallCount + }, token); + if (extensions.firstPage.length) { + addExtension(extensions.firstPage); + } + }; + + // Search for extensions by their ids + if (params.ids?.length) { + const extensions = await this.extensionWorkbenchService.getExtensions(params.ids.map(id => ({ id })), token); + addExtension(extensions); + } + + if (params.keywords?.length) { + for (const keyword of params.keywords ?? []) { + if (keyword === 'featured') { + await queryAndAddExtensions('featured'); + } else { + let text = params.category ? `category:"${params.category}"` : ''; + text = keyword ? `${text} ${keyword}`.trim() : text; + await queryAndAddExtensions(text); + } + } + } else { + await queryAndAddExtensions(`category:"${params.category}"`); + } + + const result = Array.from(extensionsMap.values()); + + return { + content: [{ + kind: 'text', + value: `Here are the list of extensions:\n${JSON.stringify(result)}\n. Important: Use the following format to display extensions to the user because there is a renderer available to parse these extensions in this format and display them with all details. So, do not describe about the extensions to the user.\n\`\`\`vscode-extensions\nextensionId1,extensionId2\n\`\`\`\n.` + }], + toolResultDetails: { + input: JSON.stringify(params), + output: JSON.stringify(result.map(extension => extension.id)) + } + }; + } +} + diff --git a/src/vs/workbench/contrib/extensions/electron-sandbox/remoteExtensionsInit.ts b/src/vs/workbench/contrib/extensions/electron-sandbox/remoteExtensionsInit.ts index 9f68162c1c1..032283378b6 100644 --- a/src/vs/workbench/contrib/extensions/electron-sandbox/remoteExtensionsInit.ts +++ b/src/vs/workbench/contrib/extensions/electron-sandbox/remoteExtensionsInit.ts @@ -8,7 +8,6 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT, IExtensionGalleryService, IExtensionManagementService, InstallExtensionInfo } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { areSameExtensions } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; -import { ExtensionType } from '../../../../platform/extensions/common/extensions.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; @@ -28,6 +27,7 @@ import { IAuthenticationService } from '../../../services/authentication/common/ import { IExtensionManagementServerService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionManifestPropertiesService } from '../../../services/extensions/common/extensionManifestPropertiesService.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; +import { IExtensionsWorkbenchService } from '../common/extensions.js'; export class InstallRemoteExtensionsContribution implements IWorkbenchContribution { constructor( @@ -35,14 +35,15 @@ export class InstallRemoteExtensionsContribution implements IWorkbenchContributi @IRemoteExtensionsScannerService private readonly remoteExtensionsScannerService: IRemoteExtensionsScannerService, @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, @IExtensionManagementServerService private readonly extensionManagementServerService: IExtensionManagementServerService, + @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @ILogService private readonly logService: ILogService, @IConfigurationService private readonly configurationService: IConfigurationService ) { - this.installDefaultRemoteExtensions(); + this.installExtensionsIfInstalledLocallyInRemote(); this.installFailedRemoteExtensions(); } - private async installDefaultRemoteExtensions(): Promise { + private async installExtensionsIfInstalledLocallyInRemote(): Promise { if (!this.remoteAgentService.getConnection()) { return; } @@ -62,49 +63,20 @@ export class InstallRemoteExtensionsContribution implements IWorkbenchContributi return; } - this.logService.info(`Evaluating '${settingValue.length}' default remote extensions`); + const alreadyInstalledLocally = await this.extensionsWorkbenchService.queryLocal(this.extensionManagementServerService.localExtensionManagementServer); + const alreadyInstalledRemotely = await this.extensionsWorkbenchService.queryLocal(this.extensionManagementServerService.remoteExtensionManagementServer); + const extensionsToInstall = alreadyInstalledLocally + .filter(ext => settingValue.some(id => areSameExtensions(ext.identifier, { id }))) + .filter(ext => !alreadyInstalledRemotely.some(e => areSameExtensions(e.identifier, ext.identifier))); - const galleryExtensions = await this.extensionGalleryService.getExtensions(settingValue.map((id) => ({ id })), CancellationToken.None); - const alreadyInstalledInRemote = await this.extensionManagementServerService.remoteExtensionManagementServer.extensionManagementService.getInstalled(ExtensionType.User); - const alreadyInstalledLocally = await this.extensionManagementServerService.localExtensionManagementServer.extensionManagementService.getInstalled(ExtensionType.User); - const prereleaseExtensionInfo: InstallExtensionInfo[] = []; - const extensionInfo: InstallExtensionInfo[] = []; - for (const id of settingValue) { - const alreadyInstalled = alreadyInstalledInRemote.some(e => areSameExtensions(e.identifier, { id })); - if (alreadyInstalled) { - this.logService.trace(`Default remote extension '${id}' is already installed`); - continue; - } - - const installedLocally = alreadyInstalledLocally.find(e => areSameExtensions(e.identifier, { id })); - if (!installedLocally) { - this.logService.trace(`Default remote extension '${id}' is not installed locally`); - continue; - } - - const extension = galleryExtensions.find(e => areSameExtensions(e.identifier, { id })); - if (!extension) { - this.logService.warn(`Default remote extension '${id}' is not found`); - continue; - } - - const installPreReleaseVersion = installedLocally.isPreReleaseVersion; - this.logService.trace(`Default remote extension '${id}' queued for install (pre-release=${installPreReleaseVersion})`); - (installPreReleaseVersion ? prereleaseExtensionInfo : extensionInfo).push({ - extension, options: { installPreReleaseVersion }, - }); + if (!extensionsToInstall.length) { + return; } - // Install pre-release extensions first to avoid a situation where: - // An extension without a pre-release (A) is installed first and depends on an extension that has a pre-release version (B) - // If this happens, the extension A may result in the installation of the stable version of B - if (prereleaseExtensionInfo.length) { - await Promise.allSettled(prereleaseExtensionInfo.map(e => this.extensionManagementServerService.remoteExtensionManagementServer!.extensionManagementService.installFromGallery(e.extension, e.options))); - } - if (extensionInfo.length) { - await Promise.allSettled(extensionInfo.map(e => this.extensionManagementServerService.remoteExtensionManagementServer!.extensionManagementService.installFromGallery(e.extension, e.options))); - } + await Promise.allSettled(extensionsToInstall.map(ext => { + this.extensionsWorkbenchService.installInServer(ext, this.extensionManagementServerService.remoteExtensionManagementServer!, { donotIncludePackAndDependencies: true }); + })); } private async installFailedRemoteExtensions(): Promise { diff --git a/src/vs/workbench/contrib/extensions/test/common/extensionQuery.test.ts b/src/vs/workbench/contrib/extensions/test/common/extensionQuery.test.ts index 61457a1e254..6d31b784df3 100644 --- a/src/vs/workbench/contrib/extensions/test/common/extensionQuery.test.ts +++ b/src/vs/workbench/contrib/extensions/test/common/extensionQuery.test.ts @@ -140,10 +140,10 @@ suite('Extension query', () => { }); test('autocomplete', () => { - Query.suggestions('@sort:in').some(x => x === '@sort:installs '); - Query.suggestions('@sort:installs').every(x => x !== '@sort:rating '); + Query.suggestions('@sort:in', null).some(x => x === '@sort:installs '); + Query.suggestions('@sort:installs', null).every(x => x !== '@sort:rating '); - Query.suggestions('@category:blah').some(x => x === '@category:"extension packs" '); - Query.suggestions('@category:"extension packs"').every(x => x !== '@category:formatters '); + Query.suggestions('@category:blah', null).some(x => x === '@category:"extension packs" '); + Query.suggestions('@category:"extension packs"', null).every(x => x !== '@category:formatters '); }); }); diff --git a/src/vs/workbench/contrib/files/browser/explorerFileContrib.ts b/src/vs/workbench/contrib/files/browser/explorerFileContrib.ts index 116ec0994a9..4eb1ae4b3d3 100644 --- a/src/vs/workbench/contrib/files/browser/explorerFileContrib.ts +++ b/src/vs/workbench/contrib/files/browser/explorerFileContrib.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter } from '../../../../base/common/event.js'; -import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -36,8 +36,8 @@ export interface IExplorerFileContributionRegistry { register(descriptor: IExplorerFileContributionDescriptor): void; } -class ExplorerFileContributionRegistry implements IExplorerFileContributionRegistry { - private readonly _onDidRegisterDescriptor = new Emitter(); +class ExplorerFileContributionRegistry extends Disposable implements IExplorerFileContributionRegistry { + private readonly _onDidRegisterDescriptor = this._register(new Emitter()); public readonly onDidRegisterDescriptor = this._onDidRegisterDescriptor.event; private readonly descriptors: IExplorerFileContributionDescriptor[] = []; diff --git a/src/vs/workbench/contrib/files/browser/fileCommands.ts b/src/vs/workbench/contrib/files/browser/fileCommands.ts index 69cfd27a424..267b609b778 100644 --- a/src/vs/workbench/contrib/files/browser/fileCommands.ts +++ b/src/vs/workbench/contrib/files/browser/fileCommands.ts @@ -234,11 +234,10 @@ async function resourcesToClipboard(resources: URI[], relative: boolean, clipboa const lineDelimiter = isWindows ? '\r\n' : '\n'; let separator: '/' | '\\' | undefined = undefined; - if (relative) { - const relativeSeparator = configurationService.getValue('explorer.copyRelativePathSeparator'); - if (relativeSeparator === '/' || relativeSeparator === '\\') { - separator = relativeSeparator; - } + const copyRelativeOrFullPathSeparatorSection = relative ? 'explorer.copyRelativePathSeparator' : 'explorer.copyPathSeparator'; + const copyRelativeOrFullPathSeparator: '/' | '\\' | undefined = configurationService.getValue(copyRelativeOrFullPathSeparatorSection); + if (copyRelativeOrFullPathSeparator === '/' || copyRelativeOrFullPathSeparator === '\\') { + separator = copyRelativeOrFullPathSeparator; } const text = resources.map(resource => labelService.getUriLabel(resource, { relative, noPrefix: true, separator })).join(lineDelimiter); diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index 606dcf8ebce..b15c9c52ce6 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -584,6 +584,21 @@ configurationRegistry.registerConfiguration({ 'description': nls.localize('copyRelativePathSeparator', "The path separation character used when copying relative file paths."), 'default': 'auto' }, + 'explorer.copyPathSeparator': { + 'type': 'string', + 'enum': [ + '/', + '\\', + 'auto' + ], + 'enumDescriptions': [ + nls.localize('copyPathSeparator.slash', "Use slash as path separation character."), + nls.localize('copyPathSeparator.backslash', "Use backslash as path separation character."), + nls.localize('copyPathSeparator.auto', "Uses operating system specific path separation character."), + ], + 'description': nls.localize('copyPathSeparator', "The path separation character used when copying file paths."), + 'default': 'auto' + }, 'explorer.excludeGitIgnore': { type: 'boolean', markdownDescription: nls.localize('excludeGitignore', "Controls whether entries in .gitignore should be parsed and excluded from the Explorer. Similar to {0}.", '`#files.exclude#`'), diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index c26b4b49211..ab86b66a756 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -7,7 +7,7 @@ import { EditorContributionInstantiation, registerEditorContribution } from '../ import { IMenuItem, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { InlineChatController, InlineChatController1, InlineChatController2 } from './inlineChatController.js'; import * as InlineChatActions from './inlineChatActions.js'; -import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, INLINE_CHAT_ID, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, INLINE_CHAT_ID, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; @@ -28,8 +28,11 @@ registerEditorContribution(InlineChatController2.ID, InlineChatController2, Edit registerEditorContribution(INLINE_CHAT_ID, InlineChatController1, EditorContributionInstantiation.Eager); // EAGER because of notebook dispose/create of editors registerEditorContribution(InlineChatController.ID, InlineChatController, EditorContributionInstantiation.Eager); // EAGER because of notebook dispose/create of editors -registerAction2(InlineChatActions.StopSessionAction2); +registerAction2(InlineChatActions.KeepSessionAction2); +registerAction2(InlineChatActions.UndoSessionAction2); +registerAction2(InlineChatActions.CloseSessionAction2); registerAction2(InlineChatActions.RevealWidget); +registerAction2(InlineChatActions.CancelRequestAction); // --- browser @@ -53,7 +56,8 @@ const editActionMenuItem: IMenuItem = { when: ContextKeyExpr.and( ChatContextKeys.inputHasText, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS.toNegated(), - CTX_INLINE_CHAT_EDITING + CTX_INLINE_CHAT_EDITING, + CTX_INLINE_CHAT_HAS_AGENT ), }; @@ -67,7 +71,8 @@ const generateActionMenuItem: IMenuItem = { when: ContextKeyExpr.and( ChatContextKeys.inputHasText, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS.toNegated(), - CTX_INLINE_CHAT_EDITING.toNegated() + CTX_INLINE_CHAT_EDITING.toNegated(), + CTX_INLINE_CHAT_HAS_AGENT ), }; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 434bffb469d..4227acfa8cc 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -12,7 +12,7 @@ import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/code import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { InlineChatController, InlineChatController1, InlineChatController2, InlineChatRunOptions } from './inlineChatController.js'; import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_STASHED_SESSION, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, CTX_INLINE_CHAT_INNER_CURSOR_LAST, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_WIDGET_STATUS, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, InlineChatResponseType, ACTION_REGENERATE_RESPONSE, ACTION_VIEW_IN_CHAT, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, MENU_INLINE_CHAT_ZONE, ACTION_DISCARD_CHANGES, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_HAS_AGENT2, MENU_INLINE_CHAT_SIDE } from '../common/inlineChat.js'; -import { ctxIsGlobalEditingSession, ctxRequestCount } from '../../chat/browser/chatEditing/chatEditingEditorContextKeys.js'; +import { ctxHasEditorModification, ctxHasRequestInProgress, ctxIsGlobalEditingSession, ctxRequestCount } from '../../chat/browser/chatEditing/chatEditingEditorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -178,6 +178,22 @@ export abstract class AbstractInline1ChatAction extends EditorAction2 { static readonly category = localize2('cat', "Inline Chat"); constructor(desc: IAction2Options) { + + const massageMenu = (menu: IAction2Options['menu'] | undefined) => { + if (Array.isArray(menu)) { + for (const entry of menu) { + entry.when = ContextKeyExpr.and(CTX_INLINE_CHAT_HAS_AGENT, entry.when); + } + } else if (menu) { + menu.when = ContextKeyExpr.and(CTX_INLINE_CHAT_HAS_AGENT, menu.when); + } + }; + if (Array.isArray(desc.menu)) { + massageMenu(desc.menu); + } else { + massageMenu(desc.menu); + } + super({ ...desc, category: AbstractInline1ChatAction.category, @@ -385,9 +401,14 @@ export class CloseAction extends AbstractInline1ChatAction { id: MENU_INLINE_CHAT_WIDGET_STATUS, group: '0_main', order: 1, + when: CTX_INLINE_CHAT_REQUEST_IN_PROGRESS.negate() + }, { + id: MENU_INLINE_CHAT_SIDE, + group: 'navigation', when: ContextKeyExpr.and( - CTX_INLINE_CHAT_REQUEST_IN_PROGRESS.negate() - ), + CTX_INLINE_CHAT_RESPONSE_TYPE.isEqualTo(InlineChatResponseType.None), + CTX_INLINE_CHAT_HAS_AGENT2.negate(), + ) }] }); } @@ -530,6 +551,21 @@ abstract class AbstractInline2ChatAction extends EditorAction2 { static readonly category = localize2('cat', "Inline Chat"); constructor(desc: IAction2Options) { + const massageMenu = (menu: IAction2Options['menu'] | undefined) => { + if (Array.isArray(menu)) { + for (const entry of menu) { + entry.when = ContextKeyExpr.and(CTX_INLINE_CHAT_HAS_AGENT2, entry.when); + } + } else if (menu) { + menu.when = ContextKeyExpr.and(CTX_INLINE_CHAT_HAS_AGENT2, menu.when); + } + }; + if (Array.isArray(desc.menu)) { + massageMenu(desc.menu); + } else { + massageMenu(desc.menu); + } + super({ ...desc, category: AbstractInline2ChatAction.category, @@ -576,15 +612,75 @@ abstract class AbstractInline2ChatAction extends EditorAction2 { abstract runInlineChatCommand(accessor: ServicesAccessor, ctrl: InlineChatController2, editor: ICodeEditor, ...args: any[]): void; } -export class StopSessionAction2 extends AbstractInline2ChatAction { +class KeepOrUndoSessionAction extends AbstractInline2ChatAction { + + constructor(id: string, private readonly _keep: boolean) { + super({ + id, + title: _keep + ? localize2('Keep', "Keep") + : localize2('Undo', "Undo"), + f1: true, + icon: _keep ? Codicon.check : Codicon.discard, + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE, ctxHasRequestInProgress.negate()), + keybinding: [{ + weight: KeybindingWeight.WorkbenchContrib, + primary: _keep + ? KeyMod.CtrlCmd | KeyCode.Enter + : KeyMod.CtrlCmd | KeyCode.Backspace + }], + menu: [{ + id: MENU_INLINE_CHAT_WIDGET_STATUS, + group: '0_main', + order: 1, + when: ContextKeyExpr.and(CTX_INLINE_CHAT_HAS_AGENT2, ContextKeyExpr.greater(ctxRequestCount.key, 0), ctxHasEditorModification), + }] + }); + } + + override async runInlineChatCommand(accessor: ServicesAccessor, _ctrl: InlineChatController2, editor: ICodeEditor, ..._args: any[]): Promise { + const inlineChatSessions = accessor.get(IInlineChatSessionService); + if (!editor.hasModel()) { + return; + } + const textModel = editor.getModel(); + const session = inlineChatSessions.getSession2(textModel.uri); + if (session) { + if (this._keep) { + await session.editingSession.accept(); + } else { + await session.editingSession.reject(); + } + session.dispose(); + } + } +} + +export class KeepSessionAction2 extends KeepOrUndoSessionAction { + constructor() { + super('inlineChat2.keep', true); + } +} + +export class UndoSessionAction2 extends KeepOrUndoSessionAction { + constructor() { + super('inlineChat2.undo', false); + } +} + +export class CloseSessionAction2 extends AbstractInline2ChatAction { constructor() { super({ - id: 'inlineChat2.stop', - title: localize2('stop', "Undo & Close"), + id: 'inlineChat2.close', + title: localize2('close2', "Close"), f1: true, icon: Codicon.close, - precondition: CTX_INLINE_CHAT_VISIBLE, + precondition: ContextKeyExpr.and( + CTX_INLINE_CHAT_VISIBLE, + ctxHasRequestInProgress.negate(), + ContextKeyExpr.or(ctxRequestCount.isEqualTo(0), ctxHasEditorModification.negate()) + ), keybinding: [{ when: ctxRequestCount.isEqualTo(0), weight: KeybindingWeight.WorkbenchContrib, @@ -593,20 +689,25 @@ export class StopSessionAction2 extends AbstractInline2ChatAction { weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.Escape, }], - menu: { + menu: [{ id: MENU_INLINE_CHAT_SIDE, group: 'navigation', - } + when: ContextKeyExpr.and(CTX_INLINE_CHAT_HAS_AGENT2, ctxRequestCount.isEqualTo(0)), + }, { + id: MENU_INLINE_CHAT_WIDGET_STATUS, + group: '0_main', + order: 1, + when: ContextKeyExpr.and(CTX_INLINE_CHAT_HAS_AGENT2, ctxHasEditorModification.negate()), + }] }); } runInlineChatCommand(accessor: ServicesAccessor, _ctrl: InlineChatController2, editor: ICodeEditor, ...args: any[]): void { const inlineChatSessions = accessor.get(IInlineChatSessionService); - if (!editor.hasModel()) { - return; + if (editor.hasModel()) { + const textModel = editor.getModel(); + inlineChatSessions.getSession2(textModel.uri)?.dispose(); } - const textModel = editor.getModel(); - inlineChatSessions.getSession2(textModel.uri)?.dispose(); } } @@ -618,7 +719,9 @@ export class RevealWidget extends AbstractInline2ChatAction { f1: true, icon: Codicon.copilot, precondition: ContextKeyExpr.and(ctxIsGlobalEditingSession.negate(), ContextKeyExpr.greaterEquals(ctxRequestCount.key, 1)), - toggled: CTX_INLINE_CHAT_VISIBLE, + toggled: { + condition: CTX_INLINE_CHAT_VISIBLE, + }, keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyI @@ -640,3 +743,33 @@ export class RevealWidget extends AbstractInline2ChatAction { ctrl.markActiveController(); } } + +export class CancelRequestAction extends AbstractInline2ChatAction { + constructor() { + super({ + id: 'inlineChat2.cancelRequest', + title: localize2('cancel', "Cancel Request"), + f1: true, + icon: Codicon.stopCircle, + precondition: ContextKeyExpr.and(ctxIsGlobalEditingSession.negate(), ctxHasRequestInProgress), + toggled: CTX_INLINE_CHAT_VISIBLE, + menu: { + id: MenuId.ChatEditingEditorContent, + when: ContextKeyExpr.and(ctxIsGlobalEditingSession.negate(), ctxHasRequestInProgress), + group: 'a_request', + order: 1, + } + }); + } + + runInlineChatCommand(accessor: ServicesAccessor, ctrl: InlineChatController2, _editor: ICodeEditor): void { + const chatService = accessor.get(IChatService); + + const { viewModel } = ctrl.widget.chatWidget; + if (viewModel) { + ctrl.toggleWidgetUntilNextRequest(); + ctrl.markActiveController(); + chatService.cancelCurrentRequestForSession(viewModel.sessionId); + } + } +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index e9181c51ce0..9c387879fd8 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -11,11 +11,13 @@ import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; import { MovingAverage } from '../../../../base/common/numbers.js'; import { autorun, autorunWithStore, derived, IObservable, observableFromEvent, observableSignalFromEvent, observableValue, transaction, waitForState } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { assertType } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ICodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; @@ -41,10 +43,10 @@ import { IEditorService, SIDE_GROUP } from '../../../services/editor/common/edit import { IViewsService } from '../../../services/views/common/viewsService.js'; import { showChatView } from '../../chat/browser/chat.js'; import { IChatWidgetLocationOptions } from '../../chat/browser/chatWidget.js'; -import { ChatModel, ChatRequestRemovalReason, IChatRequestModel, IChatTextEditGroup, IChatTextEditGroupState, IResponse } from '../../chat/common/chatModel.js'; +import { ChatModel, ChatRequestRemovalReason, IChatRequestModel, IChatRequestVariableEntry, IChatTextEditGroup, IChatTextEditGroupState, IResponse } from '../../chat/common/chatModel.js'; import { IChatService } from '../../chat/common/chatService.js'; import { INotebookEditorService } from '../../notebook/browser/services/notebookEditorService.js'; -import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_HAS_AGENT2, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_VISIBLE, INLINE_CHAT_ID, InlineChatConfigKeys, InlineChatResponseType } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_VISIBLE, INLINE_CHAT_ID, InlineChatConfigKeys, InlineChatResponseType } from '../common/inlineChat.js'; import { HunkInformation, Session, StashedSession } from './inlineChatSession.js'; import { IInlineChatSession2, IInlineChatSessionService } from './inlineChatSessionService.js'; import { InlineChatError } from './inlineChatSessionServiceImpl.js'; @@ -53,7 +55,11 @@ import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; import { InlineChatZoneWidget } from './inlineChatZoneWidget.js'; import { ChatAgentLocation } from '../../chat/common/constants.js'; import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; -import { IChatEditingService, WorkingSetEntryState } from '../../chat/common/chatEditingService.js'; +import { IChatEditingService, ModifiedFileEntryState } from '../../chat/common/chatEditingService.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { resolveImageEditorAttachContext } from '../../chat/browser/chatAttachmentResolve.js'; export const enum State { CREATE_SESSION = 'CREATE_SESSION', @@ -79,12 +85,13 @@ export abstract class InlineChatRunOptions { initialSelection?: ISelection; initialRange?: IRange; message?: string; + attachments?: URI[]; autoSend?: boolean; existingSession?: Session; position?: IPosition; static isInlineChatRunOptions(options: any): options is InlineChatRunOptions { - const { initialSelection, initialRange, message, autoSend, position, existingSession } = options; + const { initialSelection, initialRange, message, autoSend, position, existingSession, attachments: attachments } = options; if ( typeof message !== 'undefined' && typeof message !== 'string' || typeof autoSend !== 'undefined' && typeof autoSend !== 'boolean' @@ -92,6 +99,7 @@ export abstract class InlineChatRunOptions { || typeof initialSelection !== 'undefined' && !Selection.isISelection(initialSelection) || typeof position !== 'undefined' && !Position.isIPosition(position) || typeof existingSession !== 'undefined' && !(existingSession instanceof Session) + || typeof attachments !== 'undefined' && (!Array.isArray(attachments) || !attachments.every(item => item instanceof URI)) ) { return false; } @@ -111,10 +119,10 @@ export class InlineChatController implements IEditorContribution { constructor( editor: ICodeEditor, - @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService configurationService: IConfigurationService, ) { - const inlineChat2 = observableFromEvent(this, Event.filter(contextKeyService.onDidChangeContext, e => e.affectsSome(new Set(CTX_INLINE_CHAT_HAS_AGENT2.keys()))), () => contextKeyService.contextMatchesRules(CTX_INLINE_CHAT_HAS_AGENT2)); + const inlineChat2 = observableConfigValue(InlineChatConfigKeys.EnableV2, false, configurationService); this._delegate = derived(r => { if (inlineChat2.read(r)) { @@ -199,6 +207,8 @@ export class InlineChatController1 implements IEditorContribution { @IChatService private readonly _chatService: IChatService, @IEditorService private readonly _editorService: IEditorService, @INotebookEditorService notebookEditorService: INotebookEditorService, + @ISharedWebContentExtractorService private readonly _webContentExtractorService: ISharedWebContentExtractorService, + @IFileService private readonly _fileService: IFileService, ) { this._ctxVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); this._ctxEditing = CTX_INLINE_CHAT_EDITING.bindTo(contextKeyService); @@ -574,6 +584,12 @@ export class InlineChatController1 implements IEditorContribution { barrier.open(); })); + if (options.attachments) { + await Promise.all(options.attachments.map(async attachment => { + await this._ui.value.widget.chatWidget.attachmentModel.addFile(attachment); + })); + delete options.attachments; + } if (options.autoSend) { delete options.autoSend; this._showWidget(this._session.headless, false); @@ -1166,6 +1182,21 @@ export class InlineChatController1 implements IEditorContribution { get isActive() { return Boolean(this._currentRun); } + + async createImageAttachment(attachment: URI): Promise { + if (attachment.scheme === Schemas.file) { + if (await this._fileService.canHandleResource(attachment)) { + return await resolveImageEditorAttachContext(this._fileService, this._dialogService, attachment); + } + } else if (attachment.scheme === Schemas.http || attachment.scheme === Schemas.https) { + const extractedImages = await this._webContentExtractorService.readImage(attachment, CancellationToken.None); + if (extractedImages) { + return await resolveImageEditorAttachContext(this._fileService, this._dialogService, attachment, extractedImages); + } + } + + return undefined; + } } export class InlineChatController2 implements IEditorContribution { @@ -1198,6 +1229,11 @@ export class InlineChatController2 implements IEditorContribution { @IInlineChatSessionService private readonly _inlineChatSessions: IInlineChatSessionService, @ICodeEditorService codeEditorService: ICodeEditorService, @IContextKeyService contextKeyService: IContextKeyService, + @ISharedWebContentExtractorService private readonly _webContentExtractorService: ISharedWebContentExtractorService, + @IFileService private readonly _fileService: IFileService, + @IDialogService private readonly _dialogService: IDialogService, + @IEditorService private readonly _editorService: IEditorService, + @IInlineChatSessionService inlineChatService: IInlineChatSessionService, ) { const ctxInlineChatVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); @@ -1237,8 +1273,7 @@ export class InlineChatController2 implements IEditorContribution { { enableWorkingSet: 'implicit', rendererOptions: { - renderCodeBlockPills: true, - renderTextEditsAsSummary: uri => isEqual(uri, _editor.getModel()?.uri) + renderTextEditsAsSummary: _uri => true } }, this._editor @@ -1275,7 +1310,7 @@ export class InlineChatController2 implements IEditorContribution { break; } } - if (!foundOne && _editor.hasWidgetFocus()) { + if (!foundOne && editorObs.isFocused.read(r)) { this._isActiveController.set(true, undefined); } })); @@ -1296,40 +1331,44 @@ export class InlineChatController2 implements IEditorContribution { const { chatModel } = session; const showShowUntil = this._showWidgetOverrideObs.read(r); const hasNoRequests = chatModel.getRequests().length === 0; - + const hideOnRequest = inlineChatService.hideOnRequest.read(r); const responseListener = store.add(new MutableDisposable()); - store.add(chatModel.onDidChange(e => { - if (e.kind === 'addRequest') { - transaction(tx => { - this._showWidgetOverrideObs.set(false, tx); - visibleSessionObs.set(undefined, tx); - }); - const { response } = e.request; - if (!response) { - return; - } - responseListener.value = response.onDidChange(async e => { - - if (!response.isComplete) { + if (hideOnRequest) { + // hide the request once the request has been added, reveal it again when no edit was made + // or when an error happened + store.add(chatModel.onDidChange(e => { + if (e.kind === 'addRequest') { + transaction(tx => { + this._showWidgetOverrideObs.set(false, tx); + visibleSessionObs.set(undefined, tx); + }); + const { response } = e.request; + if (!response) { return; } + responseListener.value = response.onDidChange(async e => { - const shouldShow = response.isCanceled // cancelled - || response.result?.errorDetails // errors - || !response.response.value.find(part => part.kind === 'textEditGroup' - && part.edits.length > 0 - && isEqual(part.uri, model.uri)); // NO edits for file + if (!response.isComplete) { + return; + } - if (shouldShow) { - visibleSessionObs.set(session, undefined); - } - }); - } - })); + const shouldShow = response.isCanceled // cancelled + || response.result?.errorDetails // errors + || !response.response.value.find(part => part.kind === 'textEditGroup' + && part.edits.length > 0 + && isEqual(part.uri, model.uri)); // NO edits for file - if (showShowUntil || hasNoRequests) { + if (shouldShow) { + visibleSessionObs.set(session, undefined); + } + }); + } + })); + } + + if (showShowUntil || hasNoRequests || !hideOnRequest) { visibleSessionObs.set(session, undefined); } else { visibleSessionObs.set(undefined, undefined); @@ -1352,7 +1391,23 @@ export class InlineChatController2 implements IEditorContribution { } this._zone.value.reveal(this._zone.value.position!); this._zone.value.widget.focus(); - session.editingSession.getEntry(session.uri)?.autoAcceptController.get()?.cancel(); + this._zone.value.widget.updateToolbar(true); + const entry = session.editingSession.getEntry(session.uri); + + entry?.autoAcceptController.get()?.cancel(); + + const requestCount = observableFromEvent(this, session.chatModel.onDidChange, () => session.chatModel.getRequests().length).read(r); + this._zone.value.widget.updateToolbar(requestCount > 0); + } + })); + + this._store.add(autorun(r => { + + const session = visibleSessionObs.read(r); + const entry = session?.editingSession.readEntry(session.uri, r); + const pane = this._editorService.visibleEditorPanes.find(candidate => candidate.getControl() === this._editor); + if (pane && entry) { + entry?.getEditorIntegration(pane); } })); } @@ -1394,6 +1449,12 @@ export class InlineChatController2 implements IEditorContribution { if (arg.initialSelection) { this._editor.setSelection(arg.initialSelection); } + if (arg.attachments) { + await Promise.all(arg.attachments.map(async attachment => { + await this._zone.value.widget.chatWidget.attachmentModel.addFile(attachment); + })); + delete arg.attachments; + } if (arg.message) { this._zone.value.widget.chatWidget.setInput(arg.message); if (arg.autoSend) { @@ -1404,7 +1465,7 @@ export class InlineChatController2 implements IEditorContribution { await Event.toPromise(session.editingSession.onDidDispose); - const rejected = session.editingSession.getEntry(uri)?.state.get() === WorkingSetEntryState.Rejected; + const rejected = session.editingSession.getEntry(uri)?.state.get() === ModifiedFileEntryState.Rejected; return !rejected; } @@ -1412,6 +1473,24 @@ export class InlineChatController2 implements IEditorContribution { const value = this._currentSession.get(); value?.editingSession.accept(); } + + async createImageAttachment(attachment: URI): Promise { + const value = this._currentSession.get(); + if (!value) { + return undefined; + } + if (attachment.scheme === Schemas.file) { + if (await this._fileService.canHandleResource(attachment)) { + return await resolveImageEditorAttachContext(this._fileService, this._dialogService, attachment); + } + } else if (attachment.scheme === Schemas.http || attachment.scheme === Schemas.https) { + const extractedImages = await this._webContentExtractorService.readImage(attachment, CancellationToken.None); + if (extractedImages) { + return await resolveImageEditorAttachContext(this._fileService, this._dialogService, attachment, extractedImages); + } + } + return undefined; + } } export async function reviewEdits(accessor: ServicesAccessor, editor: ICodeEditor, stream: AsyncIterable, token: CancellationToken): Promise { @@ -1423,9 +1502,9 @@ export async function reviewEdits(accessor: ServicesAccessor, editor: ICodeEdito const chatEditingService = accessor.get(IChatEditingService); const uri = editor.getModel().uri; - const chatModel = chatService.startSession(ChatAgentLocation.Editor, token); + const chatModel = chatService.startSession(ChatAgentLocation.Editor, token, false); - const editSession = await chatEditingService.createEditingSession(chatModel.sessionId); + const editSession = await chatEditingService.createEditingSession(chatModel); const store = new DisposableStore(); store.add(chatModel); @@ -1456,7 +1535,7 @@ export async function reviewEdits(accessor: ServicesAccessor, editor: ICodeEdito return false; } const state = entry.state.read(r); - return state === WorkingSetEntryState.Accepted || state === WorkingSetEntryState.Rejected; + return state === ModifiedFileEntryState.Accepted || state === ModifiedFileEntryState.Rejected; }); const whenDecided = waitForState(isSettled, Boolean); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatCurrentLine.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatCurrentLine.ts index 530120c5d97..c17f655ba9f 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatCurrentLine.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatCurrentLine.ts @@ -228,7 +228,7 @@ export class InlineChatHintsController extends Disposable implements IEditorCont const ghostState = ghostCtrl?.model.read(r)?.state.read(r); const textFocus = editorObs.isTextFocused.read(r); - const position = editorObs.cursorPosition.read(r); + let position = editorObs.cursorPosition.read(r); const model = editorObs.model.read(r); const kb = keyObs.read(r); @@ -241,12 +241,16 @@ export class InlineChatHintsController extends Disposable implements IEditorCont return undefined; } + // DEBT - I cannot use `model.onDidChangeContent` directly here // https://github.com/microsoft/vscode/issues/242059 const emitter = store.add(new Emitter()); store.add(model.onDidChangeContent(() => emitter.fire())); observableFromEvent(emitter.event, () => model.getVersionId()).read(r); + // position can be wrong + position = model.validatePosition(position); + const visible = this._visibilityObs.read(r); const isEol = model.getLineMaxColumn(position.lineNumber) === position.column; const isWhitespace = model.getLineLastNonWhitespaceColumn(position.lineNumber) === 0 && model.getValueLength() > 0 && position.column > 1; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts index a6e06ad4b36..c20396091f0 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts @@ -5,6 +5,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IActiveCodeEditor, ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { Position } from '../../../../editor/common/core/position.js'; @@ -63,6 +64,8 @@ export interface IInlineChatSessionService { dispose(): void; + hideOnRequest: IObservable; + createSession2(editor: ICodeEditor, uri: URI, token: CancellationToken): Promise; getSession2(uri: URI): IInlineChatSession2 | undefined; onDidChangeSessions: Event; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts index f3b196eb244..52169f74762 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -5,37 +5,39 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; +import { autorun, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { assertType } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IActiveCodeEditor, ICodeEditor, isCodeEditor, isCompositeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { Range } from '../../../../editor/common/core/range.js'; +import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { IValidEditOperation } from '../../../../editor/common/model.js'; import { createTextBufferFactoryFromSnapshot } from '../../../../editor/common/model/textModel.js'; import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js'; import { IModelService } from '../../../../editor/common/services/model.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { DEFAULT_EDITOR_ASSOCIATION } from '../../../common/editor.js'; -import { IChatAgentService } from '../../chat/common/chatAgents.js'; -import { IChatService } from '../../chat/common/chatService.js'; -import { CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_AGENT2, CTX_INLINE_CHAT_POSSIBLE } from '../common/inlineChat.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; import { UntitledTextEditorInput } from '../../../services/untitled/common/untitledTextEditorInput.js'; +import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { IChatAgentService } from '../../chat/common/chatAgents.js'; +import { ModifiedFileEntryState } from '../../chat/common/chatEditingService.js'; +import { IChatService } from '../../chat/common/chatService.js'; +import { ChatAgentLocation } from '../../chat/common/constants.js'; +import { CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_AGENT2, CTX_INLINE_CHAT_POSSIBLE, InlineChatConfigKeys } from '../common/inlineChat.js'; import { HunkData, Session, SessionWholeRange, StashedSession, TelemetryData, TelemetryDataClassification } from './inlineChatSession.js'; import { IInlineChatSession2, IInlineChatSessionEndEvent, IInlineChatSessionEvent, IInlineChatSessionService, ISessionKeyComputer } from './inlineChatSessionService.js'; -import { isEqual } from '../../../../base/common/resources.js'; -import { ILanguageService } from '../../../../editor/common/languages/language.js'; -import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; -import { IChatEditingService, WorkingSetEntryState } from '../../chat/common/chatEditingService.js'; -import { assertType } from '../../../../base/common/types.js'; -import { autorun } from '../../../../base/common/observable.js'; -import { ResourceMap } from '../../../../base/common/map.js'; -import { IChatWidgetService } from '../../chat/browser/chat.js'; -import { ChatAgentLocation } from '../../chat/common/constants.js'; type SessionData = { @@ -74,6 +76,8 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { private readonly _sessions = new Map(); private readonly _keyComputers = new Map(); + readonly hideOnRequest: IObservable; + constructor( @ITelemetryService private readonly _telemetryService: ITelemetryService, @IModelService private readonly _modelService: IModelService, @@ -86,9 +90,11 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { @ILanguageService private readonly _languageService: ILanguageService, @IChatService private readonly _chatService: IChatService, @IChatAgentService private readonly _chatAgentService: IChatAgentService, - @IChatEditingService private readonly _chatEditingService: IChatEditingService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - ) { } + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + this.hideOnRequest = observableConfigValue(InlineChatConfigKeys.HideOnRequest, false, this._configurationService); + } dispose() { this._store.dispose(); @@ -165,7 +171,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { })); store.add(this._chatAgentService.onDidChangeAgents(e => { - if (e === undefined && (!this._chatAgentService.getAgent(agent.id) || !this._chatAgentService.getActivatedAgents().includes(agent))) { + if (e === undefined && (!this._chatAgentService.getAgent(agent.id) || !this._chatAgentService.getActivatedAgents().map(agent => agent.id).includes(agent.id))) { this._logService.trace(`[IE] provider GONE for ${editor.getId()}, ${agent.extensionId}`); this._releaseSession(session, true); } @@ -338,11 +344,11 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { this._onWillStartSession.fire(editor as IActiveCodeEditor); - const chatModel = this._chatService.startSession(ChatAgentLocation.EditingSession, token); + const chatModel = this._chatService.startSession(ChatAgentLocation.Panel, token, false); - const editingSession = await this._chatEditingService.createEditingSession(chatModel.sessionId); + const editingSession = await chatModel.editingSessionObs?.promise!; const widget = this._chatWidgetService.getWidgetBySessionId(chatModel.sessionId); - widget?.attachmentModel.addFile(uri); + await widget?.attachmentModel.addFile(uri); const store = new DisposableStore(); store.add(toDisposable(() => { @@ -351,7 +357,6 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { this._sessions2.delete(uri); this._onDidChangeSessions.fire(this); })); - store.add(editingSession); store.add(chatModel); store.add(autorun(r => { @@ -363,10 +368,11 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { const allSettled = entries.every(entry => { const state = entry.state.read(r); - return state === WorkingSetEntryState.Accepted || state === WorkingSetEntryState.Rejected; + return (state === ModifiedFileEntryState.Accepted || state === ModifiedFileEntryState.Rejected) + && !entry.isCurrentlyBeingModifiedBy.read(r); }); - if (allSettled) { + if (allSettled && !chatModel.requestInProgress) { // self terminate store.dispose(); } @@ -385,7 +391,19 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { } getSession2(uri: URI): IInlineChatSession2 | undefined { - return this._sessions2.get(uri); + let result = this._sessions2.get(uri); + if (!result) { + // no direct session, try to find an editing session which has a file entry for the uri + for (const [_, candidate] of this._sessions2) { + const entry = candidate.editingSession.getEntry(uri); + if (entry) { + result = candidate; + break; + } + } + } + + return result; } } @@ -403,27 +421,29 @@ export class InlineChatEnabler { @IContextKeyService contextKeyService: IContextKeyService, @IChatAgentService chatAgentService: IChatAgentService, @IEditorService editorService: IEditorService, + @IConfigurationService configService: IConfigurationService, ) { this._ctxHasProvider = CTX_INLINE_CHAT_HAS_AGENT.bindTo(contextKeyService); this._ctxHasProvider2 = CTX_INLINE_CHAT_HAS_AGENT2.bindTo(contextKeyService); this._ctxPossible = CTX_INLINE_CHAT_POSSIBLE.bindTo(contextKeyService); - const updateAgent = () => { - const agent = chatAgentService.getDefaultAgent(ChatAgentLocation.Editor); - if (agent?.id === 'github.copilot.editor') { - this._ctxHasProvider.set(true); + const agentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.Editor)); + const inlineChat2Obs = observableConfigValue(InlineChatConfigKeys.EnableV2, false, configService); + + this._store.add(autorun(r => { + const v2 = inlineChat2Obs.read(r); + const agent = agentObs.read(r); + if (!agent) { + this._ctxHasProvider.reset(); this._ctxHasProvider2.reset(); - } else if (agent?.id === 'github.copilot.editingSessionEditor') { + } else if (v2) { this._ctxHasProvider.reset(); this._ctxHasProvider2.set(true); } else { - this._ctxHasProvider.reset(); + this._ctxHasProvider.set(true); this._ctxHasProvider2.reset(); } - }; - - this._store.add(chatAgentService.onDidChangeAgents(updateAgent)); - updateAgent(); + })); const updateEditor = () => { const ctrl = editorService.activeEditorPane?.getControl(); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts index d3d0b420edf..6207826407e 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts @@ -157,6 +157,7 @@ export class InlineChatWidget { } return true; }, + dndContainer: this._elements.root, ..._options.chatWidgetViewOptions }, { @@ -471,7 +472,7 @@ export class InlineChatWidget { } reset() { - this._chatWidget.attachmentModel.clear(); + this._chatWidget.attachmentModel.clear(true); this._chatWidget.saveState(); reset(this._elements.statusLabel); diff --git a/src/vs/workbench/contrib/inlineChat/browser/utils.ts b/src/vs/workbench/contrib/inlineChat/browser/utils.ts index 020cd9e0e58..c7db4044055 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/utils.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/utils.ts @@ -11,6 +11,7 @@ import { IProgress } from '../../../../platform/progress/common/progress.js'; import { IntervalTimer, AsyncIterableSource } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { getNWords } from '../../chat/common/chatWordCounter.js'; +import { TextModelChangeRecorder } from '../../../../editor/contrib/inlineCompletions/browser/model/changeRecorder.js'; @@ -47,9 +48,11 @@ export async function performAsyncTextEdit(model: ITextModel, edit: AsyncTextEdi ? EditOperation.replace(range, part) // first edit needs to override the "anchor" : EditOperation.insert(range.getEndPosition(), part); obs?.start(); - model.pushEditOperations(null, [edit], (undoEdits) => { - progress?.report(undoEdits); - return null; + TextModelChangeRecorder.editWithMetadata({ source: 'inlineChat.applyEdit' }, () => { + model.pushEditOperations(null, [edit], (undoEdits) => { + progress?.report(undoEdits); + return null; + }); }); obs?.stop(); first = false; diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 3b6979b8f10..a3436539e99 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -18,7 +18,9 @@ export const enum InlineChatConfigKeys { HoldToSpeech = 'inlineChat.holdToSpeech', AccessibleDiffView = 'inlineChat.accessibleDiffView', LineEmptyHint = 'inlineChat.lineEmptyHint', - LineNLHint = 'inlineChat.lineNaturalLanguageHint' + LineNLHint = 'inlineChat.lineNaturalLanguageHint', + EnableV2 = 'inlineChat.enableV2', + HideOnRequest = 'inlineChat.hideOnRequest' } Registry.as(Extensions.Configuration).registerConfiguration({ @@ -57,6 +59,18 @@ Registry.as(Extensions.Configuration).registerConfigurat type: 'boolean', tags: ['experimental'], }, + [InlineChatConfigKeys.EnableV2]: { + description: localize('enableV2', "Whether to use the next version of inline chat."), + default: false, + type: 'boolean', + tags: ['preview', 'onExp'], + }, + [InlineChatConfigKeys.HideOnRequest]: { + description: localize('hideOnRequest', "Whether to hide the inline chat widget after making a request. When enabled, the widget hides after a request has been made and instead the chat overlay shows. When hidden, the widget can always be shown again with the inline chat keybinding or from the chat overlay widget."), + default: false, + type: 'boolean', + tags: ['preview', 'onExp'], + }, } }); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts index 49aa40744ee..f7fedd73265 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts @@ -71,7 +71,9 @@ import { ChatInputBoxContentProvider } from '../../../chat/browser/chatEdinputIn import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { ILanguageModelToolsService } from '../../../chat/common/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../../../chat/test/common/mockLanguageModelToolsService.js'; -import { ChatAgentLocation } from '../../../chat/common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../../../chat/common/constants.js'; +import { IPromptsService } from '../../../chat/common/promptSyntax/service/types.js'; +import { URI } from '../../../../../base/common/uri.js'; suite('InlineChatController', function () { @@ -84,6 +86,7 @@ suite('InlineChatController', function () { name: 'testEditorAgent', isDefault: true, locations: [ChatAgentLocation.Editor], + modes: [ChatMode.Ask], metadata: {}, slashCommands: [], disambiguation: [], @@ -195,6 +198,11 @@ suite('InlineChatController', function () { [ILanguageModelsService, new SyncDescriptor(LanguageModelsService)], [ITextModelService, new SyncDescriptor(TextModelResolverService)], [ILanguageModelToolsService, new SyncDescriptor(MockLanguageModelToolsService)], + [IPromptsService, new class extends mock() { + override async findInstructionFilesFor(_file: readonly URI[]): Promise { + return []; + } + }], ); instaService = store.add((store.add(workbenchInstantiationService(undefined, store))).createChild(serviceCollection)); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts index 5e2024222e7..e5562966c0a 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts @@ -62,7 +62,8 @@ import { IChatRequestModel } from '../../../chat/common/chatModel.js'; import { assertSnapshot } from '../../../../../base/test/common/snapshot.js'; import { IObservable, constObservable } from '../../../../../base/common/observable.js'; import { IChatEditingService, IChatEditingSession } from '../../../chat/common/chatEditingService.js'; -import { ChatAgentLocation } from '../../../chat/common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../../../chat/common/constants.js'; +import { ChatTransferService, IChatTransferService } from '../../../chat/common/chatTransferService.js'; suite('InlineChatSession', function () { @@ -89,6 +90,7 @@ suite('InlineChatSession', function () { [IChatWidgetHistoryService, new SyncDescriptor(ChatWidgetHistoryService)], [IChatWidgetService, new SyncDescriptor(ChatWidgetService)], [IChatSlashCommandService, new SyncDescriptor(ChatSlashCommandService)], + [IChatTransferService, new SyncDescriptor(ChatTransferService)], [IChatService, new SyncDescriptor(ChatService)], [IEditorWorkerService, new SyncDescriptor(TestWorkerService)], [IChatAgentService, new SyncDescriptor(ChatAgentService)], @@ -139,6 +141,7 @@ suite('InlineChatSession', function () { name: 'testAgent', isDefault: true, locations: [ChatAgentLocation.Editor], + modes: [ChatMode.Ask], metadata: {}, slashCommands: [], disambiguation: [], diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/testWorkerService.ts b/src/vs/workbench/contrib/inlineChat/test/browser/testWorkerService.ts index b8e2b138690..416e3cf3a47 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/testWorkerService.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/testWorkerService.ts @@ -9,7 +9,7 @@ import { IModelService } from '../../../../../editor/common/services/model.js'; import { assertType } from '../../../../../base/common/types.js'; import { DiffAlgorithmName, IEditorWorkerService, ILineChange } from '../../../../../editor/common/services/editorWorker.js'; import { IDocumentDiff, IDocumentDiffProviderOptions } from '../../../../../editor/common/diff/documentDiffProvider.js'; -import { BaseEditorSimpleWorker } from '../../../../../editor/common/services/editorSimpleWorker.js'; +import { EditorWorker } from '../../../../../editor/common/services/editorWebWorker.js'; import { LineRange } from '../../../../../editor/common/core/lineRange.js'; import { MovedText } from '../../../../../editor/common/diff/linesDiffComputer.js'; import { LineRangeMapping, DetailedLineRangeMapping, RangeMapping } from '../../../../../editor/common/diff/rangeMapping.js'; @@ -18,7 +18,7 @@ import { TextEdit } from '../../../../../editor/common/languages.js'; export class TestWorkerService extends mock() { - private readonly _worker = new BaseEditorSimpleWorker(); + private readonly _worker = new EditorWorker(); constructor(@IModelService private readonly _modelService: IModelService) { super(); diff --git a/src/vs/workbench/contrib/inlineCompletions/browser/inlineCompletionLanguageStatusBarContribution.ts b/src/vs/workbench/contrib/inlineCompletions/browser/inlineCompletionLanguageStatusBarContribution.ts index 75930b380dd..586e2251675 100644 --- a/src/vs/workbench/contrib/inlineCompletions/browser/inlineCompletionLanguageStatusBarContribution.ts +++ b/src/vs/workbench/contrib/inlineCompletions/browser/inlineCompletionLanguageStatusBarContribution.ts @@ -3,16 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../nls.js'; import { createHotClass } from '../../../../base/common/hotReloadHelpers.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorunWithStore, derived } from '../../../../base/common/observable.js'; -import { debouncedObservable } from '../../../../base/common/observableInternal/utils.js'; +import { autorunWithStore, debouncedObservable, derived } from '../../../../base/common/observable.js'; import Severity from '../../../../base/common/severity.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; -import { InlineCompletionsController } from '../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; -import { ILanguageStatusService } from '../../../services/languageStatus/common/languageStatusService.js'; import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; +import { InlineCompletionsController } from '../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; +import { localize } from '../../../../nls.js'; +import { ILanguageStatusService } from '../../../services/languageStatus/common/languageStatusService.js'; export class InlineCompletionLanguageStatusBarContribution extends Disposable { public static readonly hot = createHotClass(InlineCompletionLanguageStatusBarContribution); diff --git a/src/vs/workbench/contrib/issue/browser/baseIssueReporterService.ts b/src/vs/workbench/contrib/issue/browser/baseIssueReporterService.ts index c5074ca006a..bbaeec9bde6 100644 --- a/src/vs/workbench/contrib/issue/browser/baseIssueReporterService.ts +++ b/src/vs/workbench/contrib/issue/browser/baseIssueReporterService.ts @@ -454,14 +454,16 @@ export class BaseIssueReporterService extends Disposable { descriptionTextArea.placeholder = localize('undefinedPlaceholder', "Please enter a title"); } - let fileOnExtension, fileOnMarketplace = false; + let fileOnExtension, fileOnMarketplace, fileOnProduct = false; if (value === IssueSource.Extension) { fileOnExtension = true; } else if (value === IssueSource.Marketplace) { fileOnMarketplace = true; + } else if (value === IssueSource.VSCode) { + fileOnProduct = true; } - this.issueReporterModel.update({ fileOnExtension, fileOnMarketplace }); + this.issueReporterModel.update({ fileOnExtension, fileOnMarketplace, fileOnProduct }); this.render(); const title = (this.getElementById('issue-title')).value; @@ -1153,9 +1155,11 @@ export class BaseIssueReporterService extends Disposable { const baseUrl = this.getIssueUrlWithTitle((this.getElementById('issue-title')).value, issueUrl); let url = baseUrl + `&body=${encodeURIComponent(issueBody)}`; + url += this.addTemplateToUrl(gitHubDetails?.owner, gitHubDetails?.repositoryName); + if (url.length > MAX_URL_LENGTH) { try { - url = await this.writeToClipboard(baseUrl, issueBody); + url = await this.writeToClipboard(baseUrl, issueBody) + this.addTemplateToUrl(gitHubDetails?.owner, gitHubDetails?.repositoryName); } catch (_) { console.error('Writing to clipboard failed'); return false; @@ -1176,6 +1180,20 @@ export class BaseIssueReporterService extends Disposable { return baseUrl + `&body=${encodeURIComponent(localize('pasteData', "We have written the needed data into your clipboard because it was too large to send. Please paste."))}`; } + public addTemplateToUrl(owner?: string, repositoryName?: string): string { + const isVscode = this.issueReporterModel.getData().fileOnProduct; + const isCopilot = owner === 'microsoft' && repositoryName === 'vscode-copilot-release'; + if (isVscode) { + return `&template=bug_report.md`; + } + + if (isCopilot) { + return `&template=bug_report_chat.md`; + } + + return ''; + } + public getIssueUrl(): string { return this.issueReporterModel.fileOnExtension() ? this.getExtensionGitHubUrl() diff --git a/src/vs/workbench/contrib/issue/common/issue.contribution.ts b/src/vs/workbench/contrib/issue/common/issue.contribution.ts index 43be68be59e..0c1effb4b15 100644 --- a/src/vs/workbench/contrib/issue/common/issue.contribution.ts +++ b/src/vs/workbench/contrib/issue/common/issue.contribution.ts @@ -66,7 +66,7 @@ export class BaseIssueContribution extends Disposable implements IWorkbenchContr ) { super(); - if (configurationService.getValue('telemetry.disableFeedback')) { + if (!configurationService.getValue('telemetry.feedback.enabled')) { this._register(CommandsRegistry.registerCommand({ id: 'workbench.action.openIssueReporter', handler: function (accessor) { diff --git a/src/vs/workbench/contrib/issue/electron-sandbox/issue.contribution.ts b/src/vs/workbench/contrib/issue/electron-sandbox/issue.contribution.ts index 9d0bcff4959..547921c95de 100644 --- a/src/vs/workbench/contrib/issue/electron-sandbox/issue.contribution.ts +++ b/src/vs/workbench/contrib/issue/electron-sandbox/issue.contribution.ts @@ -35,7 +35,7 @@ class NativeIssueContribution extends BaseIssueContribution { ) { super(productService, configurationService); - if (configurationService.getValue('telemetry.disableFeedback')) { + if (!configurationService.getValue('telemetry.feedback.enabled')) { return; } diff --git a/src/vs/workbench/contrib/issue/electron-sandbox/issueReporterService.ts b/src/vs/workbench/contrib/issue/electron-sandbox/issueReporterService.ts index bb27e35e45d..ae07c39997a 100644 --- a/src/vs/workbench/contrib/issue/electron-sandbox/issueReporterService.ts +++ b/src/vs/workbench/contrib/issue/electron-sandbox/issueReporterService.ts @@ -228,6 +228,8 @@ export class IssueReporter extends BaseIssueReporterService { const baseUrl = this.getIssueUrlWithTitle((this.getElementById('issue-title')).value, issueUrl); let url = baseUrl + `&body=${encodeURIComponent(issueBody)}`; + url += this.addTemplateToUrl(gitHubDetails?.owner, gitHubDetails?.repositoryName); + if (this.data.githubAccessToken && gitHubDetails) { if (await this.submitToGitHub(issueTitle, issueBody, gitHubDetails)) { return true; @@ -236,7 +238,7 @@ export class IssueReporter extends BaseIssueReporterService { try { if (url.length > MAX_URL_LENGTH || issueBody.length > MAX_GITHUB_API_LENGTH) { - url = await this.writeToClipboard(baseUrl, issueBody); + url = await this.writeToClipboard(baseUrl, issueBody) + this.addTemplateToUrl(gitHubDetails?.owner, gitHubDetails?.repositoryName); } } catch (_) { console.error('Writing to clipboard failed'); diff --git a/src/vs/workbench/contrib/languageStatus/browser/languageStatus.ts b/src/vs/workbench/contrib/languageStatus/browser/languageStatus.ts index 17bff692d74..329707002d5 100644 --- a/src/vs/workbench/contrib/languageStatus/browser/languageStatus.ts +++ b/src/vs/workbench/contrib/languageStatus/browser/languageStatus.ts @@ -308,16 +308,10 @@ class LanguageStatus { left.classList.add('left'); element.appendChild(left); - const label = document.createElement('span'); - label.classList.add('label'); - const labelValue = typeof status.label === 'string' ? status.label : status.label.value; - dom.append(label, ...renderLabelWithIcons(computeText(labelValue, status.busy))); - left.appendChild(label); + const label = typeof status.label === 'string' ? status.label : status.label.value; + dom.append(left, ...renderLabelWithIcons(computeText(label, status.busy))); - const detail = document.createElement('span'); - detail.classList.add('detail'); - this._renderTextPlus(detail, status.detail, store); - left.appendChild(detail); + this._renderTextPlus(left, status.detail, store); const right = document.createElement('div'); right.classList.add('right'); @@ -379,7 +373,12 @@ class LanguageStatus { } private _renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void { + let didRenderSeparator = false; for (const node of parseLinkedText(text).nodes) { + if (!didRenderSeparator) { + dom.append(target, dom.$('span.separator')); + didRenderSeparator = true; + } if (typeof node === 'string') { const parts = renderLabelWithIcons(node); dom.append(target, ...parts); diff --git a/src/vs/workbench/contrib/languageStatus/browser/media/languageStatus.css b/src/vs/workbench/contrib/languageStatus/browser/media/languageStatus.css index 25433bee4a4..60fc3e78f2b 100644 --- a/src/vs/workbench/contrib/languageStatus/browser/media/languageStatus.css +++ b/src/vs/workbench/contrib/languageStatus/browser/media/languageStatus.css @@ -87,18 +87,22 @@ flex-grow: 100; } -.monaco-workbench .hover-language-status > .element > .left > .detail:not(:empty)::before { +.monaco-workbench .hover-language-status > .element > .left > .separator::before { content: '\2013'; - padding: 0 4px; + padding: 0 2px; opacity: 0.6; } -.monaco-workbench .hover-language-status > .element > .left > .label:empty { +.monaco-workbench .hover-language-status > .element > .left:empty { display: none; } .monaco-workbench .hover-language-status > .element .left { margin: auto 0; + display: flex; + align-items: center; + gap: 3px; + white-space: nowrap; } .monaco-workbench .hover-language-status > .element .right { diff --git a/src/vs/workbench/contrib/localization/electron-sandbox/localization.contribution.ts b/src/vs/workbench/contrib/localization/electron-sandbox/localization.contribution.ts index 9dfa4afa29a..4201a6563f1 100644 --- a/src/vs/workbench/contrib/localization/electron-sandbox/localization.contribution.ts +++ b/src/vs/workbench/contrib/localization/electron-sandbox/localization.contribution.ts @@ -9,7 +9,7 @@ import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } fr import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import * as platform from '../../../../base/common/platform.js'; import { IExtensionManagementService, IExtensionGalleryService, InstallOperation, ILocalExtension, InstallExtensionResult, DidUninstallExtensionEvent } from '../../../../platform/extensionManagement/common/extensionManagement.js'; -import { INotificationService, NeverShowAgainScope } from '../../../../platform/notification/common/notification.js'; +import { INotificationService, NeverShowAgainScope, NotificationPriority } from '../../../../platform/notification/common/notification.js'; import Severity from '../../../../base/common/severity.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; @@ -73,6 +73,7 @@ class NativeLocalizationWorkbenchContribution extends BaseLocalizationWorkbenchC }], { sticky: true, + priority: NotificationPriority.URGENT, neverShowAgain: { id: 'langugage.update.donotask', isSecondary: true, scope: NeverShowAgainScope.APPLICATION } } ); @@ -205,6 +206,7 @@ class NativeLocalizationWorkbenchContribution extends BaseLocalizationWorkbenchC } }], { + priority: NotificationPriority.OPTIONAL, onCancel: () => { logUserReaction('cancelled'); } diff --git a/src/vs/workbench/contrib/markers/browser/markersViewActions.ts b/src/vs/workbench/contrib/markers/browser/markersViewActions.ts index fdd6529ef23..efbf85fa4cf 100644 --- a/src/vs/workbench/contrib/markers/browser/markersViewActions.ts +++ b/src/vs/workbench/contrib/markers/browser/markersViewActions.ts @@ -9,7 +9,7 @@ import { IContextMenuService } from '../../../../platform/contextview/browser/co import Messages from './messages.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Marker } from './markersModel.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -39,20 +39,30 @@ export class MarkersFilters extends Disposable { private readonly _onDidChange: Emitter = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - constructor(options: IMarkersFiltersOptions, private readonly contextKeyService: IContextKeyService) { + constructor(options: IMarkersFiltersOptions, contextKeyService: IContextKeyService) { super(); - this._showErrors.set(options.showErrors); - this._showWarnings.set(options.showWarnings); - this._showInfos.set(options.showInfos); + this._excludedFiles = MarkersContextKeys.ShowExcludedFilesFilterContextKey.bindTo(contextKeyService); this._excludedFiles.set(options.excludedFiles); + + this._activeFile = MarkersContextKeys.ShowActiveFileFilterContextKey.bindTo(contextKeyService); this._activeFile.set(options.activeFile); + + this._showWarnings = MarkersContextKeys.ShowWarningsFilterContextKey.bindTo(contextKeyService); + this._showWarnings.set(options.showWarnings); + + this._showInfos = MarkersContextKeys.ShowInfoFilterContextKey.bindTo(contextKeyService); + this._showInfos.set(options.showInfos); + + this._showErrors = MarkersContextKeys.ShowErrorsFilterContextKey.bindTo(contextKeyService); + this._showErrors.set(options.showErrors); + this.filterHistory = options.filterHistory; } filterHistory: string[]; - private readonly _excludedFiles = MarkersContextKeys.ShowExcludedFilesFilterContextKey.bindTo(this.contextKeyService); + private readonly _excludedFiles: IContextKey; get excludedFiles(): boolean { return !!this._excludedFiles.get(); } @@ -63,7 +73,7 @@ export class MarkersFilters extends Disposable { } } - private readonly _activeFile = MarkersContextKeys.ShowActiveFileFilterContextKey.bindTo(this.contextKeyService); + private readonly _activeFile: IContextKey; get activeFile(): boolean { return !!this._activeFile.get(); } @@ -74,7 +84,7 @@ export class MarkersFilters extends Disposable { } } - private readonly _showWarnings = MarkersContextKeys.ShowWarningsFilterContextKey.bindTo(this.contextKeyService); + private readonly _showWarnings: IContextKey; get showWarnings(): boolean { return !!this._showWarnings.get(); } @@ -85,7 +95,7 @@ export class MarkersFilters extends Disposable { } } - private readonly _showErrors = MarkersContextKeys.ShowErrorsFilterContextKey.bindTo(this.contextKeyService); + private readonly _showErrors: IContextKey; get showErrors(): boolean { return !!this._showErrors.get(); } @@ -96,7 +106,7 @@ export class MarkersFilters extends Disposable { } } - private readonly _showInfos = MarkersContextKeys.ShowInfoFilterContextKey.bindTo(this.contextKeyService); + private readonly _showInfos: IContextKey; get showInfos(): boolean { return !!this._showInfos.get(); } diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index f886d782765..b4b3aaed20f 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -14,30 +14,46 @@ import { ConfigMcpDiscovery } from '../common/discovery/configMcpDiscovery.js'; import { ExtensionMcpDiscovery } from '../common/discovery/extensionMcpDiscovery.js'; import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js'; import { RemoteNativeMpcDiscovery } from '../common/discovery/nativeMcpRemoteDiscovery.js'; +import { CursorWorkspaceMcpDiscoveryAdapter } from '../common/discovery/workspaceMcpDiscoveryAdapter.js'; +import { IMcpConfigPathsService, McpConfigPathsService } from '../common/mcpConfigPathsService.js'; import { mcpServerSchema } from '../common/mcpConfiguration.js'; import { McpContextKeysController } from '../common/mcpContextKeys.js'; import { McpRegistry } from '../common/mcpRegistry.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; import { McpService } from '../common/mcpService.js'; import { IMcpService } from '../common/mcpTypes.js'; -import { AddConfigurationAction, ListMcpServerCommand, MCPServerActionRendering, McpServerOptionsCommand, ResetMcpCachedTools, ResetMcpTrustCommand } from './mcpCommands.js'; +import { AddConfigurationAction, EditStoredInput, InstallFromActivation, ListMcpServerCommand, MCPServerActionRendering, McpServerOptionsCommand, RemoveStoredInput, ResetMcpCachedTools, ResetMcpTrustCommand, RestartServer, ShowConfiguration, ShowOutput, StartServer, StopServer } from './mcpCommands.js'; import { McpDiscovery } from './mcpDiscovery.js'; +import { McpLanguageFeatures } from './mcpLanguageFeatures.js'; +import { McpUrlHandler } from './mcpUrlHandler.js'; registerSingleton(IMcpRegistry, McpRegistry, InstantiationType.Delayed); registerSingleton(IMcpService, McpService, InstantiationType.Delayed); +registerSingleton(IMcpConfigPathsService, McpConfigPathsService, InstantiationType.Delayed); mcpDiscoveryRegistry.register(new SyncDescriptor(RemoteNativeMpcDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(ConfigMcpDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(ExtensionMcpDiscovery)); +mcpDiscoveryRegistry.register(new SyncDescriptor(CursorWorkspaceMcpDiscoveryAdapter)); registerWorkbenchContribution2('mcpDiscovery', McpDiscovery, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2('mcpContextKeys', McpContextKeysController, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2('mcpLanguageFeatures', McpLanguageFeatures, WorkbenchPhase.Eventually); +registerWorkbenchContribution2('mcpUrlHandler', McpUrlHandler, WorkbenchPhase.BlockRestore); registerAction2(ListMcpServerCommand); registerAction2(McpServerOptionsCommand); registerAction2(ResetMcpTrustCommand); registerAction2(ResetMcpCachedTools); registerAction2(AddConfigurationAction); +registerAction2(RemoveStoredInput); +registerAction2(EditStoredInput); +registerAction2(StartServer); +registerAction2(StopServer); +registerAction2(ShowOutput); +registerAction2(InstallFromActivation); +registerAction2(RestartServer); +registerAction2(ShowConfiguration); registerWorkbenchContribution2('mcpActionRendering', MCPServerActionRendering, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 1fcf57c33a4..6166392e158 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -17,10 +17,13 @@ import { IActionViewItemService } from '../../../../platform/actions/browser/act import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { Action2, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { spinningLoading } from '../../../../platform/theme/common/iconRegistry.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { ActiveEditorContext, ResourceContextKey } from '../../../common/contextkeys.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -31,6 +34,8 @@ import { McpContextKeys } from '../common/mcpContextKeys.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; import { IMcpServer, IMcpService, LazyCollectionState, McpConnectionState, McpServerToolsState } from '../common/mcpTypes.js'; import { McpAddConfigurationCommand } from './mcpCommandsAddConfiguration.js'; +import { McpUrlHandler } from './mcpUrlHandler.js'; +import { McpCommandIds } from '../common/mcpCommandIds.js'; // acroynms do not get localized const category: ILocalizedString = { @@ -39,10 +44,9 @@ const category: ILocalizedString = { }; export class ListMcpServerCommand extends Action2 { - public static readonly id = 'workbench.mcp.listServer'; constructor() { super({ - id: ListMcpServerCommand.id, + id: McpCommandIds.ListServer, title: localize2('mcp.list', 'List Servers'), icon: Codicon.server, category, @@ -52,9 +56,9 @@ export class ListMcpServerCommand extends Action2 { ContextKeyExpr.or(McpContextKeys.hasUnknownTools, McpContextKeys.hasServersWithErrors), ChatContextKeys.chatMode.isEqualTo(ChatMode.Agent) ), - id: MenuId.ChatInputAttachmentToolbar, + id: MenuId.ChatInput, group: 'navigation', - order: 0 + order: 101 }, }); } @@ -68,21 +72,28 @@ export class ListMcpServerCommand extends Action2 { const store = new DisposableStore(); const pick = quickInput.createQuickPick({ useSeparators: true }); - pick.title = localize('mcp.selectServer', 'Select an MCP Server'); + pick.placeholder = localize('mcp.selectServer', 'Select an MCP Server'); store.add(pick); + store.add(autorun(reader => { const servers = groupBy(mcpService.servers.read(reader).slice().sort((a, b) => (a.collection.presentation?.order || 0) - (b.collection.presentation?.order || 0)), s => s.collection.id); - pick.items = Object.values(servers).flatMap(servers => { - return [ + const firstRun = pick.items.length === 0; + pick.items = [ + { id: '$add', label: localize('mcp.addServer', 'Add Server'), description: localize('mcp.addServer.description', 'Add a new server configuration'), alwaysShow: true, iconClass: ThemeIcon.asClassName(Codicon.add) }, + ...Object.values(servers).filter(s => s.length).flatMap((servers): (ItemType | IQuickPickSeparator)[] => [ { type: 'separator', label: servers[0].collection.label, id: servers[0].collection.id }, ...servers.map(server => ({ id: server.definition.id, label: server.definition.label, description: McpConnectionState.toString(server.connectionState.read(reader)), })), - ]; - }); + ]), + ]; + + if (firstRun && pick.items.length > 3) { + pick.activeItems = pick.items.slice(2, 3) as ItemType[]; // select the first server by default + } })); @@ -98,20 +109,21 @@ export class ListMcpServerCommand extends Action2 { store.dispose(); - if (picked) { - commandService.executeCommand(McpServerOptionsCommand.id, picked.id); + if (!picked) { + // no-op + } else if (picked.id === '$add') { + commandService.executeCommand(AddConfigurationAction.ID); + } else { + commandService.executeCommand(McpCommandIds.ServerOptions, picked.id); } } } export class McpServerOptionsCommand extends Action2 { - - static readonly id = 'workbench.mcp.serverOptions'; - constructor() { super({ - id: McpServerOptionsCommand.id, + id: McpCommandIds.ServerOptions, title: localize2('mcp.options', 'Server Options'), category, f1: false, @@ -205,10 +217,7 @@ export class McpServerOptionsCommand extends Action2 { } } - export class MCPServerActionRendering extends Disposable implements IWorkbenchContribution { - public static readonly ID = 'workbench.contrib.mcp.discovery'; - constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IMcpService mcpService: IMcpService, @@ -233,6 +242,7 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo let thisState = DisplayedState.None; switch (server.toolsState.read(reader)) { case McpServerToolsState.Unknown: + case McpServerToolsState.Outdated: if (server.trusted.read(reader) === false) { thisState = DisplayedState.None; } else { @@ -262,7 +272,7 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo return { state: maxState, servers: serversPerState[maxState] || [] }; }); - this._store.add(actionViewItemService.register(MenuId.ChatInputAttachmentToolbar, ListMcpServerCommand.id, (action, options) => { + this._store.add(actionViewItemService.register(MenuId.ChatInput, McpCommandIds.ListServer, (action, options) => { if (!(action instanceof MenuItemAction)) { return undefined; } @@ -310,17 +320,17 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo const { state, servers } = displayedState.get(); if (state === DisplayedState.NewTools) { - servers.forEach(server => server.start()); + servers.forEach(server => server.stop().then(() => server.start())); mcpService.activateCollections(); } else if (state === DisplayedState.Refreshing) { servers.at(-1)?.showOutput(); } else if (state === DisplayedState.Error) { const server = servers.at(-1); if (server) { - commandService.executeCommand(McpServerOptionsCommand.id, server.definition.id); + commandService.executeCommand(McpCommandIds.ServerOptions, server.definition.id); } } else { - commandService.executeCommand(ListMcpServerCommand.id); + commandService.executeCommand(McpCommandIds.ListServer); } } @@ -348,11 +358,9 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo } export class ResetMcpTrustCommand extends Action2 { - static readonly ID = 'workbench.mcp.resetTrust'; - constructor() { super({ - id: ResetMcpTrustCommand.ID, + id: McpCommandIds.ResetTrust, title: localize2('mcp.resetTrust', "Reset Trust"), category, f1: true, @@ -368,11 +376,9 @@ export class ResetMcpTrustCommand extends Action2 { export class ResetMcpCachedTools extends Action2 { - static readonly ID = 'workbench.mcp.resetCachedTools'; - constructor() { super({ - id: ResetMcpCachedTools.ID, + id: McpCommandIds.ResetCachedTools, title: localize2('mcp.resetCachedTools', "Reset Cached Tools"), category, f1: true, @@ -412,3 +418,151 @@ export class AddConfigurationAction extends Action2 { return accessor.get(IInstantiationService).createInstance(McpAddConfigurationCommand, configUri).run(); } } + + +export class RemoveStoredInput extends Action2 { + constructor() { + super({ + id: McpCommandIds.RemoveStoredInput, + title: localize2('mcp.resetCachedTools', "Reset Cached Tools"), + category, + f1: false, + }); + } + + run(accessor: ServicesAccessor, scope: StorageScope, id?: string): void { + accessor.get(IMcpRegistry).clearSavedInputs(scope, id); + } +} + +export class EditStoredInput extends Action2 { + constructor() { + super({ + id: McpCommandIds.EditStoredInput, + title: localize2('mcp.editStoredInput', "Edit Stored Input"), + category, + f1: false, + }); + } + + run(accessor: ServicesAccessor, inputId: string, uri: URI | undefined, configSection: string, target: ConfigurationTarget): void { + const workspaceFolder = uri && accessor.get(IWorkspaceContextService).getWorkspaceFolder(uri); + accessor.get(IMcpRegistry).editSavedInput(inputId, workspaceFolder || undefined, configSection, target); + } +} + +export class ShowConfiguration extends Action2 { + constructor() { + super({ + id: McpCommandIds.ShowConfiguration, + title: localize2('mcp.command.showConfiguration', "Show Configuration"), + category, + f1: false, + }); + } + + run(accessor: ServicesAccessor, collectionId: string, serverId: string): void { + const collection = accessor.get(IMcpRegistry).collections.get().find(c => c.id === collectionId); + if (!collection) { + return; + } + + const server = collection?.serverDefinitions.get().find(s => s.id === serverId); + const editorService = accessor.get(IEditorService); + if (server?.presentation?.origin) { + editorService.openEditor({ + resource: server.presentation.origin.uri, + options: { selection: server.presentation.origin.range } + }); + } else if (collection.presentation?.origin) { + editorService.openEditor({ + resource: collection.presentation.origin, + }); + } + } +} + +export class ShowOutput extends Action2 { + constructor() { + super({ + id: McpCommandIds.ShowOutput, + title: localize2('mcp.command.showOutput', "Show Output"), + category, + f1: false, + }); + } + + run(accessor: ServicesAccessor, serverId: string): void { + accessor.get(IMcpService).servers.get().find(s => s.definition.id === serverId)?.showOutput(); + } +} + +export class RestartServer extends Action2 { + constructor() { + super({ + id: McpCommandIds.RestartServer, + title: localize2('mcp.command.restartServer', "Restart Server"), + category, + f1: false, + }); + } + + async run(accessor: ServicesAccessor, serverId: string) { + const s = accessor.get(IMcpService).servers.get().find(s => s.definition.id === serverId); + s?.showOutput(); + await s?.stop(); + await s?.start(true); + } +} + +export class StartServer extends Action2 { + constructor() { + super({ + id: McpCommandIds.StartServer, + title: localize2('mcp.command.startServer', "Start Server"), + category, + f1: false, + }); + } + + async run(accessor: ServicesAccessor, serverId: string) { + const s = accessor.get(IMcpService).servers.get().find(s => s.definition.id === serverId); + await s?.start(true); + } +} + +export class StopServer extends Action2 { + constructor() { + super({ + id: McpCommandIds.StopServer, + title: localize2('mcp.command.stopServer', "Stop Server"), + category, + f1: false, + }); + } + + async run(accessor: ServicesAccessor, serverId: string) { + const s = accessor.get(IMcpService).servers.get().find(s => s.definition.id === serverId); + await s?.stop(); + } +} + +export class InstallFromActivation extends Action2 { + constructor() { + super({ + id: McpCommandIds.InstallFromActivation, + title: localize2('mcp.command.installFromActivation', "Install..."), + category, + f1: false, + menu: { + id: MenuId.EditorContent, + when: ContextKeyExpr.equals('resourceScheme', McpUrlHandler.scheme) + } + }); + } + + async run(accessor: ServicesAccessor, uri: URI) { + const addConfigHelper = accessor.get(IInstantiationService).createInstance(McpAddConfigurationCommand, undefined); + addConfigHelper.pickForUrlHandler(uri); + } +} diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.ts index efb2bc14444..328158592db 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.ts @@ -6,32 +6,62 @@ import { mapFindFirst } from '../../../../base/common/arraysFind.js'; import { assertNever } from '../../../../base/common/assert.js'; import { disposableTimeout } from '../../../../base/common/async.js'; +import { parse as parseJsonc } from '../../../../base/common/jsonc.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, getConfigValueInTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IMcpConfiguration, IMcpConfigurationSSE, McpConfigurationServer } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IMcpConfiguration, IMcpConfigurationHTTP, McpConfigurationServer } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../platform/quickinput/common/quickInput.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { EditorsOrder } from '../../../common/editor.js'; import { IJSONEditingService } from '../../../services/configuration/common/jsonEditing.js'; +import { ConfiguredInput } from '../../../services/configurationResolver/common/configurationResolver.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { IMcpConfigurationStdio, mcpStdioServerSchema } from '../common/mcpConfiguration.js'; +import { McpCommandIds } from '../common/mcpCommandIds.js'; +import { IMcpConfigurationStdio, mcpConfigurationSection, mcpStdioServerSchema } from '../common/mcpConfiguration.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; -import { McpServerOptionsCommand } from './mcpCommands.js'; - +import { IMcpService, McpConnectionState } from '../common/mcpTypes.js'; const enum AddConfigurationType { Stdio, - SSE, + HTTP, NpmPackage, PipPackage, + DockerImage, } +type AssistedConfigurationType = AddConfigurationType.NpmPackage | AddConfigurationType.PipPackage | AddConfigurationType.DockerImage; + +const assistedTypes = { + [AddConfigurationType.NpmPackage]: { + title: localize('mcp.npm.title', "Enter NPM Package Name"), + placeholder: localize('mcp.npm.placeholder', "Package name (e.g., @org/package)"), pickLabel: localize('mcp.serverType.npm', "NPM Package"), + pickDescription: localize('mcp.serverType.npm.description', "Install from an NPM package name") + }, + [AddConfigurationType.PipPackage]: { + title: localize('mcp.pip.title', "Enter Pip Package Name"), + placeholder: localize('mcp.pip.placeholder', "Package name (e.g., package-name)"), + pickLabel: localize('mcp.serverType.pip', "Pip Package"), + pickDescription: localize('mcp.serverType.pip.description', "Install from a Pip package name") + }, + [AddConfigurationType.DockerImage]: { + title: localize('mcp.docker.title', "Enter Docker Image Name"), + placeholder: localize('mcp.docker.placeholder', "Image name (e.g., mcp/imagename)"), + pickLabel: localize('mcp.serverType.docker', "Docker Image"), + pickDescription: localize('mcp.serverType.docker.description', "Install from a Docker image") + }, +}; + const enum AddConfigurationCopilotCommand { /** Returns whether MCP enhanced setup is enabled. */ IsSupported = 'github.copilot.chat.mcp.setup.check', @@ -45,6 +75,27 @@ const enum AddConfigurationCopilotCommand { type ValidatePackageResult = { state: 'ok'; publisher: string } | { state: 'error'; error: string }; +type AddServerData = { + packageType: string; +}; +type AddServerClassification = { + owner: 'digitarald'; + comment: 'Generic details for adding a new MCP server'; + packageType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of MCP server package' }; +}; +type AddServerCompletedData = { + packageType: string; + serverType: string | undefined; + target: string; +}; +type AddServerCompletedClassification = { + owner: 'digitarald'; + comment: 'Generic details for successfully adding model-assisted MCP server'; + packageType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of MCP server package' }; + serverType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of MCP server' }; + target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The target of the MCP server configuration' }; +}; + export class McpAddConfigurationCommand { constructor( private readonly _explicitConfigUri: string | undefined, @@ -56,12 +107,17 @@ export class McpAddConfigurationCommand { @ICommandService private readonly _commandService: ICommandService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @IEditorService private readonly _openerService: IEditorService, + @IEditorService private readonly _editorService: IEditorService, + @IFileService private readonly _fileService: IFileService, + @INotificationService private readonly _notificationService: INotificationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IMcpService private readonly _mcpService: IMcpService, ) { } private async getServerType(): Promise { const items: QuickPickInput<{ kind: AddConfigurationType } & IQuickPickItem>[] = [ { kind: AddConfigurationType.Stdio, label: localize('mcp.serverType.command', "Command (stdio)"), description: localize('mcp.serverType.command.description', "Run a local command that implements the MCP protocol") }, - { kind: AddConfigurationType.SSE, label: localize('mcp.serverType.http', "HTTP (server-sent events)"), description: localize('mcp.serverType.http.description', "Connect to a remote HTTP server that implements the MCP protocol") } + { kind: AddConfigurationType.HTTP, label: localize('mcp.serverType.http', "HTTP (HTTP or Server-Sent Events)"), description: localize('mcp.serverType.http.description', "Connect to a remote HTTP server that implements the MCP protocol") } ]; let aiSupported: boolean | undefined; @@ -75,13 +131,15 @@ export class McpAddConfigurationCommand { items.unshift({ type: 'separator', label: localize('mcp.serverType.manual', "Manual Install") }); items.push( { type: 'separator', label: localize('mcp.serverType.copilot', "Model-Assisted") }, - { kind: AddConfigurationType.NpmPackage, label: localize('mcp.serverType.npm', "NPM Package"), description: localize('mcp.serverType.npm.description', "Install from an NPM package name") }, - { kind: AddConfigurationType.PipPackage, label: localize('mcp.serverType.pip', "PIP Package"), description: localize('mcp.serverType.pip.description', "Install from a PIP package name") } + ...Object.entries(assistedTypes).map(([type, { pickLabel, pickDescription }]) => ({ + kind: Number(type) as AddConfigurationType, + label: pickLabel, + description: pickDescription, + })) ); } const result = await this._quickInputService.pick<{ kind: AddConfigurationType } & IQuickPickItem>(items, { - title: localize('mcp.serverType.title', "Select Server Type"), placeHolder: localize('mcp.serverType.placeholder', "Choose the type of MCP server to add"), }); @@ -99,6 +157,10 @@ export class McpAddConfigurationCommand { return undefined; } + this._telemetryService.publicLog2('mcp.addserver', { + packageType: 'stdio' + }); + // Split command into command and args, handling quotes const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g)!; return { @@ -109,7 +171,7 @@ export class McpAddConfigurationCommand { }; } - private async getSSEConfig(): Promise { + private async getSSEConfig(): Promise { const url = await this._quickInputService.input({ title: localize('mcp.url.title', "Enter Server URL"), placeHolder: localize('mcp.url.placeholder', "URL of the MCP server (e.g., http://localhost:3000)"), @@ -120,10 +182,11 @@ export class McpAddConfigurationCommand { return undefined; } - return { - type: 'sse', - url - }; + this._telemetryService.publicLog2('mcp.addserver', { + packageType: 'sse' + }); + + return { url }; } private async getServerId(suggestion = `my-mcp-server-${generateUuid().split('-')[0]}`): Promise { @@ -162,15 +225,11 @@ export class McpAddConfigurationCommand { return targetPick?.target; } - private async getAssistedConfig(type: AddConfigurationType): Promise<{ name: string; config: McpConfigurationServer } | undefined> { + private async getAssistedConfig(type: AssistedConfigurationType): Promise<{ name: string; server: McpConfigurationServer; inputs?: ConfiguredInput[]; inputValues?: Record } | undefined> { const packageName = await this._quickInputService.input({ ignoreFocusLost: true, - title: type === AddConfigurationType.NpmPackage - ? localize('mcp.npm.title', "Enter NPM Package Name") - : localize('mcp.pip.title', "Enter Pip Package Name"), - placeHolder: type === AddConfigurationType.NpmPackage - ? localize('mcp.npm.placeholder', "Package name (e.g., @org/package)") - : localize('mcp.pip.placeholder', "Package name (e.g., package-name)") + title: assistedTypes[type].title, + placeHolder: assistedTypes[type].placeholder, }); if (!packageName) { @@ -189,10 +248,16 @@ export class McpAddConfigurationCommand { loadingQuickPick.busy = true; loadingQuickPick.ignoreFocusOut = true; + const packageType = this.getPackageType(type); + + this._telemetryService.publicLog2('mcp.addserver', { + packageType: packageType! + }); + this._commandService.executeCommand( AddConfigurationCopilotCommand.ValidatePackage, { - type: type === AddConfigurationType.NpmPackage ? 'npm' : 'pip', + type: packageType, name: packageName, targetConfig: { ...mcpStdioServerSchema, @@ -236,26 +301,25 @@ export class McpAddConfigurationCommand { return undefined; } - const configWithName = await this._commandService.executeCommand( + return await this._commandService.executeCommand<{ name: string; server: McpConfigurationServer; inputs?: ConfiguredInput[]; inputValues?: Record }>( AddConfigurationCopilotCommand.StartFlow, - { name: packageName } + { + name: packageName, + type: packageType + } ); - - if (!configWithName) { - return undefined; - } - - const { name, ...config } = configWithName; - return { name, config }; } + /** Shows the location of a server config once it's discovered. */ private showOnceDiscovered(name: string) { const store = new DisposableStore(); store.add(autorun(reader => { const colls = this._mcpRegistry.collections.read(reader); + const servers = this._mcpService.servers.read(reader); const match = mapFindFirst(colls, collection => mapFindFirst(collection.serverDefinitions.read(reader), server => server.label === name ? { server, collection } : undefined)); - if (match) { + const server = match && servers.find(s => s.definition.id === match.server.id); + if (match && server) { if (match.collection.presentation?.origin) { this._openerService.openEditor({ resource: match.collection.presentation.origin, @@ -265,9 +329,15 @@ export class McpAddConfigurationCommand { } }); } else { - this._commandService.executeCommand(McpServerOptionsCommand.id, name); + this._commandService.executeCommand(McpCommandIds.ServerOptions, name); } + server.start(true).then(state => { + if (state.state === McpConnectionState.Kind.Error) { + server.showOutput(); + } + }); + store.dispose(); } })); @@ -275,6 +345,15 @@ export class McpAddConfigurationCommand { store.add(disposableTimeout(() => store.dispose(), 5000)); } + private writeToUserSetting(name: string, config: McpConfigurationServer, target: ConfigurationTarget, inputs?: ConfiguredInput[]) { + const settings: IMcpConfiguration = { ...getConfigValueInTarget(this._configurationService.inspect(mcpConfigurationSection), target) }; + settings.servers = { ...settings.servers, [name]: config }; + if (inputs) { + settings.inputs = [...(settings.inputs || []), ...inputs]; + } + return this._configurationService.updateValue(mcpConfigurationSection, settings, target); + } + public async run(): Promise { // Step 1: Choose server type const serverType = await this.getServerType(); @@ -285,23 +364,23 @@ export class McpAddConfigurationCommand { // Step 2: Get server details based on type let serverConfig: McpConfigurationServer | undefined; let suggestedName: string | undefined; + let inputs: ConfiguredInput[] | undefined; + let inputValues: Record | undefined; switch (serverType) { case AddConfigurationType.Stdio: serverConfig = await this.getStdioConfig(); break; - case AddConfigurationType.SSE: + case AddConfigurationType.HTTP: serverConfig = await this.getSSEConfig(); break; - case AddConfigurationType.NpmPackage: { - const r = await this.getAssistedConfig(AddConfigurationType.NpmPackage); - serverConfig = r?.config; - suggestedName = r?.name; - break; - } - case AddConfigurationType.PipPackage: { - const r = await this.getAssistedConfig(AddConfigurationType.PipPackage); - serverConfig = r?.config; + case AddConfigurationType.NpmPackage: + case AddConfigurationType.PipPackage: + case AddConfigurationType.DockerImage: { + const r = await this.getAssistedConfig(serverType); + serverConfig = r?.server; suggestedName = r?.name; + inputs = r?.inputs; + inputValues = r?.inputValues; break; } default: @@ -336,16 +415,100 @@ export class McpAddConfigurationCommand { : undefined; if (writeToUriDirect) { - await this._jsonEditingService.write(writeToUriDirect, [{ - path: ['servers', serverId], - value: serverConfig - }], true); + await this._jsonEditingService.write(writeToUriDirect, [ + { + path: ['servers', serverId], + value: serverConfig + }, + ...(inputs || []).map(i => ({ + path: ['inputs', -1], + value: i, + })), + ], true); } else { - const settings: IMcpConfiguration = { ...getConfigValueInTarget(this._configurationService.inspect('mcp'), target!) }; - settings.servers = { ...settings.servers, [serverId]: serverConfig }; - await this._configurationService.updateValue('mcp', settings, target!); + await this.writeToUserSetting(serverId, serverConfig, target!, inputs); + } + + if (inputValues) { + for (const [key, value] of Object.entries(inputValues)) { + await this._mcpRegistry.setSavedInput(key, target ?? ConfigurationTarget.WORKSPACE, value); + } + } + + const packageType = this.getPackageType(serverType); + if (packageType) { + this._telemetryService.publicLog2('mcp.addserver.completed', { + packageType, + serverType: serverConfig.type, + target: target === ConfigurationTarget.WORKSPACE ? 'workspace' : 'user' + }); } this.showOnceDiscovered(serverId); } + + public async pickForUrlHandler(resource: URI, showIsPrimary = false): Promise { + const name = decodeURIComponent(basename(resource)).replace(/\.json$/, ''); + const placeHolder = localize('install.title', 'Install MCP server {0}', name); + + const items: IQuickPickItem[] = [ + { id: 'install', label: localize('install.start', 'Install Server'), description: localize('install.description', 'Install in your user settings') }, + { id: 'show', label: localize('install.show', 'Show Configuration', name) }, + { id: 'rename', label: localize('install.rename', 'Rename "{0}"', name) }, + { id: 'cancel', label: localize('cancel', 'Cancel') }, + ]; + if (showIsPrimary) { + [items[0], items[1]] = [items[1], items[0]]; + } + + const pick = await this._quickInputService.pick(items, { placeHolder, ignoreFocusLost: true }); + const getEditors = () => this._editorService.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE) + .filter(e => e.editor.resource?.toString() === resource.toString()); + + switch (pick?.id) { + case 'show': + await this._editorService.openEditor({ resource }); + break; + case 'install': + await this._editorService.save(getEditors()); + try { + const contents = await this._fileService.readFile(resource); + const { inputs, ...config }: McpConfigurationServer & { inputs?: ConfiguredInput[] } = parseJsonc(contents.value.toString()); + await this.writeToUserSetting(name, config, ConfigurationTarget.USER_LOCAL, inputs); + this._editorService.closeEditors(getEditors()); + this.showOnceDiscovered(name); + } catch (e) { + this._notificationService.error(localize('install.error', 'Error installing MCP server {0}: {1}', name, e.message)); + await this._editorService.openEditor({ resource }); + } + break; + case 'rename': { + const newName = await this._quickInputService.input({ placeHolder: localize('install.newName', 'Enter new name'), value: name }); + if (newName) { + const newURI = resource.with({ path: `/${encodeURIComponent(newName)}.json` }); + await this._editorService.save(getEditors()); + await this._fileService.move(resource, newURI); + return this.pickForUrlHandler(newURI, showIsPrimary); + } + break; + } + } + } + + private getPackageType(serverType: AddConfigurationType): string | undefined { + switch (serverType) { + case AddConfigurationType.NpmPackage: + return 'npm'; + case AddConfigurationType.PipPackage: + return 'pip'; + case AddConfigurationType.DockerImage: + return 'docker'; + case AddConfigurationType.Stdio: + return 'stdio'; + case AddConfigurationType.HTTP: + return 'sse'; + default: + return undefined; + } + } } diff --git a/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts b/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts index f9d339b7962..dc07ad2a89a 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpDiscovery.ts @@ -3,21 +3,36 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js'; +import { mcpEnabledSection } from '../common/mcpConfiguration.js'; export class McpDiscovery extends Disposable implements IWorkbenchContribution { public static readonly ID = 'workbench.contrib.mcp.discovery'; constructor( @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); - for (const discovery of mcpDiscoveryRegistry.getAll()) { - const inst = this._register(instantiationService.createInstance(discovery)); - inst.start(); - } + + const enabled = observableConfigValue(mcpEnabledSection, true, configurationService); + const store = this._register(new DisposableStore()); + + this._register(autorun(reader => { + if (enabled.read(reader)) { + for (const discovery of mcpDiscoveryRegistry.getAll()) { + const inst = store.add(instantiationService.createInstance(discovery)); + inst.start(); + } + } else { + store.clear(); + } + })); } } diff --git a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts new file mode 100644 index 00000000000..c9f58f04511 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts @@ -0,0 +1,372 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { computeLevenshteinDistance } from '../../../../base/common/diff/diff.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { markdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { findNodeAtLocation, Node, parseTree } from '../../../../base/common/json.js'; +import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { CodeLensList, CodeLensProvider, InlayHint, InlayHintList } from '../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { localize } from '../../../../nls.js'; +import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { McpCommandIds } from '../common/mcpCommandIds.js'; +import { IMcpConfigPath, IMcpConfigPathsService } from '../common/mcpConfigPathsService.js'; +import { mcpConfigurationSection } from '../common/mcpConfiguration.js'; +import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; +import { IMcpService, McpConnectionState } from '../common/mcpTypes.js'; + +const diagnosticOwner = 'vscode.mcp'; + +export class McpLanguageFeatures extends Disposable implements IWorkbenchContribution { + private readonly _cachedMcpSection = this._register(new MutableDisposable<{ model: ITextModel; inConfig: IMcpConfigPath; tree: Node } & IDisposable>()); + + constructor( + @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, + @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + @IMcpConfigPathsService private readonly _mcpConfigPathsService: IMcpConfigPathsService, + @IMcpService private readonly _mcpService: IMcpService, + @IMarkerService private readonly _markerService: IMarkerService, + @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, + ) { + super(); + + const patterns = [ + { pattern: '**/.vscode/mcp.json' }, + { pattern: '**/settings.json' }, + { pattern: '**/workspace.json' }, + ]; + + const onDidChangeCodeLens = this._register(new Emitter()); + const codeLensProvider: CodeLensProvider = { + onDidChange: onDidChangeCodeLens.event, + provideCodeLenses: (model, range) => this._provideCodeLenses(model, () => onDidChangeCodeLens.fire(codeLensProvider)), + }; + this._register(languageFeaturesService.codeLensProvider.register(patterns, codeLensProvider)); + + this._register(languageFeaturesService.inlayHintsProvider.register(patterns, { + onDidChangeInlayHints: _mcpRegistry.onDidChangeInputs, + provideInlayHints: (model, range) => this._provideInlayHints(model, range), + })); + } + + /** Simple mechanism to avoid extra json parsing for hints+lenses */ + private _parseModel(model: ITextModel) { + if (this._cachedMcpSection.value?.model === model) { + return this._cachedMcpSection.value; + } + + const uri = model.uri; + const inConfig = this._mcpConfigPathsService.paths.get().find(u => isEqual(u.uri, uri)); + if (!inConfig) { + return undefined; + } + + const value = model.getValue(); + const tree = parseTree(value); + const listeners = [ + model.onDidChangeContent(() => this._cachedMcpSection.clear()), + model.onWillDispose(() => this._cachedMcpSection.clear()), + ]; + this._addDiagnostics(model, value, tree, inConfig); + + return this._cachedMcpSection.value = { + model, + tree, + inConfig, + dispose: () => { + this._markerService.remove(diagnosticOwner, [uri]); + dispose(listeners); + } + }; + } + + private _addDiagnostics(tm: ITextModel, value: string, tree: Node, inConfig: IMcpConfigPath) { + const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, 'servers'] : ['servers']); + if (!serversNode) { + return; + } + + const getClosestMatchingVariable = (name: string) => { + let bestValue = ''; + let bestDistance = Infinity; + for (const variable of this._configurationResolverService.resolvableVariables) { + const distance = computeLevenshteinDistance(name, variable); + if (distance < bestDistance) { + bestDistance = distance; + bestValue = variable; + } + } + return bestValue; + }; + + const diagnostics: IMarkerData[] = []; + forEachPropertyWithReplacement(serversNode, node => { + const expr = ConfigurationResolverExpression.parse(node.value); + + for (const { id, name, arg } of expr.unresolved()) { + if (!this._configurationResolverService.resolvableVariables.has(name)) { + const position = value.indexOf(id, node.offset); + if (position === -1) { continue; } // unreachable? + + const start = tm.getPositionAt(position); + const end = tm.getPositionAt(position + id.length); + diagnostics.push({ + severity: MarkerSeverity.Warning, + message: localize('mcp.variableNotFound', 'Variable `{0}` not found, did you mean ${{1}}?', name, getClosestMatchingVariable(name) + (arg ? `:${arg}` : '')), + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + modelVersionId: tm.getVersionId(), + }); + } + } + }); + + if (diagnostics.length) { + this._markerService.changeOne(diagnosticOwner, tm.uri, diagnostics); + } else { + this._markerService.remove(diagnosticOwner, [tm.uri]); + } + } + + private _provideCodeLenses(model: ITextModel, onDidChangeCodeLens: () => void): CodeLensList | undefined { + const parsed = this._parseModel(model); + if (!parsed) { + return undefined; + } + + const { tree, inConfig } = parsed; + const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, 'servers'] : ['servers']); + if (!serversNode) { + return undefined; + } + + const store = new DisposableStore(); + const lenses: CodeLensList = { lenses: [], dispose: () => store.dispose() }; + const read = (observable: IObservable): T => { + store.add(Event.fromObservableLight(observable)(onDidChangeCodeLens)); + return observable.get(); + }; + + const collection = read(this._mcpRegistry.collections).find(c => isEqual(c.presentation?.origin, model.uri)); + if (!collection) { + return lenses; + } + + const mcpServers = read(this._mcpService.servers).filter(s => s.collection.id === collection.id); + for (const node of serversNode.children || []) { + if (node.type !== 'property' || node.children?.[0]?.type !== 'string') { + continue; + } + + const name = node.children[0].value as string; + const server = mcpServers.find(s => s.definition.label === name); + if (!server) { + continue; + } + + const range = Range.fromPositions(model.getPositionAt(node.children[0].offset)); + switch (read(server.connectionState).state) { + case McpConnectionState.Kind.Error: + lenses.lenses.push({ + range, + command: { + id: McpCommandIds.ShowOutput, + title: '$(error) ' + localize('server.error', 'Error'), + arguments: [server.definition.id], + }, + }, { + range, + command: { + id: McpCommandIds.RestartServer, + title: localize('mcp.restart', "Restart"), + arguments: [server.definition.id], + }, + }); + break; + case McpConnectionState.Kind.Starting: + lenses.lenses.push({ + range, + command: { + id: McpCommandIds.ShowOutput, + title: '$(loading~spin) ' + localize('server.starting', 'Starting'), + arguments: [server.definition.id], + }, + }, { + range, + command: { + id: McpCommandIds.StopServer, + title: localize('cancel', "Cancel"), + arguments: [server.definition.id], + }, + }); + break; + case McpConnectionState.Kind.Running: + lenses.lenses.push({ + range, + command: { + id: McpCommandIds.ShowOutput, + title: '$(check) ' + localize('server.running', 'Running'), + arguments: [server.definition.id], + }, + }, { + range, + command: { + id: McpCommandIds.StopServer, + title: localize('mcp.stop', "Stop"), + arguments: [server.definition.id], + }, + }, { + range, + command: { + id: McpCommandIds.RestartServer, + title: localize('mcp.restart', "Restart"), + arguments: [server.definition.id], + }, + }, { + range, + command: { + id: '', + title: localize('server.toolCount', '{0} tools', read(server.tools).length), + }, + }); + break; + case McpConnectionState.Kind.Stopped: { + lenses.lenses.push({ + range, + command: { + id: McpCommandIds.StartServer, + title: '$(debug-start) ' + localize('mcp.start', "Start"), + arguments: [server.definition.id], + }, + }); + const toolCount = read(server.tools).length; + if (toolCount) { + lenses.lenses.push({ + range, + command: { + id: '', + title: localize('server.toolCountCached', '{0} cached tools', toolCount), + } + }); + } + } + } + } + + return lenses; + } + + private async _provideInlayHints(model: ITextModel, range: Range): Promise { + const parsed = this._parseModel(model); + if (!parsed) { + return undefined; + } + + const { tree, inConfig } = parsed; + const mcpSection = inConfig.section ? findNodeAtLocation(tree, [...inConfig.section]) : tree; + if (!mcpSection) { + return undefined; + } + + const inputsNode = findNodeAtLocation(mcpSection, ['inputs']); + if (!inputsNode) { + return undefined; + } + + const inputs = await this._mcpRegistry.getSavedInputs(inConfig.scope); + const hints: InlayHint[] = []; + + const serversNode = findNodeAtLocation(mcpSection, ['servers']); + if (serversNode) { + annotateServers(serversNode); + } + annotateInputs(inputsNode); + + return { hints, dispose: () => { } }; + + function annotateServers(servers: Node) { + forEachPropertyWithReplacement(servers, node => { + const expr = ConfigurationResolverExpression.parse(node.value); + for (const { id } of expr.unresolved()) { + const saved = inputs[id]; + if (saved) { + pushAnnotation(id, node.offset + node.value.indexOf(id) + id.length, saved); + } + } + }); + } + + function annotateInputs(node: Node) { + if (node.type !== 'array' || !node.children) { + return; + } + + for (const input of node.children) { + if (input.type !== 'object' || !input.children) { + continue; + } + + const idProp = input.children.find(c => c.type === 'property' && c.children?.[0].value === 'id'); + if (!idProp) { + continue; + } + + const id = idProp.children![1]; + if (!id || id.type !== 'string' || !id.value) { + continue; + } + + const savedId = '${input:' + id.value + '}'; + const saved = inputs[savedId]; + if (saved) { + pushAnnotation(savedId, id.offset + 1 + id.length, saved); + } + } + } + + function pushAnnotation(savedId: string, offset: number, saved: IResolvedValue): InlayHint { + const tooltip = new MarkdownString([ + markdownCommandLink({ id: McpCommandIds.EditStoredInput, title: localize('edit', 'Edit'), arguments: [savedId, model.uri, mcpConfigurationSection, inConfig!.target] }), + markdownCommandLink({ id: McpCommandIds.RemoveStoredInput, title: localize('clear', 'Clear'), arguments: [inConfig!.scope, savedId] }), + markdownCommandLink({ id: McpCommandIds.RemoveStoredInput, title: localize('clearAll', 'Clear All'), arguments: [inConfig!.scope] }), + ].join(' | '), { isTrusted: true }); + + const hint: InlayHint = { + label: '= ' + (saved.input?.type === 'promptString' && saved.input.password ? '*'.repeat(10) : (saved.value || '')), + position: model.getPositionAt(offset), + tooltip, + paddingLeft: true, + }; + + hints.push(hint); + return hint; + } + } +} + + + +function forEachPropertyWithReplacement(node: Node, callback: (node: Node) => void) { + if (node.type === 'string' && typeof node.value === 'string' && node.value.includes(ConfigurationResolverExpression.VARIABLE_LHS)) { + callback(node); + } else if (node.type === 'property') { + // skip the property name + node.children?.slice(1).forEach(n => forEachPropertyWithReplacement(n, callback)); + } else { + node.children?.forEach(n => forEachPropertyWithReplacement(n, callback)); + } +} + + diff --git a/src/vs/workbench/contrib/mcp/browser/mcpUrlHandler.ts b/src/vs/workbench/contrib/mcp/browser/mcpUrlHandler.ts new file mode 100644 index 00000000000..b62212441c1 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/browser/mcpUrlHandler.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { Lazy } from '../../../../base/common/lazy.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { McpConfigurationServer } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { IOpenURLOptions, IURLHandler, IURLService } from '../../../../platform/url/common/url.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { McpAddConfigurationCommand } from './mcpCommandsAddConfiguration.js'; + +const providerScheme = 'mcp-install'; + +export class McpUrlHandler extends Disposable implements IWorkbenchContribution, IURLHandler { + public static readonly scheme = providerScheme; + + + private readonly _fileSystemProvider = new Lazy(() => { + return this._instaService.invokeFunction(accessor => { + const fileService = accessor.get(IFileService); + const filesystem = new InMemoryFileSystemProvider(); + this._register(fileService.registerProvider(providerScheme, filesystem)); + return providerScheme; + }); + }); + + constructor( + @IURLService urlService: IURLService, + @IInstantiationService private readonly _instaService: IInstantiationService, + @IFileService private readonly _fileService: IFileService, + ) { + super(); + this._register(urlService.registerHandler(this)); + } + + async handleURL(uri: URI, options?: IOpenURLOptions): Promise { + if (uri.path !== 'mcp/install') { + return false; + } + + let parsed: McpConfigurationServer & { name: string }; + try { + parsed = JSON.parse(decodeURIComponent(uri.query)); + } catch (e) { + return false; + } + + const { name, ...rest } = parsed; + + const scheme = this._fileSystemProvider.value; + const fileUri = URI.from({ scheme, path: `/${encodeURIComponent(name)}.json` }); + + await this._fileService.writeFile( + fileUri, + VSBuffer.fromString(JSON.stringify(rest, null, '\t')), + ); + + const addConfigHelper = this._instaService.createInstance(McpAddConfigurationCommand, undefined); + addConfigHelper.pickForUrlHandler(fileUri, true); + + return Promise.resolve(true); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts index e57794e8cdb..46943d3a639 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/configMcpDiscovery.ts @@ -6,111 +6,77 @@ import { equals as arrayEquals } from '../../../../../base/common/arrays.js'; import { Throttler } from '../../../../../base/common/async.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { autorunDelta, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { Location } from '../../../../../editor/common/languages.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; -import { localize } from '../../../../../nls.js'; -import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IRemoteAgentEnvironment } from '../../../../../platform/remote/common/remoteAgentEnvironment.js'; -import { StorageScope } from '../../../../../platform/storage/common/storage.js'; -import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; -import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; -import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { getMcpServerMapping } from '../mcpConfigFileUtils.js'; +import { IMcpConfigPath, IMcpConfigPathsService } from '../mcpConfigPathsService.js'; import { IMcpConfiguration, mcpConfigurationSection } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { McpCollectionSortOrder, McpServerDefinition, McpServerTransportType } from '../mcpTypes.js'; +import { McpServerDefinition, McpServerTransportType } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; +interface ConfigSource { + path: IMcpConfigPath; + serverDefinitions: ISettableObservable; + disposable: MutableDisposable; + getServerToLocationMapping(uri: URI): Promise>; +} /** * Discovers MCP servers based on various config sources. */ export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery { - private readonly configSources: { - key: 'userLocalValue' | 'userRemoteValue' | 'workspaceValue'; - label: string; - serverDefinitions: ISettableObservable; - scope: StorageScope; - target: ConfigurationTarget; - disposable: MutableDisposable; - order: number; - remoteAuthority?: string; - uri(): URI | undefined; - getServerToLocationMapping(uri: URI): Promise>; - }[]; - - private _remoteEnvironment: IRemoteAgentEnvironment | null = null; + private configSources: ConfigSource[] = []; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, - @IProductService productService: IProductService, - @ILabelService labelService: ILabelService, - @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, - @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, - @IPreferencesService preferencesService: IPreferencesService, @ITextModelService private readonly _textModelService: ITextModelService, + @IMcpConfigPathsService private readonly _mcpConfigPathsService: IMcpConfigPathsService, ) { super(); - const remoteLabel = environmentService.remoteAuthority ? labelService.getHostLabel(Schemas.vscodeRemote, environmentService.remoteAuthority) : 'Remote'; - this.configSources = [ - { - key: 'userLocalValue', - target: ConfigurationTarget.USER_LOCAL, - label: localize('mcp.configuration.userLocalValue', 'Global in {0}', productService.nameShort), - serverDefinitions: observableValue(this, []), - scope: StorageScope.PROFILE, - disposable: this._register(new MutableDisposable()), - order: McpCollectionSortOrder.User, - uri: () => preferencesService.userSettingsResource, - getServerToLocationMapping: uri => this._getServerIdMapping(uri, [mcpConfigurationSection, 'servers']), - }, - { - key: 'userRemoteValue', - target: ConfigurationTarget.USER_REMOTE, - label: localize('mcp.configuration.userRemoteValue', 'From {0}', remoteLabel), - serverDefinitions: observableValue(this, []), - scope: StorageScope.PROFILE, - disposable: this._register(new MutableDisposable()), - remoteAuthority: environmentService.remoteAuthority, - order: McpCollectionSortOrder.User + McpCollectionSortOrder.RemotePenalty, - uri: () => this._remoteEnvironment?.settingsPath, - getServerToLocationMapping: uri => this._getServerIdMapping(uri, [mcpConfigurationSection, 'servers']), - }, - { - key: 'workspaceValue', - target: ConfigurationTarget.WORKSPACE, - label: localize('mcp.configuration.workspaceValue', 'From your workspace'), - serverDefinitions: observableValue(this, []), - scope: StorageScope.WORKSPACE, - disposable: this._register(new MutableDisposable()), - order: McpCollectionSortOrder.Workspace, - uri: () => preferencesService.workspaceSettingsResource ? URI.joinPath(preferencesService.workspaceSettingsResource, '../mcp.json') : undefined, - getServerToLocationMapping: uri => this._getServerIdMapping(uri, ['servers']), - }, - ]; } public start() { const throttler = this._register(new Throttler()); + const addPath = (path: IMcpConfigPath) => { + this.configSources.push({ + path, + serverDefinitions: observableValue(this, []), + disposable: this._register(new MutableDisposable()), + getServerToLocationMapping: (uri) => this._getServerIdMapping(uri, path.section ? [...path.section, 'servers'] : ['servers']), + }); + }; + this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(mcpConfigurationSection)) { throttler.queue(() => this.sync()); } })); - this._remoteAgentService.getEnvironment().then(remoteEnvironment => { - this._remoteEnvironment = remoteEnvironment; - throttler.queue(() => this.sync()); - }); + this._register(autorunDelta(this._mcpConfigPathsService.paths, ({ lastValue, newValue }) => { + for (const last of lastValue || []) { + if (!newValue.includes(last)) { + const idx = this.configSources.findIndex(src => src.path.id === last.id); + if (idx !== -1) { + this.configSources[idx].disposable.dispose(); + this.configSources.splice(idx, 1); + } + } + } - throttler.queue(() => this.sync()); + for (const next of newValue) { + if (!lastValue || !lastValue.includes(next)) { + addPath(next); + } + } + + this.sync(); + })); } private async _getServerIdMapping(resource: URI, pathToServers: string[]): Promise> { @@ -130,26 +96,30 @@ export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery { private async sync() { const configurationKey = this._configurationService.inspect(mcpConfigurationSection); const configMappings = await Promise.all(this.configSources.map(src => { - const uri = src.uri(); + const uri = src.path.uri; return uri && src.getServerToLocationMapping(uri); })); for (const [index, src] of this.configSources.entries()) { - const collectionId = `mcp.config.${src.key}`; - let value = configurationKey[src.key]; + const collectionId = `mcp.config.${src.path.id}`; + // inspect() will give the first workspace folder, and must be + // asked for explicitly for other folders. + let value = src.path.workspaceFolder + ? this._configurationService.inspect(mcpConfigurationSection, { resource: src.path.workspaceFolder.uri })[src.path.key] + : configurationKey[src.path.key]; // If we see there are MCP servers, migrate them automatically if (value?.mcpServers) { value = { ...value, servers: { ...value.servers, ...value.mcpServers }, mcpServers: undefined }; - this._configurationService.updateValue(mcpConfigurationSection, value, {}, src.target, { donotNotifyError: true }); + this._configurationService.updateValue(mcpConfigurationSection, value, {}, src.path.target, { donotNotifyError: true }); } const configMapping = configMappings[index]; const nextDefinitions = Object.entries(value?.servers || {}).map(([name, value]): McpServerDefinition => ({ id: `${collectionId}.${name}`, label: name, - launch: 'type' in value && value.type === 'sse' ? { - type: McpServerTransportType.SSE, + launch: 'url' in value ? { + type: McpServerTransportType.HTTP, uri: URI.parse(value.url), headers: Object.entries(value.headers || {}), } : { @@ -157,14 +127,17 @@ export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery { args: value.args || [], command: value.command, env: value.env || {}, + envFile: value.envFile, cwd: undefined, }, + roots: src.path.workspaceFolder ? [src.path.workspaceFolder.uri] : [], variableReplacement: { + folder: src.path.workspaceFolder, section: mcpConfigurationSection, - target: src.target, + target: src.path.target, }, presentation: { - order: src.order, + order: src.path.order, origin: configMapping?.get(name), } })); @@ -180,12 +153,12 @@ export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery { src.serverDefinitions.set(nextDefinitions, undefined); src.disposable.value ??= this._mcpRegistry.registerCollection({ id: collectionId, - label: src.label, - presentation: { order: src.order, origin: src.uri() }, - remoteAuthority: src.remoteAuthority || null, + label: src.path.label, + presentation: { order: src.path.order, origin: src.path.uri }, + remoteAuthority: src.path.remoteAuthority || null, serverDefinitions: src.serverDefinitions, isTrustedByDefault: true, - scope: src.scope, + scope: src.path.scope, }); } } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts index 75cbd2081f6..1c3a96b5097 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts @@ -5,6 +5,9 @@ import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../base/common/observable.js'; +import { isFalsyOrWhitespace } from '../../../../../base/common/strings.js'; +import { localize } from '../../../../../nls.js'; +import { IMcpCollectionContribution } from '../../../../../platform/extensions/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; @@ -66,6 +69,11 @@ export class ExtensionMcpDiscovery extends Disposable implements IMcpDiscovery { } for (const collections of added) { + + if (!ExtensionMcpDiscovery._validate(collections)) { + continue; + } + for (const coll of collections.value) { const id = extensionPrefixedIdentifier(collections.description.identifier, coll.id); this._extensionCollectionIdsToPersist.add(id); @@ -96,4 +104,25 @@ export class ExtensionMcpDiscovery extends Disposable implements IMcpDiscovery { await Promise.all(this._mcpRegistry.delegates .map(r => r.waitForInitialProviderPromises())); } + + private static _validate(user: extensionsRegistry.IExtensionPointUser): boolean { + + if (!Array.isArray(user.value)) { + user.collector.error(localize('invalidData', "Expected an array of MCP collections")); + return false; + } + + for (const contribution of user.value) { + if (typeof contribution.id !== 'string' || isFalsyOrWhitespace(contribution.id)) { + user.collector.error(localize('invalidId', "Expected 'id' to be a non-empty string.")); + return false; + } + if (typeof contribution.label !== 'string' || isFalsyOrWhitespace(contribution.label)) { + user.collector.error(localize('invalidLabel', "Expected 'label' to be a non-empty string.")); + return false; + } + } + + return true; + } } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts index 803439a8b83..c2a7e6494fd 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../../base/common/async.js'; -import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { autorunWithStore, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { autorunWithStore, IObservable, IReader, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -17,38 +18,105 @@ import { INativeMcpDiscoveryData } from '../../../../../platform/mcp/common/nati import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { StorageScope } from '../../../../../platform/storage/common/storage.js'; import { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; -import { mcpDiscoverySection } from '../mcpConfiguration.js'; +import { DiscoverySource, discoverySourceLabel, mcpDiscoverySection } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; import { McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; -import { ClaudeDesktopMpcDiscoveryAdapter, NativeMpcDiscoveryAdapter } from './nativeMcpDiscoveryAdapters.js'; +import { ClaudeDesktopMpcDiscoveryAdapter, CursorDesktopMpcDiscoveryAdapter, NativeMpcDiscoveryAdapter, WindsurfDesktopMpcDiscoveryAdapter } from './nativeMcpDiscoveryAdapters.js'; + +export type WritableMcpCollectionDefinition = McpCollectionDefinition & { serverDefinitions: ISettableObservable }; + +export abstract class FilesystemMcpDiscovery extends Disposable { + protected readonly _fsDiscoveryEnabled: IObservable; + + constructor( + @IConfigurationService configurationService: IConfigurationService, + @IFileService private readonly _fileService: IFileService, + @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + ) { + super(); + + this._fsDiscoveryEnabled = observableConfigValue(mcpDiscoverySection, true, configurationService); + } + + protected _isDiscoveryEnabled(reader: IReader, discoverySource: DiscoverySource | undefined): boolean { + const fsDiscovery = this._fsDiscoveryEnabled.read(reader); + if (typeof fsDiscovery === 'boolean') { + return fsDiscovery; + } + if (discoverySource && fsDiscovery[discoverySource] === false) { + return false; + } + return true; + } + + protected watchFile( + file: URI, + collection: WritableMcpCollectionDefinition, + discoverySource: DiscoverySource | undefined, + adaptFile: (contents: VSBuffer) => McpServerDefinition[] | undefined, + ): IDisposable { + const store = new DisposableStore(); + const collectionRegistration = store.add(new MutableDisposable()); + const updateFile = async () => { + let definitions: McpServerDefinition[] = []; + try { + const contents = await this._fileService.readFile(file); + definitions = adaptFile(contents.value) || []; + } catch { + // ignored + } + if (!definitions.length) { + collectionRegistration.clear(); + } else { + collection.serverDefinitions.set(definitions, undefined); + if (!collectionRegistration.value) { + collectionRegistration.value = this._mcpRegistry.registerCollection(collection); + } + } + }; + + store.add(autorunWithStore((reader, store) => { + if (!this._isDiscoveryEnabled(reader, discoverySource)) { + collectionRegistration.clear(); + return; + } + + const throttler = store.add(new RunOnceScheduler(updateFile, 500)); + const watcher = store.add(this._fileService.createWatcher(file, { recursive: false, excludes: [] })); + store.add(watcher.onDidChange(() => throttler.schedule())); + updateFile(); + })); + + return store; + } +} /** * Base class that discovers MCP servers on a filesystem, outside of the ones * defined in VS Code settings. */ -export abstract class FilesystemMpcDiscovery extends Disposable implements IMcpDiscovery { +export abstract class NativeFilesystemMcpDiscovery extends FilesystemMcpDiscovery implements IMcpDiscovery { private readonly adapters: readonly NativeMpcDiscoveryAdapter[]; - private _fsDiscoveryEnabled: IObservable; private suffix = ''; constructor( remoteAuthority: string | null, @ILabelService labelService: ILabelService, - @IFileService private readonly fileService: IFileService, + @IFileService fileService: IFileService, @IInstantiationService instantiationService: IInstantiationService, - @IMcpRegistry private readonly mcpRegistry: IMcpRegistry, + @IMcpRegistry mcpRegistry: IMcpRegistry, @IConfigurationService configurationService: IConfigurationService, ) { - super(); + super(configurationService, fileService, mcpRegistry); if (remoteAuthority) { this.suffix = ' ' + localize('onRemoteLabel', ' on {0}', labelService.getHostLabel(Schemas.vscodeRemote, remoteAuthority)); } - this._fsDiscoveryEnabled = observableConfigValue(mcpDiscoverySection, false, configurationService); - this.adapters = [ - instantiationService.createInstance(ClaudeDesktopMpcDiscoveryAdapter, remoteAuthority) + instantiationService.createInstance(ClaudeDesktopMpcDiscoveryAdapter, remoteAuthority), + instantiationService.createInstance(CursorDesktopMpcDiscoveryAdapter, remoteAuthority), + instantiationService.createInstance(WindsurfDesktopMpcDiscoveryAdapter, remoteAuthority), ]; } @@ -72,49 +140,20 @@ export abstract class FilesystemMpcDiscovery extends Disposable implements IMcpD continue; } - const collection = { + const collection: WritableMcpCollectionDefinition = { id: adapter.id, - label: adapter.label + this.suffix, + label: discoverySourceLabel[adapter.discoverySource] + this.suffix, remoteAuthority: adapter.remoteAuthority, scope: StorageScope.PROFILE, isTrustedByDefault: false, serverDefinitions: observableValue(this, []), presentation: { origin: file, - order: adapter.order + (adapter.remoteAuthority ? McpCollectionSortOrder.RemotePenalty : 0), + order: adapter.order + (adapter.remoteAuthority ? McpCollectionSortOrder.RemoteBoost : 0), }, - } satisfies McpCollectionDefinition; - - const collectionRegistration = this._register(new MutableDisposable()); - const updateFile = async () => { - let definitions: McpServerDefinition[] = []; - try { - const contents = await this.fileService.readFile(file); - definitions = adapter.adaptFile(contents.value, details) || []; - } catch { - // ignored - } - if (!definitions.length) { - collectionRegistration.clear(); - } else { - collection.serverDefinitions.set(definitions, undefined); - if (!collectionRegistration.value) { - collectionRegistration.value = this.mcpRegistry.registerCollection(collection); - } - } }; - this._register(autorunWithStore((reader, store) => { - if (!this._fsDiscoveryEnabled.read(reader)) { - collectionRegistration.clear(); - return; - } - - const throttler = store.add(new RunOnceScheduler(updateFile, 500)); - const watcher = store.add(this.fileService.createWatcher(file, { recursive: false, excludes: [] })); - store.add(watcher.onDidChange(() => throttler.schedule())); - updateFile(); - })); + this._register(this.watchFile(file, collection, adapter.discoverySource, contents => adapter.adaptFile(contents, details))); } } } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts index 8eb01b9c722..e425b5d43a7 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts @@ -5,24 +5,62 @@ import { VSBuffer } from '../../../../../base/common/buffer.js'; import { Platform } from '../../../../../base/common/platform.js'; +import { Mutable } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { INativeMcpDiscoveryData } from '../../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { DiscoverySource } from '../mcpConfiguration.js'; import { McpCollectionSortOrder, McpServerDefinition, McpServerTransportType } from '../mcpTypes.js'; export interface NativeMpcDiscoveryAdapter { readonly remoteAuthority: string | null; readonly id: string; - readonly label: string; readonly order: number; + readonly discoverySource: DiscoverySource; getFilePath(details: INativeMcpDiscoveryData): URI | undefined; adaptFile(contents: VSBuffer, details: INativeMcpDiscoveryData): McpServerDefinition[] | undefined; } +export function claudeConfigToServerDefinition(idPrefix: string, contents: VSBuffer, cwd?: URI) { + let parsed: { + mcpServers: Record; + url?: string; + }>; + }; + + try { + parsed = JSON.parse(contents.toString()); + } catch { + return; + } + + return Object.entries(parsed.mcpServers).map(([name, server]): Mutable => { + return { + id: `${idPrefix}.${name}`, + label: name, + launch: server.url ? { + type: McpServerTransportType.HTTP, + uri: URI.parse(server.url), + headers: [], + } : { + type: McpServerTransportType.Stdio, + args: server.args || [], + command: server.command, + env: server.env || {}, + envFile: undefined, + cwd, + } + }; + }); +} + export class ClaudeDesktopMpcDiscoveryAdapter implements NativeMpcDiscoveryAdapter { - public readonly id: string; - public readonly label: string = 'Claude Desktop'; + public id: string; public readonly order = McpCollectionSortOrder.Filesystem; + public readonly discoverySource: DiscoverySource = DiscoverySource.ClaudeDesktop; constructor(public readonly remoteAuthority: string | null) { this.id = `claude-desktop.${this.remoteAuthority}`; @@ -41,31 +79,32 @@ export class ClaudeDesktopMpcDiscoveryAdapter implements NativeMpcDiscoveryAdapt } adaptFile(contents: VSBuffer, { homedir }: INativeMcpDiscoveryData): McpServerDefinition[] | undefined { - let parsed: { - mcpServers: Record; - }>; - }; - - try { - parsed = JSON.parse(contents.toString()); - } catch { - return; - } - return Object.entries(parsed.mcpServers).map(([name, server]): McpServerDefinition => { - return { - id: `claude_desktop_config.${name}`, - label: name, - launch: { - type: McpServerTransportType.Stdio, - args: server.args || [], - command: server.command, - env: server.env || {}, - cwd: homedir, - } - }; - }); + return claudeConfigToServerDefinition(this.id, contents, homedir); + } +} + +export class WindsurfDesktopMpcDiscoveryAdapter extends ClaudeDesktopMpcDiscoveryAdapter { + public override readonly discoverySource: DiscoverySource = DiscoverySource.Windsurf; + + constructor(remoteAuthority: string | null) { + super(remoteAuthority); + this.id = `windsurf.${this.remoteAuthority}`; + } + + override getFilePath({ homedir }: INativeMcpDiscoveryData): URI | undefined { + return URI.joinPath(homedir, '.codeium', 'windsurf', 'mcp_config.json'); + } +} + +export class CursorDesktopMpcDiscoveryAdapter extends ClaudeDesktopMpcDiscoveryAdapter { + public override readonly discoverySource: DiscoverySource = DiscoverySource.CursorGlobal; + + constructor(remoteAuthority: string | null) { + super(remoteAuthority); + this.id = `cursor.${this.remoteAuthority}`; + } + + override getFilePath({ homedir }: INativeMcpDiscoveryData): URI | undefined { + return URI.joinPath(homedir, '.cursor', 'mcp.json'); } } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts index 4f525561f6f..a2473c986b0 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpRemoteDiscovery.ts @@ -12,12 +12,12 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { FilesystemMpcDiscovery } from './nativeMcpDiscoveryAbstract.js'; +import { NativeFilesystemMcpDiscovery } from './nativeMcpDiscoveryAbstract.js'; /** * Discovers MCP servers on the remote filesystem, if any. */ -export class RemoteNativeMpcDiscovery extends FilesystemMpcDiscovery { +export class RemoteNativeMpcDiscovery extends NativeFilesystemMcpDiscovery { constructor( @IRemoteAgentService private readonly remoteAgent: IRemoteAgentService, @ILogService private readonly logService: ILogService, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts b/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts new file mode 100644 index 00000000000..4701f10cef8 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableMap, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; +import { DiscoverySource } from '../mcpConfiguration.js'; +import { IMcpRegistry } from '../mcpRegistryTypes.js'; +import { McpCollectionSortOrder } from '../mcpTypes.js'; +import { IMcpDiscovery } from './mcpDiscovery.js'; +import { FilesystemMcpDiscovery, WritableMcpCollectionDefinition } from './nativeMcpDiscoveryAbstract.js'; +import { claudeConfigToServerDefinition } from './nativeMcpDiscoveryAdapters.js'; + +export class CursorWorkspaceMcpDiscoveryAdapter extends FilesystemMcpDiscovery implements IMcpDiscovery { + private readonly _collections = this._register(new DisposableMap()); + + constructor( + @IFileService fileService: IFileService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IMcpRegistry mcpRegistry: IMcpRegistry, + @IConfigurationService configurationService: IConfigurationService, + @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + ) { + super(configurationService, fileService, mcpRegistry); + } + + start(): void { + this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(e => { + for (const removed of e.removed) { + this._collections.deleteAndDispose(removed.uri.toString()); + } + for (const added of e.added) { + this.watchFolder(added); + } + })); + + for (const folder of this._workspaceContextService.getWorkspace().folders) { + this.watchFolder(folder); + } + } + + private watchFolder(folder: IWorkspaceFolder) { + const configFile = joinPath(folder.uri, '.cursor', 'mcp.json'); + const collection: WritableMcpCollectionDefinition = { + id: `cursor-workspace.${folder.index}`, + label: `${folder.name}/.cursor/mcp.json`, + remoteAuthority: this._remoteAgentService.getConnection()?.remoteAuthority || null, + scope: StorageScope.WORKSPACE, + isTrustedByDefault: false, + serverDefinitions: observableValue(this, []), + presentation: { + origin: configFile, + order: McpCollectionSortOrder.WorkspaceFolder + 1, + }, + }; + + this._collections.set(folder.uri.toString(), this.watchFile( + URI.joinPath(folder.uri, '.cursor', 'mcp.json'), + collection, + DiscoverySource.CursorWorkspace, + contents => { + const defs = claudeConfigToServerDefinition(collection.id, contents, folder.uri); + defs?.forEach(d => d.roots = [folder.uri]); + return defs; + } + )); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpCommandIds.ts b/src/vs/workbench/contrib/mcp/common/mcpCommandIds.ts new file mode 100644 index 00000000000..f76e2408b12 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpCommandIds.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Contains all MCP command IDs used in the workbench. + */ +export const enum McpCommandIds { + ListServer = 'workbench.mcp.listServer', + ServerOptions = 'workbench.mcp.serverOptions', + ResetTrust = 'workbench.mcp.resetTrust', + ResetCachedTools = 'workbench.mcp.resetCachedTools', + AddConfiguration = 'workbench.mcp.addConfiguration', + RemoveStoredInput = 'workbench.mcp.removeStoredInput', + EditStoredInput = 'workbench.mcp.editStoredInput', + ShowConfiguration = 'workbench.mcp.showConfiguration', + ShowOutput = 'workbench.mcp.showOutput', + RestartServer = 'workbench.mcp.restartServer', + StartServer = 'workbench.mcp.startServer', + StopServer = 'workbench.mcp.stopServer', + InstallFromActivation = 'workbench.mcp.installFromActivation' +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfigPathsService.ts b/src/vs/workbench/contrib/mcp/common/mcpConfigPathsService.ts new file mode 100644 index 00000000000..e86c822b6e5 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpConfigPathsService.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.js'; +import { isDefined } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { FOLDER_SETTINGS_PATH, IPreferencesService } from '../../../services/preferences/common/preferences.js'; +import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; +import { mcpConfigurationSection } from './mcpConfiguration.js'; +import { McpCollectionSortOrder } from './mcpTypes.js'; + +export interface IMcpConfigPath { + /** Short, unique ID for this config. */ + id: string; + /** Configuration scope that maps to this path. */ + key: 'userLocalValue' | 'userRemoteValue' | 'workspaceValue' | 'workspaceFolderValue'; + /** Display name */ + label: string; + /** Storage where associated data should be stored. */ + scope: StorageScope; + /** Configuration target that correspond to this file */ + target: ConfigurationTarget; + /** Order in which the configuration should be displayed */ + order: number; + /** Config's remote authority */ + remoteAuthority?: string; + /** Config file URI. */ + uri: URI | undefined; + /** When MCP config is nested in a config file, the parent nested key. */ + section?: string[]; + /** Workspace folder, when the config refers to a workspace folder value. */ + workspaceFolder?: IWorkspaceFolder; +} + +export interface IMcpConfigPathsService { + _serviceBrand: undefined; + + readonly paths: IObservable; +} + +export const IMcpConfigPathsService = createDecorator('IMcpConfigPathsService'); + +export class McpConfigPathsService extends Disposable implements IMcpConfigPathsService { + _serviceBrand: undefined; + + private readonly _paths: ISettableObservable; + + public get paths(): IObservable { + return this._paths; + } + + constructor( + @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IProductService productService: IProductService, + @ILabelService labelService: ILabelService, + @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, + @IRemoteAgentService remoteAgentService: IRemoteAgentService, + @IPreferencesService preferencesService: IPreferencesService, + ) { + super(); + + const workspaceConfig = workspaceContextService.getWorkspace().configuration; + const initialPaths: (IMcpConfigPath | undefined | null)[] = [ + { + id: 'usrlocal', + key: 'userLocalValue', + target: ConfigurationTarget.USER_LOCAL, + label: localize('mcp.configuration.userLocalValue', 'Global in {0}', productService.nameShort), + scope: StorageScope.PROFILE, + order: McpCollectionSortOrder.User, + uri: preferencesService.userSettingsResource, + section: [mcpConfigurationSection], + }, + workspaceConfig && { + id: 'workspace', + key: 'workspaceValue', + target: ConfigurationTarget.WORKSPACE, + label: basename(workspaceConfig), + scope: StorageScope.WORKSPACE, + order: McpCollectionSortOrder.Workspace, + remoteAuthority: _environmentService.remoteAuthority, + uri: workspaceConfig, + section: ['settings', mcpConfigurationSection], + }, + ...workspaceContextService.getWorkspace() + .folders + .map(wf => this._fromWorkspaceFolder(wf)) + ]; + + this._paths = observableValue('mcpConfigPaths', initialPaths.filter(isDefined)); + + remoteAgentService.getEnvironment().then((env) => { + const label = _environmentService.remoteAuthority ? labelService.getHostLabel(Schemas.vscodeRemote, _environmentService.remoteAuthority) : 'Remote'; + + this._paths.set([ + ...this.paths.get(), + { + id: 'usrremote', + key: 'userRemoteValue', + target: ConfigurationTarget.USER_REMOTE, + label, + scope: StorageScope.PROFILE, + order: McpCollectionSortOrder.User + McpCollectionSortOrder.RemoteBoost, + uri: env?.settingsPath, + remoteAuthority: _environmentService.remoteAuthority, + section: [mcpConfigurationSection], + } + ], undefined); + }); + + this._register(workspaceContextService.onDidChangeWorkspaceFolders(e => { + const next = this._paths.get().slice(); + for (const folder of e.added) { + next.push(this._fromWorkspaceFolder(folder)); + } + for (const folder of e.removed) { + const idx = next.findIndex(c => c.workspaceFolder === folder); + if (idx !== -1) { + next.splice(idx, 1); + } + } + this._paths.set(next, undefined); + })); + } + + private _fromWorkspaceFolder(workspaceFolder: IWorkspaceFolder): IMcpConfigPath { + return { + id: `wf${workspaceFolder.index}`, + key: 'workspaceFolderValue', + target: ConfigurationTarget.WORKSPACE_FOLDER, + label: `${workspaceFolder.name}/.vscode/mcp.json`, + scope: StorageScope.WORKSPACE, + remoteAuthority: this._environmentService.remoteAuthority, + order: McpCollectionSortOrder.WorkspaceFolder, + uri: URI.joinPath(workspaceFolder.uri, FOLDER_SETTINGS_PATH, '../mcp.json'), + workspaceFolder, + }; + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index 46864f954c1..2d3e09b6a4d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -16,14 +16,30 @@ const mcpActivationEventPrefix = 'onMcpCollection:'; export const mcpActivationEvent = (collectionId: string) => mcpActivationEventPrefix + collectionId; -const mcpSchemaExampleServer = { - command: 'node', - args: ['my-mcp-server.js'], - env: {}, +export const enum DiscoverySource { + ClaudeDesktop = 'claude-desktop', + Windsurf = 'windsurf', + CursorGlobal = 'cursor-global', + CursorWorkspace = 'cursor-workspace', +} + +export const allDiscoverySources = Object.keys({ + [DiscoverySource.ClaudeDesktop]: true, + [DiscoverySource.Windsurf]: true, + [DiscoverySource.CursorGlobal]: true, + [DiscoverySource.CursorWorkspace]: true, +} satisfies Record) as DiscoverySource[]; + +export const discoverySourceLabel: Record = { + [DiscoverySource.ClaudeDesktop]: localize('mcp.discovery.source.claude-desktop', "Claude Desktop"), + [DiscoverySource.Windsurf]: localize('mcp.discovery.source.windsurf', "Windsurf"), + [DiscoverySource.CursorGlobal]: localize('mcp.discovery.source.cursor-global', "Cursor (Global)"), + [DiscoverySource.CursorWorkspace]: localize('mcp.discovery.source.cursor-workspace', "Cursor (Workspace)"), }; export const mcpConfigurationSection = 'mcp'; export const mcpDiscoverySection = 'chat.mcp.discovery.enabled'; +export const mcpEnabledSection = 'chat.mcp.enabled'; export const mcpSchemaExampleServers = { 'mcp-server-time': { @@ -33,10 +49,17 @@ export const mcpSchemaExampleServers = { } }; +const httpSchemaExamples = { + 'my-mcp-server': { + url: 'http://localhost:3001/mcp', + headers: {}, + } +}; + export const mcpStdioServerSchema: IJSONSchema = { type: 'object', additionalProperties: false, - examples: [mcpSchemaExampleServer], + examples: [mcpSchemaExampleServers['mcp-server-time']], properties: { type: { type: 'string', @@ -54,6 +77,11 @@ export const mcpStdioServerSchema: IJSONSchema = { type: 'string' }, }, + envFile: { + type: 'string', + description: localize('app.mcp.envFile.command', "Path to a file containing environment variables for the server."), + examples: ['${workspaceFolder}/.env'], + }, env: { description: localize('app.mcp.env.command', "Environment variables passed to the server."), additionalProperties: { @@ -76,29 +104,29 @@ export const mcpServerSchema: IJSONSchema = { additionalProperties: false, properties: { servers: { - examples: [mcpSchemaExampleServers], + examples: [ + mcpSchemaExampleServers, + httpSchemaExamples, + ], additionalProperties: { oneOf: [mcpStdioServerSchema, { type: 'object', additionalProperties: false, - required: ['url', 'type'], - examples: [{ - type: 'sse', - url: 'http://localhost:3001', - headers: {}, - }], + required: ['url'], + examples: [httpSchemaExamples['my-mcp-server']], properties: { type: { type: 'string', - enum: ['sse'], + enum: ['http', 'sse'], description: localize('app.mcp.json.type', "The type of the server.") }, url: { type: 'string', format: 'uri', - description: localize('app.mcp.json.url', "The URL of the server-sent-event (SSE) server.") + description: localize('app.mcp.json.url', "The URL of the Streamable HTTP or SSE endpoint.") }, - env: { + headers: { + type: 'object', description: localize('app.mcp.json.headers', "Additional headers sent to the server."), additionalProperties: { type: 'string' }, }, diff --git a/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts b/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts index 0d0aff9aa6d..3cbb821058c 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpContextKeys.ts @@ -56,7 +56,7 @@ export class McpContextKeysController extends Disposable implements IWorkbenchCo } const toolState = s.toolsState.read(r); - return toolState === McpServerToolsState.Unknown || toolState === McpServerToolsState.RefreshingFromUnknown; + return toolState === McpServerToolsState.Unknown || toolState === McpServerToolsState.Outdated || toolState === McpServerToolsState.RefreshingFromUnknown; })); })); } diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index e53a22faca3..78aea6c1fb7 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -3,23 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter } from '../../../../base/common/event.js'; import { StringSHA1 } from '../../../../base/common/hash.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { derived, IObservable, observableValue } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/resources.js'; +import { indexOfPattern } from '../../../../base/common/strings.js'; import { localize } from '../../../../nls.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { observableMemento } from '../../../../platform/observable/common/observableMemento.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { mcpEnabledSection } from './mcpConfiguration.js'; import { McpRegistryInputStorage } from './mcpRegistryInputStorage.js'; import { IMcpHostDelegate, IMcpRegistry, IMcpResolveConnectionOptions } from './mcpRegistryTypes.js'; import { McpServerConnection } from './mcpServerConnection.js'; -import { IMcpServerConnection, LazyCollectionState, McpCollectionDefinition, McpCollectionReference } from './mcpTypes.js'; +import { IMcpServerConnection, LazyCollectionState, McpCollectionDefinition, McpCollectionReference, McpServerDefinition, McpServerLaunch } from './mcpTypes.js'; const createTrustMemento = observableMemento>>({ defaultValue: {}, @@ -35,7 +45,13 @@ export class McpRegistry extends Disposable implements IMcpRegistry { private readonly _collections = observableValue('collections', []); private readonly _delegates: IMcpHostDelegate[] = []; - public readonly collections: IObservable = this._collections; + private readonly _enabled: IObservable; + public readonly collections: IObservable = derived(reader => { + if (!this._enabled.read(reader)) { + return []; + } + return this._collections.read(reader); + }); private readonly _collectionToPrefixes = this._collections.map(c => { // This creates tool prefixes based on a hash of the collection ID. This is @@ -48,7 +64,9 @@ export class McpRegistry extends Disposable implements IMcpRegistry { const hashes = c.map((collection): CollectionHash => { const sha = new StringSHA1(); sha.update(collection.id); - return { view: 0, hash: sha.digest(), collection }; + const hash = sha.digest(); + // Gemini errors if the name starts with a number (microsoft/vscode-copilot-release#7152) + return { view: indexOfPattern(hash, /[a-z]/i), hash, collection }; }); const view = (h: CollectionHash) => h.hash.slice(h.view, h.view + collectionPrefixLen); @@ -78,6 +96,10 @@ export class McpRegistry extends Disposable implements IMcpRegistry { private readonly _ongoingLazyActivations = observableValue(this, 0); public readonly lazyCollectionState = derived(reader => { + if (this._enabled.read(reader) === false) { + return LazyCollectionState.AllKnown; + } + if (this._ongoingLazyActivations.read(reader) > 0) { return LazyCollectionState.LoadingUnknown; } @@ -89,18 +111,27 @@ export class McpRegistry extends Disposable implements IMcpRegistry { return this._delegates; } + private readonly _onDidChangeInputs = this._register(new Emitter()); + public readonly onDidChangeInputs = this._onDidChangeInputs.event; + constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, @IDialogService private readonly _dialogService: IDialogService, @IStorageService private readonly _storageService: IStorageService, @IProductService private readonly _productService: IProductService, + @INotificationService private readonly _notificationService: INotificationService, + @IEditorService private readonly _editorService: IEditorService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); + this._enabled = observableConfigValue(mcpEnabledSection, true, configurationService); } public registerDelegate(delegate: IMcpHostDelegate): IDisposable { this._delegates.push(delegate); + this._delegates.sort((a, b) => b.priority - a.priority); + return { dispose: () => { const index = this._delegates.indexOf(delegate); @@ -160,9 +191,51 @@ export class McpRegistry extends Disposable implements IMcpRegistry { return found; } - public clearSavedInputs() { - this._profileStorage.value.clearAll(); - this._workspaceStorage.value.clearAll(); + private _getInputStorage(scope: StorageScope): McpRegistryInputStorage { + return scope === StorageScope.WORKSPACE ? this._workspaceStorage.value : this._profileStorage.value; + } + + private _getInputStorageInConfigTarget(configTarget: ConfigurationTarget): McpRegistryInputStorage { + return this._getInputStorage( + configTarget === ConfigurationTarget.WORKSPACE || configTarget === ConfigurationTarget.WORKSPACE_FOLDER + ? StorageScope.WORKSPACE + : StorageScope.PROFILE + ); + } + + public async clearSavedInputs(scope: StorageScope, inputId?: string) { + const storage = this._getInputStorage(scope); + if (inputId) { + await storage.clear(inputId); + } else { + storage.clearAll(); + } + + this._onDidChangeInputs.fire(); + } + + public async editSavedInput(inputId: string, folderData: IWorkspaceFolderData | undefined, configSection: string, target: ConfigurationTarget): Promise { + const storage = this._getInputStorageInConfigTarget(target); + const expr = ConfigurationResolverExpression.parse(inputId); + + const stored = await storage.getMap(); + const previous = stored[inputId].value; + await this._configurationResolverService.resolveWithInteraction(folderData, expr, configSection, previous ? { [inputId.slice(2, -1)]: previous } : {}, target); + await this._updateStorageWithExpressionInputs(storage, expr); + } + + public async setSavedInput(inputId: string, target: ConfigurationTarget, value: string): Promise { + const storage = this._getInputStorageInConfigTarget(target); + const expr = ConfigurationResolverExpression.parse(inputId); + for (const unresolved of expr.unresolved()) { + expr.resolve(unresolved, value); + break; + } + await this._updateStorageWithExpressionInputs(storage, expr); + } + + public getSavedInputs(scope: StorageScope): Promise<{ [id: string]: IResolvedValue }> { + return this._getInputStorage(scope).getMap(); } public resetTrust(): void { @@ -201,9 +274,13 @@ export class McpRegistry extends Disposable implements IMcpRegistry { { message: localize('trustTitleWithOrigin', 'Trust MCP servers from {0}?', collection.label), custom: { + icon: Codicon.shield, markdownDetails: [{ markdown: new MarkdownString(localize('mcp.trust.details', '{0} discovered Model Context Protocol servers from {1} (`{2}`). {0} can use their capabilities in Chat.\n\nDo you want to allow running MCP servers from {3}?', this._productService.nameShort, collection.label, collection.serverDefinitions.get().map(s => s.label).join('`, `'), labelWithOrigin)), - dismissOnLinkClick: true, + actionHandler: () => { + const editor = this._editorService.openEditor({ resource: collection.presentation!.origin! }, AUX_WINDOW_GROUP); + return editor.then(Boolean); + }, }] }, buttons: [ @@ -216,8 +293,55 @@ export class McpRegistry extends Disposable implements IMcpRegistry { return result.result; } - public async resolveConnection({ collectionRef, definitionRef, forceTrust }: IMcpResolveConnectionOptions): Promise { - const collection = this._collections.get().find(c => c.id === collectionRef.id); + private async _updateStorageWithExpressionInputs(inputStorage: McpRegistryInputStorage, expr: ConfigurationResolverExpression): Promise { + const secrets: Record = {}; + const inputs: Record = {}; + for (const [replacement, resolved] of expr.resolved()) { + if (resolved.input?.type === 'promptString' && resolved.input.password) { + secrets[replacement.id] = resolved; + } else { + inputs[replacement.id] = resolved; + } + } + + inputStorage.setPlainText(inputs); + await inputStorage.setSecrets(secrets); + this._onDidChangeInputs.fire(); + } + + private async _replaceVariablesInLaunch(definition: McpServerDefinition, launch: McpServerLaunch) { + if (!definition.variableReplacement) { + return launch; + } + + const { section, target, folder } = definition.variableReplacement; + const inputStorage = this._getInputStorageInConfigTarget(target); + const previouslyStored = await inputStorage.getMap(); + + // pre-fill the variables we already resolved to avoid extra prompting + const expr = ConfigurationResolverExpression.parse(launch); + for (const replacement of expr.unresolved()) { + if (previouslyStored.hasOwnProperty(replacement.id)) { + expr.resolve(replacement, previouslyStored[replacement.id]); + } + } + + // resolve variables requiring user input + await this._configurationResolverService.resolveWithInteraction(folder, expr, section, undefined, target); + + await this._updateStorageWithExpressionInputs(inputStorage, expr); + + // resolve other non-interactive variables, returning the final object + return await this._configurationResolverService.resolveAsync(folder, expr); + } + + public async resolveConnection({ collectionRef, definitionRef, forceTrust, logger }: IMcpResolveConnectionOptions): Promise { + let collection = this._collections.get().find(c => c.id === collectionRef.id); + if (collection?.lazy) { + await collection.lazy.load(); + collection = this._collections.get().find(c => c.id === collectionRef.id); + } + const definition = collection?.serverDefinitions.get().find(s => s.id === definitionRef.id); if (!collection || !definition) { throw new Error(`Collection or definition not found for ${collectionRef.id} and ${definitionRef.id}`); @@ -247,32 +371,46 @@ export class McpRegistry extends Disposable implements IMcpRegistry { } } - let launch = definition.launch; - - if (definition.variableReplacement) { - const inputStorage = definition.variableReplacement.folder ? this._workspaceStorage.value : this._profileStorage.value; - const previouslyStored = await inputStorage.getMap(); - - const { folder, section, target } = definition.variableReplacement; - - // based on _configurationResolverService.resolveWithInteractionReplace - launch = await this._configurationResolverService.resolveAnyAsync(folder, launch); - - const newVariables = await this._configurationResolverService.resolveWithInteraction(folder, launch, section, previouslyStored, target); - - if (newVariables?.size) { - const completeVariables = { ...previouslyStored, ...Object.fromEntries(newVariables) }; - launch = await this._configurationResolverService.resolveAnyAsync(folder, launch, completeVariables); - await inputStorage.setSecrets(completeVariables); + let launch: McpServerLaunch | undefined = definition.launch; + if (collection.resolveServerLanch) { + launch = await collection.resolveServerLanch(definition); + if (!launch) { + return undefined; // interaction cancelled by user } } + try { + launch = await this._replaceVariablesInLaunch(definition, launch); + } catch (e) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('mcp.launchError', 'Error starting {0}: {1}', definition.label, String(e)), + actions: { + primary: collection.presentation?.origin && [ + { + id: 'mcp.launchError.openConfig', + class: undefined, + enabled: true, + tooltip: '', + label: localize('mcp.launchError.openConfig', 'Open Configuration'), + run: () => this._editorService.openEditor({ + resource: collection.presentation!.origin, + options: { selection: definition.presentation?.origin?.range } + }), + } + ] + } + }); + return; + } + return this._instantiationService.createInstance( McpServerConnection, collection, definition, delegate, launch, + logger, ); } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts index 6e23a059998..91f473aafda 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistryInputStorage.ts @@ -11,6 +11,7 @@ import { isEmptyObject } from '../../../../base/common/types.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; const MCP_ENCRYPTION_KEY_NAME = 'mcpEncryptionKey'; const MCP_ENCRYPTION_KEY_ALGORITHM = 'AES-GCM'; @@ -21,12 +22,12 @@ const MCP_DATA_STORED_KEY = 'mcpInputs'; interface IStoredData { version: number; - values: Record; + values: Record; secrets?: { value: string; iv: string }; // base64, encrypted } interface IHydratedData extends IStoredData { - unsealedSecrets?: Record; + unsealedSecrets?: Record; } export class McpRegistryInputStorage extends Disposable { @@ -113,53 +114,41 @@ export class McpRegistryInputStorage extends Disposable { } /** Updates the input data mapping. */ - public async setPlainText(values: Record) { + public async setPlainText(values: Record) { Object.assign(this._record.value.values, values); this._didChange = true; } /** Updates the input secrets mapping. */ - public async setSecrets(values: Record) { + public async setSecrets(values: Record) { const unsealed = await this._unsealSecrets(); Object.assign(unsealed, values); await this._sealSecrets(); } private async _sealSecrets() { + const key = await this._getEncryptionKey.value; return this._secretsSealerSequencer.queue(async () => { if (!this._record.value.unsealedSecrets || isEmptyObject(this._record.value.unsealedSecrets)) { this._record.value.secrets = undefined; return; } - if (!this._record.value.secrets) { - const iv = crypto.getRandomValues(new Uint8Array(MCP_ENCRYPTION_IV_LENGTH)); - this._record.value.secrets = { - value: '', - iv: encodeBase64(VSBuffer.wrap(iv)), - }; - } - const toSeal = JSON.stringify(this._record.value.unsealedSecrets); - const iv = decodeBase64(this._record.value.secrets.iv); - const key = await this._getEncryptionKey.value; + const iv = crypto.getRandomValues(new Uint8Array(MCP_ENCRYPTION_IV_LENGTH)); const encrypted = await crypto.subtle.encrypt( { name: MCP_ENCRYPTION_KEY_ALGORITHM, iv: iv.buffer }, key, - new TextEncoder().encode(toSeal).buffer, + new TextEncoder().encode(toSeal).buffer as ArrayBuffer, ); const enc = encodeBase64(VSBuffer.wrap(new Uint8Array(encrypted))); - if (this._record.value.secrets.value === enc) { - return; - } - - this._record.value.secrets.value = enc; + this._record.value.secrets = { iv: encodeBase64(VSBuffer.wrap(iv)), value: enc }; this._didChange = true; }); } - private async _unsealSecrets(): Promise> { + private async _unsealSecrets(): Promise> { if (!this._record.value.secrets) { return this._record.value.unsealedSecrets ??= {}; } diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts index e835be770b8..83d37b7b710 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistryTypes.ts @@ -6,8 +6,13 @@ import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable } from '../../../../base/common/observable.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpConnectionState, IMcpServerConnection, McpCollectionReference, McpDefinitionReference, LazyCollectionState } from './mcpTypes.js'; +import { ILogger, LogLevel } from '../../../../platform/log/common/log.js'; +import { StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; +import { IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { IMcpServerConnection, LazyCollectionState, McpCollectionDefinition, McpCollectionReference, McpConnectionState, McpDefinitionReference, McpServerDefinition, McpServerLaunch } from './mcpTypes.js'; import { MCP } from './modelContextProtocol.js'; export const IMcpRegistry = createDecorator('mcpRegistry'); @@ -15,19 +20,22 @@ export const IMcpRegistry = createDecorator('mcpRegistry'); /** Message transport to a single MCP server. */ export interface IMcpMessageTransport extends IDisposable { readonly state: IObservable; - readonly onDidLog: Event; + readonly onDidLog: Event<{ level: LogLevel; message: string }>; readonly onDidReceiveMessage: Event; send(message: MCP.JSONRPCMessage): void; stop(): void; } export interface IMcpHostDelegate { + /** Priority for this delegate, delegates are tested in descending priority order */ + readonly priority: number; waitForInitialProviderPromises(): Promise; canStart(collectionDefinition: McpCollectionDefinition, serverDefinition: McpServerDefinition): boolean; start(collectionDefinition: McpCollectionDefinition, serverDefinition: McpServerDefinition, resolvedLaunch: McpServerLaunch): IMcpMessageTransport; } export interface IMcpResolveConnectionOptions { + logger: ILogger; collectionRef: McpCollectionReference; definitionRef: McpDefinitionReference; /** If set, the user will be asked to trust the collection even if they untrusted it previously */ @@ -37,14 +45,17 @@ export interface IMcpResolveConnectionOptions { export interface IMcpRegistry { readonly _serviceBrand: undefined; + /** Fired when the user provides more inputs when creating a connection. */ + readonly onDidChangeInputs: Event; + readonly collections: IObservable; readonly delegates: readonly IMcpHostDelegate[]; + /** Whether there are new collections that can be resolved with a discover() call */ + readonly lazyCollectionState: IObservable; /** Gets the prefix that should be applied to a collection's tools in order to avoid ID conflicts */ collectionToolPrefix(collection: McpCollectionReference): IObservable; - /** Whether there are new collections that can be resolved with a discover() call */ - readonly lazyCollectionState: IObservable; /** Discover new collections, returning newly-discovered ones. */ discoverCollections(): Promise; @@ -57,8 +68,14 @@ export interface IMcpRegistry { /** Gets whether the collection is trusted. */ getTrust(collection: McpCollectionReference): IObservable; - /** Resets any saved inputs for the connection. */ - clearSavedInputs(collection: McpCollectionReference, definition: McpServerDefinition): void; + /** Resets any saved inputs for the input, or globally. */ + clearSavedInputs(scope: StorageScope, inputId?: string): Promise; + /** Edits a previously-saved input. */ + editSavedInput(inputId: string, folderData: IWorkspaceFolderData | undefined, configSection: string, target: ConfigurationTarget): Promise; + /** Updates a saved input. */ + setSavedInput(inputId: string, target: ConfigurationTarget, value: string): Promise; + /** Gets saved inputs from storage. */ + getSavedInputs(scope: StorageScope): Promise<{ [id: string]: IResolvedValue }>; /** Creates a connection for the collection and definition. */ resolveConnection(options: IMcpResolveConnectionOptions): Promise; } diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 2e32c72c970..7e303106f04 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -5,28 +5,70 @@ import { raceCancellationError, Sequencer } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import * as json from '../../../../base/common/json.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { autorun, autorunWithStore, derived, disposableObservableValue, IObservable, ITransaction, observableFromEvent, ObservablePromise, observableValue, transaction } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; +import { INotificationService, IPromptChoice, Severity } from '../../../../platform/notification/common/notification.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IOutputService } from '../../../services/output/common/output.js'; +import { ToolProgress } from '../../chat/common/languageModelToolsService.js'; import { mcpActivationEvent } from './mcpConfiguration.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; -import { extensionMcpCollectionPrefix, IMcpServer, IMcpServerConnection, IMcpTool, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, McpServerDefinition, McpServerToolsState } from './mcpTypes.js'; +import { extensionMcpCollectionPrefix, IMcpServer, IMcpServerConnection, IMcpTool, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, McpServerDefinition, McpServerToolsState, McpServerTransportType } from './mcpTypes.js'; import { MCP } from './modelContextProtocol.js'; +type ServerBootData = { + supportsLogging: boolean; + supportsPrompts: boolean; + supportsResources: boolean; + toolCount: number; +}; +type ServerBootClassification = { + owner: 'connor4312'; + comment: 'Details the capabilities of the MCP server'; + supportsLogging: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the server supports logging' }; + supportsPrompts: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the server supports prompts' }; + supportsResources: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the server supports resource' }; + toolCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of tools the server advertises' }; +}; + +type ServerBootState = { + state: string; + time: number; +}; +type ServerBootStateClassification = { + owner: 'connor4312'; + comment: 'Details the capabilities of the MCP server'; + state: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The server outcome' }; + time: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Duration in milliseconds to reach that state' }; +}; interface IToolCacheEntry { + readonly nonce: string | undefined; /** Cached tools so we can show what's available before it's started */ - readonly tools: readonly MCP.Tool[]; + readonly tools: readonly IValidatedMcpTool[]; } interface IServerCacheEntry { readonly servers: readonly McpServerDefinition.Serialized[]; } +const toolInvalidCharRe = /[^a-z0-9_-]/gi; + export class McpServerMetadataCache extends Disposable { private didChange = false; private readonly cache = new LRUCache(128); @@ -71,13 +113,13 @@ export class McpServerMetadataCache extends Disposable { } /** Gets cached tools for a server (used before a server is running) */ - getTools(definitionId: string): readonly MCP.Tool[] | undefined { - return this.cache.get(definitionId)?.tools; + getTools(definitionId: string) { + return this.cache.get(definitionId); } /** Sets cached tools for a server */ - storeTools(definitionId: string, tools: readonly MCP.Tool[]): void { - this.cache.set(definitionId, { ...this.cache.get(definitionId), tools }); + storeTools(definitionId: string, nonce: string | undefined, tools: readonly IValidatedMcpTool[]): void { + this.cache.set(definitionId, { ...this.cache.get(definitionId), nonce, tools }); this.didChange = true; } @@ -97,6 +139,15 @@ export class McpServerMetadataCache extends Disposable { } } +interface IValidatedMcpTool extends MCP.Tool { + /** + * Tool name as published by the MCP server. This may + * be different than the one in {@link definition} due to name normalization + * in {@link McpServer._getValidatedTools}. + */ + serverToolName: string; +} + export class McpServer extends Disposable implements IMcpServer { private readonly _connectionSequencer = new Sequencer(); private readonly _connection = this._register(disposableObservableValue(this, undefined)); @@ -107,17 +158,33 @@ export class McpServer extends Disposable implements IMcpServer { private get toolsFromCache() { return this._toolCache.getTools(this.definition.id); } - private readonly toolsFromServerPromise = observableValue | undefined>(this, undefined); + private readonly toolsFromServerPromise = observableValue | undefined>(this, undefined); private readonly toolsFromServer = derived(reader => this.toolsFromServerPromise.read(reader)?.promiseResult.read(reader)?.data); public readonly tools: IObservable; public readonly toolsState = derived(reader => { + const currentNonce = () => this._mcpRegistry.collections.read(reader) + .find(c => c.id === this.collection.id) + ?.serverDefinitions.read(reader) + .find(d => d.id === this.definition.id) + ?.cacheNonce; + const stateWhenServingFromCache = () => { + if (!this.toolsFromCache) { + return McpServerToolsState.Unknown; + } + + return currentNonce() === this.toolsFromCache.nonce ? McpServerToolsState.Cached : McpServerToolsState.Outdated; + }; + const fromServer = this.toolsFromServerPromise.read(reader); const connectionState = this.connectionState.read(reader); const isIdle = McpConnectionState.canBeStarted(connectionState.state) && !fromServer; if (isIdle) { - return this.toolsFromCache ? McpServerToolsState.Cached : McpServerToolsState.Unknown; + return stateWhenServingFromCache(); } const fromServerResult = fromServer?.promiseResult.read(reader); @@ -125,9 +192,16 @@ export class McpServer extends Disposable implements IMcpServer { return this.toolsFromCache ? McpServerToolsState.RefreshingFromCached : McpServerToolsState.RefreshingFromUnknown; } - return fromServerResult.error ? (this.toolsFromCache ? McpServerToolsState.Cached : McpServerToolsState.Unknown) : McpServerToolsState.Live; + if (fromServerResult.error) { + return stateWhenServingFromCache(); + } + + return fromServerResult.data?.nonce === currentNonce() ? McpServerToolsState.Live : McpServerToolsState.Outdated; }); + private readonly _loggerId: string; + private readonly _logger: ILogger; + public get trusted() { return this._mcpRegistry.getTrust(this.collection); } @@ -135,20 +209,36 @@ export class McpServer extends Disposable implements IMcpServer { constructor( public readonly collection: McpCollectionReference, public readonly definition: McpDefinitionReference, + explicitRoots: URI[] | undefined, private readonly _requiresExtensionActivation: boolean | undefined, private readonly _toolCache: McpServerMetadataCache, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @IWorkspaceContextService workspacesService: IWorkspaceContextService, @IExtensionService private readonly _extensionService: IExtensionService, + @ILoggerService private readonly _loggerService: ILoggerService, + @IOutputService private readonly _outputService: IOutputService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ICommandService private readonly _commandService: ICommandService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @INotificationService private readonly _notificationService: INotificationService, + @IOpenerService private readonly _openerService: IOpenerService, ) { super(); + this._loggerId = `mcpServer.${definition.id}`; + this._logger = this._register(_loggerService.createLogger(this._loggerId, { hidden: true, name: `MCP: ${definition.label}` })); + // If the logger is disposed but not deregistered, then the disposed instance + // is reused and no-ops. todo@sandy081 this seems like a bug. + this._register(toDisposable(() => _loggerService.deregisterLogger(this._loggerId))); + // 1. Reflect workspaces into the MCP roots - const workspaces = observableFromEvent( - this, - workspacesService.onDidChangeWorkspaceFolders, - () => workspacesService.getWorkspace().folders, - ); + const workspaces = explicitRoots + ? observableValue(this, explicitRoots.map(uri => ({ uri, name: basename(uri) }))) + : observableFromEvent( + this, + workspacesService.onDidChangeWorkspaceFolders, + () => workspacesService.getWorkspace().folders, + ); this._register(autorunWithStore(reader => { const cnx = this._connection.read(reader)?.handler.read(reader); @@ -164,34 +254,28 @@ export class McpServer extends Disposable implements IMcpServer { // 2. Populate this.tools when we connect to a server. this._register(autorunWithStore((reader, store) => { - const cnx = this._connection.read(reader)?.handler.read(reader); - if (cnx) { - this.populateLiveData(cnx, store); + const cnx = this._connection.read(reader); + const handler = cnx?.handler.read(reader); + if (handler) { + this.populateLiveData(handler, cnx?.definition.cacheNonce, store); } else { this.resetLiveData(); } })); - // 3. Update the cache when tools update - this._register(autorun(reader => { - const tools = this.toolsFromServer.read(reader); - if (tools) { - this._toolCache.storeTools(definition.id, tools); - } - })); - - // 4. Publish tools + // 3. Publish tools const toolPrefix = this._mcpRegistry.collectionToolPrefix(this.collection); this.tools = derived(reader => { const serverTools = this.toolsFromServer.read(reader); - const definitions = serverTools ?? this.toolsFromCache ?? []; + const definitions = serverTools?.tools ?? this.toolsFromCache?.tools ?? []; const prefix = toolPrefix.read(reader); - return definitions.map(def => new McpTool(this, prefix, def)); + return definitions.map(def => new McpTool(this, prefix, def)).sort((a, b) => a.compare(b)); }); } public showOutput(): void { - this._connection.get()?.showOutput(); + this._loggerService.setVisibility(this._loggerId, true); + this._outputService.showChannel(this._loggerId); } public start(isFromInteraction?: boolean): Promise { @@ -217,6 +301,7 @@ export class McpServer extends Disposable implements IMcpServer { if (!connection) { connection = await this._mcpRegistry.resolveConnection({ + logger: this._logger, collectionRef: this.collection, definitionRef: this.definition, forceTrust: isFromInteraction, @@ -233,10 +318,51 @@ export class McpServer extends Disposable implements IMcpServer { this._connection.set(connection, undefined); } - return connection.start(); + const start = Date.now(); + const state = await connection.start(); + this._telemetryService.publicLog2('mcp/serverBootState', { + state: McpConnectionState.toKindString(state.state), + time: Date.now() - start, + }); + + if (state.state === McpConnectionState.Kind.Error && isFromInteraction) { + this.showInteractiveError(connection, state); + } + + return state; }); } + private showInteractiveError(cnx: IMcpServerConnection, error: McpConnectionState.Error) { + if (error.code === 'ENOENT' && cnx.launchDefinition.type === McpServerTransportType.Stdio) { + let docsLink: string | undefined; + switch (cnx.launchDefinition.command) { + case 'uvx': + docsLink = `https://aka.ms/vscode-mcp-install/uvx`; + break; + case 'npx': + docsLink = `https://aka.ms/vscode-mcp-install/npx`; + break; + } + + const options: IPromptChoice[] = [{ + label: localize('mcp.command.showOutput', "Show Output"), + run: () => this.showOutput(), + }]; + + if (docsLink) { + options.push({ + label: localize('mcpServerInstall', 'Install {0}', cnx.launchDefinition.command), + run: () => this._openerService.open(URI.parse(docsLink)), + }); + } + + this._notificationService.prompt(Severity.Error, localize('mcpServerNotFound', 'The command "{0}" needed to run {1} was not found.', cnx.launchDefinition.command, cnx.definition.label), options); + } else { + this._notificationService.warn(localize('mcpServerError', 'The MCP server {0} could not be started: {1}', cnx.definition.label, error.message)); + } + } + public stop(): Promise { return this._connection.get()?.stop() || Promise.resolve(); } @@ -247,7 +373,71 @@ export class McpServer extends Disposable implements IMcpServer { }); } - private populateLiveData(handler: McpServerRequestHandler, store: DisposableStore) { + private async _normalizeTool(originalTool: MCP.Tool): Promise { + const tool: IValidatedMcpTool = { ...originalTool, serverToolName: originalTool.name }; + if (!tool.description) { + // Ensure a description is provided for each tool, #243919 + this._logger.warn(`Tool ${tool.name} does not have a description. Tools must be accurately described to be called`); + tool.description = ''; + } + + if (toolInvalidCharRe.test(tool.name)) { + this._logger.warn(`Tool ${JSON.stringify(tool.name)} is invalid. Tools names may only contain [a-z0-9_-]`); + tool.name = tool.name.replace(toolInvalidCharRe, '_'); + } + + type JsonDiagnostic = { message: string; range: { line: number; character: number }[] }; + + let diagnostics: JsonDiagnostic[] = []; + const toolJson = JSON.stringify(tool.inputSchema); + try { + const schemaUri = URI.parse('https://json-schema.org/draft-07/schema'); + diagnostics = await this._commandService.executeCommand('json.validate', schemaUri, toolJson) || []; + } catch (e) { + // ignored (error in json extension?); + } + + if (!diagnostics.length) { + return tool; + } + + // because it's all one line from JSON.stringify, we can treat characters as offsets. + const tree = json.parseTree(toolJson); + const messages = diagnostics.map(d => { + const node = json.findNodeAtOffset(tree, d.range[0].character); + const path = node && `/${json.getNodePath(node).join('/')}`; + return d.message + (path ? ` (at ${path})` : ''); + }); + + return { error: messages }; + } + + private async _getValidatedTools(handler: McpServerRequestHandler, tools: MCP.Tool[]): Promise { + let error = ''; + + const validations = await Promise.all(tools.map(t => this._normalizeTool(t))); + const validated: IValidatedMcpTool[] = []; + for (const [i, result] of validations.entries()) { + if ('error' in result) { + error += localize('mcpBadSchema.tool', 'Tool `{0}` has invalid JSON parameters:', tools[i].name) + '\n'; + for (const message of result.error) { + error += `\t- ${message}\n`; + } + error += `\t- Schema: ${JSON.stringify(tools[i].inputSchema)}\n\n`; + } else { + validated.push(result); + } + } + + if (error) { + handler.logger.warn(`${tools.length - validated.length} tools have invalid JSON schemas and will be omitted`); + warnInvalidTools(this._instantiationService, this.definition.label, error); + } + + return validated; + } + + private populateLiveData(handler: McpServerRequestHandler, cacheNonce: string | undefined, store: DisposableStore) { const cts = new CancellationTokenSource(); store.add(toDisposable(() => cts.dispose(true))); @@ -255,13 +445,34 @@ export class McpServer extends Disposable implements IMcpServer { const updateTools = (tx: ITransaction | undefined) => { const toolPromise = handler.capabilities.tools ? handler.listTools({}, cts.token) : Promise.resolve([]); - this.toolsFromServerPromise.set(new ObservablePromise(toolPromise), tx); + const toolPromiseSafe = toolPromise.then(async tools => { + handler.logger.info(`Discovered ${tools.length} tools`); + return { tools: await this._getValidatedTools(handler, tools), nonce: cacheNonce }; + }); + this.toolsFromServerPromise.set(new ObservablePromise(toolPromiseSafe), tx); + + return [toolPromiseSafe]; }; - store.add(handler.onDidChangeToolList(() => updateTools(undefined))); + store.add(handler.onDidChangeToolList(() => { + handler.logger.info('Tool list changed, refreshing tools...'); + updateTools(undefined); + })); + let promises: ReturnType; transaction(tx => { - updateTools(tx); + promises = updateTools(tx); + }); + + Promise.all(promises!).then(([{ tools }]) => { + this._toolCache.storeTools(this.definition.id, cacheNonce, tools); + + this._telemetryService.publicLog2('mcp/serverBoot', { + supportsLogging: !!handler.capabilities.logging, + supportsPrompts: !!handler.capabilities.prompts, + supportsResources: !!handler.capabilities.resources, + toolCount: tools.length, + }); }); } @@ -270,7 +481,6 @@ export class McpServer extends Disposable implements IMcpServer { * connection started if it is not already. */ public async callOn(fn: (handler: McpServerRequestHandler) => Promise, token: CancellationToken = CancellationToken.None): Promise { - await this.start(); // idempotent let ranOnce = false; @@ -312,15 +522,84 @@ export class McpTool implements IMcpTool { readonly id: string; + public get definition(): MCP.Tool { return this._definition; } + constructor( private readonly _server: McpServer, idPrefix: string, - public readonly definition: MCP.Tool, + private readonly _definition: IValidatedMcpTool, ) { - this.id = (idPrefix + definition.name).replaceAll('.', '_'); + this.id = (idPrefix + _definition.name).replaceAll('.', '_'); } call(params: Record, token?: CancellationToken): Promise { - return this._server.callOn(h => h.callTool({ name: this.definition.name, arguments: params }), token); + // serverToolName is always set now, but older cache entries (from 1.99-Insiders) may not have it. + const name = this._definition.serverToolName ?? this._definition.name; + return this._server.callOn(h => h.callTool({ name, arguments: params }, token), token); + } + + callWithProgress(params: Record, progress: ToolProgress, token?: CancellationToken): Promise { + return this._callWithProgress(params, progress, token); + } + + _callWithProgress(params: Record, progress: ToolProgress, token?: CancellationToken, allowRetry = true): Promise { + // serverToolName is always set now, but older cache entries (from 1.99-Insiders) may not have it. + const name = this._definition.serverToolName ?? this._definition.name; + const progressToken = generateUuid(); + + return this._server.callOn(h => { + let lastProgressN = 0; + const listener = h.onDidReceiveProgressNotification((e) => { + if (e.params.progressToken === progressToken) { + progress.report({ + message: e.params.message, + increment: e.params.progress - lastProgressN, + total: e.params.total, + }); + lastProgressN = e.params.progress; + } + }); + + return h.callTool({ name, arguments: params, _meta: { progressToken } }, token) + .finally(() => listener.dispose()) + .catch(err => { + const state = this._server.connectionState.get(); + if (allowRetry && state.state === McpConnectionState.Kind.Error && state.shouldRetry) { + return this._callWithProgress(params, progress, token, false); + } else { + throw err; + } + }); + }, token); + } + + compare(other: IMcpTool): number { + return this._definition.name.localeCompare(other.definition.name); } } + +function warnInvalidTools(instaService: IInstantiationService, serverName: string, errorText: string) { + instaService.invokeFunction((accessor) => { + const notificationService = accessor.get(INotificationService); + const editorService = accessor.get(IEditorService); + notificationService.notify({ + severity: Severity.Warning, + message: localize('mcpBadSchema', 'MCP server `{0}` has tools with invalid parameters which will be omitted.', serverName), + actions: { + primary: [{ + class: undefined, + enabled: true, + id: 'mcpBadSchema.show', + tooltip: '', + label: localize('mcpBadSchema.show', 'Show'), + run: () => { + editorService.openEditor({ + resource: undefined, + contents: errorText, + }); + } + }] + } + }); + }); +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts index 5016ebe7f53..30fff6f2f16 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts @@ -4,15 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; import { Disposable, DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, IObservable, observableValue } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; -import { IOutputService } from '../../../services/output/common/output.js'; +import { ILogger, log } from '../../../../platform/log/common/log.js'; import { IMcpHostDelegate, IMcpMessageTransport } from './mcpRegistryTypes.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; -import { McpCollectionDefinition, IMcpServerConnection, McpServerDefinition, McpConnectionState, McpServerLaunch } from './mcpTypes.js'; +import { IMcpServerConnection, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch } from './mcpTypes.js'; export class McpServerConnection extends Disposable implements IMcpServerConnection { private readonly _launch = this._register(new MutableDisposable>()); @@ -22,28 +22,15 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect public readonly state: IObservable = this._state; public readonly handler: IObservable = this._requestHandler; - private readonly _loggerId: string; - private readonly _logger: ILogger; - private _launchId = 0; - constructor( private readonly _collection: McpCollectionDefinition, public readonly definition: McpServerDefinition, private readonly _delegate: IMcpHostDelegate, public readonly launchDefinition: McpServerLaunch, - @ILoggerService private readonly _loggerService: ILoggerService, - @IOutputService private readonly _outputService: IOutputService, + private readonly _logger: ILogger, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); - this._loggerId = `mcpServer/${definition.id}`; - this._logger = this._register(_loggerService.createLogger(this._loggerId, { hidden: true, name: `MCP: ${definition.label}` })); - } - - /** @inheritdoc */ - public showOutput(): void { - this._loggerService.setVisibility(this._loggerId, true); - this._outputService.showChannel(this._loggerId); } /** @inheritdoc */ @@ -53,14 +40,13 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect return this._waitForState(McpConnectionState.Kind.Running, McpConnectionState.Kind.Error); } - const launchId = ++this._launchId; this._launch.value = undefined; this._state.set({ state: McpConnectionState.Kind.Starting }, undefined); this._logger.info(localize('mcpServer.starting', 'Starting server {0}', this.definition.label)); try { const launch = this._delegate.start(this._collection, this.definition, this.launchDefinition); - this._launch.value = this.adoptLaunch(launch, launchId); + this._launch.value = this.adoptLaunch(launch); return this._waitForState(McpConnectionState.Kind.Running, McpConnectionState.Kind.Error); } catch (e) { const errorState: McpConnectionState = { @@ -72,14 +58,14 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect } } - private adoptLaunch(launch: IMcpMessageTransport, launchId: number): IReference { + private adoptLaunch(launch: IMcpMessageTransport): IReference { const store = new DisposableStore(); const cts = new CancellationTokenSource(); store.add(toDisposable(() => cts.dispose(true))); store.add(launch); - store.add(launch.onDidLog(msg => { - this._logger.info(msg); + store.add(launch.onDidLog(({ level, message }) => { + log(this._logger, level, message); })); let didStart = false; @@ -92,18 +78,24 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect didStart = true; McpServerRequestHandler.create(this._instantiationService, launch, this._logger, cts.token).then( handler => { - if (this._launchId === launchId) { + if (!store.isDisposed) { this._requestHandler.set(handler, undefined); } else { handler.dispose(); } }, err => { - store.dispose(); - if (this._launchId === launchId) { - this._logger.error(err); - this._state.set({ state: McpConnectionState.Kind.Error, message: `Could not initialize MCP server: ${err.message}` }, undefined); + if (!store.isDisposed) { + let message = err.message; + if (err instanceof CancellationError) { + message = 'Server exited before responding to `initialize` request.'; + this._logger.error(message); + } else { + this._logger.error(err); + } + this._state.set({ state: McpConnectionState.Kind.Error, message }, undefined); } + store.dispose(); }, ); } @@ -113,14 +105,12 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect } public async stop(): Promise { - this._launchId = -1; this._logger.info(localize('mcpServer.stopping', 'Stopping server {0}', this.definition.label)); this._launch.value?.object.stop(); await this._waitForState(McpConnectionState.Kind.Stopped, McpConnectionState.Kind.Error); } public override dispose(): void { - this._launchId = -1; this._requestHandler.get()?.dispose(); super.dispose(); this._state.set({ state: McpConnectionState.Kind.Stopped }, undefined); diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts index 651cb01d8cd..011f50f026d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { equals } from '../../../../base/common/arrays.js'; -import { DeferredPromise } from '../../../../base/common/async.js'; +import { DeferredPromise, IntervalTimer } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ILogger } from '../../../../platform/log/common/log.js'; +import { canLog, ILogger, LogLevel } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IMcpMessageTransport } from './mcpRegistryTypes.js'; import { McpConnectionState, MpcResponseError } from './mcpTypes.js'; @@ -81,7 +82,13 @@ export class McpServerRequestHandler extends Disposable { */ public static async create(instaService: IInstantiationService, launch: IMcpMessageTransport, logger: ILogger, token?: CancellationToken) { const mcp = new McpServerRequestHandler(launch, logger); + const store = new DisposableStore(); try { + const timer = store.add(new IntervalTimer()); + timer.cancelAndSet(() => { + logger.info('Waiting for server to respond to `initialize` request...'); + }, 5000); + await instaService.invokeFunction(async accessor => { const productService = accessor.get(IProductService); const initialized = await mcp.sendRequest({ @@ -109,12 +116,14 @@ export class McpServerRequestHandler extends Disposable { } catch (e) { mcp.dispose(); throw e; + } finally { + store.dispose(); } } protected constructor( private readonly launch: IMcpMessageTransport, - private readonly logger: ILogger, + public readonly logger: ILogger, ) { super(); this._register(launch.onDidReceiveMessage(message => this.handleMessage(message))); @@ -140,6 +149,10 @@ export class McpServerRequestHandler extends Disposable { request: Pick, token: CancellationToken = CancellationToken.None ): Promise { + if (this._store.isDisposed) { + return Promise.reject(new CancellationError()); + } + const id = this._nextRequestId++; // Create the full JSON-RPC request @@ -152,7 +165,6 @@ export class McpServerRequestHandler extends Disposable { const promise = new DeferredPromise(); // Store the pending request this._pendingRequests.set(id, { promise }); - // Set up cancellation const cancelListener = token.onCancellationRequested(() => { if (!promise.isSettled) { @@ -160,10 +172,11 @@ export class McpServerRequestHandler extends Disposable { this.sendNotification({ method: 'notifications/cancelled', params: { requestId: id } }); promise.cancel(); } + cancelListener.dispose(); }); // Send the request - this.launch.send(jsonRpcRequest); + this.send(jsonRpcRequest); const ret = promise.p.finally(() => { cancelListener.dispose(); this._pendingRequests.delete(id); @@ -172,6 +185,14 @@ export class McpServerRequestHandler extends Disposable { return ret as Promise; } + private send(mcp: MCP.JSONRPCMessage) { + if (canLog(this.logger.getLevel(), LogLevel.Debug)) { // avoid building the string if we don't need to + this.logger.debug(`[editor -> server] ${JSON.stringify(mcp)}`); + } + + this.launch.send(mcp); + } + /** * Handles paginated requests by making multiple requests until all items are retrieved. * @@ -200,13 +221,17 @@ export class McpServerRequestHandler extends Disposable { } private sendNotification(notification: N): void { - this.launch.send({ ...notification, jsonrpc: MCP.JSONRPC_VERSION }); + this.send({ ...notification, jsonrpc: MCP.JSONRPC_VERSION }); } /** * Handle incoming messages from the server */ private handleMessage(message: MCP.JSONRPCMessage): void { + if (canLog(this.logger.getLevel(), LogLevel.Debug)) { // avoid building the string if we don't need to + this.logger.debug(`[server <- editor] ${JSON.stringify(message)}`); + } + // Handle responses to our requests if ('id' in message) { if ('result' in message) { @@ -268,7 +293,7 @@ export class McpServerRequestHandler extends Disposable { message: `Method not found: ${request.method}` } }; - this.launch.send(errorResponse); + this.send(errorResponse); break; } } @@ -347,7 +372,7 @@ export class McpServerRequestHandler extends Disposable { id: request.id, result }; - this.launch.send(response); + this.send(response); } /** @@ -441,7 +466,7 @@ export class McpServerRequestHandler extends Disposable { /** * Call a specific tool */ - callTool(params: MCP.CallToolRequest['params'], token?: CancellationToken): Promise { + callTool(params: MCP.CallToolRequest['params'] & MCP.Request['params'], token?: CancellationToken): Promise { return this.sendRequest({ method: 'tools/call', params }, token); } diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index 5845db90584..b544b9602b2 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { decodeBase64 } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { markdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { equals } from '../../../../base/common/objects.js'; import { autorun, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; @@ -14,15 +16,15 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { ILogService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; -import { CountTokensCallback, ILanguageModelToolsService, IToolData, IToolImpl, IToolInvocation, IToolResult } from '../../chat/common/languageModelToolsService.js'; +import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolResult, ToolProgress } from '../../chat/common/languageModelToolsService.js'; +import { McpCommandIds } from './mcpCommandIds.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; import { McpServer, McpServerMetadataCache } from './mcpServer.js'; import { IMcpServer, IMcpService, IMcpTool, McpCollectionDefinition, McpServerDefinition, McpServerToolsState } from './mcpTypes.js'; interface ISyncedToolData { toolData: IToolData; - toolDispose: IDisposable; - implDispose: IDisposable; + store: DisposableStore; } type IMcpServerRec = IReference; @@ -43,7 +45,6 @@ export class McpService extends Disposable implements IMcpService { @IInstantiationService private readonly _instantiationService: IInstantiationService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, - @IProductService productService: IProductService, @ILogService private readonly _logService: ILogService, ) { super(); @@ -95,38 +96,49 @@ export class McpService extends Disposable implements IMcpService { const toDelete = new Set(tools.keys()); for (const tool of server.tools.read(reader)) { const existing = tools.get(tool.id); + const collection = this._mcpRegistry.collections.get().find(c => c.id === server.collection.id); const toolData: IToolData = { id: tool.id, - displayName: tool.definition.name, + source: { type: 'mcp', label: server.definition.label, collectionId: server.collection.id, definitionId: server.definition.id }, + icon: Codicon.tools, + displayName: tool.definition.annotations?.title || tool.definition.name, toolReferenceName: tool.definition.name, modelDescription: tool.definition.description ?? '', userDescription: tool.definition.description ?? '', inputSchema: tool.definition.inputSchema, canBeReferencedInPrompt: true, - tags: ['mcp', 'vscode_editing'], // TODO@jrieken remove this tag + supportsToolPicker: true, + alwaysDisplayInputOutput: true, + runsInWorkspace: collection?.scope === StorageScope.WORKSPACE || !!collection?.remoteAuthority, + tags: ['mcp'], }; + const registerTool = (store: DisposableStore) => { + store.add(this._toolsService.registerToolData(toolData)); + store.add(this._toolsService.registerToolImplementation(tool.id, this._instantiationService.createInstance(McpToolImplementation, tool, server))); + }; + + if (existing) { if (!equals(existing.toolData, toolData)) { existing.toolData = toolData; - existing.toolDispose.dispose(); - existing.toolDispose = this._toolsService.registerToolData(toolData); + existing.store.clear(); + // We need to re-register both the data and implementation, as the + // implementation is discarded when the data is removed (#245921) + registerTool(store); } toDelete.delete(tool.id); } else { - tools.set(tool.id, { - toolData, - toolDispose: this._toolsService.registerToolData(toolData), - implDispose: this._toolsService.registerToolImplementation(tool.id, this._instantiationService.createInstance(McpToolImplementation, tool, server)), - }); + const store = new DisposableStore(); + registerTool(store); + tools.set(tool.id, { toolData, store }); } } for (const id of toDelete) { const tool = tools.get(id); if (tool) { - tool.toolDispose.dispose(); - tool.implDispose.dispose(); + tool.store.dispose(); tools.delete(id); } } @@ -134,8 +146,7 @@ export class McpService extends Disposable implements IMcpService { store.add(toDisposable(() => { for (const tool of tools.values()) { - tool.toolDispose.dispose(); - tool.implDispose.dispose(); + tool.store.dispose(); } })); } @@ -175,7 +186,14 @@ export class McpService extends Disposable implements IMcpService { // Create any new servers that are needed. for (const def of nextDefinitions) { const store = new DisposableStore(); - const object = this._instantiationService.createInstance(McpServer, def.collectionDefinition, def.serverDefinition, false, def.collectionDefinition.scope === StorageScope.WORKSPACE ? this.workspaceCache : this.userCache); + const object = this._instantiationService.createInstance( + McpServer, + def.collectionDefinition, + def.serverDefinition, + def.serverDefinition.roots, + !!def.collectionDefinition.lazy, + def.collectionDefinition.scope === StorageScope.WORKSPACE ? this.workspaceCache : this.userCache, + ); store.add(object); this._syncTools(object, store); @@ -204,45 +222,72 @@ class McpToolImplementation implements IToolImpl { @IProductService private readonly _productService: IProductService, ) { } - async prepareToolInvocation(parameters: any, token: CancellationToken) { + async prepareToolInvocation(parameters: any): Promise { const tool = this._tool; const server = this._server; const mcpToolWarning = localize( 'mcp.tool.warning', - "MCP servers or malicious conversation content may attempt to misuse '{0}' through the installed tools. Please carefully review any requested actions.", + "{0} Note that MCP servers or malicious conversation content may attempt to misuse '{1}' through tools.", + '$(info)', this._productService.nameShort ); + const needsConfirmation = !tool.definition.annotations?.readOnlyHint; + const title = tool.definition.annotations?.title || ('`' + tool.definition.name + '`'); + const subtitle = localize('msg.subtitle', "{0} (MCP Server)", server.definition.label); + return { - confirmationMessages: { - title: localize('msg.title', "Run `{0}` from $(server) `{1}` (MCP server)", tool.definition.name, server.definition.label), - message: new MarkdownString(localize('msg.msg', "{0}\n\nInput:\n\n```json\n{1}\n```\n\n$(warning) {2}", tool.definition.description, JSON.stringify(parameters, undefined, 2), mcpToolWarning), { supportThemeIcons: true }) - }, - invocationMessage: new MarkdownString(localize('msg.run', "Running `{0}`", tool.definition.name, server.definition.label)), - pastTenseMessage: new MarkdownString(localize('msg.ran', "Ran `{0}` ", tool.definition.name, server.definition.label)) + confirmationMessages: needsConfirmation ? { + title: localize('msg.title', "Run {0}", title), + message: new MarkdownString(localize('msg.msg', "{0}\n\n {1}", tool.definition.description, mcpToolWarning), { supportThemeIcons: true }), + allowAutoConfirm: true, + } : undefined, + invocationMessage: new MarkdownString(localize('msg.run', "Running {0}", title)), + pastTenseMessage: new MarkdownString(localize('msg.ran', "Ran {0} ", title)), + originMessage: new MarkdownString(markdownCommandLink({ + id: McpCommandIds.ShowConfiguration, + title: subtitle, + arguments: [server.collection.id, server.definition.id], + }), { isTrusted: true }), + toolSpecificData: { + kind: 'input', + rawInput: parameters + } }; } - async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, token: CancellationToken) { + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken) { const result: IToolResult = { content: [] }; - const callResult = await this._tool.call(invocation.parameters as Record, token); + const outputParts: string[] = []; + + const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, token); for (const item of callResult.content) { if (item.type === 'text') { result.content.push({ kind: 'text', value: item.text }); + + outputParts.push(item.text); + } else if (item.type === 'image' || item.type === 'audio') { + result.content.push({ + kind: 'data', + value: { mimeType: item.mimeType, data: decodeBase64(item.data) } + }); } else { - // TODO@jrieken handle different item types + // unsupported for now. } } - // result.toolResultMessage = new MarkdownString(localize('reuslt.pattern', "```json\n{0}\n```", JSON.stringify(callResult, undefined, 2))); + result.toolResultDetails = { + input: JSON.stringify(invocation.parameters, undefined, 2), + output: outputParts.join('\n') + }; return result; } diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 3aa12716c2e..f43dc1472ff 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { equals as arraysEqual } from '../../../../base/common/arrays.js'; import { assertNever } from '../../../../base/common/assert.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; @@ -16,6 +17,7 @@ import { ExtensionIdentifier } from '../../../../platform/extensions/common/exte import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; +import { ToolProgress } from '../../chat/common/languageModelToolsService.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; import { MCP } from './modelContextProtocol.js'; @@ -43,6 +45,9 @@ export interface McpCollectionDefinition { /** Scope where associated collection info should be stored. */ readonly scope: StorageScope; + /** Resolves a server definition. If present, always called before a server starts. */ + resolveServerLanch?(definition: McpServerDefinition): Promise; + /** For lazy-loaded collections only: */ readonly lazy?: { /** True if `serverDefinitions` were loaded from the cache */ @@ -62,12 +67,13 @@ export interface McpCollectionDefinition { } export const enum McpCollectionSortOrder { - Workspace = 0, - User = 100, - Extension = 200, - Filesystem = 300, + WorkspaceFolder = 0, + Workspace = 100, + User = 200, + Extension = 300, + Filesystem = 400, - RemotePenalty = 50, + RemoteBoost = -50, } export namespace McpCollectionDefinition { @@ -76,6 +82,8 @@ export namespace McpCollectionDefinition { readonly label: string; readonly isTrustedByDefault: boolean; readonly scope: StorageScope; + readonly canResolveLaunch: boolean; + readonly extensionId: string; } export function equals(a: McpCollectionDefinition, b: McpCollectionDefinition): boolean { @@ -93,8 +101,12 @@ export interface McpServerDefinition { readonly label: string; /** Descriptor defining how the configuration should be launched. */ readonly launch: McpServerLaunch; + /** Explicit roots. If undefined, all workspace folders. */ + readonly roots?: URI[] | undefined; /** If set, allows configuration variables to be resolved in the {@link launch} with the given context */ readonly variableReplacement?: McpServerDefinitionVariableReplacement; + /** Nonce used for caching the server. Changing the nonce will indicate that tools need to be refreshed. */ + readonly cacheNonce?: string; readonly presentation?: { /** Sort order of the definition. */ @@ -108,6 +120,7 @@ export namespace McpServerDefinition { export interface Serialized { readonly id: string; readonly label: string; + readonly cacheNonce?: string; readonly launch: McpServerLaunch.Serialized; readonly variableReplacement?: McpServerDefinitionVariableReplacement.Serialized; } @@ -120,6 +133,7 @@ export namespace McpServerDefinition { return { id: def.id, label: def.label, + cacheNonce: def.cacheNonce, launch: McpServerLaunch.fromSerialized(def.launch), variableReplacement: def.variableReplacement ? McpServerDefinitionVariableReplacement.fromSerialized(def.variableReplacement) : undefined, }; @@ -128,7 +142,9 @@ export namespace McpServerDefinition { export function equals(a: McpServerDefinition, b: McpServerDefinition): boolean { return a.id === b.id && a.label === b.label + && arraysEqual(a.roots, b.roots, (a, b) => a.toString() === b.toString()) && objectsEqual(a.launch, b.launch) + && objectsEqual(a.presentation, b.presentation) && objectsEqual(a.variableReplacement, b.variableReplacement); } } @@ -137,14 +153,14 @@ export namespace McpServerDefinition { export interface McpServerDefinitionVariableReplacement { section?: string; // e.g. 'mcp' folder?: IWorkspaceFolderData; - target?: ConfigurationTarget; + target: ConfigurationTarget; } export namespace McpServerDefinitionVariableReplacement { export interface Serialized { + target: ConfigurationTarget; section?: string; folder?: { name: string; index: number; uri: UriComponents }; - target?: ConfigurationTarget; } export function toSerialized(def: McpServerDefinitionVariableReplacement): McpServerDefinitionVariableReplacement.Serialized { @@ -222,6 +238,8 @@ export const enum McpServerToolsState { Unknown, /** Tools were read from the cache */ Cached, + /** Tools were read from the cache or live, but they may be outdated. */ + Outdated, /** Tools are refreshing for the first time */ RefreshingFromUnknown, /** Tools are refreshing and the current tools are cached */ @@ -242,13 +260,18 @@ export interface IMcpTool { * @throws {@link McpConnectionFailedError} if the connection to the server fails */ call(params: Record, token?: CancellationToken): Promise; + + /** + * Identical to {@link call}, but reports progress. + */ + callWithProgress(params: Record, progress: ToolProgress, token?: CancellationToken): Promise; } export const enum McpServerTransportType { /** A command-line MCP server communicating over standard in/out */ Stdio = 1 << 0, /** An MCP server that uses Server-Sent Events */ - SSE = 1 << 1, + HTTP = 1 << 1, } /** @@ -261,26 +284,28 @@ export interface McpServerTransportStdio { readonly command: string; readonly args: readonly string[]; readonly env: Record; + readonly envFile: string | undefined; } /** - * MCP server launched on the command line which communicated over server-sent-events. + * MCP server launched on the command line which communicated over SSE or Streamable HTTP. * https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#http-with-sse + * https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http */ -export interface McpServerTransportSSE { - readonly type: McpServerTransportType.SSE; +export interface McpServerTransportHTTP { + readonly type: McpServerTransportType.HTTP; readonly uri: URI; readonly headers: [string, string][]; } export type McpServerLaunch = | McpServerTransportStdio - | McpServerTransportSSE; + | McpServerTransportHTTP; export namespace McpServerLaunch { export type Serialized = - | { type: McpServerTransportType.SSE; uri: UriComponents; headers: [string, string][] } - | { type: McpServerTransportType.Stdio; cwd: UriComponents | undefined; command: string; args: readonly string[]; env: Record }; + | { type: McpServerTransportType.HTTP; uri: UriComponents; headers: [string, string][] } + | { type: McpServerTransportType.Stdio; cwd: UriComponents | undefined; command: string; args: readonly string[]; env: Record; envFile: string | undefined }; export function toSerialized(launch: McpServerLaunch): McpServerLaunch.Serialized { return launch; @@ -288,7 +313,7 @@ export namespace McpServerLaunch { export function fromSerialized(launch: McpServerLaunch.Serialized): McpServerLaunch { switch (launch.type) { - case McpServerTransportType.SSE: + case McpServerTransportType.HTTP: return { type: launch.type, uri: URI.revive(launch.uri), headers: launch.headers }; case McpServerTransportType.Stdio: return { @@ -297,6 +322,7 @@ export namespace McpServerLaunch { command: launch.command, args: launch.args, env: launch.env, + envFile: launch.envFile, }; } } @@ -313,9 +339,10 @@ export interface IMcpServerConnection extends IDisposable { readonly handler: IObservable; /** - * Shows the current server output. + * Resolved launch definition. Might not match the `definition.launch` due to + * resolution logic in extension-provided MCPs. */ - showOutput(): void; + readonly launchDefinition: McpServerLaunch; /** * Starts the server if it's stopped. Returns a promise that resolves once @@ -356,9 +383,27 @@ export namespace McpConnectionState { } }; + export const toKindString = (s: McpConnectionState.Kind): string => { + switch (s) { + case Kind.Stopped: + return 'stopped'; + case Kind.Starting: + return 'starting'; + case Kind.Running: + return 'running'; + case Kind.Error: + return 'error'; + default: + assertNever(s); + } + }; + /** Returns if the MCP state is one where starting a new server is valid */ export const canBeStarted = (s: Kind) => s === Kind.Error || s === Kind.Stopped; + /** Gets whether the state is a running state. */ + export const isRunning = (s: McpConnectionState) => !canBeStarted(s.state); + export interface Stopped { readonly state: Kind.Stopped; } @@ -373,6 +418,8 @@ export namespace McpConnectionState { export interface Error { readonly state: Kind.Error; + readonly code?: string; + readonly shouldRetry?: boolean; readonly message: string; } } diff --git a/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts b/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts index 582e69eaf93..b5f8eeeff62 100644 --- a/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts +++ b/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable @stylistic/ts/member-delimiter-style */ /* eslint-disable local/code-no-unexternalized-strings */ /** @@ -13,24 +12,38 @@ * ⚠️ Do not edit within `namespace` manually except to update schema versions ⚠️ */ export namespace MCP { - /* JSON-RPC types */ + /** + * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + */ export type JSONRPCMessage = | JSONRPCRequest | JSONRPCNotification + | JSONRPCBatchRequest | JSONRPCResponse - | JSONRPCError; + | JSONRPCError + | JSONRPCBatchResponse; - export const LATEST_PROTOCOL_VERSION = "2024-11-05"; + /** + * A JSON-RPC batch request, as described in https://www.jsonrpc.org/specification#batch. + */ + export type JSONRPCBatchRequest = (JSONRPCRequest | JSONRPCNotification)[]; + + /** + * A JSON-RPC batch response, as described in https://www.jsonrpc.org/specification#batch. + */ + export type JSONRPCBatchResponse = (JSONRPCResponse | JSONRPCError)[]; + + export const LATEST_PROTOCOL_VERSION = "2025-03-26"; export const JSONRPC_VERSION = "2.0"; /** - * A progress token, used to associate progress notifications with the original request. - */ + * A progress token, used to associate progress notifications with the original request. + */ export type ProgressToken = string | number; /** - * An opaque token used to represent a cursor for pagination. - */ + * An opaque token used to represent a cursor for pagination. + */ export type Cursor = string; export interface Request { @@ -66,28 +79,28 @@ export namespace MCP { } /** - * A uniquely identifying ID for a request in JSON-RPC. - */ + * A uniquely identifying ID for a request in JSON-RPC. + */ export type RequestId = string | number; /** - * A request that expects a response. - */ + * A request that expects a response. + */ export interface JSONRPCRequest extends Request { jsonrpc: typeof JSONRPC_VERSION; id: RequestId; } /** - * A notification which does not expect a response. - */ + * A notification which does not expect a response. + */ export interface JSONRPCNotification extends Notification { jsonrpc: typeof JSONRPC_VERSION; } /** - * A successful (non-error) response to a request. - */ + * A successful (non-error) response to a request. + */ export interface JSONRPCResponse { jsonrpc: typeof JSONRPC_VERSION; id: RequestId; @@ -102,8 +115,8 @@ export namespace MCP { export const INTERNAL_ERROR = -32603; /** - * A response to a request that indicates an error occurred. - */ + * A response to a request that indicates an error occurred. + */ export interface JSONRPCError { jsonrpc: typeof JSONRPC_VERSION; id: RequestId; @@ -125,20 +138,20 @@ export namespace MCP { /* Empty result */ /** - * A response that indicates success but carries no data. - */ + * A response that indicates success but carries no data. + */ export type EmptyResult = Result; /* Cancellation */ /** - * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. - * - * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. - * - * This notification indicates that the result will be unused, so any associated processing SHOULD cease. - * - * A client MUST NOT attempt to cancel its `initialize` request. - */ + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its `initialize` request. + */ export interface CancelledNotification extends Notification { method: "notifications/cancelled"; params: { @@ -158,8 +171,8 @@ export namespace MCP { /* Initialization */ /** - * This request is sent from the client to the server when it first connects, asking it to begin initialization. - */ + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + */ export interface InitializeRequest extends Request { method: "initialize"; params: { @@ -173,8 +186,8 @@ export namespace MCP { } /** - * After receiving an initialize request from the client, the server sends this response. - */ + * After receiving an initialize request from the client, the server sends this response. + */ export interface InitializeResult extends Result { /** * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. @@ -182,6 +195,7 @@ export namespace MCP { protocolVersion: string; capabilities: ServerCapabilities; serverInfo: Implementation; + /** * Instructions describing how to use the server and its features. * @@ -191,15 +205,15 @@ export namespace MCP { } /** - * This notification is sent from the client to the server after initialization has finished. - */ + * This notification is sent from the client to the server after initialization has finished. + */ export interface InitializedNotification extends Notification { method: "notifications/initialized"; } /** - * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. - */ + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + */ export interface ClientCapabilities { /** * Experimental, non-standard capabilities that the client supports. @@ -221,8 +235,8 @@ export namespace MCP { } /** - * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. - */ + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + */ export interface ServerCapabilities { /** * Experimental, non-standard capabilities that the server supports. @@ -232,6 +246,10 @@ export namespace MCP { * Present if the server supports sending log messages to the client. */ logging?: object; + /** + * Present if the server supports argument autocompletion suggestions. + */ + completions?: object; /** * Present if the server offers any prompt templates. */ @@ -266,8 +284,8 @@ export namespace MCP { } /** - * Describes the name and version of an MCP implementation. - */ + * Describes the name and version of an MCP implementation. + */ export interface Implementation { name: string; version: string; @@ -275,16 +293,16 @@ export namespace MCP { /* Ping */ /** - * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. - */ + * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + */ export interface PingRequest extends Request { method: "ping"; } /* Progress notifications */ /** - * An out-of-band notification used to inform the receiver of a progress update for a long-running request. - */ + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + */ export interface ProgressNotification extends Notification { method: "notifications/progress"; params: { @@ -304,6 +322,10 @@ export namespace MCP { * @TJS-type number */ total?: number; + /** + * An optional message describing the current progress. + */ + message?: string; }; } @@ -328,36 +350,36 @@ export namespace MCP { /* Resources */ /** - * Sent from the client to request a list of resources the server has. - */ + * Sent from the client to request a list of resources the server has. + */ export interface ListResourcesRequest extends PaginatedRequest { method: "resources/list"; } /** - * The server's response to a resources/list request from the client. - */ + * The server's response to a resources/list request from the client. + */ export interface ListResourcesResult extends PaginatedResult { resources: Resource[]; } /** - * Sent from the client to request a list of resource templates the server has. - */ + * Sent from the client to request a list of resource templates the server has. + */ export interface ListResourceTemplatesRequest extends PaginatedRequest { method: "resources/templates/list"; } /** - * The server's response to a resources/templates/list request from the client. - */ + * The server's response to a resources/templates/list request from the client. + */ export interface ListResourceTemplatesResult extends PaginatedResult { resourceTemplates: ResourceTemplate[]; } /** - * Sent from the client to the server, to read a specific resource URI. - */ + * Sent from the client to the server, to read a specific resource URI. + */ export interface ReadResourceRequest extends Request { method: "resources/read"; params: { @@ -371,22 +393,22 @@ export namespace MCP { } /** - * The server's response to a resources/read request from the client. - */ + * The server's response to a resources/read request from the client. + */ export interface ReadResourceResult extends Result { contents: (TextResourceContents | BlobResourceContents)[]; } /** - * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. - */ + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + */ export interface ResourceListChangedNotification extends Notification { method: "notifications/resources/list_changed"; } /** - * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. - */ + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + */ export interface SubscribeRequest extends Request { method: "resources/subscribe"; params: { @@ -400,8 +422,8 @@ export namespace MCP { } /** - * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. - */ + * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + */ export interface UnsubscribeRequest extends Request { method: "resources/unsubscribe"; params: { @@ -415,8 +437,8 @@ export namespace MCP { } /** - * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. - */ + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + */ export interface ResourceUpdatedNotification extends Notification { method: "notifications/resources/updated"; params: { @@ -430,9 +452,9 @@ export namespace MCP { } /** - * A known resource that the server is capable of reading. - */ - export interface Resource extends Annotated { + * A known resource that the server is capable of reading. + */ + export interface Resource { /** * The URI of this resource. * @@ -459,6 +481,11 @@ export namespace MCP { */ mimeType?: string; + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + /** * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. * @@ -468,9 +495,9 @@ export namespace MCP { } /** - * A template description for resources available on the server. - */ - export interface ResourceTemplate extends Annotated { + * A template description for resources available on the server. + */ + export interface ResourceTemplate { /** * A URI template (according to RFC 6570) that can be used to construct resource URIs. * @@ -496,11 +523,16 @@ export namespace MCP { * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. */ mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; } /** - * The contents of a specific resource or sub-resource. - */ + * The contents of a specific resource or sub-resource. + */ export interface ResourceContents { /** * The URI of this resource. @@ -532,22 +564,22 @@ export namespace MCP { /* Prompts */ /** - * Sent from the client to request a list of prompts and prompt templates the server has. - */ + * Sent from the client to request a list of prompts and prompt templates the server has. + */ export interface ListPromptsRequest extends PaginatedRequest { method: "prompts/list"; } /** - * The server's response to a prompts/list request from the client. - */ + * The server's response to a prompts/list request from the client. + */ export interface ListPromptsResult extends PaginatedResult { prompts: Prompt[]; } /** - * Used by the client to get a prompt provided by the server. - */ + * Used by the client to get a prompt provided by the server. + */ export interface GetPromptRequest extends Request { method: "prompts/get"; params: { @@ -563,8 +595,8 @@ export namespace MCP { } /** - * The server's response to a prompts/get request from the client. - */ + * The server's response to a prompts/get request from the client. + */ export interface GetPromptResult extends Result { /** * An optional description for the prompt. @@ -574,8 +606,8 @@ export namespace MCP { } /** - * A prompt or prompt template that the server offers. - */ + * A prompt or prompt template that the server offers. + */ export interface Prompt { /** * The name of the prompt or prompt template. @@ -592,8 +624,8 @@ export namespace MCP { } /** - * Describes an argument that a prompt can accept. - */ + * Describes an argument that a prompt can accept. + */ export interface PromptArgument { /** * The name of the argument. @@ -610,68 +642,73 @@ export namespace MCP { } /** - * The sender or recipient of messages and data in a conversation. - */ + * The sender or recipient of messages and data in a conversation. + */ export type Role = "user" | "assistant"; /** - * Describes a message returned as part of a prompt. - * - * This is similar to `SamplingMessage`, but also supports the embedding of - * resources from the MCP server. - */ + * Describes a message returned as part of a prompt. + * + * This is similar to `SamplingMessage`, but also supports the embedding of + * resources from the MCP server. + */ export interface PromptMessage { role: Role; - content: TextContent | ImageContent | EmbeddedResource; + content: TextContent | ImageContent | AudioContent | EmbeddedResource; } /** - * The contents of a resource, embedded into a prompt or tool call result. - * - * It is up to the client how best to render embedded resources for the benefit - * of the LLM and/or the user. - */ - export interface EmbeddedResource extends Annotated { + * The contents of a resource, embedded into a prompt or tool call result. + * + * It is up to the client how best to render embedded resources for the benefit + * of the LLM and/or the user. + */ + export interface EmbeddedResource { type: "resource"; resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; } /** - * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. - */ + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + */ export interface PromptListChangedNotification extends Notification { method: "notifications/prompts/list_changed"; } /* Tools */ /** - * Sent from the client to request a list of tools the server has. - */ + * Sent from the client to request a list of tools the server has. + */ export interface ListToolsRequest extends PaginatedRequest { method: "tools/list"; } /** - * The server's response to a tools/list request from the client. - */ + * The server's response to a tools/list request from the client. + */ export interface ListToolsResult extends PaginatedResult { tools: Tool[]; } /** - * The server's response to a tool call. - * - * Any errors that originate from the tool SHOULD be reported inside the result - * object, with `isError` set to true, _not_ as an MCP protocol-level error - * response. Otherwise, the LLM would not be able to see that an error occurred - * and self-correct. - * - * However, any errors in _finding_ the tool, an error indicating that the - * server does not support tool calls, or any other exceptional conditions, - * should be reported as an MCP error response. - */ + * The server's response to a tool call. + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ export interface CallToolResult extends Result { - content: (TextContent | ImageContent | EmbeddedResource)[]; + content: (TextContent | ImageContent | AudioContent | EmbeddedResource)[]; /** * Whether the tool call ended in an error. @@ -682,8 +719,8 @@ export namespace MCP { } /** - * Used by the client to invoke a tool provided by the server. - */ + * Used by the client to invoke a tool provided by the server. + */ export interface CallToolRequest extends Request { method: "tools/call"; params: { @@ -693,24 +730,82 @@ export namespace MCP { } /** - * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. - */ + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + */ export interface ToolListChangedNotification extends Notification { method: "notifications/tools/list_changed"; } /** - * Definition for a tool the client can call. - */ + * Additional properties describing a Tool to clients. + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on ToolAnnotations + * received from untrusted servers. + */ + export interface ToolAnnotations { + /** + * A human-readable title for the tool. + */ + title?: string; + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint?: boolean; + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint?: boolean; + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on the its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint?: boolean; + + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint?: boolean; + } + + /** + * Definition for a tool the client can call. + */ export interface Tool { /** * The name of the tool. */ name: string; + /** * A human-readable description of the tool. + * + * This can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a "hint" to the model. */ description?: string; + /** * A JSON Schema object defining the expected parameters for the tool. */ @@ -719,12 +814,17 @@ export namespace MCP { properties?: { [key: string]: object }; required?: string[]; }; + + /** + * Optional additional tool information. + */ + annotations?: ToolAnnotations; } /* Logging */ /** - * A request from the client to the server, to enable or adjust logging. - */ + * A request from the client to the server, to enable or adjust logging. + */ export interface SetLevelRequest extends Request { method: "logging/setLevel"; params: { @@ -736,8 +836,8 @@ export namespace MCP { } /** - * Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. - */ + * Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. + */ export interface LoggingMessageNotification extends Notification { method: "notifications/message"; params: { @@ -757,11 +857,11 @@ export namespace MCP { } /** - * The severity of a log message. - * - * These map to syslog message severities, as specified in RFC-5424: - * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 - */ + * The severity of a log message. + * + * These map to syslog message severities, as specified in RFC-5424: + * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + */ export type LoggingLevel = | "debug" | "info" @@ -774,8 +874,8 @@ export namespace MCP { /* Sampling */ /** - * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. - */ + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + */ export interface CreateMessageRequest extends Request { method: "sampling/createMessage"; params: { @@ -809,8 +909,8 @@ export namespace MCP { } /** - * The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. - */ + * The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. + */ export interface CreateMessageResult extends Result, SamplingMessage { /** * The name of the model that generated the message. @@ -823,81 +923,116 @@ export namespace MCP { } /** - * Describes a message issued to or received from an LLM API. - */ + * Describes a message issued to or received from an LLM API. + */ export interface SamplingMessage { role: Role; - content: TextContent | ImageContent; + content: TextContent | ImageContent | AudioContent; } /** - * Base for objects that include optional annotations for the client. The client can use annotations to inform how objects are used or displayed - */ - export interface Annotated { - annotations?: { - /** - * Describes who the intended customer of this object or data is. - * - * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). - */ - audience?: Role[]; + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + */ + export interface Annotations { + /** + * Describes who the intended customer of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + */ + audience?: Role[]; - /** - * Describes how important this data is for operating the server. - * - * A value of 1 means "most important," and indicates that the data is - * effectively required, while 0 means "least important," and indicates that - * the data is entirely optional. - * - * @TJS-type number - * @minimum 0 - * @maximum 1 - */ - priority?: number; - } + /** + * Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + priority?: number; } /** - * Text provided to or from an LLM. - */ - export interface TextContent extends Annotated { + * Text provided to or from an LLM. + */ + export interface TextContent { type: "text"; + /** * The text content of the message. */ text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; } /** - * An image provided to or from an LLM. - */ - export interface ImageContent extends Annotated { + * An image provided to or from an LLM. + */ + export interface ImageContent { type: "image"; + /** * The base64-encoded image data. * * @format byte */ data: string; + /** * The MIME type of the image. Different providers may support different image types. */ mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; } /** - * The server's preferences for model selection, requested of the client during sampling. - * - * Because LLMs can vary along multiple dimensions, choosing the "best" model is - * rarely straightforward. Different models excel in different areas-some are - * faster but less capable, others are more capable but more expensive, and so - * on. This interface allows servers to express their priorities across multiple - * dimensions to help clients make an appropriate selection for their use case. - * - * These preferences are always advisory. The client MAY ignore them. It is also - * up to the client to decide how to interpret these preferences and how to - * balance them against other considerations. - */ + * Audio provided to or from an LLM. + */ + export interface AudioContent { + type: "audio"; + + /** + * The base64-encoded audio data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + } + + /** + * The server's preferences for model selection, requested of the client during sampling. + * + * Because LLMs can vary along multiple dimensions, choosing the "best" model is + * rarely straightforward. Different models excel in different areas-some are + * faster but less capable, others are more capable but more expensive, and so + * on. This interface allows servers to express their priorities across multiple + * dimensions to help clients make an appropriate selection for their use case. + * + * These preferences are always advisory. The client MAY ignore them. It is also + * up to the client to decide how to interpret these preferences and how to + * balance them against other considerations. + */ export interface ModelPreferences { /** * Optional hints to use for model selection. @@ -945,11 +1080,11 @@ export namespace MCP { } /** - * Hints to use for model selection. - * - * Keys not declared here are currently left unspecified by the spec and are up - * to the client to interpret. - */ + * Hints to use for model selection. + * + * Keys not declared here are currently left unspecified by the spec and are up + * to the client to interpret. + */ export interface ModelHint { /** * A hint for a model name. @@ -967,8 +1102,8 @@ export namespace MCP { /* Autocomplete */ /** - * A request from the client to the server, to ask for completion options. - */ + * A request from the client to the server, to ask for completion options. + */ export interface CompleteRequest extends Request { method: "completion/complete"; params: { @@ -990,8 +1125,8 @@ export namespace MCP { } /** - * The server's response to a completion/complete request - */ + * The server's response to a completion/complete request + */ export interface CompleteResult extends Result { completion: { /** @@ -1010,8 +1145,8 @@ export namespace MCP { } /** - * A reference to a resource or resource template definition. - */ + * A reference to a resource or resource template definition. + */ export interface ResourceReference { type: "ref/resource"; /** @@ -1023,8 +1158,8 @@ export namespace MCP { } /** - * Identifies a prompt. - */ + * Identifies a prompt. + */ export interface PromptReference { type: "ref/prompt"; /** @@ -1035,30 +1170,30 @@ export namespace MCP { /* Roots */ /** - * Sent from the server to request a list of root URIs from the client. Roots allow - * servers to ask for specific directories or files to operate on. A common example - * for roots is providing a set of repositories or directories a server should operate - * on. - * - * This request is typically used when the server needs to understand the file system - * structure or access specific locations that the client has permission to read from. - */ + * Sent from the server to request a list of root URIs from the client. Roots allow + * servers to ask for specific directories or files to operate on. A common example + * for roots is providing a set of repositories or directories a server should operate + * on. + * + * This request is typically used when the server needs to understand the file system + * structure or access specific locations that the client has permission to read from. + */ export interface ListRootsRequest extends Request { method: "roots/list"; } /** - * The client's response to a roots/list request from the server. - * This result contains an array of Root objects, each representing a root directory - * or file that the server can operate on. - */ + * The client's response to a roots/list request from the server. + * This result contains an array of Root objects, each representing a root directory + * or file that the server can operate on. + */ export interface ListRootsResult extends Result { roots: Root[]; } /** - * Represents a root directory or file that the server can operate on. - */ + * Represents a root directory or file that the server can operate on. + */ export interface Root { /** * The URI identifying the root. This *must* start with file:// for now. @@ -1077,10 +1212,10 @@ export namespace MCP { } /** - * A notification from the client to the server, informing it that the list of roots has changed. - * This notification should be sent whenever the client adds, removes, or modifies any root. - * The server should then request an updated list of roots using the ListRootsRequest. - */ + * A notification from the client to the server, informing it that the list of roots has changed. + * This notification should be sent whenever the client adds, removes, or modifies any root. + * The server should then request an updated list of roots using the ListRootsRequest. + */ export interface RootsListChangedNotification extends Notification { method: "notifications/roots/list_changed"; } diff --git a/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts b/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts index 11da0b14991..df53ead17fa 100644 --- a/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/electron-sandbox/nativeMpcDiscovery.ts @@ -11,10 +11,10 @@ import { IMainProcessService } from '../../../../platform/ipc/common/mainProcess import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; -import { FilesystemMpcDiscovery } from '../common/discovery/nativeMcpDiscoveryAbstract.js'; +import { NativeFilesystemMcpDiscovery } from '../common/discovery/nativeMcpDiscoveryAbstract.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; -export class NativeMcpDiscovery extends FilesystemMpcDiscovery { +export class NativeMcpDiscovery extends NativeFilesystemMcpDiscovery { constructor( @IMainProcessService private readonly mainProcess: IMainProcessService, @ILogService private readonly logService: ILogService, diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index 774878b19d2..8fc67844c70 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -5,16 +5,17 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import { cloneAndChange } from '../../../../../base/common/objects.js'; +import { timeout } from '../../../../../base/common/async.js'; import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { upcast } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { ILoggerService } from '../../../../../platform/log/common/log.js'; +import { ILogger, ILoggerService, NullLogger } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ISecretStorageService } from '../../../../../platform/secrets/common/secrets.js'; import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; @@ -24,10 +25,11 @@ import { TestLoggerService, TestStorageService } from '../../../../test/common/w import { McpRegistry } from '../../common/mcpRegistry.js'; import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistryTypes.js'; import { McpServerConnection } from '../../common/mcpServerConnection.js'; -import { LazyCollectionState, McpCollectionDefinition, McpCollectionReference, McpServerDefinition, McpServerTransportType } from '../../common/mcpTypes.js'; +import { LazyCollectionState, McpCollectionDefinition, McpCollectionReference, McpServerDefinition, McpServerTransportStdio, McpServerTransportType } from '../../common/mcpTypes.js'; import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; -import { timeout } from '../../../../../base/common/async.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { ConfigurationResolverExpression } from '../../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { mcpEnabledSection } from '../../common/mcpConfiguration.js'; class TestConfigurationResolverService implements Partial { declare readonly _serviceBrand: undefined; @@ -44,67 +46,27 @@ class TestConfigurationResolverService implements Partial { - if (typeof value === 'string') { - return Promise.resolve(this.replaceVariables(value)); - } else if (Array.isArray(value)) { - return Promise.resolve(value.map(v => typeof v === 'string' ? this.replaceVariables(v) : v)); - } else { - const result: Record = {}; - for (const key in value) { - if (typeof value[key] === 'string') { - result[key] = this.replaceVariables(value[key]); - } else { - result[key] = value[key]; - } + const parsed = ConfigurationResolverExpression.parse(value); + for (const variable of parsed.unresolved()) { + const resolved = this.resolvedVariables.get(variable.inner); + if (resolved) { + parsed.resolve(variable, resolved); } - return Promise.resolve(result); } - } - private replaceVariables(value: string): string { - let result = value; - for (const [key, val] of this.resolvedVariables.entries()) { - result = result.replace(`\${${key}}`, val); - } - return result; - } - - resolveAnyAsync(folder: any, config: any, commandValueMapping?: Record): Promise { - // Use cloneAndChange to recursively replace variables in the config - const newConfig = cloneAndChange(config, (value) => { - if (typeof value === 'string') { - // Replace any ${variable} with its value - let result = value; - for (const [key, val] of this.resolvedVariables.entries()) { - result = result.replace(`\${${key}}`, val); - } - - // If a commandValueMapping is provided, use it for additional replacements - if (commandValueMapping) { - for (const [key, val] of Object.entries(commandValueMapping)) { - result = result.replace(`\${${key}}`, val); - } - } - - return result === value ? undefined : result; - } - return undefined; - }); - - return Promise.resolve(newConfig); + return Promise.resolve(parsed.toObject()); } resolveWithInteraction(folder: any, config: any, section?: string, variables?: Record, target?: ConfigurationTarget): Promise | undefined> { + const parsed = ConfigurationResolverExpression.parse(config); // For testing, we simulate interaction by returning a map with some variables const result = new Map(); result.set('input:testInteractive', `interactiveValue${this.interactiveCounter++}`); result.set('command:testCommand', `commandOutput${this.interactiveCounter++}}`); // If variables are provided, include those too - if (variables) { - Object.entries(variables).forEach(([key, value]) => { - result.set(key, value); - }); + for (const [k, v] of result.entries()) { + parsed.resolve({ id: '${' + k + '}' } as any, v); } return Promise.resolve(result); @@ -112,6 +74,8 @@ class TestConfigurationResolverService implements Partial { let testDialogService: TestDialogService; let testCollection: McpCollectionDefinition & { serverDefinitions: ISettableObservable }; let baseDefinition: McpServerDefinition; + let configurationService: TestConfigurationService; + let logger: ILogger; setup(() => { testConfigResolverService = new TestConfigurationResolverService(); testStorageService = store.add(new TestStorageService()); testDialogService = new TestDialogService(); + configurationService = new TestConfigurationService({ [mcpEnabledSection]: true }); const services = new ServiceCollection( + [IConfigurationService, configurationService], [IConfigurationResolverService, testConfigResolverService], [IStorageService, testStorageService], [ISecretStorageService, new TestSecretStorageService()], @@ -176,6 +144,8 @@ suite('Workbench - MCP - Registry', () => { [IProductService, {}], ); + logger = new NullLogger(); + const instaService = store.add(new TestInstantiationService(services)); registry = store.add(instaService.createInstance(McpRegistry)); @@ -198,6 +168,7 @@ suite('Workbench - MCP - Registry', () => { command: 'test-command', args: [], env: {}, + envFile: undefined, cwd: URI.parse('file:///test') } }; @@ -214,6 +185,21 @@ suite('Workbench - MCP - Registry', () => { assert.strictEqual(registry.collections.get().length, 0); }); + test('collections are not visible when not enabled', () => { + const disposable = registry.registerCollection(testCollection); + store.add(disposable); + + assert.strictEqual(registry.collections.get().length, 1); + + configurationService.setUserConfiguration(mcpEnabledSection, false); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as any); + + assert.strictEqual(registry.collections.get().length, 0); + + configurationService.setUserConfiguration(mcpEnabledSection, true); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as any); + }); + test('registerDelegate adds delegate to registry', () => { const delegate = new TestMcpHostDelegate(); const disposable = registry.registerDelegate(delegate); @@ -236,10 +222,12 @@ suite('Workbench - MCP - Registry', () => { env: { PATH: '${input:testInteractive}' }, + envFile: undefined, cwd: URI.parse('file:///test') }, variableReplacement: { - section: 'mcp' + section: 'mcp', + target: ConfigurationTarget.WORKSPACE, } }; @@ -248,7 +236,7 @@ suite('Workbench - MCP - Registry', () => { testCollection.serverDefinitions.set([definition], undefined); store.add(registry.registerCollection(testCollection)); - const connection = await registry.resolveConnection({ collectionRef: testCollection, definitionRef: definition }) as McpServerConnection; + const connection = await registry.resolveConnection({ collectionRef: testCollection, definitionRef: definition, logger }) as McpServerConnection; assert.ok(connection); assert.strictEqual(connection.definition, definition); @@ -256,21 +244,62 @@ suite('Workbench - MCP - Registry', () => { assert.strictEqual((connection.launchDefinition as any).env.PATH, 'interactiveValue0'); connection.dispose(); - const connection2 = await registry.resolveConnection({ collectionRef: testCollection, definitionRef: definition }) as McpServerConnection; + const connection2 = await registry.resolveConnection({ collectionRef: testCollection, definitionRef: definition, logger }) as McpServerConnection; assert.ok(connection2); assert.strictEqual((connection2.launchDefinition as any).env.PATH, 'interactiveValue0'); connection2.dispose(); - registry.clearSavedInputs(); + registry.clearSavedInputs(StorageScope.WORKSPACE); - const connection3 = await registry.resolveConnection({ collectionRef: testCollection, definitionRef: definition }) as McpServerConnection; + const connection3 = await registry.resolveConnection({ collectionRef: testCollection, definitionRef: definition, logger }) as McpServerConnection; assert.ok(connection3); assert.strictEqual((connection3.launchDefinition as any).env.PATH, 'interactiveValue4'); connection3.dispose(); }); + test('resolveConnection uses user-provided launch configuration', async () => { + // Create a collection with custom launch resolver + const customCollection: McpCollectionDefinition = { + ...testCollection, + resolveServerLanch: async (def) => { + return { + ...(def.launch as McpServerTransportStdio), + env: { CUSTOM_ENV: 'value' }, + }; + } + }; + + // Create a definition with variable replacement + const definition: McpServerDefinition = { + ...baseDefinition, + variableReplacement: { + section: 'mcp', + target: ConfigurationTarget.WORKSPACE, + } + }; + + const delegate = new TestMcpHostDelegate(); + store.add(registry.registerDelegate(delegate)); + testCollection.serverDefinitions.set([definition], undefined); + store.add(registry.registerCollection(customCollection)); + + // Resolve connection should use the custom launch configuration + const connection = await registry.resolveConnection({ + collectionRef: customCollection, + definitionRef: definition, + logger + }) as McpServerConnection; + + assert.ok(connection); + + // Verify the launch configuration passed to _replaceVariablesInLaunch was the custom one + assert.deepStrictEqual((connection.launchDefinition as McpServerTransportStdio).env, { CUSTOM_ENV: 'value' }); + + connection.dispose(); + }); + suite('Trust Management', () => { setup(() => { const delegate = new TestMcpHostDelegate(); @@ -282,7 +311,7 @@ suite('Workbench - MCP - Registry', () => { store.add(registry.registerCollection(testCollection)); testCollection.serverDefinitions.set([definition], undefined); - const connection = await registry.resolveConnection({ collectionRef: testCollection, definitionRef: definition }); + const connection = await registry.resolveConnection({ collectionRef: testCollection, definitionRef: definition, logger }); assert.ok(connection); assert.strictEqual(testDialogService.promptSpy.called, false); @@ -302,6 +331,7 @@ suite('Workbench - MCP - Registry', () => { testDialogService.setPromptResult(true); const connection = await registry.resolveConnection({ + logger, collectionRef: untrustedCollection, definitionRef: definition }); @@ -312,6 +342,7 @@ suite('Workbench - MCP - Registry', () => { testDialogService.promptSpy.resetHistory(); const connection2 = await registry.resolveConnection({ + logger, collectionRef: untrustedCollection, definitionRef: definition }); @@ -334,6 +365,7 @@ suite('Workbench - MCP - Registry', () => { testDialogService.setPromptResult(false); const connection = await registry.resolveConnection({ + logger, collectionRef: untrustedCollection, definitionRef: definition }); @@ -343,6 +375,7 @@ suite('Workbench - MCP - Registry', () => { testDialogService.promptSpy.resetHistory(); const connection2 = await registry.resolveConnection({ + logger, collectionRef: untrustedCollection, definitionRef: definition }); @@ -364,6 +397,7 @@ suite('Workbench - MCP - Registry', () => { testDialogService.setPromptResult(false); const connection1 = await registry.resolveConnection({ + logger, collectionRef: untrustedCollection, definitionRef: definition }); @@ -374,6 +408,7 @@ suite('Workbench - MCP - Registry', () => { testDialogService.setPromptResult(true); const connection2 = await registry.resolveConnection({ + logger, collectionRef: untrustedCollection, definitionRef: definition, forceTrust: true @@ -385,6 +420,7 @@ suite('Workbench - MCP - Registry', () => { testDialogService.promptSpy.resetHistory(); const connection3 = await registry.resolveConnection({ + logger, collectionRef: untrustedCollection, definitionRef: definition }); @@ -578,6 +614,23 @@ suite('Workbench - MCP - Registry', () => { assert.strictEqual(prefix2, ''); }); + test('prefix does not start with a number', () => { + const collection: McpCollectionDefinition = { + id: 'foo', + label: 'Collection 1', + remoteAuthority: null, + serverDefinitions: observableValue('serverDefs', []), + isTrustedByDefault: true, + scope: StorageScope.APPLICATION + }; + + const disposable = registry.registerCollection(collection); + store.add(disposable); + + const prefix1 = registry.collectionToolPrefix(collection).get(); + assert.strictEqual(prefix1, 'bee.'); // normally 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33 + }); + test('prefix is empty for unknown collections', () => { const unknownCollection: McpCollectionReference = { id: 'unknown', diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts index f6f745d5751..36cc0ca4eb6 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryInputStorage.test.ts @@ -36,54 +36,54 @@ suite('Workbench - MCP - RegistryInputStorage', () => { test('setPlainText stores values that can be retrieved with getMap', async () => { const values = { - 'key1': 'value1', - 'key2': 'value2' + 'key1': { value: 'value1' }, + 'key2': { value: 'value2' } }; await mcpInputStorage.setPlainText(values); const result = await mcpInputStorage.getMap(); - assert.strictEqual(result.key1, 'value1'); - assert.strictEqual(result.key2, 'value2'); + assert.strictEqual(result.key1.value, 'value1'); + assert.strictEqual(result.key2.value, 'value2'); }); test('setSecrets stores encrypted values that can be retrieved with getMap', async () => { const secrets = { - 'secretKey1': 'secretValue1', - 'secretKey2': 'secretValue2' + 'secretKey1': { value: 'secretValue1' }, + 'secretKey2': { value: 'secretValue2' } }; await mcpInputStorage.setSecrets(secrets); const result = await mcpInputStorage.getMap(); - assert.strictEqual(result.secretKey1, 'secretValue1'); - assert.strictEqual(result.secretKey2, 'secretValue2'); + assert.strictEqual(result.secretKey1.value, 'secretValue1'); + assert.strictEqual(result.secretKey2.value, 'secretValue2'); }); test('getMap returns combined plain text and secret values', async () => { await mcpInputStorage.setPlainText({ - 'plainKey': 'plainValue' + 'plainKey': { value: 'plainValue' } }); await mcpInputStorage.setSecrets({ - 'secretKey': 'secretValue' + 'secretKey': { value: 'secretValue' } }); const result = await mcpInputStorage.getMap(); - assert.strictEqual(result.plainKey, 'plainValue'); - assert.strictEqual(result.secretKey, 'secretValue'); + assert.strictEqual(result.plainKey.value, 'plainValue'); + assert.strictEqual(result.secretKey.value, 'secretValue'); }); test('clear removes specific values', async () => { await mcpInputStorage.setPlainText({ - 'key1': 'value1', - 'key2': 'value2' + 'key1': { value: 'value1' }, + 'key2': { value: 'value2' } }); await mcpInputStorage.setSecrets({ - 'secretKey1': 'secretValue1', - 'secretKey2': 'secretValue2' + 'secretKey1': { value: 'secretValue1' }, + 'secretKey2': { value: 'secretValue2' } }); // Clear one plain and one secret value @@ -93,18 +93,18 @@ suite('Workbench - MCP - RegistryInputStorage', () => { const result = await mcpInputStorage.getMap(); assert.strictEqual(result.key1, undefined); - assert.strictEqual(result.key2, 'value2'); + assert.strictEqual(result.key2.value, 'value2'); assert.strictEqual(result.secretKey1, undefined); - assert.strictEqual(result.secretKey2, 'secretValue2'); + assert.strictEqual(result.secretKey2.value, 'secretValue2'); }); test('clearAll removes all values', async () => { await mcpInputStorage.setPlainText({ - 'key1': 'value1' + 'key1': { value: 'value1' } }); await mcpInputStorage.setSecrets({ - 'secretKey1': 'secretValue1' + 'secretKey1': { value: 'secretValue1' } }); mcpInputStorage.clearAll(); @@ -116,44 +116,44 @@ suite('Workbench - MCP - RegistryInputStorage', () => { test('updates to plain text values overwrite existing values', async () => { await mcpInputStorage.setPlainText({ - 'key1': 'value1', - 'key2': 'value2' + 'key1': { value: 'value1' }, + 'key2': { value: 'value2' } }); await mcpInputStorage.setPlainText({ - 'key1': 'updatedValue1' + 'key1': { value: 'updatedValue1' } }); const result = await mcpInputStorage.getMap(); - assert.strictEqual(result.key1, 'updatedValue1'); - assert.strictEqual(result.key2, 'value2'); + assert.strictEqual(result.key1.value, 'updatedValue1'); + assert.strictEqual(result.key2.value, 'value2'); }); test('updates to secret values overwrite existing values', async () => { await mcpInputStorage.setSecrets({ - 'secretKey1': 'secretValue1', - 'secretKey2': 'secretValue2' + 'secretKey1': { value: 'secretValue1' }, + 'secretKey2': { value: 'secretValue2' } }); await mcpInputStorage.setSecrets({ - 'secretKey1': 'updatedSecretValue1' + 'secretKey1': { value: 'updatedSecretValue1' } }); const result = await mcpInputStorage.getMap(); - assert.strictEqual(result.secretKey1, 'updatedSecretValue1'); - assert.strictEqual(result.secretKey2, 'secretValue2'); + assert.strictEqual(result.secretKey1.value, 'updatedSecretValue1'); + assert.strictEqual(result.secretKey2.value, 'secretValue2'); }); test('storage persists values across instances', async () => { // Set values on first instance await mcpInputStorage.setPlainText({ - 'key1': 'value1' + 'key1': { value: 'value1' } }); await mcpInputStorage.setSecrets({ - 'secretKey1': 'secretValue1' + 'secretKey1': { value: 'secretValue1' } }); await testStorageService.flush(); @@ -169,8 +169,8 @@ suite('Workbench - MCP - RegistryInputStorage', () => { const result = await secondInstance.getMap(); - assert.strictEqual(result.key1, 'value1'); - assert.strictEqual(result.secretKey1, 'secretValue1'); + assert.strictEqual(result.key1.value, 'value1'); + assert.strictEqual(result.secretKey1.value, 'secretValue1'); assert.ok(!testStorageService.get('mcpInputs', StorageScope.APPLICATION)?.includes('secretValue1')); }); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts index 567c613329d..36ce1367838 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts @@ -6,6 +6,7 @@ import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../base/common/observable.js'; +import { LogLevel } from '../../../../../platform/log/common/log.js'; import { IMcpMessageTransport } from '../../common/mcpRegistryTypes.js'; import { McpConnectionState } from '../../common/mcpTypes.js'; import { MCP } from '../../common/modelContextProtocol.js'; @@ -15,7 +16,7 @@ import { MCP } from '../../common/modelContextProtocol.js'; * Allows tests to easily send/receive messages and control the connection state. */ export class TestMcpMessageTransport extends Disposable implements IMcpMessageTransport { - private readonly _onDidLog = this._register(new Emitter()); + private readonly _onDidLog = this._register(new Emitter<{ level: LogLevel; message: string }>()); public readonly onDidLog = this._onDidLog.event; private readonly _onDidReceiveMessage = this._register(new Emitter()); @@ -81,7 +82,7 @@ export class TestMcpMessageTransport extends Disposable implements IMcpMessageTr * Simulate a log event. */ public simulateLog(message: string): void { - this._onDidLog.fire(message); + this._onDidLog.fire({ level: LogLevel.Info, message }); } /** diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts index 83b9933c3f2..bcc62930a84 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts @@ -12,7 +12,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { ILogger, ILoggerService } from '../../../../../platform/log/common/log.js'; +import { ILogger, ILoggerService, LogLevel, NullLogger } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IOutputService } from '../../../../services/output/common/output.js'; @@ -26,6 +26,8 @@ class TestMcpHostDelegate extends Disposable implements IMcpHostDelegate { private readonly _transport: TestMcpMessageTransport; private _canStartValue = true; + priority = 0; + constructor() { super(); this._transport = this._register(new TestMcpMessageTransport()); @@ -97,6 +99,7 @@ suite('Workbench - MCP - ServerConnection', () => { command: 'test-command', args: [], env: {}, + envFile: undefined, cwd: URI.parse('file:///test') } }; @@ -126,7 +129,8 @@ suite('Workbench - MCP - ServerConnection', () => { collection, serverDefinition, delegate, - serverDefinition.launch + serverDefinition.launch, + new NullLogger(), ); store.add(connection); @@ -153,7 +157,8 @@ suite('Workbench - MCP - ServerConnection', () => { collection, serverDefinition, delegate, - serverDefinition.launch + serverDefinition.launch, + new NullLogger(), ); store.add(connection); @@ -171,7 +176,8 @@ suite('Workbench - MCP - ServerConnection', () => { collection, serverDefinition, delegate, - serverDefinition.launch + serverDefinition.launch, + new NullLogger(), ); store.add(connection); @@ -196,7 +202,8 @@ suite('Workbench - MCP - ServerConnection', () => { collection, serverDefinition, delegate, - serverDefinition.launch + serverDefinition.launch, + new NullLogger(), ); store.add(connection); @@ -219,7 +226,8 @@ suite('Workbench - MCP - ServerConnection', () => { collection, serverDefinition, delegate, - serverDefinition.launch + serverDefinition.launch, + new NullLogger(), ); store.add(connection); @@ -241,6 +249,8 @@ suite('Workbench - MCP - ServerConnection', () => { transport.simulateInitialized(); assert.ok(await waitForHandler(connection)); + + connection.dispose(); }); test('should clean up when disposed', async () => { @@ -250,7 +260,8 @@ suite('Workbench - MCP - ServerConnection', () => { collection, serverDefinition, delegate, - serverDefinition.launch + serverDefinition.launch, + new NullLogger(), ); // Start the connection @@ -264,81 +275,25 @@ suite('Workbench - MCP - ServerConnection', () => { assert.strictEqual(connection.state.get().state, McpConnectionState.Kind.Stopped); }); - test('showOutput should call logger and output services', () => { - let channelShown = false; - const outputService = { - showChannel: (id: string) => { - assert.strictEqual(id, `mcpServer/${serverDefinition.id}`); - channelShown = true; - } - }; - - let loggerVisible = false; - const loggerService = new class extends TestLoggerService { - override setVisibility(id: string, visible: boolean): void { - assert.strictEqual(id, `mcpServer/${serverDefinition.id}`); - assert.strictEqual(visible, true); - loggerVisible = true; - } - }; - - // Override services - const services = new ServiceCollection( - [ILoggerService, store.add(loggerService)], - [IOutputService, upcast(outputService)], - [IStorageService, store.add(new TestStorageService())] - ); - - const localInstantiationService = store.add(new TestInstantiationService(services)); - - // Create server connection - const connection = localInstantiationService.createInstance( - McpServerConnection, - collection, - serverDefinition, - delegate, - serverDefinition.launch - ); - store.add(connection); - - // Show output - connection.showOutput(); - - assert.strictEqual(channelShown, true); - assert.strictEqual(loggerVisible, true); - }); - test('should log transport messages', async () => { // Track logged messages const loggedMessages: string[] = []; - const loggerService = new class extends TestLoggerService { - override createLogger(id: string) { - return { - info: (message: string) => { - loggedMessages.push(message); - }, - error: () => { }, - dispose: () => { } - } as Partial as ILogger; - } - }; - - // Override services - const services = new ServiceCollection( - [ILoggerService, store.add(loggerService)], - [IOutputService, upcast({ showChannel: () => { } })], - [IStorageService, store.add(new TestStorageService())] - ); - - const localInstantiationService = store.add(new TestInstantiationService(services)); // Create server connection - const connection = localInstantiationService.createInstance( + const connection = instantiationService.createInstance( McpServerConnection, collection, serverDefinition, delegate, - serverDefinition.launch + serverDefinition.launch, + { + getLevel: () => LogLevel.Debug, + info: (message: string) => { + loggedMessages.push(message); + }, + error: () => { }, + dispose: () => { } + } as Partial as ILogger, ); store.add(connection); @@ -354,6 +309,9 @@ suite('Workbench - MCP - ServerConnection', () => { // Check that the message was logged assert.ok(loggedMessages.some(msg => msg === 'Test log message')); + + connection.dispose(); + await timeout(10); }); test('should correctly handle transitions to and from error state', async () => { @@ -363,7 +321,8 @@ suite('Workbench - MCP - ServerConnection', () => { collection, serverDefinition, delegate, - serverDefinition.launch + serverDefinition.launch, + new NullLogger(), ); store.add(connection); @@ -400,7 +359,8 @@ suite('Workbench - MCP - ServerConnection', () => { collection, serverDefinition, delegate, - serverDefinition.launch + serverDefinition.launch, + new NullLogger(), ); store.add(connection); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts index ebbfeb75798..205465a0f08 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts @@ -24,6 +24,8 @@ import { CancellationTokenSource } from '../../../../../base/common/cancellation class TestMcpHostDelegate extends Disposable implements IMcpHostDelegate { private readonly _transport: TestMcpMessageTransport; + priority = 0; + constructor() { super(); this._transport = this._register(new TestMcpMessageTransport()); diff --git a/src/vs/workbench/contrib/mergeEditor/browser/commands/commands.ts b/src/vs/workbench/contrib/mergeEditor/browser/commands/commands.ts index 81e0dce9de2..f0c63879e6b 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/commands/commands.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/commands/commands.ts @@ -22,6 +22,10 @@ import { MergeEditor } from '../view/mergeEditor.js'; import { MergeEditorViewModel } from '../view/viewModel.js'; import { ctxIsMergeEditor, ctxMergeEditorLayout, ctxMergeEditorShowBase, ctxMergeEditorShowBaseAtTop, ctxMergeEditorShowNonConflictingChanges, StorageCloseWithConflicts } from '../../common/mergeEditor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { transaction } from '../../../../../base/common/observable.js'; +import { ModifiedBaseRangeStateKind } from '../model/modifiedBaseRange.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; abstract class MergeEditorAction extends Action2 { constructor(desc: Readonly) { @@ -513,7 +517,7 @@ export class AcceptAllInput1 extends MergeEditorAction { super({ id: 'merge.acceptAllInput1', category: mergeEditorCategory, - title: localize2('merge.acceptAllInput1', "Accept All Changes from Left"), + title: localize2('merge.acceptAllInput1', "Accept All Incoming Changes from Left"), f1: true, precondition: ctxIsMergeEditor, menu: { id: MenuId.MergeInput1Toolbar, group: 'primary' }, @@ -531,7 +535,7 @@ export class AcceptAllInput2 extends MergeEditorAction { super({ id: 'merge.acceptAllInput2', category: mergeEditorCategory, - title: localize2('merge.acceptAllInput2', "Accept All Changes from Right"), + title: localize2('merge.acceptAllInput2', "Accept All Current Changes from Right"), f1: true, precondition: ctxIsMergeEditor, menu: { id: MenuId.MergeInput2Toolbar, group: 'primary' }, @@ -577,6 +581,41 @@ export class ResetCloseWithConflictsChoice extends Action2 { } } +export class AcceptAllCombination extends MergeEditorAction2 { + constructor() { + super({ + id: 'mergeEditor.acceptAllCombination', + category: mergeEditorCategory, + title: localize2('mergeEditor.acceptAllCombination', "Accept All Combination"), + f1: true, + }); + } + + override runWithMergeEditor(context: MergeEditorAction2Args, accessor: ServicesAccessor, ...args: any[]) { + const { viewModel } = context; + const modifiedBaseRanges = viewModel.model.modifiedBaseRanges.get(); + const model = viewModel.model; + transaction((tx) => { + for (const m of modifiedBaseRanges) { + const state = model.getState(m).get(); + if (state.kind !== ModifiedBaseRangeStateKind.unrecognized && !state.isInputIncluded(1) && (!state.isInputIncluded(2) || !viewModel.shouldUseAppendInsteadOfAccept.get()) && m.canBeCombined) { + model.setState( + m, + state + .withInputValue(1, true) + .withInputValue(2, true, true), + true, + tx + ); + model.telemetry.reportSmartCombinationInvoked(state.includesInput(2)); + } + } + }); + return { success: true }; + + } +} + // this is an API command export class AcceptMerge extends MergeEditorAction2 { constructor() { @@ -584,8 +623,15 @@ export class AcceptMerge extends MergeEditorAction2 { id: 'mergeEditor.acceptMerge', category: mergeEditorCategory, title: localize2('mergeEditor.acceptMerge', "Complete Merge"), - f1: false, - precondition: ctxIsMergeEditor + f1: true, + precondition: ctxIsMergeEditor, + keybinding: [ + { + primary: KeyMod.CtrlCmd | KeyCode.Enter, + weight: KeybindingWeight.EditorContrib, + when: ctxIsMergeEditor, + } + ] }); } @@ -615,3 +661,34 @@ export class AcceptMerge extends MergeEditorAction2 { }; } } + +export class ToggleBetweenInputs extends MergeEditorAction2 { + constructor() { + super({ + id: 'mergeEditor.toggleBetweenInputs', + category: mergeEditorCategory, + title: localize2('mergeEditor.toggleBetweenInputs', "Toggle Between Merge Editor Inputs"), + f1: true, + precondition: ctxIsMergeEditor, + keybinding: [ + { + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyT, + // Override reopen closed editor + weight: KeybindingWeight.WorkbenchContrib + 10, + when: ctxIsMergeEditor, + } + ] + }); + } + + override runWithMergeEditor({ viewModel }: MergeEditorAction2Args, accessor: ServicesAccessor) { + const input1IsFocused = viewModel.inputCodeEditorView1.editor.hasWidgetFocus(); + + // Toggle focus between inputs + if (input1IsFocused) { + viewModel.inputCodeEditorView2.editor.focus(); + } else { + viewModel.inputCodeEditorView1.editor.focus(); + } + } +} diff --git a/src/vs/workbench/contrib/mergeEditor/browser/mergeEditor.contribution.ts b/src/vs/workbench/contrib/mergeEditor/browser/mergeEditor.contribution.ts index bbf7bf34479..6399b030b16 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/mergeEditor.contribution.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/mergeEditor.contribution.ts @@ -15,7 +15,8 @@ import { AcceptAllInput1, AcceptAllInput2, AcceptMerge, CompareInput1WithBaseCommand, CompareInput2WithBaseCommand, GoToNextUnhandledConflict, GoToPreviousUnhandledConflict, OpenBaseFile, OpenMergeEditor, OpenResultResource, ResetToBaseAndAutoMergeCommand, SetColumnLayout, SetMixedLayout, ShowHideTopBase, ShowHideCenterBase, ShowHideBase, - ShowNonConflictingChanges, ToggleActiveConflictInput1, ToggleActiveConflictInput2, ResetCloseWithConflictsChoice + ShowNonConflictingChanges, ToggleActiveConflictInput1, ToggleActiveConflictInput2, ResetCloseWithConflictsChoice, + AcceptAllCombination, ToggleBetweenInputs } from './commands/commands.js'; import { MergeEditorCopyContentsToJSON, MergeEditorLoadContentsFromFolder, MergeEditorSaveContentsToFolder } from './commands/devCommands.js'; import { MergeEditorInput } from './mergeEditorInput.js'; @@ -86,6 +87,9 @@ registerAction2(ResetToBaseAndAutoMergeCommand); registerAction2(AcceptMerge); registerAction2(ResetCloseWithConflictsChoice); +registerAction2(AcceptAllCombination); + +registerAction2(ToggleBetweenInputs); // Dev Commands registerAction2(MergeEditorCopyContentsToJSON); diff --git a/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorAccessibilityHelp.ts b/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorAccessibilityHelp.ts index 119438c9b3f..cfa00747647 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorAccessibilityHelp.ts @@ -7,28 +7,18 @@ import { ICodeEditorService } from '../../../../editor/browser/services/codeEdit import { localize } from '../../../../nls.js'; import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; -import { ContextKeyEqualsExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyEqualsExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; -import { getCommentCommandInfo } from '../../accessibility/browser/editorAccessibilityHelp.js'; -import { MergeEditor } from './view/mergeEditor.js'; + export class MergeEditorAccessibilityHelpProvider implements IAccessibleViewImplementation { readonly name = 'mergeEditor'; readonly type = AccessibleViewType.Help; - readonly priority = 105; + readonly priority = 125; readonly when = ContextKeyEqualsExpr.create('isMergeEditor', true); getProvider(accessor: ServicesAccessor) { - const editorService = accessor.get(IEditorService); const codeEditorService = accessor.get(ICodeEditorService); - const keybindingService = accessor.get(IKeybindingService); - const contextKeyService = accessor.get(IContextKeyService); - - if (!(editorService.activeTextEditorControl instanceof MergeEditor)) { - return; - } const codeEditor = codeEditorService.getActiveCodeEditor() || codeEditorService.getFocusedCodeEditor(); if (!codeEditor) { @@ -37,13 +27,12 @@ export class MergeEditorAccessibilityHelpProvider implements IAccessibleViewImpl const content = [ localize('msg1', "You are in a merge editor."), - localize('msg2', "Navigate between merge conflicts using the commands Go to Next Unhandled Conflict and Go to Previous Unhandled Conflict."), - localize('msg3', "Run the command Merge Editor: Accept Merge to accept the current conflict."), + localize('msg2', "Navigate between merge conflicts using the commands Go to Next Unhandled Conflict{0} and Go to Previous Unhandled Conflict{1}.", '', ''), + localize('msg3', "Run the command Merge Editor: Accept All Incoming Changes from the Left{0} and Merge Editor: Accept All Current Changes from the Right{1}", '', ''), + localize('msg4', "Complete the Merge{0}.", ''), + localize('msg5', "Toggle between merge editor inputs, incoming and current changes {0}.", ''), ]; - const commentCommandInfo = getCommentCommandInfo(keybindingService, contextKeyService, codeEditor); - if (commentCommandInfo) { - content.push(commentCommandInfo); - } + return new AccessibleContentProvider( AccessibleViewProviderId.MergeEditor, { type: AccessibleViewType.Help }, diff --git a/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts b/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts index 28217f06aa7..2f1bfb218dd 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts @@ -23,6 +23,8 @@ import { MergeEditorTelemetry } from './telemetry.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js'; import { ILanguageSupport, ITextFileSaveOptions, ITextFileService } from '../../../services/textfile/common/textfiles.js'; +import { alert } from '../../../../base/browser/ui/aria/aria.js'; +import { MergeEditorType } from './view/viewModel.js'; export class MergeEditorInputData { constructor( @@ -38,6 +40,8 @@ export class MergeEditorInput extends AbstractTextResourceEditorInput implements private _inputModel?: IMergeEditorInputModel; + private _focusedEditor: MergeEditorType = 'result'; + override closeHandler: IEditorCloseHandler = { showConfirm: () => this._inputModel?.shouldConfirmClose() ?? false, confirm: async (editors) => { @@ -178,5 +182,29 @@ export class MergeEditorInput extends AbstractTextResourceEditorInput implements this._inputModel?.model.setLanguageId(languageId, source); } + /** + * Updates the focused editor and triggers a name change event + */ + public updateFocusedEditor(editor: MergeEditorType): void { + if (this._focusedEditor !== editor) { + this._focusedEditor = editor; + alertFocusedEditor(editor); + } + } + // implement get/set encoding } + +function alertFocusedEditor(editor: MergeEditorType) { + switch (editor) { + case 'input1': + alert(localize('mergeEditor.input1', "Incoming, Left Input")); + break; + case 'input2': + alert(localize('mergeEditor.input2', "Current, Right Input")); + break; + case 'result': + alert(localize('mergeEditor.result', "Merge Result")); + break; + } +} diff --git a/src/vs/workbench/contrib/mergeEditor/browser/model/lineRange.ts b/src/vs/workbench/contrib/mergeEditor/browser/model/lineRange.ts index eff77556a78..8c9f2b57bf4 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/model/lineRange.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/model/lineRange.ts @@ -40,7 +40,7 @@ export class LineRange { } public join(other: LineRange): LineRange { - return new LineRange(Math.min(this.startLineNumber, other.startLineNumber), Math.max(this.endLineNumberExclusive, other.endLineNumberExclusive) - this.startLineNumber); + return LineRange.fromLineNumbers(Math.min(this.startLineNumber, other.startLineNumber), Math.max(this.endLineNumberExclusive, other.endLineNumberExclusive)); } public get endLineNumberExclusive(): number { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/model/mapping.ts b/src/vs/workbench/contrib/mergeEditor/browser/model/mapping.ts index 3025d37f4be..13bd79f8584 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/model/mapping.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/model/mapping.ts @@ -3,14 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { compareBy, numberComparator } from '../../../../../base/common/arrays.js'; +import { compareBy, concatArrays, numberComparator } from '../../../../../base/common/arrays.js'; import { findLast } from '../../../../../base/common/arraysFind.js'; import { assertFn, checkAdjacentItems } from '../../../../../base/common/assert.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; import { Position } from '../../../../../editor/common/core/position.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { ITextModel } from '../../../../../editor/common/model.js'; -import { concatArrays } from '../utils.js'; import { LineRangeEdit } from './editing.js'; import { LineRange } from './lineRange.js'; import { addLength, lengthBetweenPositions, rangeContainsPosition, rangeIsBeforeOrTouching } from './rangeUtils.js'; diff --git a/src/vs/workbench/contrib/mergeEditor/browser/model/mergeEditorModel.ts b/src/vs/workbench/contrib/mergeEditor/browser/model/mergeEditorModel.ts index 26c5f44afd1..8885b623ea1 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/model/mergeEditorModel.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/model/mergeEditorModel.ts @@ -77,15 +77,18 @@ export class MergeEditorModel extends EditorModel { this._register( autorunHandleChanges( { - handleChange: (ctx) => { - if (ctx.didChange(this.modifiedBaseRangeResultStates)) { - shouldRecomputeHandledFromAccepted = true; - } - return ctx.didChange(this.resultTextModelDiffs.diffs) - // Ignore non-text changes as we update the state directly - ? ctx.change === TextModelDiffChangeReason.textChange - : true; - }, + changeTracker: { + createChangeSummary: () => undefined, + handleChange: (ctx) => { + if (ctx.didChange(this.modifiedBaseRangeResultStates)) { + shouldRecomputeHandledFromAccepted = true; + } + return ctx.didChange(this.resultTextModelDiffs.diffs) + // Ignore non-text changes as we update the state directly + ? ctx.change === TextModelDiffChangeReason.textChange + : true; + }, + } }, (reader) => { /** @description Merge Editor Model: Recompute State From Result */ diff --git a/src/vs/workbench/contrib/mergeEditor/browser/model/modifiedBaseRange.ts b/src/vs/workbench/contrib/mergeEditor/browser/model/modifiedBaseRange.ts index cd62b5944a1..3a4bd9618e9 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/model/modifiedBaseRange.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/model/modifiedBaseRange.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { compareBy, equals, numberComparator, tieBreakComparators } from '../../../../../base/common/arrays.js'; +import { compareBy, concatArrays, equals, numberComparator, tieBreakComparators } from '../../../../../base/common/arrays.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; import { splitLines } from '../../../../../base/common/strings.js'; import { Constants } from '../../../../../base/common/uint.js'; @@ -13,7 +13,6 @@ import { ITextModel } from '../../../../../editor/common/model.js'; import { LineRangeEdit, RangeEdit } from './editing.js'; import { LineRange } from './lineRange.js'; import { DetailedLineRangeMapping, MappingAlignment } from './mapping.js'; -import { concatArrays } from '../utils.js'; /** * Describes modifications in input 1 and input 2 for a specific range in base. diff --git a/src/vs/workbench/contrib/mergeEditor/browser/utils.ts b/src/vs/workbench/contrib/mergeEditor/browser/utils.ts index 308db886d5b..8c1698a42e8 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/utils.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/utils.ts @@ -77,10 +77,6 @@ export function* join( } } -export function concatArrays(...arrays: TArr): TArr[number][number][] { - return ([] as any[]).concat(...arrays); -} - export function elementAtOrUndefined(arr: T[], index: number): T | undefined { return arr[index]; } diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts index 666ad3f373e..6c6e6b5eaf3 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts @@ -218,6 +218,18 @@ export class MergeEditor extends AbstractTextEditor { }); this._sessionDisposables.add(viewModel); + // Track focus changes to update the editor name + this._sessionDisposables.add(autorun(reader => { + /** @description Update focused editor name based on focus */ + const focusedType = viewModel.focusedEditorType.read(reader); + + if (!(input instanceof MergeEditorInput)) { + return; + } + + input.updateFocusedEditor(focusedType || 'result'); + })); + // Set/unset context keys based on input this._ctxResultUri.set(inputModel.resultUri.toString()); this._ctxBaseUri.set(model.base.uri.toString()); @@ -226,12 +238,19 @@ export class MergeEditor extends AbstractTextEditor { this._ctxResultUri.reset(); })); + const viewZoneRegistrationStore = new DisposableStore(); + this._sessionDisposables.add(viewZoneRegistrationStore); // Set the view zones before restoring view state! // Otherwise scrolling will be off - this._sessionDisposables.add(autorunWithStore((reader, store) => { + this._sessionDisposables.add(autorunWithStore((reader) => { /** @description update alignment view zones */ const baseView = this.baseView.read(reader); + const resultScrollTop = this.inputResultView.editor.getScrollTop(); + this.scrollSynchronizer.stopSync(); + + viewZoneRegistrationStore.clear(); + this.inputResultView.editor.changeViewZones(resultViewZoneAccessor => { const layout = this._layoutModeObs.read(reader); const shouldAlignResult = layout.kind === 'columns'; @@ -241,7 +260,7 @@ export class MergeEditor extends AbstractTextEditor { this.input2View.editor.changeViewZones(input2ViewZoneAccessor => { if (baseView) { baseView.editor.changeViewZones(baseViewZoneAccessor => { - store.add(this.setViewZones(reader, + viewZoneRegistrationStore.add(this.setViewZones(reader, viewModel, this.input1View.editor, input1ViewZoneAccessor, @@ -256,7 +275,7 @@ export class MergeEditor extends AbstractTextEditor { )); }); } else { - store.add(this.setViewZones(reader, + viewZoneRegistrationStore.add(this.setViewZones(reader, viewModel, this.input1View.editor, input1ViewZoneAccessor, @@ -274,6 +293,9 @@ export class MergeEditor extends AbstractTextEditor { }); }); + this.inputResultView.editor.setScrollTop(resultScrollTop, ScrollType.Smooth); + + this.scrollSynchronizer.startSync(); this.scrollSynchronizer.updateScrolling(); })); diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/scrollSynchronizer.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/scrollSynchronizer.ts index 4d09d327f29..dec1af45d1b 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/scrollSynchronizer.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/scrollSynchronizer.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { autorunWithStore, IObservable } from '../../../../../base/common/observable.js'; +import { derivedWithStore, IObservable } from '../../../../../base/common/observable.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { ScrollType } from '../../../../../editor/common/editorCommon.js'; import { DocumentLineRangeMap } from '../model/mapping.js'; @@ -14,6 +14,9 @@ import { IMergeEditorLayout } from './mergeEditor.js'; import { MergeEditorViewModel } from './viewModel.js'; import { InputCodeEditorView } from './editors/inputCodeEditorView.js'; import { ResultCodeEditorView } from './editors/resultCodeEditorView.js'; +import { CodeEditorView } from './editors/codeEditorView.js'; +import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { isDefined } from '../../../../../base/common/types.js'; export class ScrollSynchronizer extends Disposable { private get model() { return this.viewModel.get()?.model; } @@ -22,8 +25,10 @@ export class ScrollSynchronizer extends Disposable { public readonly updateScrolling: () => void; - private get shouldAlignResult() { return this.layout.get().kind === 'columns'; } - private get shouldAlignBase() { return this.layout.get().kind === 'mixed' && !this.layout.get().showBaseAtTop; } + private get lockResultWithInputs() { return this.layout.get().kind === 'columns'; } + private get lockBaseWithInputs() { return this.layout.get().kind === 'mixed' && !this.layout.get().showBaseAtTop; } + + private _isSyncing = true; constructor( private readonly viewModel: IObservable, @@ -35,149 +40,132 @@ export class ScrollSynchronizer extends Disposable { ) { super(); - const handleInput1OnScroll = this.updateScrolling = () => { - if (!this.model) { - return; - } + const s = derivedWithStore((reader, store) => { + const baseView = this.baseView.read(reader); + const editors = [this.input1View, this.input2View, this.inputResultView, baseView].filter(isDefined); - this.input2View.editor.setScrollTop(this.input1View.editor.getScrollTop(), ScrollType.Immediate); - - if (this.shouldAlignResult) { - this.inputResultView.editor.setScrollTop(this.input1View.editor.getScrollTop(), ScrollType.Immediate); - } else { - const mappingInput1Result = this.model.input1ResultMapping.get(); - this.synchronizeScrolling(this.input1View.editor, this.inputResultView.editor, mappingInput1Result); - } - - const baseView = this.baseView.get(); - if (baseView) { - if (this.shouldAlignBase) { - this.baseView.get()?.editor.setScrollTop(this.input1View.editor.getScrollTop(), ScrollType.Immediate); - } else { - const mapping = new DocumentLineRangeMap(this.model.baseInput1Diffs.get(), -1).reverse(); - this.synchronizeScrolling(this.input1View.editor, baseView.editor, mapping); - } - } - }; - - this._store.add( - this.input1View.editor.onDidScrollChange( - this.reentrancyBarrier.makeExclusiveOrSkip((c) => { - if (c.scrollTopChanged) { - handleInput1OnScroll(); + const alignScrolling = (source: CodeEditorView, updateScrollLeft: boolean, updateScrollTop: boolean) => { + this.reentrancyBarrier.runExclusivelyOrSkip(() => { + if (updateScrollLeft) { + const scrollLeft = source.editor.getScrollLeft(); + for (const editorView of editors) { + if (editorView !== source) { + editorView.editor.setScrollLeft(scrollLeft, ScrollType.Immediate); + } + } } - if (c.scrollLeftChanged) { - this.baseView.get()?.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - this.input2View.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - this.inputResultView.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); + if (updateScrollTop) { + const scrollTop = source.editor.getScrollTop(); + for (const editorView of editors) { + if (editorView !== source) { + if (this._shouldLock(source, editorView)) { + editorView.editor.setScrollTop(scrollTop, ScrollType.Immediate); + } else { + const m = this._getMapping(source, editorView); + if (m) { + this._synchronizeScrolling(source.editor, editorView.editor, m); + } + } + } + } } - }) - ) - ); + }); + }; - this._store.add( - this.input2View.editor.onDidScrollChange( - this.reentrancyBarrier.makeExclusiveOrSkip((c) => { - if (!this.model) { + for (const editorView of editors) { + store.add(editorView.editor.onDidScrollChange(e => { + if (!this._isSyncing) { return; } + alignScrolling(editorView, e.scrollLeftChanged, e.scrollTopChanged); + })); + } - if (c.scrollTopChanged) { - this.input1View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate); - - if (this.shouldAlignResult) { - this.inputResultView.editor.setScrollTop(this.input2View.editor.getScrollTop(), ScrollType.Immediate); - } else { - const mappingInput2Result = this.model.input2ResultMapping.get(); - this.synchronizeScrolling(this.input2View.editor, this.inputResultView.editor, mappingInput2Result); - } - - const baseView = this.baseView.get(); - if (baseView && this.model) { - if (this.shouldAlignBase) { - this.baseView.get()?.editor.setScrollTop(c.scrollTop, ScrollType.Immediate); - } else { - const mapping = new DocumentLineRangeMap(this.model.baseInput2Diffs.get(), -1).reverse(); - this.synchronizeScrolling(this.input2View.editor, baseView.editor, mapping); - } - } - } - if (c.scrollLeftChanged) { - this.baseView.get()?.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - this.input1View.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - this.inputResultView.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - } - }) - ) - ); - this._store.add( - this.inputResultView.editor.onDidScrollChange( - this.reentrancyBarrier.makeExclusiveOrSkip((c) => { - if (c.scrollTopChanged) { - if (this.shouldAlignResult) { - this.input1View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate); - this.input2View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate); - } else { - const mapping1 = this.model?.resultInput1Mapping.get(); - this.synchronizeScrolling(this.inputResultView.editor, this.input1View.editor, mapping1); - - const mapping2 = this.model?.resultInput2Mapping.get(); - this.synchronizeScrolling(this.inputResultView.editor, this.input2View.editor, mapping2); - } - - const baseMapping = this.model?.resultBaseMapping.get(); - const baseView = this.baseView.get(); - if (baseView && this.model) { - this.synchronizeScrolling(this.inputResultView.editor, baseView.editor, baseMapping); - } - } - if (c.scrollLeftChanged) { - this.baseView.get()?.editor?.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - this.input1View.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - this.input2View.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - } - }) - ) - ); - - this._store.add( - autorunWithStore((reader, store) => { - /** @description set baseViewEditor.onDidScrollChange */ - const baseView = this.baseView.read(reader); - if (baseView) { - store.add(baseView.editor.onDidScrollChange( - this.reentrancyBarrier.makeExclusiveOrSkip((c) => { - if (c.scrollTopChanged) { - if (!this.model) { - return; - } - if (this.shouldAlignBase) { - this.input1View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate); - this.input2View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate); - } else { - const baseInput1Mapping = new DocumentLineRangeMap(this.model.baseInput1Diffs.get(), -1); - this.synchronizeScrolling(baseView.editor, this.input1View.editor, baseInput1Mapping); - - const baseInput2Mapping = new DocumentLineRangeMap(this.model.baseInput2Diffs.get(), -1); - this.synchronizeScrolling(baseView.editor, this.input2View.editor, baseInput2Mapping); - } - - const baseMapping = this.model?.baseResultMapping.get(); - this.synchronizeScrolling(baseView.editor, this.inputResultView.editor, baseMapping); - } - if (c.scrollLeftChanged) { - this.inputResultView.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - this.input1View.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - this.input2View.editor.setScrollLeft(c.scrollLeft, ScrollType.Immediate); - } - }) - )); + return { + update: () => { + alignScrolling(this.inputResultView, true, true); } - }) - ); + }; + }).recomputeInitiallyAndOnChange(this._store); + + this.updateScrolling = () => { + s.get().update(); + }; } - private synchronizeScrolling(scrollingEditor: CodeEditorWidget, targetEditor: CodeEditorWidget, mapping: DocumentLineRangeMap | undefined) { + public stopSync(): void { + this._isSyncing = false; + } + + public startSync(): void { + this._isSyncing = true; + } + + private _shouldLock(editor1: CodeEditorView, editor2: CodeEditorView): boolean { + const isInput = (editor: CodeEditorView) => editor === this.input1View || editor === this.input2View; + if (isInput(editor1) && editor2 === this.inputResultView || isInput(editor2) && editor1 === this.inputResultView) { + return this.lockResultWithInputs; + } + if (isInput(editor1) && editor2 === this.baseView.get() || isInput(editor2) && editor1 === this.baseView.get()) { + return this.lockBaseWithInputs; + } + if (isInput(editor1) && isInput(editor2)) { + return true; + } + return false; + } + + private _getMapping(editor1: CodeEditorView, editor2: CodeEditorView): DocumentLineRangeMap | undefined { + if (editor1 === this.input1View) { + if (editor2 === this.input2View) { + return undefined; + } else if (editor2 === this.inputResultView) { + return this.model?.input1ResultMapping.get()!; + } else if (editor2 === this.baseView.get()) { + const b = this.model?.baseInput1Diffs.get(); + if (!b) { return undefined; } + return new DocumentLineRangeMap(b, -1).reverse(); + } + } else if (editor1 === this.input2View) { + if (editor2 === this.input1View) { + return undefined; + } else if (editor2 === this.inputResultView) { + return this.model?.input2ResultMapping.get()!; + } else if (editor2 === this.baseView.get()) { + const b = this.model?.baseInput2Diffs.get(); + if (!b) { return undefined; } + return new DocumentLineRangeMap(b, -1).reverse(); + } + } else if (editor1 === this.inputResultView) { + if (editor2 === this.input1View) { + return this.model?.resultInput1Mapping.get()!; + } else if (editor2 === this.input2View) { + return this.model?.resultInput2Mapping.get()!; + } else if (editor2 === this.baseView.get()) { + const b = this.model?.resultBaseMapping.get(); + if (!b) { return undefined; } + return b; + } + } else if (editor1 === this.baseView.get()) { + if (editor2 === this.input1View) { + const b = this.model?.baseInput1Diffs.get(); + if (!b) { return undefined; } + return new DocumentLineRangeMap(b, -1); + } else if (editor2 === this.input2View) { + const b = this.model?.baseInput2Diffs.get(); + if (!b) { return undefined; } + return new DocumentLineRangeMap(b, -1); + } else if (editor2 === this.inputResultView) { + const b = this.model?.baseResultMapping.get(); + if (!b) { return undefined; } + return b; + } + } + + throw new BugIndicatingError(); + } + + private _synchronizeScrolling(scrollingEditor: CodeEditorWidget, targetEditor: CodeEditorWidget, mapping: DocumentLineRangeMap | undefined) { if (!mapping) { return; } diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/viewModel.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/viewModel.ts index 74322dc65a4..b8158dc995a 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/viewModel.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/viewModel.ts @@ -116,6 +116,29 @@ export class MergeEditorViewModel extends Disposable { return undefined; }); + /** + * Returns an observable that tracks which editor type is currently focused + */ + public readonly focusedEditorType = derived(this, reader => { + const lastFocusedEditor = this.lastFocusedEditor.read(reader); + + if (!lastFocusedEditor.view) { + return undefined; + } + + if (lastFocusedEditor.view === this.inputCodeEditorView1) { + return 'input1'; + } else if (lastFocusedEditor.view === this.inputCodeEditorView2) { + return 'input2'; + } else if (lastFocusedEditor.view === this.resultCodeEditorView) { + return 'result'; + } else if (lastFocusedEditor.view === this.baseCodeEditorView.read(reader)) { + return 'base'; + } + + return undefined; + }); + public readonly selectionInBase = derived(this, reader => { const sourceEditor = this.lastFocusedEditor.read(reader).view; if (!sourceEditor) { @@ -343,3 +366,5 @@ interface IAttachedHistoryElement { undo(): void; redo(): void; } + +export type MergeEditorType = 'input1' | 'input2' | 'result' | 'base'; diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticEditorContrib.ts b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticEditorContrib.ts index 584fc890a92..dfd645ce241 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticEditorContrib.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticEditorContrib.ts @@ -15,6 +15,7 @@ import { CodeCellViewModel } from '../../viewModel/codeCellViewModel.js'; import { Event } from '../../../../../../base/common/event.js'; import { IChatAgentService } from '../../../../chat/common/chatAgents.js'; import { ChatAgentLocation } from '../../../../chat/common/constants.js'; +import { autorun } from '../../../../../../base/common/observable.js'; export class CellDiagnostics extends Disposable implements INotebookEditorContribution { @@ -121,6 +122,11 @@ export class CellDiagnostics extends Disposable implements INotebookEditorContri disposables.push(toDisposable(() => this.markerService.changeOne(CellDiagnostics.ID, cell.uri, []))); cell.executionErrorDiagnostic.set(metadata.error, undefined); disposables.push(toDisposable(() => cell.executionErrorDiagnostic.set(undefined, undefined))); + disposables.push(autorun((r) => { + if (!cell.executionErrorDiagnostic.read(r)) { + this.clear(cellHandle); + } + })); disposables.push(cell.model.onDidChangeOutputs(() => { if (cell.model.outputs.length === 0) { this.clear(cellHandle); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/chat/notebookChatUtils.ts b/src/vs/workbench/contrib/notebook/browser/contrib/chat/notebookChatUtils.ts new file mode 100644 index 00000000000..69f25f754e6 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/contrib/chat/notebookChatUtils.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { normalizeDriveLetter } from '../../../../../../base/common/labels.js'; +import { basenameOrAuthority } from '../../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { INotebookOutputVariableEntry } from '../../../../chat/common/chatModel.js'; +import { CellUri } from '../../../common/notebookCommon.js'; +import { ICellOutputViewModel, INotebookEditor } from '../../notebookBrowser.js'; + +export const NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT_CONST = [ + 'text/plain', + 'text/html', + 'application/vnd.code.notebook.error', + 'application/vnd.code.notebook.stdout', + 'application/x.notebook.stdout', + 'application/x.notebook.stream', + 'application/vnd.code.notebook.stderr', + 'application/x.notebook.stderr', + 'image/png', + 'image/jpeg', + 'image/svg', +]; + +export function createNotebookOutputVariableEntry(outputViewModel: ICellOutputViewModel, mimeType: string, notebookEditor: INotebookEditor): INotebookOutputVariableEntry | undefined { + + // get the cell index + const cellFromViewModelHandle = outputViewModel.cellViewModel.handle; + const notebookModel = notebookEditor.textModel; + const cell = notebookEditor.getCellByHandle(cellFromViewModelHandle); + if (!cell || cell.outputsViewModels.length === 0 || !notebookModel) { + return; + } + // uri of the cell + const notebookUri = notebookModel.uri; + const cellUri = cell.uri; + const cellIndex = notebookModel.cells.indexOf(cell.model); + + // get the output index + const outputId = outputViewModel?.model.outputId; + let outputIndex: number = 0; + if (outputId !== undefined) { + // find the output index + outputIndex = cell.outputsViewModels.findIndex(output => { + return output.model.outputId === outputId; + }); + } + + // construct the URI using the cell uri and output index + const outputCellUri = CellUri.generateCellOutputUriWithIndex(notebookUri, cellUri, outputIndex); + const fileName = normalizeDriveLetter(basenameOrAuthority(notebookUri)); + + const l: INotebookOutputVariableEntry = { + value: outputCellUri, + id: outputCellUri.toString(), + name: localize('notebookOutputCellLabel', "{0} • Cell {1} • Output {2}", fileName, `${cellIndex + 1}`, `${outputIndex + 1}`), + icon: mimeType === 'application/vnd.code.notebook.error' ? ThemeIcon.fromId('error') : undefined, + kind: 'notebookOutput', + outputIndex, + mimeType + }; + + return l; +} diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/debug/notebookDebugDecorations.ts b/src/vs/workbench/contrib/notebook/browser/contrib/debug/notebookDebugDecorations.ts index 3185f9269bc..43215bff942 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/debug/notebookDebugDecorations.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/debug/notebookDebugDecorations.ts @@ -10,7 +10,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { debugIconBreakpointForeground } from '../../../../debug/browser/breakpointEditorContribution.js'; import { focusedStackFrameColor, topStackFrameColor } from '../../../../debug/browser/callStackEditorContribution.js'; import { IDebugService, IStackFrame } from '../../../../debug/common/debug.js'; -import { INotebookCellDecorationOptions, INotebookDeltaDecoration, INotebookEditor, INotebookEditorContribution, NotebookOverviewRulerLane } from '../../notebookBrowser.js'; +import { INotebookCellDecorationOptions, INotebookDeltaCellDecoration, INotebookEditor, INotebookEditorContribution, NotebookOverviewRulerLane } from '../../notebookBrowser.js'; import { registerNotebookContribution } from '../../notebookEditorExtensions.js'; import { runningCellRulerDecorationColor } from '../../notebookEditorWidget.js'; import { CellUri, NotebookCellExecutionState } from '../../../common/notebookCommon.js'; @@ -96,7 +96,7 @@ export class PausedCellDecorationContribution extends Disposable implements INot } private setTopFrameDecoration(handlesAndRanges: ICellAndRange[]): void { - const newDecorations = handlesAndRanges.map(({ handle, range }) => { + const newDecorations: INotebookDeltaCellDecoration[] = handlesAndRanges.map(({ handle, range }) => { const options: INotebookCellDecorationOptions = { overviewRuler: { color: topStackFrameColor, @@ -105,14 +105,17 @@ export class PausedCellDecorationContribution extends Disposable implements INot position: NotebookOverviewRulerLane.Full } }; - return { handle, options }; + return { + handle, + options + }; }); this._currentTopDecorations = this._notebookEditor.deltaCellDecorations(this._currentTopDecorations, newDecorations); } private setFocusedFrameDecoration(focusedFrameCellAndRange: ICellAndRange | undefined): void { - let newDecorations: INotebookDeltaDecoration[] = []; + let newDecorations: INotebookDeltaCellDecoration[] = []; if (focusedFrameCellAndRange) { const options: INotebookCellDecorationOptions = { overviewRuler: { @@ -122,14 +125,17 @@ export class PausedCellDecorationContribution extends Disposable implements INot position: NotebookOverviewRulerLane.Full } }; - newDecorations = [{ handle: focusedFrameCellAndRange.handle, options }]; + newDecorations = [{ + handle: focusedFrameCellAndRange.handle, + options + }]; } this._currentOtherDecorations = this._notebookEditor.deltaCellDecorations(this._currentOtherDecorations, newDecorations); } private setExecutingCellDecorations(handles: number[]): void { - const newDecorations = handles.map(handle => { + const newDecorations: INotebookDeltaCellDecoration[] = handles.map(handle => { const options: INotebookCellDecorationOptions = { overviewRuler: { color: runningCellRulerDecorationColor, @@ -138,7 +144,10 @@ export class PausedCellDecorationContribution extends Disposable implements INot position: NotebookOverviewRulerLane.Left } }; - return { handle, options }; + return { + handle, + options + }; }); this._executingCellDecorations = this._notebookEditor.deltaCellDecorations(this._executingCellDecorations, newDecorations); @@ -180,7 +189,7 @@ export class NotebookBreakpointDecorations extends Disposable implements INotebo } }; return { handle: parsed.handle, options }; - }).filter(x => !!x) as INotebookDeltaDecoration[] + }).filter(x => !!x) as INotebookDeltaCellDecoration[] : []; this._currentDecorations = this._notebookEditor.deltaCellDecorations(this._currentDecorations, newDecorations); } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts index 7df058a6901..0870ac79d1b 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts @@ -6,64 +6,41 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { ICodeEditor } from '../../../../../../editor/browser/editorBrowser.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../../../editor/browser/editorExtensions.js'; -import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; -import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; -import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; -import { IProductService } from '../../../../../../platform/product/common/productService.js'; -import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IChatAgentService } from '../../../../chat/common/chatAgents.js'; -import { EmptyTextEditorHintContribution, IEmptyTextEditorHintOptions } from '../../../../codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.js'; +import { EmptyTextEditorHintContribution } from '../../../../codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.js'; import { IInlineChatSessionService } from '../../../../inlineChat/browser/inlineChatSessionService.js'; import { getNotebookEditorFromEditorPane } from '../../notebookBrowser.js'; -import { IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; export class EmptyCellEditorHintContribution extends EmptyTextEditorHintContribution { public static readonly CONTRIB_ID = 'notebook.editor.contrib.emptyCellEditorHint'; constructor( editor: ICodeEditor, @IEditorService private readonly _editorService: IEditorService, - @IEditorGroupsService editorGroupsService: IEditorGroupsService, - @ICommandService commandService: ICommandService, @IConfigurationService configurationService: IConfigurationService, - @IHoverService hoverService: IHoverService, - @IKeybindingService keybindingService: IKeybindingService, @IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService, @IChatAgentService chatAgentService: IChatAgentService, - @ITelemetryService telemetryService: ITelemetryService, - @IProductService productService: IProductService, - @IContextMenuService contextMenuService: IContextMenuService + @IInstantiationService instantiationService: IInstantiationService ) { super( editor, - editorGroupsService, - commandService, configurationService, - hoverService, - keybindingService, inlineChatSessionService, chatAgentService, - telemetryService, - productService, - contextMenuService + instantiationService ); const activeEditor = getNotebookEditorFromEditorPane(this._editorService.activeEditorPane); - if (!activeEditor) { return; } - this.toDispose.push(activeEditor.onDidChangeActiveCell(() => this.update())); + this._register(activeEditor.onDidChangeActiveCell(() => this.update())); } - protected override _getOptions(): IEmptyTextEditorHintOptions { - return { clickable: false }; - } - - protected override _shouldRenderHint(): boolean { + protected override shouldRenderHint(): boolean { const model = this.editor.getModel(); if (!model) { return false; @@ -79,7 +56,7 @@ export class EmptyCellEditorHintContribution extends EmptyTextEditorHintContribu return false; } - const shouldRenderHint = super._shouldRenderHint(); + const shouldRenderHint = super.shouldRenderHint(); if (!shouldRenderHint) { return false; } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts index f2de65d5bdc..7f266b59151 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts @@ -24,6 +24,7 @@ import { Event } from '../../../../../../base/common/event.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { isSafari } from '../../../../../../base/common/platform.js'; +import { IHistory } from '../../../../../../base/common/history.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { FindReplaceState, FindReplaceStateChangedEvent } from '../../../../../../editor/contrib/find/browser/findState.js'; @@ -35,6 +36,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService, IContextViewService } from '../../../../../../platform/contextview/browser/contextView.js'; import { ContextScopedReplaceInput, registerAndCreateHistoryNavigationContext } from '../../../../../../platform/history/browser/contextScopedHistoryWidget.js'; + import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { defaultInputBoxStyles, defaultProgressBarStyles, defaultToggleStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; @@ -327,6 +329,8 @@ export abstract class SimpleFindReplaceWidget extends Widget { @IHoverService hoverService: IHoverService, protected readonly _state: FindReplaceState = new FindReplaceState(), protected readonly _notebookEditor: INotebookEditor, + private readonly _findWidgetSearchHistory: IHistory | undefined, + private readonly _replaceWidgetHistory: IHistory | undefined, ) { super(); @@ -339,6 +343,9 @@ export abstract class SimpleFindReplaceWidget extends Widget { codeOutput: boolean; }>(NotebookSetting.findFilters) ?? { markupSource: true, markupPreview: true, codeSource: true, codeOutput: true }; + const findHistoryConfig = this._configurationService.getValue<'never' | 'workspace'>('editor.find.history'); + const replaceHistoryConfig = this._configurationService.getValue<'never' | 'workspace'>('editor.find.replaceHistory'); + this._filters = new NotebookFindFilters(findFilters.markupSource, findFilters.markupPreview, findFilters.codeSource, findFilters.codeOutput, { findScopeType: NotebookFindScopeType.None }); this._state.change({ filters: this._filters }, false); @@ -414,7 +421,8 @@ export abstract class SimpleFindReplaceWidget extends Widget { flexibleWidth: true, showCommonFindToggles: true, inputBoxStyles: defaultInputBoxStyles, - toggleStyles: defaultToggleStyles + toggleStyles: defaultToggleStyles, + history: findHistoryConfig === 'workspace' ? this._findWidgetSearchHistory : new Set([]), } )); @@ -568,7 +576,7 @@ export abstract class SimpleFindReplaceWidget extends Widget { this._replaceInput = this._register(new ContextScopedReplaceInput(null, undefined, { label: NLS_REPLACE_INPUT_LABEL, placeholder: NLS_REPLACE_INPUT_PLACEHOLDER, - history: new Set([]), + history: replaceHistoryConfig === 'workspace' ? this._replaceWidgetHistory : new Set([]), inputBoxStyles: defaultInputBoxStyles, toggleStyles: defaultToggleStyles }, contextKeyService, false)); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts index 6c57739806f..bb0c49f4bba 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts @@ -15,12 +15,15 @@ import { FindMatch } from '../../../../../../editor/common/model.js'; import { MATCHES_LIMIT } from '../../../../../../editor/contrib/find/browser/findModel.js'; import { FindReplaceState } from '../../../../../../editor/contrib/find/browser/findState.js'; import { NLS_MATCHES_LOCATION, NLS_NO_RESULTS } from '../../../../../../editor/contrib/find/browser/findWidget.js'; +import { FindWidgetSearchHistory } from '../../../../../../editor/contrib/find/browser/findWidgetSearchHistory.js'; +import { ReplaceWidgetHistory } from '../../../../../../editor/contrib/find/browser/replaceWidgetHistory.js'; import { localize } from '../../../../../../nls.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService, IContextViewService } from '../../../../../../platform/contextview/browser/contextView.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { NotebookFindFilters } from './findFilters.js'; import { FindModel } from './findModel.js'; import { SimpleFindReplaceWidget } from './notebookFindReplaceWidget.js'; @@ -91,8 +94,12 @@ class NotebookFindWidget extends SimpleFindReplaceWidget implements INotebookEdi @IContextMenuService contextMenuService: IContextMenuService, @IHoverService hoverService: IHoverService, @IInstantiationService instantiationService: IInstantiationService, + @IStorageService storageService: IStorageService, ) { - super(contextViewService, contextKeyService, configurationService, contextMenuService, instantiationService, hoverService, new FindReplaceState(), _notebookEditor); + const findSearchHistory = FindWidgetSearchHistory.getOrCreate(storageService); + const replaceHistory = ReplaceWidgetHistory.getOrCreate(storageService); + + super(contextViewService, contextKeyService, configurationService, contextMenuService, instantiationService, hoverService, new FindReplaceState(), _notebookEditor, findSearchHistory, replaceHistory); this._findModel = new FindModel(this._notebookEditor, this._state, this._configurationService); DOM.append(this._notebookEditor.getDomNode(), this.getDomNode()); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/cellOutputActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/cellOutputActions.ts index 43b0f24804e..099b3639901 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/cellOutputActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/cellOutputActions.ts @@ -26,7 +26,7 @@ registerAction2(class ShowAllOutputsAction extends Action2 { constructor() { super({ id: 'notebook.cellOuput.showEmptyOutputs', - title: localize('notebookActions.showAllOutput', "Show empty outputs"), + title: localize('notebookActions.showAllOutput', "Show Empty Outputs"), menu: { id: MenuId.NotebookOutputToolbar, when: ContextKeyExpr.and(NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_HAS_HIDDEN_OUTPUTS) @@ -176,8 +176,87 @@ registerAction2(class OpenCellOutputInEditorAction extends Action2 { if (outputViewModel?.model.outputId && notebookEditor.textModel?.uri) { // reserve notebook document reference since the active notebook editor might not be pinned so it can be replaced by the output editor const ref = await notebookModelService.resolve(notebookEditor.textModel.uri); - await openerService.open(CellUri.generateCellOutputUri(notebookEditor.textModel.uri, outputViewModel.model.outputId)); + await openerService.open(CellUri.generateCellOutputUriWithId(notebookEditor.textModel.uri, outputViewModel.model.outputId)); ref.dispose(); } } }); + +export const OPEN_OUTPUT_IN_OUTPUT_PREVIEW_COMMAND_ID = 'notebook.cellOutput.openInOutputPreview'; + +registerAction2(class OpenCellOutputInNotebookOutputEditorAction extends Action2 { + constructor() { + super({ + id: OPEN_OUTPUT_IN_OUTPUT_PREVIEW_COMMAND_ID, + title: localize('notebookActions.openOutputInNotebookOutputEditor', "Open in Output Preview"), + menu: { + id: MenuId.NotebookOutputToolbar, + when: ContextKeyExpr.and(NOTEBOOK_CELL_HAS_OUTPUTS, ContextKeyExpr.equals('config.notebook.output.openInPreviewEditor.enabled', true)) + }, + f1: false, + category: NOTEBOOK_ACTIONS_CATEGORY, + }); + } + + private getNotebookEditor(editorService: IEditorService, outputContext: INotebookOutputActionContext | { outputViewModel: ICellOutputViewModel } | undefined): INotebookEditor | undefined { + if (outputContext && 'notebookEditor' in outputContext) { + return outputContext.notebookEditor; + } + return getNotebookEditorFromEditorPane(editorService.activeEditorPane); + } + + async run(accessor: ServicesAccessor, outputContext: INotebookOutputActionContext | { outputViewModel: ICellOutputViewModel } | undefined): Promise { + const notebookEditor = this.getNotebookEditor(accessor.get(IEditorService), outputContext); + if (!notebookEditor) { + return; + } + + let outputViewModel: ICellOutputViewModel | undefined; + if (outputContext && 'outputId' in outputContext && typeof outputContext.outputId === 'string') { + outputViewModel = getOutputViewModelFromId(outputContext.outputId, notebookEditor); + } else if (outputContext && 'outputViewModel' in outputContext) { + outputViewModel = outputContext.outputViewModel; + } + + if (!outputViewModel) { + return; + } + + const genericCellViewModel = outputViewModel.cellViewModel; + if (!genericCellViewModel) { + return; + } + + // get cell index + const cellViewModel = notebookEditor.getCellByHandle(genericCellViewModel.handle); + if (!cellViewModel) { + return; + } + const cellIndex = notebookEditor.getCellIndex(cellViewModel); + if (cellIndex === undefined) { + return; + } + + // get output index + const outputIndex = genericCellViewModel.outputsViewModels.indexOf(outputViewModel); + if (outputIndex === -1) { + return; + } + + if (!notebookEditor.textModel) { + return; + } + + // craft rich output URI to pass data to the notebook output editor/viewer + const outputURI = CellUri.generateOutputEditorUri( + notebookEditor.textModel.uri, + cellViewModel.id, + cellIndex, + outputViewModel.model.outputId, + outputIndex, + ); + + const openerService = accessor.get(IOpenerService); + openerService.open(outputURI, { openToSide: true }); + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts index c2f474c1039..6993def3c8a 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts @@ -19,14 +19,13 @@ import { ServicesAccessor } from '../../../../../../platform/instantiation/commo import { IQuickInputService, IQuickPickItem } from '../../../../../../platform/quickinput/common/quickInput.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { IChatWidget, IChatWidgetService } from '../../../../chat/browser/chat.js'; +import { IChatWidget, IChatWidgetService, showChatView } from '../../../../chat/browser/chat.js'; import { ChatInputPart } from '../../../../chat/browser/chatInputPart.js'; import { ChatDynamicVariableModel } from '../../../../chat/browser/contrib/chatDynamicVariables.js'; import { computeCompletionRanges } from '../../../../chat/browser/contrib/chatInputCompletions.js'; import { IChatAgentService } from '../../../../chat/common/chatAgents.js'; import { ChatAgentLocation } from '../../../../chat/common/constants.js'; import { ChatContextKeys } from '../../../../chat/common/chatContextKeys.js'; -import { IChatRequestPasteVariableEntry } from '../../../../chat/common/chatModel.js'; import { chatVariableLeader } from '../../../../chat/common/chatParserTypes.js'; import { NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT, NOTEBOOK_CELL_OUTPUT_MIMETYPE } from '../../../common/notebookContextKeys.js'; import { INotebookKernelService } from '../../../common/notebookKernelService.js'; @@ -36,6 +35,8 @@ import { getOutputViewModelFromId } from '../cellOutputActions.js'; import { INotebookOutputActionContext, NOTEBOOK_ACTIONS_CATEGORY } from '../coreActions.js'; import './cellChatActions.js'; import { CTX_NOTEBOOK_CHAT_HAS_AGENT } from './notebookChatContext.js'; +import { IViewsService } from '../../../../../services/views/common/viewsService.js'; +import { createNotebookOutputVariableEntry, NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT_CONST } from '../../contrib/chat/notebookChatUtils.js'; const NotebookKernelVariableKey = 'kernelVariable'; @@ -103,7 +104,7 @@ class NotebookChatContribution extends Disposable implements IWorkbenchContribut })); // output context - NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT.bindTo(contextKeyService).set(['image/png']); + NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT.bindTo(contextKeyService).set(NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT_CONST); } private async addKernelVariableCompletion(widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) { @@ -233,6 +234,7 @@ export class SelectAndInsertKernelVariableAction extends Action2 { name: variableName, value: variableName, icon: codiconsLibrary.variable, + kind: 'generic' }); } } @@ -247,7 +249,8 @@ registerAction2(class CopyCellOutputAction extends Action2 { menu: { id: MenuId.NotebookOutputToolbar, when: ContextKeyExpr.and(NOTEBOOK_CELL_HAS_OUTPUTS, ContextKeyExpr.in(NOTEBOOK_CELL_OUTPUT_MIMETYPE.key, NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT.key)), - order: 10 + order: 10, + group: 'notebook_chat_actions' }, category: NOTEBOOK_ACTIONS_CATEGORY, icon: icons.copyIcon, @@ -264,6 +267,7 @@ registerAction2(class CopyCellOutputAction extends Action2 { async run(accessor: ServicesAccessor, outputContext: INotebookOutputActionContext | { outputViewModel: ICellOutputViewModel } | undefined): Promise { const notebookEditor = this.getNoteboookEditor(accessor.get(IEditorService), outputContext); + const viewService = accessor.get(IViewsService); if (!notebookEditor) { return; @@ -298,42 +302,24 @@ registerAction2(class CopyCellOutputAction extends Action2 { const mimeType = outputViewModel.pickedMimeType?.mimeType; - if (mimeType === 'image/png') { - const chatWidgetService = accessor.get(IChatWidgetService); + const chatWidgetService = accessor.get(IChatWidgetService); + let widget = chatWidgetService.lastFocusedWidget; + if (!widget) { + const widgets = chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel); + if (widgets.length === 0) { + return; + } + widget = widgets[0]; + } + if (mimeType && NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT_CONST.includes(mimeType)) { - const widget = chatWidgetService.lastFocusedWidget; - if (!widget) { + const entry = createNotebookOutputVariableEntry(outputViewModel, mimeType, notebookEditor); + if (!entry) { return; } - const imageOutput = outputViewModel.model.outputs.find(output => output.mime === mimeType); - if (!imageOutput) { - return; - } - - const attachedVariables = widget.attachmentModel.attachments; - const displayName = localize('cellOutputDisplayname', 'Notebook Cell Output Image'); - let tempDisplayName = displayName; - - for (let appendValue = 2; attachedVariables.some(attachment => attachment.name === tempDisplayName); appendValue++) { - tempDisplayName = `${displayName} ${appendValue}`; - } - - const imageData = imageOutput.data; - const variableEntry: IChatRequestPasteVariableEntry = { - kind: 'paste', - code: '', - language: '', - pastedLines: '', - fileName: 'notebook-cell-output-image-' + outputViewModel.model.outputId, - copiedFrom: undefined, - id: 'notebook-cell-output-image-' + outputViewModel.model.outputId, - name: tempDisplayName, - isImage: true, - value: imageData.buffer, - }; - - widget.attachmentModel.addContext(variableEntry); + widget.attachmentModel.addContext(entry); + (await showChatView(viewService))?.focusInput(); } } diff --git a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookDeletedCellDecorator.ts b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookDeletedCellDecorator.ts index b75579cab13..9666f98016a 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookDeletedCellDecorator.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookDeletedCellDecorator.ts @@ -13,13 +13,14 @@ import { NotebookCellTextModel } from '../../../common/model/notebookCellTextMod import { NotebookTextModel } from '../../../common/model/notebookTextModel.js'; import { DefaultLineHeight } from '../diffElementViewModel.js'; import { CellDiffInfo } from '../notebookDiffViewModel.js'; -import { INotebookEditor } from '../../notebookBrowser.js'; +import { INotebookEditor, NotebookOverviewRulerLane } from '../../notebookBrowser.js'; import * as DOM from '../../../../../../base/browser/dom.js'; import { MenuWorkbenchToolBar, HiddenItemStrategy } from '../../../../../../platform/actions/browser/toolbar.js'; import { MenuId } from '../../../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { overviewRulerDeletedForeground } from '../../../../scm/common/quickDiff.js'; const ttPolicy = createTrustedTypesPolicy('notebookRenderer', { createHTML: value => value }); @@ -143,6 +144,16 @@ export class NotebookDeletedCellDecorator extends Disposable implements INoteboo const id = accessor.addZone(notebookViewZone); accessor.layoutZone(id); this.createdViewZones.set(index, id); + + const deletedCellOverviewRulereDecorationIds = this._notebookEditor.deltaCellDecorations([], [{ + viewZoneId: id, + options: { + overviewRuler: { + color: overviewRulerDeletedForeground, + position: NotebookOverviewRulerLane.Center, + } + } + }]); this.zoneRemover.add(toDisposable(() => { if (this.createdViewZones.get(index) === id) { this.createdViewZones.delete(index); @@ -152,6 +163,8 @@ export class NotebookDeletedCellDecorator extends Disposable implements INoteboo accessor.removeZone(id); dispose(widgets); }); + + this._notebookEditor.deltaCellDecorations(deletedCellOverviewRulereDecorationIds, []); } })); }); @@ -207,7 +220,7 @@ export class NotebookDeletedCellWidget extends Disposable { if (this._toolbarOptions) { const toolbar = document.createElement('div'); - toolbar.className = this._toolbarOptions?.className; + toolbar.className = this._toolbarOptions.className; rootContainer.appendChild(toolbar); const scopedInstaService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this._notebookEditor.scopedContextKeyService]))); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookInsertedCellDecorator.ts b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookInsertedCellDecorator.ts index 996497312f3..7d3121d3465 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookInsertedCellDecorator.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookInsertedCellDecorator.ts @@ -5,7 +5,8 @@ import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { CellDiffInfo } from '../notebookDiffViewModel.js'; -import { INotebookEditor } from '../../notebookBrowser.js'; +import { INotebookEditor, NotebookOverviewRulerLane } from '../../notebookBrowser.js'; +import { overviewRulerAddedForeground } from '../../../../scm/common/quickDiff.js'; export class NotebookInsertedCellDecorator extends Disposable { private readonly decorators = this._register(new DisposableStore()); @@ -23,7 +24,14 @@ export class NotebookInsertedCellDecorator extends Disposable { const cells = diffInfo.filter(diff => diff.type === 'insert').map((diff) => model.cells[diff.modifiedCellIndex]); const ids = this.notebookEditor.deltaCellDecorations([], cells.map(cell => ({ handle: cell.handle, - options: { className: 'nb-insertHighlight', outputClassName: 'nb-insertHighlight' } + options: { + className: 'nb-insertHighlight', outputClassName: 'nb-insertHighlight', overviewRuler: { + color: overviewRulerAddedForeground, + modelRanges: [], + includeOutput: true, + position: NotebookOverviewRulerLane.Full + } + } }))); this.clear(); this.decorators.add(toDisposable(() => { diff --git a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookModifiedCellDecorator.ts b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookModifiedCellDecorator.ts index f2993cbb42e..7475ec97b58 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookModifiedCellDecorator.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookModifiedCellDecorator.ts @@ -5,9 +5,9 @@ import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { CellDiffInfo } from '../notebookDiffViewModel.js'; -import { INotebookEditor } from '../../notebookBrowser.js'; -import { CellKind } from '../../../common/notebookCommon.js'; +import { INotebookEditor, NotebookOverviewRulerLane } from '../../notebookBrowser.js'; import { NotebookCellTextModel } from '../../../common/model/notebookCellTextModel.js'; +import { overviewRulerModifiedForeground } from '../../../../scm/common/quickDiff.js'; export class NotebookModifiedCellDecorator extends Disposable { private readonly decorators = this._register(new DisposableStore()); @@ -23,19 +23,24 @@ export class NotebookModifiedCellDecorator extends Disposable { return; } - const modifiedMarkdownCells: NotebookCellTextModel[] = []; + const modifiedCells: NotebookCellTextModel[] = []; for (const diff of diffInfo) { if (diff.type === 'modified') { const cell = model.cells[diff.modifiedCellIndex]; - if (cell.cellKind === CellKind.Markup) { - modifiedMarkdownCells.push(cell); - } + modifiedCells.push(cell); } } - const ids = this.notebookEditor.deltaCellDecorations([], modifiedMarkdownCells.map(cell => ({ + const ids = this.notebookEditor.deltaCellDecorations([], modifiedCells.map(cell => ({ handle: cell.handle, - options: { outputClassName: 'nb-insertHighlight' } + options: { + overviewRuler: { + color: overviewRulerModifiedForeground, + modelRanges: [], + includeOutput: true, + position: NotebookOverviewRulerLane.Full + } + } }))); this.clear(); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts index 639e2fcd80d..45f4aa68cfe 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts @@ -929,9 +929,9 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD deltaCellOutputContainerClassNames(diffSide: DiffSide, cellId: string, added: string[], removed: string[]) { if (diffSide === DiffSide.Original) { - this._originalWebview?.deltaCellContainerClassNames(cellId, added, removed); + this._originalWebview?.deltaCellOutputContainerClassNames(cellId, added, removed); } else { - this._modifiedWebview?.deltaCellContainerClassNames(cellId, added, removed); + this._modifiedWebview?.deltaCellOutputContainerClassNames(cellId, added, removed); } } diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffOverviewRuler.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffOverviewRuler.ts index 912e7709369..f24aabe73b4 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffOverviewRuler.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffOverviewRuler.ts @@ -57,7 +57,7 @@ export class NotebookDiffOverviewRuler extends Themable { })); this._register(this.themeService.onDidColorThemeChange(e => { - const colorChanged = this.applyColors(e.theme); + const colorChanged = this.applyColors(e); if (colorChanged) { this._scheduleRender(); } diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts index c6d7549eb6e..1f6a760cdff 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts @@ -12,7 +12,7 @@ import { IEditorOpenContext } from '../../../../common/editor.js'; import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { INotebookEditorWorkerService } from '../../common/services/notebookWorkerService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IEditorOptions as ICodeEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; @@ -46,7 +46,7 @@ export class NotebookMultiTextDiffEditor extends EditorPane { static readonly ID: string = NOTEBOOK_MULTI_DIFF_EDITOR_ID; private _fontInfo: FontInfo | undefined; protected _scopeContextKeyService!: IContextKeyService; - private readonly modelSpecificResources = this._register(new DisposableStore()); + private readonly modelSpecificResources: DisposableStore; private _model?: INotebookDiffEditorModel; private viewModel?: NotebookDiffViewModel; private widgetViewModel?: MultiDiffEditorViewModel; @@ -57,9 +57,9 @@ export class NotebookMultiTextDiffEditor extends EditorPane { get notebookOptions() { return this._notebookOptions; } - private readonly ctxAllCollapsed = this._parentContextKeyService.createKey(NOTEBOOK_DIFF_CELLS_COLLAPSED.key, false); - private readonly ctxHasUnchangedCells = this._parentContextKeyService.createKey(NOTEBOOK_DIFF_HAS_UNCHANGED_CELLS.key, false); - private readonly ctxHiddenUnchangedCells = this._parentContextKeyService.createKey(NOTEBOOK_DIFF_UNCHANGED_CELLS_HIDDEN.key, true); + private readonly ctxAllCollapsed: IContextKey; + private readonly ctxHasUnchangedCells: IContextKey; + private readonly ctxHiddenUnchangedCells: IContextKey; constructor( group: IEditorGroup, @@ -73,6 +73,10 @@ export class NotebookMultiTextDiffEditor extends EditorPane { @INotebookService private readonly notebookService: INotebookService, ) { super(NotebookMultiTextDiffEditor.ID, group, telemetryService, themeService, storageService); + this.modelSpecificResources = this._register(new DisposableStore()); + this.ctxAllCollapsed = this._parentContextKeyService.createKey(NOTEBOOK_DIFF_CELLS_COLLAPSED.key, false); + this.ctxHasUnchangedCells = this._parentContextKeyService.createKey(NOTEBOOK_DIFF_HAS_UNCHANGED_CELLS.key, false); + this.ctxHiddenUnchangedCells = this._parentContextKeyService.createKey(NOTEBOOK_DIFF_UNCHANGED_CELLS_HIDDEN.key, true); this._notebookOptions = instantiationService.createInstance(NotebookOptions, this.window, false, undefined); this._register(this._notebookOptions); } diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebook.css b/src/vs/workbench/contrib/notebook/browser/media/notebook.css index 6c2f0f8c545..38042ed1fe9 100644 --- a/src/vs/workbench/contrib/notebook/browser/media/notebook.css +++ b/src/vs/workbench/contrib/notebook/browser/media/notebook.css @@ -334,10 +334,6 @@ flex-direction: row-reverse; } -.monaco-workbench .notebookOverlay .monaco-list:focus-within .monaco-list-row .codicon:not(.suggest-icon) { - color: var(--vscode-icon-foreground); -} - .monaco-workbench .notebookOverlay > .cell-list-container .notebook-overview-ruler-container { position: absolute; top: 0; diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index 10a50d99cdf..c01afe272ab 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -132,6 +132,8 @@ import { NotebookMultiDiffEditorInput } from './diff/notebookMultiDiffEditorInpu import { getFormattedMetadataJSON } from '../common/model/notebookCellTextModel.js'; import { INotebookOutlineEntryFactory, NotebookOutlineEntryFactory } from './viewModel/notebookOutlineEntryFactory.js'; import { getFormattedNotebookMetadataJSON } from '../common/model/notebookMetadataTextModel.js'; +import { NotebookOutputEditor } from './outputEditor/notebookOutputEditor.js'; +import { NotebookOutputEditorInput } from './outputEditor/notebookOutputEditorInput.js'; /*--------------------------------------------------------------------------------------------- */ @@ -157,6 +159,17 @@ Registry.as(EditorExtensions.EditorPane).registerEditorPane ] ); +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + NotebookOutputEditor, + NotebookOutputEditor.ID, + 'Notebook Output Editor' + ), + [ + new SyncDescriptor(NotebookOutputEditorInput) + ] +); + Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( NotebookMultiTextDiffEditor, @@ -239,6 +252,32 @@ class NotebookEditorSerializer implements IEditorSerializer { } } +export type SerializedNotebookOutputEditorData = { notebookUri: URI; cellIndex: number; outputIndex: number }; +class NotebookOutputEditorSerializer implements IEditorSerializer { + canSerialize(input: EditorInput): boolean { + return input.typeId === NotebookOutputEditorInput.ID; + } + serialize(input: EditorInput): string | undefined { + assertType(input instanceof NotebookOutputEditorInput); + + const data = input.getSerializedData(); // in case of cell movement etc get latest indices + if (!data) { + return undefined; + } + + return JSON.stringify(data); + } + deserialize(instantiationService: IInstantiationService, raw: string): EditorInput | undefined { + const data = parse(raw); + if (!data) { + return undefined; + } + + const input = instantiationService.createInstance(NotebookOutputEditorInput, data.notebookUri, data.cellIndex, undefined, data.outputIndex); + return input; + } +} + Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer( NotebookEditorInput.ID, NotebookEditorSerializer @@ -249,6 +288,11 @@ Registry.as(EditorExtensions.EditorFactory).registerEdit NotebookDiffEditorSerializer ); +Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer( + NotebookOutputEditorInput.ID, + NotebookOutputEditorSerializer +); + export class NotebookContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.notebook'; @@ -1046,6 +1090,12 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['notebookLayout'] }, + [NotebookSetting.openOutputInPreviewEditor]: { + description: nls.localize('notebook.output.openInPreviewEditor.description', "Controls whether or not the action to open a cell output in a preview editor is enabled. This action can be used via the cell output menu."), + type: 'boolean', + default: false, + tags: ['preview'] + }, [NotebookSetting.showFoldingControls]: { description: nls.localize('notebook.showFoldingControls.description', "Controls when the Markdown header folding arrow is shown."), type: 'string', diff --git a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts index b37aaca062a..cf385680dfc 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts @@ -332,11 +332,33 @@ export interface INotebookCellDecorationOptions { }; } -export interface INotebookDeltaDecoration { +export interface INotebookViewZoneDecorationOptions { + overviewRuler?: { + color: string; + position: NotebookOverviewRulerLane; + }; +} + +export interface INotebookDeltaCellDecoration { readonly handle: number; readonly options: INotebookCellDecorationOptions; } +export interface INotebookDeltaViewZoneDecoration { + readonly viewZoneId: string; + readonly options: INotebookViewZoneDecorationOptions; +} + +export function isNotebookCellDecoration(obj: unknown): obj is INotebookDeltaCellDecoration { + return !!obj && typeof (obj as INotebookDeltaCellDecoration).handle === 'number'; +} + +export function isNotebookViewZoneDecoration(obj: unknown): obj is INotebookDeltaViewZoneDecoration { + return !!obj && typeof (obj as INotebookDeltaViewZoneDecoration).viewZoneId === 'string'; +} + +export type INotebookDeltaDecoration = INotebookDeltaCellDecoration | INotebookDeltaViewZoneDecoration; + export interface INotebookDeltaCellStatusBarItems { readonly handle: number; readonly items: readonly INotebookCellStatusBarItem[]; @@ -442,6 +464,17 @@ export interface INotebookViewZoneChangeAccessor { layoutZone(id: string): void; } +export interface INotebookCellOverlay { + cell: ICellViewModel; + domNode: HTMLElement; +} + +export interface INotebookCellOverlayChangeAccessor { + addOverlay(overlay: INotebookCellOverlay): string; + removeOverlay(id: string): void; + layoutOverlay(id: string): void; +} + export type NotebookViewCellsSplice = [ number /* start */, number /* delete count */, @@ -464,6 +497,7 @@ export interface INotebookViewModel { getNearestVisibleCellIndexUpwards(index: number): number; getTrackedRange(id: string): ICellRange | null; setTrackedRange(id: string | null, newRange: ICellRange | null, newStickiness: TrackedRangeStickiness): string | null; + getOverviewRulerDecorations(): INotebookDeltaViewZoneDecoration[]; getSelections(): ICellRange[]; getCellIndex(cell: ICellViewModel): number; getMostRecentlyExecutedCell(): ICellViewModel | undefined; @@ -737,6 +771,10 @@ export interface INotebookEditor { changeViewZones(callback: (accessor: INotebookViewZoneChangeAccessor) => void): void; + changeCellOverlays(callback: (accessor: INotebookCellOverlayChangeAccessor) => void): void; + + getViewZoneLayoutInfo(id: string): { top: number; height: number } | null; + /** * Get a contribution of this editor. * @id Unique identifier of the contribution. @@ -807,7 +845,7 @@ export interface INotebookEditorDelegate extends INotebookEditor { * Hide the inset in the webview layer without removing it */ hideInset(output: IDisplayOutputViewModel): void; - deltaCellContainerClassNames(cellId: string, added: string[], removed: string[]): void; + deltaCellContainerClassNames(cellId: string, added: string[], removed: string[], cellKind: CellKind): void; } export interface IActiveNotebookEditorDelegate extends INotebookEditorDelegate { diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 9e0b6f8cf18..c6d652b63a0 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -55,7 +55,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { contrastBorder, errorForeground, focusBorder, foreground, listInactiveSelectionBackground, registerColor, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground, transparent } from '../../../../platform/theme/common/colorRegistry.js'; import { EDITOR_PANE_BACKGROUND, PANEL_BORDER, SIDE_BAR_BACKGROUND } from '../../../common/theme.js'; import { debugIconStartForeground } from '../../debug/browser/debugColors.js'; -import { CellEditState, CellFindMatchWithIndex, CellFocusMode, CellLayoutContext, CellRevealRangeType, CellRevealType, IActiveNotebookEditorDelegate, IBaseCellEditorOptions, ICellOutputViewModel, ICellViewModel, ICommonCellInfo, IDisplayOutputLayoutUpdateRequest, IFocusNotebookCellOptions, IInsetRenderOutput, IModelDecorationsChangeAccessor, INotebookDeltaDecoration, INotebookEditor, INotebookEditorContribution, INotebookEditorContributionDescription, INotebookEditorCreationOptions, INotebookEditorDelegate, INotebookEditorMouseEvent, INotebookEditorOptions, INotebookEditorViewState, INotebookViewCellsUpdateEvent, INotebookViewZoneChangeAccessor, INotebookWebviewMessage, RenderOutputType, ScrollToRevealBehavior } from './notebookBrowser.js'; +import { CellEditState, CellFindMatchWithIndex, CellFocusMode, CellLayoutContext, CellRevealRangeType, CellRevealType, IActiveNotebookEditorDelegate, IBaseCellEditorOptions, ICellOutputViewModel, ICellViewModel, ICommonCellInfo, IDisplayOutputLayoutUpdateRequest, IFocusNotebookCellOptions, IInsetRenderOutput, IModelDecorationsChangeAccessor, INotebookCellOverlayChangeAccessor, INotebookDeltaDecoration, INotebookEditor, INotebookEditorContribution, INotebookEditorContributionDescription, INotebookEditorCreationOptions, INotebookEditorDelegate, INotebookEditorMouseEvent, INotebookEditorOptions, INotebookEditorViewState, INotebookViewCellsUpdateEvent, INotebookViewZoneChangeAccessor, INotebookWebviewMessage, RenderOutputType, ScrollToRevealBehavior } from './notebookBrowser.js'; import { NotebookEditorExtensionsRegistry } from './notebookEditorExtensions.js'; import { INotebookEditorService } from './services/notebookEditorService.js'; import { notebookDebug } from './notebookLogger.js'; @@ -1043,6 +1043,10 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._onDidScroll.fire(); this.clearActiveCellWidgets(); } + + if (e.scrollTop === e.oldScrollTop && e.scrollHeightChanged) { + this._onDidChangeLayout.fire(); + } })); this._focusTracker = this._register(DOM.trackFocus(this.getDomNode())); @@ -1617,21 +1621,21 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD store.add(cell.onCellDecorationsChanged(e => { e.added.forEach(options => { if (options.className) { - this.deltaCellContainerClassNames(cell.id, [options.className], []); + this.deltaCellContainerClassNames(cell.id, [options.className], [], cell.cellKind); } if (options.outputClassName) { - this.deltaCellContainerClassNames(cell.id, [options.outputClassName], []); + this.deltaCellContainerClassNames(cell.id, [options.outputClassName], [], cell.cellKind); } }); e.removed.forEach(options => { if (options.className) { - this.deltaCellContainerClassNames(cell.id, [], [options.className]); + this.deltaCellContainerClassNames(cell.id, [], [options.className], cell.cellKind); } if (options.outputClassName) { - this.deltaCellContainerClassNames(cell.id, [], [options.outputClassName]); + this.deltaCellContainerClassNames(cell.id, [], [options.outputClassName], cell.cellKind); } }); })); @@ -2285,8 +2289,12 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD return ret; } - deltaCellContainerClassNames(cellId: string, added: string[], removed: string[]) { - this._webview?.deltaCellContainerClassNames(cellId, added, removed); + deltaCellContainerClassNames(cellId: string, added: string[], removed: string[], cellkind: CellKind): void { + if (cellkind === CellKind.Markup) { + this._webview?.deltaMarkupPreviewClassNames(cellId, added, removed); + } else { + this._webview?.deltaCellOutputContainerClassNames(cellId, added, removed); + } } changeModelDecorations(callback: (changeAccessor: IModelDecorationsChangeAccessor) => T): T | null { @@ -2298,6 +2306,17 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD //#region View Zones changeViewZones(callback: (accessor: INotebookViewZoneChangeAccessor) => void): void { this._list.changeViewZones(callback); + this._onDidChangeLayout.fire(); + } + + getViewZoneLayoutInfo(id: string): { top: number; height: number } | null { + return this._list.getViewZoneLayoutInfo(id); + } + //#endregion + + //#region Overlay + changeCellOverlays(callback: (accessor: INotebookCellOverlayChangeAccessor) => void): void { + this._list.changeCellOverlays(callback); } //#endregion diff --git a/src/vs/workbench/contrib/notebook/browser/outputEditor/notebookOutputEditor.ts b/src/vs/workbench/contrib/notebook/browser/outputEditor/notebookOutputEditor.ts new file mode 100644 index 00000000000..36e0b518618 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/outputEditor/notebookOutputEditor.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as nls from '../../../../../nls.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { EditorPane } from '../../../../browser/parts/editor/editorPane.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { IEditorOpenContext } from '../../../../common/editor.js'; +import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { IEditorResolverService, RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; +import { CellUri, NOTEBOOK_OUTPUT_EDITOR_ID } from '../../common/notebookCommon.js'; +import { INotebookService } from '../../common/notebookService.js'; +import { CellEditState, IBaseCellEditorOptions, ICellOutputViewModel, ICommonCellInfo, IGenericCellViewModel, IInsetRenderOutput, INotebookEditorCreationOptions, RenderOutputType } from '../notebookBrowser.js'; +import { getDefaultNotebookCreationOptions } from '../notebookEditorWidget.js'; +import { NotebookOptions } from '../notebookOptions.js'; +import { BackLayerWebView, INotebookDelegateForWebview } from '../view/renderers/backLayerWebView.js'; +import { NotebookOutputEditorInput } from './notebookOutputEditorInput.js'; +import { BareFontInfo, FontInfo } from '../../../../../editor/common/config/fontInfo.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IEditorOptions as ICodeEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; +import { FontMeasurements } from '../../../../../editor/browser/config/fontMeasurements.js'; +import { PixelRatio } from '../../../../../base/browser/pixelRatio.js'; +import { NotebookViewModel } from '../viewModel/notebookViewModelImpl.js'; +import { NotebookEventDispatcher } from '../viewModel/eventDispatcher.js'; +import { ViewContext } from '../viewModel/viewContext.js'; + +export class NoopCellEditorOptions extends Disposable implements IBaseCellEditorOptions { + private static fixedEditorOptions: ICodeEditorOptions = { + scrollBeyondLastLine: false, + scrollbar: { + verticalScrollbarSize: 14, + horizontal: 'auto', + useShadows: true, + verticalHasArrows: false, + horizontalHasArrows: false, + alwaysConsumeMouseWheel: false + }, + renderLineHighlightOnlyWhenFocus: true, + overviewRulerLanes: 0, + lineDecorationsWidth: 0, + folding: true, + fixedOverflowWidgets: true, + minimap: { enabled: false }, + renderValidationDecorations: 'on', + lineNumbersMinChars: 3 + }; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + private _value: ICodeEditorOptions; + + get value(): Readonly { + return this._value; + } + + constructor() { + super(); + this._value = Object.freeze({ + ...NoopCellEditorOptions.fixedEditorOptions, + padding: { top: 12, bottom: 12 }, + readOnly: true + }); + } +} + +export class NotebookOutputEditor extends EditorPane implements INotebookDelegateForWebview { + + static readonly ID: string = NOTEBOOK_OUTPUT_EDITOR_ID; + + creationOptions: INotebookEditorCreationOptions = getDefaultNotebookCreationOptions(); + + private _rootElement!: HTMLElement; + private _outputWebview: BackLayerWebView | null = null; + + private _fontInfo: FontInfo | undefined; + + private _notebookOptions: NotebookOptions; + private _notebookViewModel: NotebookViewModel | undefined; + + private _isDisposed: boolean = false; + get isDisposed() { + return this._isDisposed; + } + + constructor( + group: IEditorGroup, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @ITelemetryService telemetryService: ITelemetryService, + @IStorageService storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @INotebookService private readonly notebookService: INotebookService, + + ) { + super(NotebookOutputEditor.ID, group, telemetryService, themeService, storageService); + this._notebookOptions = this.instantiationService.createInstance(NotebookOptions, this.window, false, undefined); + this._register(this._notebookOptions); + } + + protected createEditor(parent: HTMLElement): void { + this._rootElement = DOM.append(parent, DOM.$('.notebook-output-editor')); + } + + private get fontInfo() { + if (!this._fontInfo) { + this._fontInfo = this.createFontInfo(); + } + + return this._fontInfo; + } + + private createFontInfo() { + const editorOptions = this.configurationService.getValue('editor'); + return FontMeasurements.readFontInfo(this.window, BareFontInfo.createFromRawSettings(editorOptions, PixelRatio.getInstance(this.window).value)); + } + + private async _createOriginalWebview(id: string, viewType: string, resource: URI): Promise { + this._outputWebview?.dispose(); + + this._outputWebview = this.instantiationService.createInstance(BackLayerWebView, this, id, viewType, resource, { + ...this._notebookOptions.computeDiffWebviewOptions(), + fontFamily: this._generateFontFamily() + }, undefined) as BackLayerWebView; + + // attach the webview container to the DOM tree first + DOM.append(this._rootElement, this._outputWebview.element); + + this._outputWebview.createWebview(this.window); + this._outputWebview.element.style.width = `calc(100% - 16px)`; + this._outputWebview.element.style.left = `16px`; + + } + + private _generateFontFamily(): string { + return this.fontInfo.fontFamily ?? `"SF Mono", Monaco, Menlo, Consolas, "Ubuntu Mono", "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace`; + } + + override getTitle(): string { + if (this.input) { + return this.input.getName(); + } + + return nls.localize('notebookOutputEditor', "Notebook Output Editor"); + } + + override async setInput(input: NotebookOutputEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + await super.setInput(input, options, context, token); + + const model = await input.resolve(); + if (!model) { + throw new Error('Invalid notebook output editor input'); + } + + const resolvedNotebookEditorModel = model.resolvedNotebookEditorModel; + + await this._createOriginalWebview(generateUuid(), resolvedNotebookEditorModel.viewType, URI.from({ scheme: Schemas.vscodeNotebookCellOutput, path: '', query: 'openIn=notebookOutputEditor' })); + + const notebookTextModel = resolvedNotebookEditorModel.notebook; + const eventDispatcher = this._register(new NotebookEventDispatcher()); + const editorOptions = this._register(new NoopCellEditorOptions()); + const viewContext = new ViewContext( + this._notebookOptions, + eventDispatcher, + _language => editorOptions + ); + + this._notebookViewModel = this.instantiationService.createInstance(NotebookViewModel, notebookTextModel.viewType, notebookTextModel, viewContext, null, { isReadOnly: true }); + + const cellViewModel = this._notebookViewModel.getCellByHandle(model.cell.handle); + if (!cellViewModel) { + throw new Error('Invalid NotebookOutputEditorInput, no matching cell view model'); + } + + const cellOutputViewModel = cellViewModel.outputsViewModels.find(outputViewModel => outputViewModel.model.outputId === model.outputId); + if (!cellOutputViewModel) { + throw new Error('Invalid NotebookOutputEditorInput, no matching cell output view model'); + } + + let result: IInsetRenderOutput | undefined = undefined; + + const [mimeTypes, pick] = cellOutputViewModel.resolveMimeTypes(notebookTextModel, undefined); + const pickedMimeTypeRenderer = cellOutputViewModel.pickedMimeType || mimeTypes[pick]; + if (mimeTypes.length !== 0) { + const renderer = this.notebookService.getRendererInfo(pickedMimeTypeRenderer.rendererId); + result = renderer + ? { type: RenderOutputType.Extension, renderer, source: cellOutputViewModel, mimeType: pickedMimeTypeRenderer.mimeType } + : this._renderMissingRenderer(cellOutputViewModel, pickedMimeTypeRenderer.mimeType); + + } + + if (!result) { + throw new Error('No InsetRenderInfo for output'); + } + + const cellInfo: ICommonCellInfo = { + cellId: cellViewModel.id, + cellHandle: model.cell.handle, + cellUri: model.cell.uri, + }; + + this._outputWebview?.createOutput(cellInfo, result, 0, 0); + } + + private _renderMissingRenderer(viewModel: ICellOutputViewModel, preferredMimeType: string | undefined): IInsetRenderOutput { + if (!viewModel.model.outputs.length) { + return this._renderMessage(viewModel, nls.localize('empty', "Cell has no output")); + } + + if (!preferredMimeType) { + const mimeTypes = viewModel.model.outputs.map(op => op.mime); + const mimeTypesMessage = mimeTypes.join(', '); + return this._renderMessage(viewModel, nls.localize('noRenderer.2', "No renderer could be found for output. It has the following mimetypes: {0}", mimeTypesMessage)); + } + + return this._renderSearchForMimetype(viewModel, preferredMimeType); + } + + private _renderMessage(viewModel: ICellOutputViewModel, message: string): IInsetRenderOutput { + const el = DOM.$('p', undefined, message); + return { type: RenderOutputType.Html, source: viewModel, htmlContent: el.outerHTML }; + } + + private _renderSearchForMimetype(viewModel: ICellOutputViewModel, mimeType: string): IInsetRenderOutput { + const query = `@tag:notebookRenderer ${mimeType}`; + + const p = DOM.$('p', undefined, `No renderer could be found for mimetype "${mimeType}", but one might be available on the Marketplace.`); + const a = DOM.$('a', { href: `command:workbench.extensions.search?%22${query}%22`, class: 'monaco-button monaco-text-button', tabindex: 0, role: 'button', style: 'padding: 8px; text-decoration: none; color: rgb(255, 255, 255); background-color: rgb(14, 99, 156); max-width: 200px;' }, `Search Marketplace`); + + return { + type: RenderOutputType.Html, + source: viewModel, + htmlContent: p.outerHTML + a.outerHTML, + }; + } + + scheduleOutputHeightAck(cellInfo: ICommonCellInfo, outputId: string, height: number): void { + DOM.scheduleAtNextAnimationFrame(this.window, () => { + this._outputWebview?.ackHeight([{ cellId: cellInfo.cellId, outputId, height }]); + }, 10); + } + + async focusNotebookCell(cell: IGenericCellViewModel, focus: 'output' | 'editor' | 'container'): Promise { + + } + + async focusNextNotebookCell(cell: IGenericCellViewModel, focus: 'output' | 'editor' | 'container'): Promise { + + } + + toggleNotebookCellSelection(cell: IGenericCellViewModel) { + throw new Error('Not implemented.'); + } + + getCellById(cellId: string): IGenericCellViewModel | undefined { + throw new Error('Not implemented'); + } + + getCellByInfo(cellInfo: ICommonCellInfo): IGenericCellViewModel { + return this._notebookViewModel?.getCellByHandle(cellInfo.cellHandle) as IGenericCellViewModel; + } + + layout(dimension: DOM.Dimension, position: DOM.IDomPosition): void { + + } + + setScrollTop(scrollTop: number): void { + + } + + triggerScroll(event: any): void { + + } + + getOutputRenderer(): any { + + } + + updateOutputHeight(cellInfo: ICommonCellInfo, output: ICellOutputViewModel, height: number, isInit: boolean, source?: string): void { + + } + + updateMarkupCellHeight(cellId: string, height: number, isInit: boolean): void { + + } + + setMarkupCellEditState(cellId: string, editState: CellEditState): void { + + } + + didResizeOutput(cellId: string): void { + + } + + didStartDragMarkupCell(cellId: string, event: { dragOffsetY: number }): void { + + } + + didDragMarkupCell(cellId: string, event: { dragOffsetY: number }): void { + + } + + didDropMarkupCell(cellId: string, event: { dragOffsetY: number; ctrlKey: boolean; altKey: boolean }): void { + + } + + didEndDragMarkupCell(cellId: string): void { + + } + + updatePerformanceMetadata(cellId: string, executionId: string, duration: number, rendererId: string): void { + + } + + didFocusOutputInputChange(inputFocused: boolean): void { + + } + + override dispose() { + this._isDisposed = true; + super.dispose(); + } +} + +export class NotebookOutputEditorContribution implements IWorkbenchContribution { + + static readonly ID = 'workbench.contribution.notebookOutputEditorContribution'; + + constructor( + @IEditorResolverService editorResolverService: IEditorResolverService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService,) { + editorResolverService.registerEditor( + `${Schemas.vscodeNotebookCellOutput}:/**`, + { + id: 'notebookOutputEditor', + label: 'Notebook Output Editor', + priority: RegisteredEditorPriority.exclusive + }, + { + canSupportResource: (resource: URI) => { + if (resource.scheme === Schemas.vscodeNotebookCellOutput) { + const params = new URLSearchParams(resource.query); + return params.get('openIn') === 'notebookOutputEditor'; + } + return false; + } + }, + { + createEditorInput: async ({ resource, options }) => { + const outputUriData = CellUri.parseCellOutputUri(resource); + if (!outputUriData || !outputUriData.notebook || outputUriData.cellIndex === undefined || outputUriData.outputIndex === undefined || !outputUriData.outputId) { + throw new Error('Invalid output uri for notebook output editor'); + } + + const notebookUri = this.uriIdentityService.asCanonicalUri(outputUriData.notebook); + const cellIndex = outputUriData.cellIndex; + const outputId = outputUriData.outputId; + const outputIndex = outputUriData.outputIndex; + + const editorInput = this.instantiationService.createInstance(NotebookOutputEditorInput, notebookUri, cellIndex, outputId, outputIndex); + return { + editor: editorInput, + options: options + }; + } + } + ); + } +} + +registerWorkbenchContribution2(NotebookOutputEditorContribution.ID, NotebookOutputEditorContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/notebook/browser/outputEditor/notebookOutputEditorInput.ts b/src/vs/workbench/contrib/notebook/browser/outputEditor/notebookOutputEditorInput.ts new file mode 100644 index 00000000000..78a4f2e91a7 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/outputEditor/notebookOutputEditorInput.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from '../../../../../nls.js'; +import { IDisposable, IReference } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { EditorInputCapabilities } from '../../../../common/editor.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; +import { IResolvedNotebookEditorModel } from '../../common/notebookCommon.js'; +import { INotebookEditorModelResolverService } from '../../common/notebookEditorModelResolverService.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { NotebookCellTextModel } from '../../common/model/notebookCellTextModel.js'; + + +class ResolvedNotebookOutputEditorInputModel implements IDisposable { + constructor( + readonly resolvedNotebookEditorModel: IResolvedNotebookEditorModel, + readonly notebookUri: URI, + readonly cell: NotebookCellTextModel, + readonly outputId: string, + ) { } + + dispose(): void { + this.resolvedNotebookEditorModel.dispose(); + } +} + +// TODO @Yoyokrazy -- future feat. for viewing static outputs -- encode mime + data +// export class NotebookOutputViewerInput extends EditorInput { +// static readonly ID: string = 'workbench.input.notebookOutputViewerInput'; +// } + +export class NotebookOutputEditorInput extends EditorInput { + static readonly ID: string = 'workbench.input.notebookOutputEditorInput'; + + private _notebookRef: IReference | undefined; + private readonly _notebookUri: URI; + + readonly cellIndex: number; + + public cellUri: URI | undefined; + + readonly outputIndex: number; + private outputId: string | undefined; + + constructor( + notebookUri: URI, + cellIndex: number, + outputId: string | undefined, + outputIndex: number, + @INotebookEditorModelResolverService private readonly notebookEditorModelResolverService: INotebookEditorModelResolverService, + ) { + super(); + this._notebookUri = notebookUri; + + this.cellUri = undefined; + this.cellIndex = cellIndex; + + this.outputId = outputId; + this.outputIndex = outputIndex; + } + + override get typeId(): string { + return NotebookOutputEditorInput.ID; + } + + override async resolve(): Promise { + if (!this._notebookRef) { + this._notebookRef = await this.notebookEditorModelResolverService.resolve(this._notebookUri); + } + + const cell = this._notebookRef.object.notebook.cells[this.cellIndex]; + if (!cell) { + throw new Error('Cell not found'); + } + + this.cellUri = cell.uri; + + const resolvedOutputId = cell.outputs[this.outputIndex]?.outputId; + if (!resolvedOutputId) { + throw new Error('Output not found'); + } + + if (!this.outputId) { + this.outputId = resolvedOutputId; + } + + return new ResolvedNotebookOutputEditorInputModel( + this._notebookRef.object, + this._notebookUri, + cell, + resolvedOutputId, + ); + } + + public getSerializedData(): { notebookUri: URI; cellIndex: number; outputIndex: number } | undefined { + // need to translate from uris -> current indexes + // uris aren't deterministic across reloads, so indices are best option + + if (!this._notebookRef) { + return; + } + + const cellIndex = this._notebookRef.object.notebook.cells.findIndex(c => isEqual(c.uri, this.cellUri)); + const cell = this._notebookRef.object.notebook.cells[cellIndex]; + if (!cell) { + return; + } + + const outputIndex = cell.outputs.findIndex(o => o.outputId === this.outputId); + if (outputIndex === -1) { + return; + } + + return { + notebookUri: this._notebookUri, + cellIndex: cellIndex, + outputIndex: outputIndex, + }; + } + + override getName(): string { + return nls.localize('notebookOutputEditorInput', "Notebook Output Preview"); + } + + override get editorId(): string { + return 'notebookOutputEditor'; + } + + override get resource(): URI | undefined { + return; + } + + override get capabilities() { + return EditorInputCapabilities.Readonly; + } + + override dispose(): void { + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/services/notebookServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/services/notebookServiceImpl.ts index f2925b1b8c8..201991905d5 100644 --- a/src/vs/workbench/contrib/notebook/browser/services/notebookServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/services/notebookServiceImpl.ts @@ -48,6 +48,7 @@ import { NotebookMultiDiffEditorInput } from '../diff/notebookMultiDiffEditorInp import { SnapshotContext } from '../../../../services/workingCopy/common/fileWorkingCopy.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../base/common/errors.js'; +import { ICellRange } from '../../common/notebookRange.js'; export class NotebookProviderInfoStore extends Disposable { @@ -180,19 +181,38 @@ export class NotebookProviderInfoStore extends Disposable { }; const notebookEditorOptions = { canHandleDiff: () => !!this._configurationService.getValue(NotebookSetting.textDiffEditorPreview) && !this._accessibilityService.isScreenReaderOptimized(), - canSupportResource: (resource: URI) => resource.scheme === Schemas.untitled || resource.scheme === Schemas.vscodeNotebookCell || this._fileService.hasProvider(resource) + canSupportResource: (resource: URI) => { + if (resource.scheme === Schemas.vscodeNotebookCellOutput) { + const params = new URLSearchParams(resource.query); + return params.get('openIn') === 'notebook'; + } + return resource.scheme === Schemas.untitled || resource.scheme === Schemas.vscodeNotebookCell || this._fileService.hasProvider(resource); + } }; - const notebookEditorInputFactory: EditorInputFactoryFunction = ({ resource, options }) => { - const data = CellUri.parse(resource); + const notebookEditorInputFactory: EditorInputFactoryFunction = async ({ resource, options }) => { + let data; + if (resource.scheme === Schemas.vscodeNotebookCellOutput) { + const outputUriData = CellUri.parseCellOutputUri(resource); + if (!outputUriData || !outputUriData.notebook || outputUriData.cellHandle === undefined) { + throw new Error('Invalid cell output uri'); + } + + data = { + notebook: outputUriData.notebook, + handle: outputUriData.cellHandle + }; + + } else { + data = CellUri.parse(resource); + } + let notebookUri: URI; let cellOptions: IResourceEditorInput | undefined; - let preferredResource = resource; if (data) { // resource is a notebook cell notebookUri = this.uriIdentService.asCanonicalUri(data.notebook); - preferredResource = data.notebook; cellOptions = { resource, options }; } else { notebookUri = this.uriIdentService.asCanonicalUri(resource); @@ -202,8 +222,38 @@ export class NotebookProviderInfoStore extends Disposable { cellOptions = (options as INotebookEditorOptions | undefined)?.cellOptions; } - const notebookOptions: INotebookEditorOptions = { ...options, cellOptions, viewState: undefined }; - const editor = NotebookEditorInput.getOrCreate(this._instantiationService, notebookUri, preferredResource, notebookProviderInfo.id); + let notebookOptions: INotebookEditorOptions; + + if (resource.scheme === Schemas.vscodeNotebookCellOutput) { + if (data?.handle === undefined || !data?.notebook) { + throw new Error('Invalid cell handle'); + } + + const cellUri = CellUri.generate(data.notebook, data.handle); + + cellOptions = { resource: cellUri, options }; + + const cellIndex = await this._notebookEditorModelResolverService.resolve(notebookUri) + .then(model => model.object.notebook.cells.findIndex(cell => cell.handle === data?.handle)) + .then(index => index >= 0 ? index : 0); + + const cellIndexesToRanges: ICellRange[] = [{ start: cellIndex, end: cellIndex + 1 }]; + + notebookOptions = { + ...options, + cellOptions, + viewState: undefined, + cellSelections: cellIndexesToRanges + }; + } else { + notebookOptions = { + ...options, + cellOptions, + viewState: undefined, + }; + } + const preferredResourceParam = cellOptions?.resource; + const editor = NotebookEditorInput.getOrCreate(this._instantiationService, notebookUri, preferredResourceParam, notebookProviderInfo.id); return { editor, options: notebookOptions }; }; diff --git a/src/vs/workbench/contrib/notebook/browser/services/notebookWorkerServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/services/notebookWorkerServiceImpl.ts index d45bdc91ad1..8086431811b 100644 --- a/src/vs/workbench/contrib/notebook/browser/services/notebookWorkerServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/services/notebookWorkerServiceImpl.ts @@ -5,17 +5,17 @@ import { Disposable, DisposableStore, dispose, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IWorkerClient, Proxied } from '../../../../../base/common/worker/simpleWorker.js'; -import { createWebWorker } from '../../../../../base/browser/defaultWorkerFactory.js'; +import { IWebWorkerClient, Proxied } from '../../../../../base/common/worker/webWorker.js'; +import { createWebWorker } from '../../../../../base/browser/webWorkerFactory.js'; import { NotebookCellTextModel } from '../../common/model/notebookCellTextModel.js'; import { CellUri, IMainCellDto, INotebookDiffResult, NotebookCellsChangeType, NotebookRawContentEventDto } from '../../common/notebookCommon.js'; import { INotebookService } from '../../common/notebookService.js'; -import { NotebookEditorSimpleWorker } from '../../common/services/notebookSimpleWorker.js'; +import { NotebookWorker } from '../../common/services/notebookWebWorker.js'; import { INotebookEditorWorkerService } from '../../common/services/notebookWorkerService.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { TextModel } from '../../../../../editor/common/model/textModel.js'; -import { Schemas } from '../../../../../base/common/network.js'; +import { FileAccess, Schemas } from '../../../../../base/common/network.js'; import { isEqual } from '../../../../../base/common/resources.js'; export class NotebookEditorWorkerServiceImpl extends Disposable implements INotebookEditorWorkerService { @@ -76,7 +76,7 @@ class NotebookEditorModelManager extends Disposable { private _syncedModelsLastUsedTime: { [modelUrl: string]: number } = Object.create(null); constructor( - private readonly _proxy: Proxied, + private readonly _proxy: Proxied, private readonly _notebookService: INotebookService, private readonly _modelService: IModelService, ) { @@ -236,7 +236,7 @@ class NotebookEditorModelManager extends Disposable { } class NotebookWorkerClient extends Disposable { - private _worker: IWorkerClient | null; + private _worker: IWebWorkerClient | null; private _modelManager: NotebookEditorModelManager | null; @@ -257,29 +257,27 @@ class NotebookWorkerClient extends Disposable { return proxy.$canPromptRecommendation(modelUri.toString()); } - private _getOrCreateModelManager(proxy: Proxied): NotebookEditorModelManager { + private _getOrCreateModelManager(proxy: Proxied): NotebookEditorModelManager { if (!this._modelManager) { this._modelManager = this._register(new NotebookEditorModelManager(proxy, this._notebookService, this._modelService)); } return this._modelManager; } - protected _ensureSyncedResources(resources: URI[]): Proxied { + protected _ensureSyncedResources(resources: URI[]): Proxied { const proxy = this._getOrCreateWorker().proxy; this._getOrCreateModelManager(proxy).ensureSyncedResources(resources); return proxy; } - private _getOrCreateWorker(): IWorkerClient { + private _getOrCreateWorker(): IWebWorkerClient { if (!this._worker) { try { - this._worker = this._register(createWebWorker( - 'vs/workbench/contrib/notebook/common/services/notebookSimpleWorker', + this._worker = this._register(createWebWorker( + FileAccess.asBrowserUri('vs/workbench/contrib/notebook/common/services/notebookWebWorkerMain.js'), 'NotebookEditorWorker' )); } catch (err) { - // logOnceWebWorkerWarning(err); - // this._worker = new SynchronousWorkerClient(new EditorSimpleWorker(new EditorWorkerHost(this), null)); throw (err); } } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellDecorations.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellDecorations.ts index 9068b002c64..4ae7534705b 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellDecorations.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellDecorations.ts @@ -72,11 +72,11 @@ export class CellDecorations extends CellContentPart { this.currentCell.getCellDecorations().forEach(options => { if (options.className && this.currentCell) { this.rootContainer.classList.add(options.className); - this.notebookEditor.deltaCellContainerClassNames(this.currentCell.id, [options.className], []); + this.notebookEditor.deltaCellContainerClassNames(this.currentCell.id, [options.className], [], this.currentCell.cellKind); } if (options.outputClassName && this.currentCell) { - this.notebookEditor.deltaCellContainerClassNames(this.currentCell.id, [options.outputClassName], []); + this.notebookEditor.deltaCellContainerClassNames(this.currentCell.id, [options.outputClassName], [], this.currentCell.cellKind); } }); } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellOutput.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellOutput.ts index dfeb1406079..20cd1c5aee0 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellOutput.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellOutput.ts @@ -194,7 +194,7 @@ class CellOutputElement extends Disposable { const notebookTextModel = this.notebookEditor.textModel; const [mimeTypes, pick] = this.output.resolveMimeTypes(notebookTextModel, this.notebookEditor.activeKernel?.preloadProvides); - + const currentMimeType = mimeTypes[pick]; if (!mimeTypes.find(mimeType => mimeType.isTrusted) || mimeTypes.length === 0) { this.viewCell.updateOutputHeight(index, 0, 'CellOutputElement#noMimeType'); return undefined; @@ -208,12 +208,12 @@ class CellOutputElement extends Disposable { const innerContainer = this._generateInnerOutputContainer(previousSibling, selectedPresentation); if (index === 0 || this.output.visible.get()) { - this._attachToolbar(innerContainer, notebookTextModel, this.notebookEditor.activeKernel, index, mimeTypes); + this._attachToolbar(innerContainer, notebookTextModel, this.notebookEditor.activeKernel, index, currentMimeType, mimeTypes); } else { this._register(autorun((reader) => { const visible = reader.readObservable(this.output.visible); if (visible && !this.toolbarAttached) { - this._attachToolbar(innerContainer, notebookTextModel, this.notebookEditor.activeKernel, index, mimeTypes); + this._attachToolbar(innerContainer, notebookTextModel, this.notebookEditor.activeKernel, index, currentMimeType, mimeTypes); } else if (!visible) { this.toolbarDisposables.clear(); } @@ -292,7 +292,7 @@ class CellOutputElement extends Disposable { return true; } - private async _attachToolbar(outputItemDiv: HTMLElement, notebookTextModel: NotebookTextModel, kernel: INotebookKernel | undefined, index: number, mimeTypes: readonly IOrderedMimeType[]) { + private async _attachToolbar(outputItemDiv: HTMLElement, notebookTextModel: NotebookTextModel, kernel: INotebookKernel | undefined, index: number, currentMimeType: IOrderedMimeType, mimeTypes: readonly IOrderedMimeType[]) { const hasMultipleMimeTypes = mimeTypes.filter(mimeType => mimeType.isTrusted).length > 1; const isCopyEnabled = this.shouldEnableCopy(mimeTypes); if (index > 0 && !hasMultipleMimeTypes && !isCopyEnabled) { @@ -329,10 +329,8 @@ class CellOutputElement extends Disposable { const isFirstCellOutput = NOTEBOOK_CELL_IS_FIRST_OUTPUT.bindTo(menuContextKeyService); const cellOutputMimetype = NOTEBOOK_CELL_OUTPUT_MIMETYPE.bindTo(menuContextKeyService); isFirstCellOutput.set(index === 0); - if (mimeTypes[index]) { - cellOutputMimetype.set(mimeTypes[index].mimeType); - } - this.toolbarDisposables.add(autorun((reader) => { hasHiddenOutputs.set(reader.readObservable(this.cellOutputContainer.hasHiddenOutputs)); })); + cellOutputMimetype.set(currentMimeType.mimeType); + this.toolbarDisposables.add(autorun((r) => { hasHiddenOutputs.set(this.cellOutputContainer.hasHiddenOutputs.read(r)); })); const menu = this.toolbarDisposables.add(this.menuService.createMenu(MenuId.NotebookOutputToolbar, menuContextKeyService)); const updateMenuToolbar = () => { @@ -492,7 +490,7 @@ export class CellOutputContainer extends CellContentPart { hasHiddenOutputs = observableValue('hasHiddenOutputs', false); checkForHiddenOutputs() { - if (this._outputEntries.find(entry => { return entry.model.visible; })) { + if (this._outputEntries.find(entry => { return !entry.model.visible.get(); })) { this.hasHiddenOutputs.set(true, undefined); } else { this.hasHiddenOutputs.set(false, undefined); @@ -774,7 +772,7 @@ export class CellOutputContainer extends CellContentPart { actionHandler: { callback: (content) => { if (content === 'command:workbench.action.openLargeOutput') { - this.openerService.open(CellUri.generateCellOutputUri(this.notebookEditor.textModel!.uri)); + this.openerService.open(CellUri.generateCellOutputUriWithId(this.notebookEditor.textModel!.uri)); } return; diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbarStickyScroll.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbarStickyScroll.ts index 548fae02c0a..aee39b19d59 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbarStickyScroll.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbarStickyScroll.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { combinedDisposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { clamp } from '../../../../../../base/common/numbers.js'; import { ICellViewModel, INotebookEditor } from '../../notebookBrowser.js'; @@ -27,5 +27,11 @@ export function registerCellToolbarStickyScroll(notebookEditor: INotebookEditor, }; updateForScroll(); - return notebookEditor.onDidScroll(() => updateForScroll()); + const disposables: IDisposable[] = []; + disposables.push( + notebookEditor.onDidScroll(() => updateForScroll()), + notebookEditor.onDidChangeLayout(() => updateForScroll()) + ); + + return combinedDisposable(...disposables); } diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts index ff0678e8b25..508e9f18e06 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts @@ -19,7 +19,7 @@ import { PrefixSumComputer } from '../../../../../editor/common/model/prefixSumC import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IListService, IWorkbenchListOptions, WorkbenchList } from '../../../../../platform/list/browser/listService.js'; -import { CursorAtBoundary, ICellViewModel, CellEditState, ICellOutputViewModel, CellRevealType, CellRevealRangeType, CursorAtLineBoundary, INotebookViewZoneChangeAccessor } from '../notebookBrowser.js'; +import { CursorAtBoundary, ICellViewModel, CellEditState, ICellOutputViewModel, CellRevealType, CellRevealRangeType, CursorAtLineBoundary, INotebookViewZoneChangeAccessor, INotebookCellOverlayChangeAccessor } from '../notebookBrowser.js'; import { CellViewModel, NotebookViewModel } from '../viewModel/notebookViewModelImpl.js'; import { diff, NOTEBOOK_EDITOR_CURSOR_BOUNDARY, CellKind, SelectionStateType, NOTEBOOK_EDITOR_CURSOR_LINE_BOUNDARY } from '../../common/notebookCommon.js'; import { ICellRange, cellRangesToIndexes, reduceCellRanges, cellRangesEqual } from '../../common/notebookRange.js'; @@ -36,6 +36,7 @@ import { NotebookOptions } from '../notebookOptions.js'; import { INotebookExecutionStateService } from '../../common/notebookExecutionStateService.js'; import { NotebookCellAnchor } from './notebookCellAnchor.js'; import { NotebookViewZones } from '../viewParts/notebookViewZones.js'; +import { NotebookCellOverlays } from '../viewParts/notebookCellOverlays.js'; const enum CellRevealPosition { Top, @@ -79,6 +80,7 @@ function validateWebviewBoundary(element: HTMLElement) { export class NotebookCellList extends WorkbenchList implements IDisposable, IStyleController, INotebookCellList { protected override readonly view!: NotebookCellListView; private viewZones!: NotebookViewZones; + private cellOverlays!: NotebookCellOverlays; get onWillScroll(): Event { return this.view.onWillScroll; } get rowsContainer(): HTMLElement { @@ -289,6 +291,7 @@ export class NotebookCellList extends WorkbenchList implements ID protected override createListView(container: HTMLElement, virtualDelegate: IListVirtualDelegate, renderers: IListRenderer[], viewOptions: IListViewOptions): IListView { const listView = new NotebookCellListView(container, virtualDelegate, renderers, viewOptions); this.viewZones = new NotebookViewZones(listView, this); + this.cellOverlays = new NotebookCellOverlays(listView); return listView; } @@ -340,6 +343,7 @@ export class NotebookCellList extends WorkbenchList implements ID // update whitespaces which are anchored to the model indexes this.viewZones.onCellsChanged(e); + this.cellOverlays.onCellsChanged(e); const currentRanges = this._hiddenRangeIds.map(id => this._viewModel!.getTrackedRange(id)).filter(range => range !== null) as ICellRange[]; const newVisibleViewCells: CellViewModel[] = getVisibleCells(this._viewModel!.viewCells as CellViewModel[], currentRanges); @@ -448,6 +452,8 @@ export class NotebookCellList extends WorkbenchList implements ID this._updateHiddenRangePrefixSum(newRanges); this.viewZones.onHiddenRangesChange(); this.viewZones.layout(); + this.cellOverlays.onHiddenRangesChange(); + this.cellOverlays.layout(); return false; } } @@ -461,12 +467,14 @@ export class NotebookCellList extends WorkbenchList implements ID this._updateHiddenRangePrefixSum(newRanges); // Update view zone positions after hidden ranges change this.viewZones.onHiddenRangesChange(); + this.cellOverlays.onHiddenRangesChange(); if (triggerViewUpdate) { this.updateHiddenAreasInView(oldRanges, newRanges); } this.viewZones.layout(); + this.cellOverlays.layout(); return true; } @@ -541,6 +549,7 @@ export class NotebookCellList extends WorkbenchList implements ID } this.viewZones.layout(); + this.cellOverlays.layout(); } getModelIndex(cell: CellViewModel): number | undefined { @@ -1232,12 +1241,14 @@ export class NotebookCellList extends WorkbenchList implements ID } this.view.updateElementHeight(index, size, anchorElementIndex); this.viewZones.layout(); + this.cellOverlays.layout(); return; } if (anchorElementIndex !== null) { this.view.updateElementHeight(index, size, anchorElementIndex); this.viewZones.layout(); + this.cellOverlays.layout(); return; } @@ -1251,12 +1262,14 @@ export class NotebookCellList extends WorkbenchList implements ID if (this._notebookCellAnchor.shouldAnchor(this.view, focus, heightDelta, this.element(index))) { this.view.updateElementHeight(index, size, focus); this.viewZones.layout(); + this.cellOverlays.layout(); return; } } this.view.updateElementHeight(index, size, null); this.viewZones.layout(); + this.cellOverlays.layout(); return; } @@ -1266,6 +1279,16 @@ export class NotebookCellList extends WorkbenchList implements ID } } + changeCellOverlays(callback: (accessor: INotebookCellOverlayChangeAccessor) => void): void { + if (this.cellOverlays.changeCellOverlays(callback)) { + this.cellOverlays.layout(); + } + } + + getViewZoneLayoutInfo(viewZoneId: string): { height: number; top: number } | null { + return this.viewZones.getViewZoneLayoutInfo(viewZoneId); + } + // override override domFocus() { const focused = this.getFocusedElements()[0]; @@ -1439,6 +1462,7 @@ export class NotebookCellList extends WorkbenchList implements ID this._localDisposableStore.dispose(); this._notebookCellAnchor.dispose(); this.viewZones.dispose(); + this.cellOverlays.dispose(); super.dispose(); // un-ref diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellListView.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellListView.ts index 7034d6bb037..8d64212d915 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookCellListView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellListView.ts @@ -306,18 +306,11 @@ export class NotebookCellListView extends ListView { removeWhitespace(id: string): void { const scrollTop = this.scrollTop; const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); - const currentPosition = this.notebookRangeMap.getWhitespacePosition(id); - - if (currentPosition > scrollTop) { - this.notebookRangeMap.removeWhitespace(id); - this.render(previousRenderRange, scrollTop, this.lastRenderHeight, undefined, undefined, false); - this._rerender(scrollTop, this.renderHeight, false); - this.eventuallyUpdateScrollDimensions(); - } else { - this.notebookRangeMap.removeWhitespace(id); - this.eventuallyUpdateScrollDimensions(); - } + this.notebookRangeMap.removeWhitespace(id); + this.render(previousRenderRange, scrollTop, this.lastRenderHeight, undefined, undefined, false); + this._rerender(scrollTop, this.renderHeight, false); + this.eventuallyUpdateScrollDimensions(); } getWhitespacePosition(id: string): number { diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts index 94ced5b89d7..0d1adcd6fec 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts @@ -16,7 +16,7 @@ import { Selection } from '../../../../../editor/common/core/selection.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchListOptionsUpdate } from '../../../../../platform/list/browser/listService.js'; -import { CellRevealRangeType, CellRevealType, ICellOutputViewModel, ICellViewModel, INotebookViewZoneChangeAccessor } from '../notebookBrowser.js'; +import { CellRevealRangeType, CellRevealType, ICellOutputViewModel, ICellViewModel, INotebookCellOverlayChangeAccessor, INotebookViewZoneChangeAccessor } from '../notebookBrowser.js'; import { CellPartsCollection } from './cellPart.js'; import { CellViewModel, NotebookViewModel } from '../viewModel/notebookViewModelImpl.js'; import { ICellRange } from '../../common/notebookRange.js'; @@ -66,6 +66,8 @@ export interface INotebookCellList extends ICoordinatesConverter { revealOffsetInCenterIfOutsideViewport(offset: number): void; setHiddenAreas(_ranges: ICellRange[], triggerViewUpdate: boolean): boolean; changeViewZones(callback: (accessor: INotebookViewZoneChangeAccessor) => void): void; + changeCellOverlays(callback: (accessor: INotebookCellOverlayChangeAccessor) => void): void; + getViewZoneLayoutInfo(viewZoneId: string): { height: number; top: number } | null; domElementOfElement(element: ICellViewModel): HTMLElement | null; focusView(): void; triggerScrollFromMouseWheelEvent(browserEvent: IMouseWheelEvent): void; diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts index 21e6cf46c13..b6c10143b39 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -762,7 +762,7 @@ export class BackLayerWebView extends Themable { } } - this.openerService.open(CellUri.generateCellOutputUri(this.documentUri, outputId)); + this.openerService.open(CellUri.generateCellOutputUriWithId(this.documentUri, outputId)); return; } if (uri.path === 'cellOutput.enableScrolling') { @@ -1855,14 +1855,24 @@ export class BackLayerWebView extends Themable { } - deltaCellContainerClassNames(cellId: string, added: string[], removed: string[]) { + deltaCellOutputContainerClassNames(cellId: string, added: string[], removed: string[]) { this._sendMessageToWebview({ type: 'decorations', cellId, addedClassNames: added, removedClassNames: removed }); + } + deltaMarkupPreviewClassNames(cellId: string, added: string[], removed: string[]) { + if (this.markupPreviewMapping.get(cellId)) { + this._sendMessageToWebview({ + type: 'markupDecorations', + cellId, + addedClassNames: added, + removedClassNames: removed + }); + } } updateOutputRenderers() { diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewMessages.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewMessages.ts index 073737f5b79..26149b87ca1 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewMessages.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewMessages.ts @@ -305,7 +305,7 @@ export interface IUpdateRenderersMessage { } export interface IUpdateDecorationsMessage { - readonly type: 'decorations'; + readonly type: 'decorations' | 'markupDecorations'; readonly cellId: string; readonly addedClassNames: readonly string[]; readonly removedClassNames: readonly string[]; diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts index 5b7077bf1d1..26f94306030 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts @@ -8,6 +8,7 @@ import type { IDisposable } from '../../../../../../base/common/lifecycle.js'; import type * as webviewMessages from './webviewMessages.js'; import type { NotebookCellMetadata } from '../../../common/notebookCommon.js'; import type * as rendererApi from 'vscode-notebook-renderer'; +import type { NotebookCellOutputTransferData } from '../../../../../../platform/dnd/browser/dnd.js'; // !! IMPORTANT !! ---------------------------------------------------------------------------------- // import { RenderOutputType } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; @@ -1780,6 +1781,16 @@ async function webviewPreloads(ctx: PreloadContext) { outputContainer?.classList.remove(...event.data.removedClassNames); break; } + case 'markupDecorations': { + const markupCell = window.document.getElementById(event.data.cellId); + // The cell may not have been added yet if it is out of view. + // Decorations will be added when the cell is shown. + if (markupCell) { + markupCell?.classList.add(...event.data.addedClassNames); + markupCell?.classList.remove(...event.data.removedClassNames); + } + break; + } case 'customKernelMessage': onDidReceiveKernelMessage.fire(event.data.message); break; @@ -2884,6 +2895,7 @@ async function webviewPreloads(ctx: PreloadContext) { private hasResizeObserver = false; private renderTaskAbort?: AbortController; + private isImageOutput = false; constructor( private readonly outputId: string, @@ -2904,6 +2916,37 @@ async function webviewPreloads(ctx: PreloadContext) { this.element.addEventListener('mouseleave', () => { postNotebookMessage('mouseleave', { id: outputId }); }); + + // Add drag handler + this.element.addEventListener('dragstart', (e: DragEvent) => { + if (!e.dataTransfer) { + return; + } + + const outputData: NotebookCellOutputTransferData = { + outputId: this.outputId, + }; + + e.dataTransfer.setData('notebook-cell-output', JSON.stringify(outputData)); + }); + + // Add alt key handlers + window.addEventListener('keydown', (e) => { + if (e.altKey) { + this.element.draggable = true; + } + }); + + window.addEventListener('keyup', (e) => { + if (!e.altKey) { + this.element.draggable = this.isImageOutput; + } + }); + + // Handle window blur to reset draggable state + window.addEventListener('blur', () => { + this.element.draggable = this.isImageOutput; + }); } public dispose() { @@ -2923,6 +2966,11 @@ async function webviewPreloads(ctx: PreloadContext) { const errors = preloadErrors.filter((e): e is Error => e instanceof Error); showRenderError(`Error loading preloads`, this.element, errors); } else { + + const imageMimeTypes = ['image/png', 'image/jpeg', 'image/svg']; + this.isImageOutput = imageMimeTypes.includes(content.output.mime); + this.element.draggable = this.isImageOutput; + const item = createOutputItem(this.outputId, content.output.mime, content.metadata, content.output.valueBytes, content.allOutputs, content.output.appended); const controller = new AbortController(); diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts index cae7e893d81..6611dd78eaf 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts @@ -656,11 +656,11 @@ export abstract class BaseCellViewModel extends Disposable { } updateEditState(newState: CellEditState, source: string) { - this._editStateSource = source; if (newState === this._editState) { return; } + this._editStateSource = source; this._editState = newState; this._onDidChangeState.fire({ editStateChanged: true }); if (this._editState === CellEditState.Preview) { diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts index 8b9ca5099a0..56a7c1409b1 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts @@ -23,7 +23,7 @@ import { FoldingRegions } from '../../../../../editor/contrib/folding/browser/fo import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IUndoRedoService } from '../../../../../platform/undoRedo/common/undoRedo.js'; import { CellFindMatchModel } from '../contrib/find/findModel.js'; -import { CellEditState, CellFindMatchWithIndex, CellFoldingState, EditorFoldingStateDelegate, ICellModelDecorations, ICellModelDeltaDecorations, ICellViewModel, IModelDecorationsChangeAccessor, INotebookDeltaCellStatusBarItems, INotebookDeltaDecoration, INotebookEditorViewState, INotebookViewCellsUpdateEvent, INotebookViewModel } from '../notebookBrowser.js'; +import { CellEditState, CellFindMatchWithIndex, CellFoldingState, EditorFoldingStateDelegate, ICellModelDecorations, ICellModelDeltaDecorations, ICellViewModel, IModelDecorationsChangeAccessor, INotebookDeltaCellStatusBarItems, INotebookEditorViewState, INotebookViewCellsUpdateEvent, INotebookViewModel, INotebookDeltaDecoration, isNotebookCellDecoration, INotebookDeltaViewZoneDecoration } from '../notebookBrowser.js'; import { NotebookLayoutInfo, NotebookMetadataChangedEvent } from '../notebookViewEvents.js'; import { NotebookCellSelectionCollection } from './cellSelectionCollection.js'; import { CodeCellViewModel } from './codeCellViewModel.js'; @@ -189,6 +189,9 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD private _decorationIdToCellMap = new Map(); private _statusBarItemIdToCellMap = new Map(); + private _lastOverviewRulerDecorationId: number = 0; + private _overviewRulerDecorations = new Map(); + constructor( public viewType: string, private _notebook: NotebookTextModel, @@ -495,6 +498,10 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD return this._hiddenRanges; } + getOverviewRulerDecorations(): INotebookDeltaViewZoneDecoration[] { + return Array.from(this._overviewRulerDecorations.values()); + } + getCellByHandle(handle: number) { return this._handleToViewCellMapping.get(handle); } @@ -723,18 +730,29 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD cell?.deltaCellDecorations([id], []); this._decorationIdToCellMap.delete(id); } + + if (this._overviewRulerDecorations.has(id)) { + this._overviewRulerDecorations.delete(id); + } }); const result: string[] = []; newDecorations.forEach(decoration => { - const cell = this.getCellByHandle(decoration.handle); - const ret = cell?.deltaCellDecorations([], [decoration.options]) || []; - ret.forEach(id => { - this._decorationIdToCellMap.set(id, decoration.handle); - }); + if (isNotebookCellDecoration(decoration)) { + const cell = this.getCellByHandle(decoration.handle); + const ret = cell?.deltaCellDecorations([], [decoration.options]) || []; + ret.forEach(id => { + this._decorationIdToCellMap.set(id, decoration.handle); + }); + result.push(...ret); + } else { + const id = ++this._lastOverviewRulerDecorationId; + const decorationId = `_overview_${this.id};${id}`; + this._overviewRulerDecorations.set(decorationId, decoration); + result.push(decorationId); + } - result.push(...ret); }); return result; diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookCellOverlays.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookCellOverlays.ts new file mode 100644 index 00000000000..82c37b940a8 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookCellOverlays.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 { createFastDomNode, FastDomNode } from '../../../../../base/browser/fastDomNode.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize2 } from '../../../../../nls.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IsDevelopmentContext } from '../../../../../platform/contextkey/common/contextkeys.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { CellKind } from '../../common/notebookCommon.js'; +import { getNotebookEditorFromEditorPane, INotebookCellOverlay, INotebookCellOverlayChangeAccessor, INotebookViewCellsUpdateEvent } from '../notebookBrowser.js'; +import { NotebookCellListView } from '../view/notebookCellListView.js'; +import { CellViewModel } from '../viewModel/notebookViewModelImpl.js'; + +interface INotebookCellOverlayWidget { + overlayId: string; + overlay: INotebookCellOverlay; + domNode: FastDomNode; +} + +export class NotebookCellOverlays extends Disposable { + private _lastOverlayId = 0; + public domNode: FastDomNode; + private _overlays: { [key: string]: INotebookCellOverlayWidget } = Object.create(null); + + constructor( + private readonly listView: NotebookCellListView + ) { + super(); + this.domNode = createFastDomNode(document.createElement('div')); + this.domNode.setClassName('cell-overlays'); + this.domNode.setPosition('absolute'); + this.domNode.setAttribute('role', 'presentation'); + this.domNode.setAttribute('aria-hidden', 'true'); + this.domNode.setWidth('100%'); + + this.listView.containerDomNode.appendChild(this.domNode.domNode); + } + + changeCellOverlays(callback: (changeAccessor: INotebookCellOverlayChangeAccessor) => void): boolean { + let overlaysHaveChanged = false; + const changeAccessor: INotebookCellOverlayChangeAccessor = { + addOverlay: (overlay: INotebookCellOverlay): string => { + overlaysHaveChanged = true; + return this._addOverlay(overlay); + }, + removeOverlay: (id: string): void => { + overlaysHaveChanged = true; + this._removeOverlay(id); + }, + layoutOverlay: (id: string): void => { + overlaysHaveChanged = true; + this._layoutOverlay(id); + } + }; + + callback(changeAccessor); + + return overlaysHaveChanged; + } + + onCellsChanged(e: INotebookViewCellsUpdateEvent): void { + this.layout(); + } + + onHiddenRangesChange() { + this.layout(); + } + + layout() { + for (const id in this._overlays) { + this._layoutOverlay(id); + } + } + + private _addOverlay(overlay: INotebookCellOverlay): string { + const overlayId = `${++this._lastOverlayId}`; + + const overlayWidget = { + overlayId, + overlay, + domNode: createFastDomNode(overlay.domNode) + }; + + this._overlays[overlayId] = overlayWidget; + overlayWidget.domNode.setClassName('cell-overlay'); + overlayWidget.domNode.setPosition('absolute'); + this.domNode.appendChild(overlayWidget.domNode); + + return overlayId; + } + + private _removeOverlay(id: string): void { + const overlay = this._overlays[id]; + if (overlay) { + // overlay.overlay.dispose(); + try { + this.domNode.removeChild(overlay.domNode); + } catch { + // no op + } + + delete this._overlays[id]; + } + } + + private _layoutOverlay(id: string): void { + const overlay = this._overlays[id]; + if (!overlay) { + return; + } + + const isInHiddenRanges = this._isInHiddenRanges(overlay); + if (isInHiddenRanges) { + overlay.domNode.setDisplay('none'); + return; + } + + overlay.domNode.setDisplay('block'); + const index = this.listView.indexOf(overlay.overlay.cell as CellViewModel); + if (index === -1) { + // should not happen + return; + } + + const top = this.listView.elementTop(index); + overlay.domNode.setTop(top); + } + + private _isInHiddenRanges(zone: INotebookCellOverlayWidget) { + const index = this.listView.indexOf(zone.overlay.cell as CellViewModel); + if (index === -1) { + return true; + } + + return false; + } +} + + + +class ToggleNotebookCellOverlaysDeveloperAction extends Action2 { + static cellOverlayIds: string[] = []; + constructor() { + super({ + id: 'notebook.developer.addCellOverlays', + title: localize2('workbench.notebook.developer.addCellOverlays', "Toggle Notebook Cell Overlays"), + category: Categories.Developer, + precondition: IsDevelopmentContext, + f1: true + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const editor = getNotebookEditorFromEditorPane(editorService.activeEditorPane); + + if (!editor) { + return; + } + + if (ToggleNotebookCellOverlaysDeveloperAction.cellOverlayIds.length > 0) { + // remove all view zones + editor.changeCellOverlays(accessor => { + ToggleNotebookCellOverlaysDeveloperAction.cellOverlayIds.forEach(id => { + accessor.removeOverlay(id); + }); + ToggleNotebookCellOverlaysDeveloperAction.cellOverlayIds = []; + }); + } else { + editor.changeCellOverlays(accessor => { + const cells = editor.getCellsInRange(); + if (cells.length === 0) { + return; + } + + const cellOverlayIds: string[] = []; + for (let i = 0; i < cells.length; i++) { + if (cells[i].cellKind !== CellKind.Markup) { + continue; + } + const domNode = document.createElement('div'); + domNode.innerText = `Cell Overlay ${i}`; + domNode.style.top = '10px'; + domNode.style.right = '10px'; + domNode.style.backgroundColor = 'rgba(0, 255, 0, 0.5)'; + const overlayId = accessor.addOverlay({ + cell: cells[i], + domNode: domNode, + }); + + cellOverlayIds.push(overlayId); + } + ToggleNotebookCellOverlaysDeveloperAction.cellOverlayIds = cellOverlayIds; + }); + } + } +} + +registerAction2(ToggleNotebookCellOverlaysDeveloperAction); diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts index 3e08af275e0..dec98508976 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts @@ -72,7 +72,7 @@ export type KernelQuickPickContext = { id: string; extension: string } | { notebookEditorId: string } | { id: string; extension: string; notebookEditorId: string } | - { ui?: boolean; notebookEditor?: NotebookEditorWidget }; + { ui?: boolean; notebookEditor?: NotebookEditorWidget; skipIfAlreadySelected?: boolean }; export interface IKernelPickerStrategy { showQuickPick(editor: IActiveNotebookEditor, wantedKernelId?: string): Promise; diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelView.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelView.ts index b4c6ca0c38e..582e967c67c 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelView.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelView.ts @@ -18,7 +18,7 @@ import { selectKernelIcon } from '../notebookIcons.js'; import { KernelPickerMRUStrategy, KernelQuickPickContext } from './notebookKernelQuickPickStrategy.js'; import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; import { NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_KERNEL_COUNT } from '../../common/notebookContextKeys.js'; -import { INotebookKernelHistoryService, INotebookKernelService } from '../../common/notebookKernelService.js'; +import { INotebookKernel, INotebookKernelHistoryService, INotebookKernelService } from '../../common/notebookKernelService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; function getEditorFromContext(editorService: IEditorService, context?: KernelQuickPickContext): INotebookEditor | undefined { @@ -39,6 +39,19 @@ function getEditorFromContext(editorService: IEditorService, context?: KernelQui return editor; } +function shouldSkip( + selected: INotebookKernel | undefined, + controllerId: string | undefined, + extensionId: string | undefined, + context: KernelQuickPickContext | undefined): boolean { + + return !!(selected && ( + (context && 'skipIfAlreadySelected' in context && context.skipIfAlreadySelected) || + // target kernel is already selected + (controllerId && selected.id === controllerId && ExtensionIdentifier.equals(selected.extension, extensionId)) + )); +} + registerAction2(class extends Action2 { constructor() { super({ @@ -115,11 +128,9 @@ registerAction2(class extends Action2 { const notebook = editor.textModel; const notebookKernelService = accessor.get(INotebookKernelService); - const matchResult = notebookKernelService.getMatchingKernel(notebook); - const { selected } = matchResult; + const { selected } = notebookKernelService.getMatchingKernel(notebook); - if (selected && controllerId && selected.id === controllerId && ExtensionIdentifier.equals(selected.extension, extensionId)) { - // current kernel is wanted kernel -> done + if (shouldSkip(selected, controllerId, extensionId, context)) { return true; } diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookOverviewRuler.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookOverviewRuler.ts index 4085b53aff2..1bab34e9d38 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookOverviewRuler.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookOverviewRuler.ts @@ -111,6 +111,45 @@ export class NotebookOverviewRuler extends Themable { currentFrom += cellHeight; } + + const overviewRulerDecorations = viewModel.getOverviewRulerDecorations(); + + for (let i = 0; i < overviewRulerDecorations.length; i++) { + const decoration = overviewRulerDecorations[i]; + if (!decoration.options.overviewRuler) { + continue; + } + const viewZoneInfo = this.notebookEditor.getViewZoneLayoutInfo(decoration.viewZoneId); + + if (!viewZoneInfo) { + continue; + } + + const fillStyle = this.getColor(decoration.options.overviewRuler.color) ?? '#000000'; + let x = 0; + switch (decoration.options.overviewRuler.position) { + case NotebookOverviewRulerLane.Left: + x = 0; + break; + case NotebookOverviewRulerLane.Center: + x = laneWidth; + break; + case NotebookOverviewRulerLane.Right: + x = laneWidth * 2; + break; + default: + break; + } + + const width = decoration.options.overviewRuler.position === NotebookOverviewRulerLane.Full ? laneWidth * 3 : laneWidth; + + ctx.fillStyle = fillStyle; + + const viewZoneHeight = (viewZoneInfo.height / scrollHeight) * ratio * height; + const viewZoneTop = (viewZoneInfo.top / scrollHeight) * ratio * height; + + ctx.fillRect(x, viewZoneTop, width, viewZoneHeight); + } } } } diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookViewZones.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookViewZones.ts index 8645d66dc19..b0ade6276a4 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookViewZones.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookViewZones.ts @@ -6,7 +6,13 @@ import { FastDomNode, createFastDomNode } from '../../../../../base/browser/fastDomNode.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { INotebookViewCellsUpdateEvent, INotebookViewZone, INotebookViewZoneChangeAccessor } from '../notebookBrowser.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize2 } from '../../../../../nls.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IsDevelopmentContext } from '../../../../../platform/contextkey/common/contextkeys.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { getNotebookEditorFromEditorPane, INotebookViewCellsUpdateEvent, INotebookViewZone, INotebookViewZoneChangeAccessor } from '../notebookBrowser.js'; import { NotebookCellListView } from '../view/notebookCellListView.js'; import { ICoordinatesConverter } from '../view/notebookRenderingCommon.js'; import { CellViewModel } from '../viewModel/notebookViewModelImpl.js'; @@ -66,6 +72,16 @@ export class NotebookViewZones extends Disposable { return zonesHaveChanged; } + getViewZoneLayoutInfo(viewZoneId: string): { height: number; top: number } | null { + const zoneWidget = this._zones[viewZoneId]; + if (!zoneWidget) { + return null; + } + const top = this.listView.getWhitespacePosition(zoneWidget.whitespaceId); + const height = zoneWidget.zone.heightInPx; + return { height: height, top: top }; + } + onCellsChanged(e: INotebookViewCellsUpdateEvent): void { const splices = e.splices.slice().reverse(); splices.forEach(splice => { @@ -141,6 +157,16 @@ export class NotebookViewZones extends Disposable { private _removeZone(id: string): void { this.listView.removeWhitespace(id); + const zoneWidget = this._zones[id]; + if (zoneWidget) { + // safely remove the dom node from its parent + try { + this.domNode.removeChild(zoneWidget.domNode); + } catch { + // ignore the error + } + } + delete this._zones[id]; } @@ -186,3 +212,59 @@ function safeInvoke1Arg(func: Function, arg1: any): void { onUnexpectedError(e); } } + +class ToggleNotebookViewZoneDeveloperAction extends Action2 { + static viewZoneIds: string[] = []; + constructor() { + super({ + id: 'notebook.developer.addViewZones', + title: localize2('workbench.notebook.developer.addViewZones', "Toggle Notebook View Zones"), + category: Categories.Developer, + precondition: IsDevelopmentContext, + f1: true + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const editor = getNotebookEditorFromEditorPane(editorService.activeEditorPane); + + if (!editor) { + return; + } + + if (ToggleNotebookViewZoneDeveloperAction.viewZoneIds.length > 0) { + // remove all view zones + editor.changeViewZones(accessor => { + // remove all view zones in reverse order, to follow how we handle this in the prod code + ToggleNotebookViewZoneDeveloperAction.viewZoneIds.reverse().forEach(id => { + accessor.removeZone(id); + }); + ToggleNotebookViewZoneDeveloperAction.viewZoneIds = []; + }); + } else { + editor.changeViewZones(accessor => { + const cells = editor.getCellsInRange(); + if (cells.length === 0) { + return; + } + + const viewZoneIds: string[] = []; + for (let i = 0; i < cells.length; i++) { + const domNode = document.createElement('div'); + domNode.innerText = `View Zone ${i}`; + domNode.style.backgroundColor = 'rgba(0, 255, 0, 0.5)'; + const viewZoneId = accessor.addZone({ + afterModelPosition: i, + heightInPx: 200, + domNode: domNode, + }); + viewZoneIds.push(viewZoneId); + } + ToggleNotebookViewZoneDeveloperAction.viewZoneIds = viewZoneIds; + }); + } + } +} + +registerAction2(ToggleNotebookViewZoneDeveloperAction); diff --git a/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts index a7f1c786742..237e0ff8bdf 100644 --- a/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts +++ b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts @@ -31,6 +31,7 @@ import { NotebookCellTextModel } from './notebookCellTextModel.js'; class StackOperation implements IWorkspaceUndoRedoElement { type: UndoRedoElementType.Workspace; + tag = 'notebookUndoRedoElement'; public get code() { return this._operations.length === 1 ? this._operations[0].code : 'undoredo.notebooks.stackOperation'; @@ -121,6 +122,7 @@ class StackOperation implements IWorkspaceUndoRedoElement { class NotebookOperationManager { private _pendingStackOperation: StackOperation | null = null; + private _isAppending: boolean = false; constructor( private readonly _textModel: NotebookTextModel, private _undoService: IUndoRedoService, @@ -136,14 +138,28 @@ class NotebookOperationManager { pushStackElement(alternativeVersionId: string, selectionState: ISelectionState | undefined) { if (this._pendingStackOperation && !this._pendingStackOperation.isEmpty) { this._pendingStackOperation.pushEndState(alternativeVersionId, selectionState); - this._undoService.pushElement(this._pendingStackOperation, this._pendingStackOperation.undoRedoGroup); + if (!this._isAppending) { + this._undoService.pushElement(this._pendingStackOperation, this._pendingStackOperation.undoRedoGroup); + } } + this._isAppending = false; this._pendingStackOperation = null; } + private _getOrCreateEditStackElement(beginSelectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined, alternativeVersionId: string) { return this._pendingStackOperation ??= new StackOperation(this._textModel, undoRedoGroup, this._pauseableEmitter, this._postUndoRedo, beginSelectionState, alternativeVersionId || ''); } + appendPreviousOperation(): boolean { + const previous = this._undoService.getLastElement(this._textModel.uri) as StackOperation; + if (previous && previous.tag === 'notebookUndoRedoElement') { + this._pendingStackOperation = previous; + this._isAppending = true; + return true; + } + return false; + } + pushEditOperation(element: IUndoRedoElement, beginSelectionState: ISelectionState | undefined, resultSelectionState: ISelectionState | undefined, alternativeVersionId: string, undoRedoGroup: UndoRedoGroup | undefined) { const pendingStackOperation = this._getOrCreateEditStackElement(beginSelectionState, undoRedoGroup, alternativeVersionId); pendingStackOperation.pushEditOperation(element, beginSelectionState, resultSelectionState, alternativeVersionId); @@ -582,10 +598,40 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel return result; } + private newCellsFromLastEdit = new Set(); + private isOnlyEditingMetadataOnNewCells(rawEdits: ICellEditOperation[]): boolean { + for (const edit of rawEdits) { + if (edit.editType === CellEditType.PartialInternalMetadata) { + continue; + } + if (edit.editType !== CellEditType.Metadata && edit.editType !== CellEditType.PartialMetadata) { + return false; + } + + if (('index' in edit) && !this.newCellsFromLastEdit.has(this.cells[edit.index].handle)) { + return false; + } + if ('handle' in edit && !this.newCellsFromLastEdit.has(edit.handle)) { + return false; + } + } + + return true; + } + applyEdits(rawEdits: ICellEditOperation[], synchronous: boolean, beginSelectionState: ISelectionState | undefined, endSelectionsComputer: () => ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined, computeUndoRedo: boolean): boolean { this._pauseableEmitter.pause(); this._operationManager.pushStackElement(this._alternativeVersionId, undefined); + if (computeUndoRedo && this.isOnlyEditingMetadataOnNewCells(rawEdits)) { + if (!this._operationManager.appendPreviousOperation()) { + // we can't append the previous operation, so just don't compute undo/redo + computeUndoRedo = false; + } + } else if (computeUndoRedo) { + this.newCellsFromLastEdit.clear(); + } + try { this._doApplyEdits(rawEdits, synchronous, computeUndoRedo, beginSelectionState, undoRedoGroup); return true; @@ -816,6 +862,8 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel const dirtyStateListener = cell.onDidChangeContent((e) => { this._bindCellContentHandler(cell, e); }); + + this.newCellsFromLastEdit.add(cell.handle); this._cellListeners.set(cell.handle, dirtyStateListener); this._register(cell); return cell; diff --git a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts index 1558447f16e..a47664c7552 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts @@ -33,7 +33,7 @@ import { ICellExecutionError } from './notebookExecutionStateService.js'; import { INotebookTextModelLike } from './notebookKernelService.js'; import { ICellRange } from './notebookRange.js'; import { RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; -import { generateMetadataUri, generate as generateUri, parseMetadataUri, parse as parseUri } from '../../../services/notebook/common/notebookDocumentService.js'; +import { generateMetadataUri, generate as generateUri, extractCellOutputDetails, parseMetadataUri, parse as parseUri } from '../../../services/notebook/common/notebookDocumentService.js'; import { IWorkingCopyBackupMeta, IWorkingCopySaveEvent } from '../../../services/workingCopy/common/workingCopy.js'; import { SnapshotContext } from '../../../services/workingCopy/common/fileWorkingCopy.js'; @@ -42,6 +42,7 @@ export const NOTEBOOK_DIFF_EDITOR_ID = 'workbench.editor.notebookTextDiffEditor' export const NOTEBOOK_MULTI_DIFF_EDITOR_ID = 'workbench.editor.notebookMultiTextDiffEditor'; export const INTERACTIVE_WINDOW_EDITOR_ID = 'workbench.editor.interactive'; export const REPL_EDITOR_ID = 'workbench.editor.repl'; +export const NOTEBOOK_OUTPUT_EDITOR_ID = 'workbench.editor.notebookOutputEditor'; export const EXECUTE_REPL_COMMAND_ID = 'replNotebook.input.execute'; @@ -125,7 +126,7 @@ export interface NotebookCellInternalMetadata { * This is not persisted and generally useful only when diffing two notebooks. * Useful only after we've manually matched a few cells together so we know which cells are matching. */ - cellId?: string; + internalId?: string; executionId?: string; executionOrder?: number; lastRunSuccess?: boolean; @@ -617,32 +618,50 @@ export namespace CellUri { return parseUri(cell); } - export function generateCellOutputUri(notebook: URI, outputId?: string) { + /** + * Generates a URI for a cell output in a notebook using the output ID. + * Used when URI should be opened as text in the editor. + */ + export function generateCellOutputUriWithId(notebook: URI, outputId?: string) { return notebook.with({ scheme: Schemas.vscodeNotebookCellOutput, - fragment: `op${outputId ?? ''},${notebook.scheme !== Schemas.file ? notebook.scheme : ''}` + query: new URLSearchParams({ + openIn: 'editor', + outputId: outputId ?? '', + notebookScheme: notebook.scheme !== Schemas.file ? notebook.scheme : '', + }).toString() + }); + } + /** + * Generates a URI for a cell output in a notebook using the output index. + * Used when URI should be opened in notebook editor. + */ + export function generateCellOutputUriWithIndex(notebook: URI, cellUri: URI, outputIndex: number): URI { + return notebook.with({ + scheme: Schemas.vscodeNotebookCellOutput, + fragment: cellUri.fragment, + query: new URLSearchParams({ + openIn: 'notebook', + outputIndex: String(outputIndex), + }).toString() }); } - export function parseCellOutputUri(uri: URI): { notebook: URI; outputId?: string } | undefined { - if (uri.scheme !== Schemas.vscodeNotebookCellOutput) { - return; - } + export function generateOutputEditorUri(notebook: URI, cellId: string, cellIndex: number, outputId: string, outputIndex: number): URI { + return notebook.with({ + scheme: Schemas.vscodeNotebookCellOutput, + query: new URLSearchParams({ + openIn: 'notebookOutputEditor', + notebook: notebook.toString(), + cellIndex: String(cellIndex), + outputId: outputId, + outputIndex: String(outputIndex), + }).toString() + }); + } - const match = /^op([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?\,(.*)$/i.exec(uri.fragment); - if (!match) { - return undefined; - } - - const outputId = (match[1] && match[1] !== '') ? match[1] : undefined; - const scheme = match[2]; - return { - outputId, - notebook: uri.with({ - scheme: scheme || Schemas.file, - fragment: null - }) - }; + export function parseCellOutputUri(uri: URI): { notebook: URI; openIn: string; outputId?: string; cellFragment?: string; outputIndex?: number; cellHandle?: number; cellIndex?: number } | undefined { + return extractCellOutputDetails(uri); } export function generateCellPropertyUri(notebook: URI, handle: number, scheme: string): URI { @@ -997,6 +1016,7 @@ export const NotebookSetting = { stickyScrollMode: 'notebook.stickyScroll.mode', undoRedoPerCell: 'notebook.undoRedoPerCell', consolidatedOutputButton: 'notebook.consolidatedOutputButton', + openOutputInPreviewEditor: 'notebook.output.openInPreviewEditor.enabled', showFoldingControls: 'notebook.showFoldingControls', dragAndDropEnabled: 'notebook.dragAndDropEnabled', cellEditorOptionsCustomizations: 'notebook.editorOptionsCustomizations', diff --git a/src/vs/workbench/contrib/notebook/common/services/notebookCellMatching.ts b/src/vs/workbench/contrib/notebook/common/services/notebookCellMatching.ts index 8e7375819c6..0df0bab4dfa 100644 --- a/src/vs/workbench/contrib/notebook/common/services/notebookCellMatching.ts +++ b/src/vs/workbench/contrib/notebook/common/services/notebookCellMatching.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { computeLevenshteinDistance } from '../../../../../base/common/diff/diff.js'; import { CellKind } from '../notebookCommon.js'; @@ -15,6 +16,9 @@ type CellEditCountCache = { }; type ICell = { + internalMetadata?: { + internalId?: string; + }; getValue(): string; getLinesContent(): string[]; cellKind: CellKind; @@ -283,6 +287,16 @@ export function matchCellBasedOnSimilarties(modifiedCells: ICell[], originalCell function computeClosestCell({ cell, index: cellIndex }: { cell: ICell; index: number }, arr: readonly ICell[], ignoreEmptyCells: boolean, cache: CellEditCountCache, canOriginalIndexBeMappedToModifiedIndex: (originalIndex: number, value: { editCount: EditCount }) => boolean): { index: number; editCount: number; percentage: number } { let min_edits = Infinity; let min_index = -1; + + // Always give preference to internal Cell Id if found. + const internalId = cell.internalMetadata?.internalId; + if (internalId) { + const internalIdIndex = arr.findIndex(cell => cell.internalMetadata?.internalId === internalId); + if (internalIdIndex >= 0) { + return { index: internalIdIndex, editCount: 0, percentage: Number.MAX_SAFE_INTEGER }; + } + } + for (let i = 0; i < arr.length; i++) { // Skip cells that are not of the same kind. if (arr[i].cellKind !== cell.cellKind) { @@ -328,178 +342,3 @@ function computeNumberOfEdits(modified: ICell, original: ICell) { return computeLevenshteinDistance(modified.getValue(), original.getValue()); } - -/** - * Precomputed equality array for character codes. - */ -const precomputedEqualityArray = new Uint32Array(0x10000); - -/** - * Computes the Levenshtein distance for strings of length <= 32. - * @param firstString - The first string. - * @param secondString - The second string. - * @returns The Levenshtein distance. - */ -const computeLevenshteinDistanceForShortStrings = (firstString: string, secondString: string): number => { - const firstStringLength = firstString.length; - const secondStringLength = secondString.length; - const lastBitMask = 1 << (firstStringLength - 1); - let positiveVector = -1; - let negativeVector = 0; - let distance = firstStringLength; - let index = firstStringLength; - - // Initialize precomputedEqualityArray for firstString - while (index--) { - precomputedEqualityArray[firstString.charCodeAt(index)] |= 1 << index; - } - - // Process each character of secondString - for (index = 0; index < secondStringLength; index++) { - let equalityMask = precomputedEqualityArray[secondString.charCodeAt(index)]; - const combinedVector = equalityMask | negativeVector; - equalityMask |= ((equalityMask & positiveVector) + positiveVector) ^ positiveVector; - negativeVector |= ~(equalityMask | positiveVector); - positiveVector &= equalityMask; - if (negativeVector & lastBitMask) { - distance++; - } - if (positiveVector & lastBitMask) { - distance--; - } - negativeVector = (negativeVector << 1) | 1; - positiveVector = (positiveVector << 1) | ~(combinedVector | negativeVector); - negativeVector &= combinedVector; - } - - // Reset precomputedEqualityArray - index = firstStringLength; - while (index--) { - precomputedEqualityArray[firstString.charCodeAt(index)] = 0; - } - - return distance; -}; - -/** - * Computes the Levenshtein distance for strings of length > 32. - * @param firstString - The first string. - * @param secondString - The second string. - * @returns The Levenshtein distance. - */ -function computeLevenshteinDistanceForLongStrings(firstString: string, secondString: string): number { - const firstStringLength = firstString.length; - const secondStringLength = secondString.length; - const horizontalBitArray = []; - const verticalBitArray = []; - const horizontalSize = Math.ceil(firstStringLength / 32); - const verticalSize = Math.ceil(secondStringLength / 32); - - // Initialize horizontal and vertical bit arrays - for (let i = 0; i < horizontalSize; i++) { - horizontalBitArray[i] = -1; - verticalBitArray[i] = 0; - } - - let verticalIndex = 0; - for (; verticalIndex < verticalSize - 1; verticalIndex++) { - let negativeVector = 0; - let positiveVector = -1; - const start = verticalIndex * 32; - const verticalLength = Math.min(32, secondStringLength) + start; - - // Initialize precomputedEqualityArray for secondString - for (let k = start; k < verticalLength; k++) { - precomputedEqualityArray[secondString.charCodeAt(k)] |= 1 << k; - } - - // Process each character of firstString - for (let i = 0; i < firstStringLength; i++) { - const equalityMask = precomputedEqualityArray[firstString.charCodeAt(i)]; - const previousBit = (horizontalBitArray[(i / 32) | 0] >>> i) & 1; - const matchBit = (verticalBitArray[(i / 32) | 0] >>> i) & 1; - const combinedVector = equalityMask | negativeVector; - const combinedHorizontalVector = ((((equalityMask | matchBit) & positiveVector) + positiveVector) ^ positiveVector) | equalityMask | matchBit; - let positiveHorizontalVector = negativeVector | ~(combinedHorizontalVector | positiveVector); - let negativeHorizontalVector = positiveVector & combinedHorizontalVector; - if ((positiveHorizontalVector >>> 31) ^ previousBit) { - horizontalBitArray[(i / 32) | 0] ^= 1 << i; - } - if ((negativeHorizontalVector >>> 31) ^ matchBit) { - verticalBitArray[(i / 32) | 0] ^= 1 << i; - } - positiveHorizontalVector = (positiveHorizontalVector << 1) | previousBit; - negativeHorizontalVector = (negativeHorizontalVector << 1) | matchBit; - positiveVector = negativeHorizontalVector | ~(combinedVector | positiveHorizontalVector); - negativeVector = positiveHorizontalVector & combinedVector; - } - - // Reset precomputedEqualityArray - for (let k = start; k < verticalLength; k++) { - precomputedEqualityArray[secondString.charCodeAt(k)] = 0; - } - } - - let negativeVector = 0; - let positiveVector = -1; - const start = verticalIndex * 32; - const verticalLength = Math.min(32, secondStringLength - start) + start; - - // Initialize precomputedEqualityArray for secondString - for (let k = start; k < verticalLength; k++) { - precomputedEqualityArray[secondString.charCodeAt(k)] |= 1 << k; - } - - let distance = secondStringLength; - - // Process each character of firstString - for (let i = 0; i < firstStringLength; i++) { - const equalityMask = precomputedEqualityArray[firstString.charCodeAt(i)]; - const previousBit = (horizontalBitArray[(i / 32) | 0] >>> i) & 1; - const matchBit = (verticalBitArray[(i / 32) | 0] >>> i) & 1; - const combinedVector = equalityMask | negativeVector; - const combinedHorizontalVector = ((((equalityMask | matchBit) & positiveVector) + positiveVector) ^ positiveVector) | equalityMask | matchBit; - let positiveHorizontalVector = negativeVector | ~(combinedHorizontalVector | positiveVector); - let negativeHorizontalVector = positiveVector & combinedHorizontalVector; - distance += (positiveHorizontalVector >>> (secondStringLength - 1)) & 1; - distance -= (negativeHorizontalVector >>> (secondStringLength - 1)) & 1; - if ((positiveHorizontalVector >>> 31) ^ previousBit) { - horizontalBitArray[(i / 32) | 0] ^= 1 << i; - } - if ((negativeHorizontalVector >>> 31) ^ matchBit) { - verticalBitArray[(i / 32) | 0] ^= 1 << i; - } - positiveHorizontalVector = (positiveHorizontalVector << 1) | previousBit; - negativeHorizontalVector = (negativeHorizontalVector << 1) | matchBit; - positiveVector = negativeHorizontalVector | ~(combinedVector | positiveHorizontalVector); - negativeVector = positiveHorizontalVector & combinedVector; - } - - // Reset precomputedEqualityArray - for (let k = start; k < verticalLength; k++) { - precomputedEqualityArray[secondString.charCodeAt(k)] = 0; - } - - return distance; -} - -/** - * Computes the Levenshtein distance between two strings. - * @param firstString - The first string. - * @param secondString - The second string. - * @returns The Levenshtein distance. - */ -function computeLevenshteinDistance(firstString: string, secondString: string): number { - if (firstString.length < secondString.length) { - const temp = secondString; - secondString = firstString; - firstString = temp; - } - if (secondString.length === 0) { - return firstString.length; - } - if (firstString.length <= 32) { - return computeLevenshteinDistanceForShortStrings(firstString, secondString); - } - return computeLevenshteinDistanceForLongStrings(firstString, secondString); -} diff --git a/src/vs/workbench/contrib/notebook/common/services/notebookSimpleWorker.ts b/src/vs/workbench/contrib/notebook/common/services/notebookWebWorker.ts similarity index 93% rename from src/vs/workbench/contrib/notebook/common/services/notebookSimpleWorker.ts rename to src/vs/workbench/contrib/notebook/common/services/notebookWebWorker.ts index 3ebedc7bab4..d95e43c5608 100644 --- a/src/vs/workbench/contrib/notebook/common/services/notebookSimpleWorker.ts +++ b/src/vs/workbench/contrib/notebook/common/services/notebookWebWorker.ts @@ -6,7 +6,7 @@ import { IDiffChange, ISequence, LcsDiff } from '../../../../../base/common/diff import { doHash, hash, numberHash } from '../../../../../base/common/hash.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IRequestHandler, IWorkerServer } from '../../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerServerRequestHandler } from '../../../../../base/common/worker/webWorker.js'; import { PieceTreeTextBufferBuilder } from '../../../../../editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder.js'; import { CellKind, IMainCellDto, INotebookDiffResult, IOutputDto, NotebookCellInternalMetadata, NotebookCellMetadata, NotebookCellsChangedEventDto, NotebookCellsChangeType, NotebookCellTextModelSplice, NotebookDocumentMetadata, TransientDocumentMetadata } from '../notebookCommon.js'; import { Range } from '../../../../../editor/common/core/range.js'; @@ -66,7 +66,7 @@ class MirrorCell { hashValue = doHash(this.getValue(), hashValue); hashValue = doHash(this.metadata, hashValue); // For purpose of diffing only cellId matters, rest do not - hashValue = doHash(this.internalMetadata?.cellId || '', hashValue); + hashValue = doHash(this.internalMetadata?.internalId || '', hashValue); for (const op of this.outputs) { hashValue = doHash(op.metadata, hashValue); for (const output of op.outputs) { @@ -162,9 +162,9 @@ class CellSequence implements ISequence { static createWithCellId(cells: MirrorCell[], includeCellContents?: boolean) { const hashValue = cells.map((c) => { if (includeCellContents) { - return `${doHash(c.internalMetadata?.cellId, numberHash(104579, 0))}#${c.getComparisonValue()}`; + return `${doHash(c.internalMetadata?.internalId, numberHash(104579, 0))}#${c.getComparisonValue()}`; } else { - return `${doHash(c.internalMetadata?.cellId, numberHash(104579, 0))}}`; + return `${doHash(c.internalMetadata?.internalId, numberHash(104579, 0))}}`; } }); return new CellSequence(hashValue); @@ -177,7 +177,7 @@ class CellSequence implements ISequence { } } -export class NotebookEditorSimpleWorker implements IRequestHandler, IDisposable { +export class NotebookWorker implements IWebWorkerServerRequestHandler, IDisposable { _requestHandlerBrand: any; private _models: { [uri: string]: MirrorNotebookDocument }; @@ -418,8 +418,8 @@ export class NotebookEditorSimpleWorker implements IRequestHandler, IDisposable } canComputeDiffWithCellInternalIds(original: MirrorNotebookDocument, modified: MirrorNotebookDocument): boolean { - const originalCellIndexIds = original.cells.map((cell, index) => ({ index, id: (cell.internalMetadata?.cellId || '') as string })); - const modifiedCellIndexIds = modified.cells.map((cell, index) => ({ index, id: (cell.internalMetadata?.cellId || '') as string })); + const originalCellIndexIds = original.cells.map((cell, index) => ({ index, id: (cell.internalMetadata?.internalId || '') as string })); + const modifiedCellIndexIds = modified.cells.map((cell, index) => ({ index, id: (cell.internalMetadata?.internalId || '') as string })); // If we have a cell without an id, do not use metadata.id for diffing. if (originalCellIndexIds.some(c => !c.id) || modifiedCellIndexIds.some(c => !c.id)) { return false; @@ -445,35 +445,35 @@ export class NotebookEditorSimpleWorker implements IRequestHandler, IDisposable // Internally we use internalMetadata.cellId for diffing, hence update the internalMetadata.cellId original.cells.map((cell, index) => { cell.internalMetadata = cell.internalMetadata || {}; - cell.internalMetadata.cellId = cell.metadata?.id as string || ''; + cell.internalMetadata.internalId = cell.metadata?.id as string || ''; }); modified.cells.map((cell, index) => { cell.internalMetadata = cell.internalMetadata || {}; - cell.internalMetadata.cellId = cell.metadata?.id as string || ''; + cell.internalMetadata.internalId = cell.metadata?.id as string || ''; }); return true; } isOriginalCellMatchedWithModifiedCell(originalCell: MirrorCell) { - return (originalCell.internalMetadata?.cellId as string || '').startsWith(PREFIX_FOR_UNMATCHED_ORIGINAL_CELLS); + return (originalCell.internalMetadata?.internalId as string || '').startsWith(PREFIX_FOR_UNMATCHED_ORIGINAL_CELLS); } updateCellIdsBasedOnMappings(mappings: { modified: number; original: number }[], originalCells: MirrorCell[], modifiedCells: MirrorCell[]): boolean { const uuids = new Map(); originalCells.map((cell, index) => { - cell.internalMetadata = cell.internalMetadata || { cellId: '' }; - cell.internalMetadata.cellId = `${PREFIX_FOR_UNMATCHED_ORIGINAL_CELLS}${generateUuid()}`; + cell.internalMetadata = cell.internalMetadata || { internalId: '' }; + cell.internalMetadata.internalId = `${PREFIX_FOR_UNMATCHED_ORIGINAL_CELLS}${generateUuid()}`; const found = mappings.find(r => r.original === index); if (found) { // Do not use the indexes as ids. // If we do, then the hashes will be very similar except for last digit. - cell.internalMetadata.cellId = generateUuid(); - uuids.set(found.modified, cell.internalMetadata.cellId as string); + cell.internalMetadata.internalId = generateUuid(); + uuids.set(found.modified, cell.internalMetadata.internalId as string); } }); modifiedCells.map((cell, index) => { - cell.internalMetadata = cell.internalMetadata || { cellId: '' }; - cell.internalMetadata.cellId = uuids.get(index) ?? generateUuid(); + cell.internalMetadata = cell.internalMetadata || { internalId: '' }; + cell.internalMetadata.internalId = uuids.get(index) ?? generateUuid(); }); return true; } @@ -521,12 +521,8 @@ export class NotebookEditorSimpleWorker implements IRequestHandler, IDisposable } } -/** - * Defines the worker entry point. Must be exported and named `create`. - * @skipMangle - */ -export function create(workerServer: IWorkerServer): IRequestHandler { - return new NotebookEditorSimpleWorker(); +export function create(): IWebWorkerServerRequestHandler { + return new NotebookWorker(); } export type CellDiffInfo = { diff --git a/src/vs/editor/common/services/editorSimpleWorkerMain.ts b/src/vs/workbench/contrib/notebook/common/services/notebookWebWorkerMain.ts similarity index 67% rename from src/vs/editor/common/services/editorSimpleWorkerMain.ts rename to src/vs/workbench/contrib/notebook/common/services/notebookWebWorkerMain.ts index 8d0e18738f4..80021b498ca 100644 --- a/src/vs/editor/common/services/editorSimpleWorkerMain.ts +++ b/src/vs/workbench/contrib/notebook/common/services/notebookWebWorkerMain.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { create } from './editorSimpleWorker.js'; -import { bootstrapSimpleEditorWorker } from './editorWorkerBootstrap.js'; +import { bootstrapWebWorker } from '../../../../../base/common/worker/webWorkerBootstrap.js'; +import { create } from './notebookWebWorker.js'; -bootstrapSimpleEditorWorker(create); +bootstrapWebWorker(create); diff --git a/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookCellDiagnostics.test.ts b/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookCellDiagnostics.test.ts index 5c218da6ee7..1077d603543 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookCellDiagnostics.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookCellDiagnostics.test.ts @@ -21,7 +21,7 @@ import { CellKind, NotebookSetting } from '../../../common/notebookCommon.js'; import { ICellExecutionStateChangedEvent, IExecutionStateChangedEvent, INotebookCellExecution, INotebookExecutionStateService, NotebookExecutionType } from '../../../common/notebookExecutionStateService.js'; import { setupInstantiationService, TestNotebookExecutionStateService, withTestNotebook } from '../testNotebookEditor.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; -import { ChatAgentLocation } from '../../../../chat/common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../../../../chat/common/constants.js'; suite('notebookCellDiagnostics', () => { @@ -73,6 +73,7 @@ suite('notebookCellDiagnostics', () => { name: 'testEditorAgent', isDefault: true, locations: [ChatAgentLocation.Notebook], + modes: [ChatMode.Ask], metadata: {}, slashCommands: [], disambiguation: [], @@ -157,12 +158,12 @@ suite('notebookCellDiagnostics', () => { testExecutionService.fireExecutionChanged(editor.textModel.uri, cell2.handle); await new Promise(resolve => Event.once(markerService.onMarkersUpdated)(resolve)); - cell.model.internalMetadata.error = undefined; + const clearMarkers = new Promise(resolve => Event.once(markerService.onMarkersUpdated)(resolve)); // on NotebookCellExecution value will make it look like its currently running testExecutionService.fireExecutionChanged(editor.textModel.uri, cell.handle, {} as INotebookCellExecution); - await new Promise(resolve => Event.once(markerService.onMarkersUpdated)(resolve)); + await clearMarkers; assert.strictEqual(cell?.executionErrorDiagnostic.get(), undefined); assert.strictEqual(cell2?.executionErrorDiagnostic.get()?.message, 'another bad thing happened', 'cell that was not executed should still have an error'); diff --git a/src/vs/workbench/contrib/notebook/test/browser/diff/notebookDiffService.test.ts b/src/vs/workbench/contrib/notebook/test/browser/diff/notebookDiffService.test.ts index afc0ab1f8ae..29a718058b8 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/diff/notebookDiffService.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/diff/notebookDiffService.test.ts @@ -9,16 +9,16 @@ import { Mimes } from '../../../../../../base/common/mime.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { CellKind, IMainCellDto, IOutputDto, NotebookCellMetadata } from '../../../common/notebookCommon.js'; import { matchCellBasedOnSimilarties } from '../../../common/services/notebookCellMatching.js'; -import { NotebookEditorSimpleWorker } from '../../../common/services/notebookSimpleWorker.js'; +import { NotebookWorker } from '../../../common/services/notebookWebWorker.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IDiffChange } from '../../../../../../base/common/diff/diff.js'; suite('NotebookDiff Diff Service', () => { ensureNoDisposablesAreLeakedInTestSuite(); - let worker: NotebookEditorSimpleWorker; + let worker: NotebookWorker; suiteSetup(() => { - worker = new NotebookEditorSimpleWorker(); + worker = new NotebookWorker(); }); suiteTeardown(() => { worker.dispose(); diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookTextModel.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookTextModel.test.ts index bbffb0d2a68..613ee43d9a3 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookTextModel.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookTextModel.test.ts @@ -874,6 +874,76 @@ suite('NotebookTextModel', () => { }); }); + test('metadata changes on newly added cells should combine their undo operations', async function () { + await withTestNotebook([ + ['var a = 1;', 'javascript', CellKind.Code, [], {}] + ], async (editor, viewModel, ds) => { + const textModel = editor.textModel; + editor.textModel.applyEdits([ + { + editType: CellEditType.Replace, index: 1, count: 0, cells: [ + ds.add(new TestCell(textModel.viewType, 1, 'var e = 5;', 'javascript', CellKind.Code, [], languageService)), + ds.add(new TestCell(textModel.viewType, 2, 'var f = 6;', 'javascript', CellKind.Code, [], languageService)) + ] + }, + ], true, undefined, () => undefined, undefined, true); + + assert.strictEqual(textModel.cells.length, 3); + + editor.textModel.applyEdits([ + { editType: CellEditType.Metadata, index: 1, metadata: { id: '123' } }, + ], true, undefined, () => undefined, undefined, true); + + assert.strictEqual(textModel.cells[1].metadata.id, '123'); + + await viewModel.undo(); + + assert.strictEqual(textModel.cells.length, 1); + + await viewModel.redo(); + + assert.strictEqual(textModel.cells.length, 3); + }); + }); + + test('changes with non-metadata edit should not combine their undo operations', async function () { + await withTestNotebook([ + ['var a = 1;', 'javascript', CellKind.Code, [], {}] + ], async (editor, viewModel, ds) => { + const textModel = editor.textModel; + editor.textModel.applyEdits([ + { + editType: CellEditType.Replace, index: 1, count: 0, cells: [ + ds.add(new TestCell(textModel.viewType, 1, 'var e = 5;', 'javascript', CellKind.Code, [], languageService)), + ds.add(new TestCell(textModel.viewType, 2, 'var f = 6;', 'javascript', CellKind.Code, [], languageService)) + ] + }, + ], true, undefined, () => undefined, undefined, true); + + assert.strictEqual(textModel.cells.length, 3); + + editor.textModel.applyEdits([ + { editType: CellEditType.Metadata, index: 1, metadata: { id: '123' } }, + { + editType: CellEditType.Output, handle: 0, append: true, outputs: [{ + outputId: 'newOutput', + outputs: [{ mime: Mimes.text, data: valueBytesFromString('cba') }, { mime: 'application/foo', data: valueBytesFromString('cba') }] + }] + } + ], true, undefined, () => undefined, undefined, true); + + assert.strictEqual(textModel.cells[1].metadata.id, '123'); + + await viewModel.undo(); + + assert.strictEqual(textModel.cells.length, 3); + + await viewModel.undo(); + + assert.strictEqual(textModel.cells.length, 1); + }); + }); + test('Destructive sorting in _doApplyEdits #121994', async function () { await withTestNotebook([ ['var a = 1;', 'javascript', CellKind.Code, [{ outputId: 'i42', outputs: [{ mime: 'm/ime', data: valueBytesFromString('test') }] }], {}] diff --git a/src/vs/workbench/contrib/output/browser/outputLinkProvider.ts b/src/vs/workbench/contrib/output/browser/outputLinkProvider.ts index e1b956a62ae..4fa628f94ac 100644 --- a/src/vs/workbench/contrib/output/browser/outputLinkProvider.ts +++ b/src/vs/workbench/contrib/output/browser/outputLinkProvider.ts @@ -12,9 +12,10 @@ import { OUTPUT_MODE_ID, LOG_MODE_ID } from '../../../services/output/common/out import { OutputLinkComputer } from '../common/outputLinkComputer.js'; import { IDisposable, dispose, Disposable } from '../../../../base/common/lifecycle.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; -import { createWebWorker } from '../../../../base/browser/defaultWorkerFactory.js'; -import { IWorkerClient } from '../../../../base/common/worker/simpleWorker.js'; +import { createWebWorker } from '../../../../base/browser/webWorkerFactory.js'; +import { IWebWorkerClient } from '../../../../base/common/worker/webWorker.js'; import { WorkerTextModelSyncClient } from '../../../../editor/common/services/textModelSync/textModelSync.impl.js'; +import { FileAccess } from '../../../../base/common/network.js'; export class OutputLinkProvider extends Disposable { @@ -88,7 +89,7 @@ export class OutputLinkProvider extends Disposable { } class OutputLinkWorkerClient extends Disposable { - private readonly _workerClient: IWorkerClient; + private readonly _workerClient: IWebWorkerClient; private readonly _workerTextModelSyncClient: WorkerTextModelSyncClient; private readonly _initializeBarrier: Promise; @@ -98,7 +99,7 @@ class OutputLinkWorkerClient extends Disposable { ) { super(); this._workerClient = this._register(createWebWorker( - 'vs/workbench/contrib/output/common/outputLinkComputer', + FileAccess.asBrowserUri('vs/workbench/contrib/output/common/outputLinkComputerMain.js'), 'OutputLinkDetectionWorker' )); this._workerTextModelSyncClient = WorkerTextModelSyncClient.create(this._workerClient, modelService); diff --git a/src/vs/workbench/contrib/output/browser/outputServices.ts b/src/vs/workbench/contrib/output/browser/outputServices.ts index f644a0f2cc1..bd0506795c6 100644 --- a/src/vs/workbench/contrib/output/browser/outputServices.ts +++ b/src/vs/workbench/contrib/output/browser/outputServices.ts @@ -108,11 +108,22 @@ class OutputViewFilters extends Disposable implements IOutputViewFilters { ) { super(); + this._trace = SHOW_TRACE_FILTER_CONTEXT.bindTo(this.contextKeyService); this._trace.set(options.trace); + + this._debug = SHOW_DEBUG_FILTER_CONTEXT.bindTo(this.contextKeyService); this._debug.set(options.debug); + + this._info = SHOW_INFO_FILTER_CONTEXT.bindTo(this.contextKeyService); this._info.set(options.info); + + this._warning = SHOW_WARNING_FILTER_CONTEXT.bindTo(this.contextKeyService); this._warning.set(options.warning); + + this._error = SHOW_ERROR_FILTER_CONTEXT.bindTo(this.contextKeyService); this._error.set(options.error); + + this._categories = HIDE_CATEGORY_FILTER_CONTEXT.bindTo(this.contextKeyService); this._categories.set(options.sources); this.filterHistory = options.filterHistory; @@ -131,7 +142,7 @@ class OutputViewFilters extends Disposable implements IOutputViewFilters { } } - private readonly _trace = SHOW_TRACE_FILTER_CONTEXT.bindTo(this.contextKeyService); + private readonly _trace: IContextKey; get trace(): boolean { return !!this._trace.get(); } @@ -142,7 +153,7 @@ class OutputViewFilters extends Disposable implements IOutputViewFilters { } } - private readonly _debug = SHOW_DEBUG_FILTER_CONTEXT.bindTo(this.contextKeyService); + private readonly _debug: IContextKey; get debug(): boolean { return !!this._debug.get(); } @@ -153,7 +164,7 @@ class OutputViewFilters extends Disposable implements IOutputViewFilters { } } - private readonly _info = SHOW_INFO_FILTER_CONTEXT.bindTo(this.contextKeyService); + private readonly _info: IContextKey; get info(): boolean { return !!this._info.get(); } @@ -164,7 +175,7 @@ class OutputViewFilters extends Disposable implements IOutputViewFilters { } } - private readonly _warning = SHOW_WARNING_FILTER_CONTEXT.bindTo(this.contextKeyService); + private readonly _warning: IContextKey; get warning(): boolean { return !!this._warning.get(); } @@ -175,7 +186,7 @@ class OutputViewFilters extends Disposable implements IOutputViewFilters { } } - private readonly _error = SHOW_ERROR_FILTER_CONTEXT.bindTo(this.contextKeyService); + private readonly _error: IContextKey; get error(): boolean { return !!this._error.get(); } @@ -186,7 +197,7 @@ class OutputViewFilters extends Disposable implements IOutputViewFilters { } } - private readonly _categories = HIDE_CATEGORY_FILTER_CONTEXT.bindTo(this.contextKeyService); + private readonly _categories: IContextKey; get categories(): string { return this._categories.get() || ','; } diff --git a/src/vs/workbench/contrib/output/common/outputLinkComputer.ts b/src/vs/workbench/contrib/output/common/outputLinkComputer.ts index bed7368cf18..95073543a07 100644 --- a/src/vs/workbench/contrib/output/common/outputLinkComputer.ts +++ b/src/vs/workbench/contrib/output/common/outputLinkComputer.ts @@ -11,20 +11,20 @@ import * as strings from '../../../../base/common/strings.js'; import { Range } from '../../../../editor/common/core/range.js'; import { isWindows } from '../../../../base/common/platform.js'; import { Schemas } from '../../../../base/common/network.js'; -import { IRequestHandler, IWorkerServer } from '../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerServerRequestHandler, IWebWorkerServer } from '../../../../base/common/worker/webWorker.js'; import { WorkerTextModelSyncServer, ICommonModel } from '../../../../editor/common/services/textModelSync/textModelSync.impl.js'; export interface IResourceCreator { toResource: (folderRelativePath: string) => URI | null; } -export class OutputLinkComputer implements IRequestHandler { +export class OutputLinkComputer implements IWebWorkerServerRequestHandler { _requestHandlerBrand: any; private readonly workerTextModelSyncServer = new WorkerTextModelSyncServer(); private patterns = new Map(); - constructor(workerServer: IWorkerServer) { + constructor(workerServer: IWebWorkerServer) { this.workerTextModelSyncServer.bindToServer(workerServer); } @@ -181,10 +181,6 @@ export class OutputLinkComputer implements IRequestHandler { } } -/** - * Defines the worker entry point. Must be exported and named `create`. - * @skipMangle - */ -export function create(workerServer: IWorkerServer): OutputLinkComputer { +export function create(workerServer: IWebWorkerServer): OutputLinkComputer { return new OutputLinkComputer(workerServer); } diff --git a/src/vs/workbench/contrib/output/common/outputLinkComputerMain.ts b/src/vs/workbench/contrib/output/common/outputLinkComputerMain.ts index 3f296c6d279..53283d5b47b 100644 --- a/src/vs/workbench/contrib/output/common/outputLinkComputerMain.ts +++ b/src/vs/workbench/contrib/output/common/outputLinkComputerMain.ts @@ -4,6 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { create } from './outputLinkComputer.js'; -import { bootstrapSimpleWorker } from '../../../../base/common/worker/simpleWorkerBootstrap.js'; +import { bootstrapWebWorker } from '../../../../base/common/worker/webWorkerBootstrap.js'; -bootstrapSimpleWorker(create); +bootstrapWebWorker(create); diff --git a/src/vs/workbench/contrib/preferences/browser/media/preferences.css b/src/vs/workbench/contrib/preferences/browser/media/preferences.css deleted file mode 100644 index f1c876cc8f8..00000000000 --- a/src/vs/workbench/contrib/preferences/browser/media/preferences.css +++ /dev/null @@ -1,242 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.preferences-editor { - display: flex; - flex-direction: column; -} - -.preferences-editor > .preferences-header { - padding-left: 27px; - padding-right: 32px; - padding-bottom: 11px; - padding-top: 11px; -} - -.preferences-editor .deprecation-warning { - display: flex; - margin-top: 4px; -} - -.preferences-editor .deprecation-warning .icon { - margin-right: 3px; -} - -.preferences-editor .deprecation-warning .learnMore-button { - margin-left: 3px; - text-decoration: underline; -} - -.preferences-editor > .preferences-editors-container.side-by-side-preferences-editor { - flex: 1; -} - -.preferences-editor > .preferences-editors-container.side-by-side-preferences-editor .preferences-header-container { - line-height: 28px; -} - -.settings-tabs-widget > .monaco-action-bar .action-item.disabled { - display: none; -} - -.settings-tabs-widget > .monaco-action-bar .action-item { - max-width: 300px; - overflow: hidden; - text-overflow: ellipsis; -} - -.default-preferences-editor-container > .preferences-header-container > .default-preferences-header, -.settings-tabs-widget > .monaco-action-bar .action-item .action-label { - text-transform: uppercase; - font-size: 11px; - margin-right: 5px; - cursor: pointer; - display: flex; - overflow: hidden; - text-overflow: ellipsis; -} - -.default-preferences-editor-container > .preferences-header-container > .default-preferences-header, -.preferences-editor .settings-tabs-widget > .monaco-action-bar .action-item .action-label { - margin-left: 33px; -} - -.settings-tabs-widget > .monaco-action-bar .action-item .action-label { - display: block; - padding: 0px; - border-radius: initial; - background: none !important; -} - -.settings-tabs-widget > .monaco-action-bar .action-item .action-label.folder-settings { - display: flex; -} - -.default-preferences-editor-container > .preferences-header-container > .default-preferences-header, -.settings-tabs-widget > .monaco-action-bar .action-item { - padding: 3px 0px; -} - -.settings-tabs-widget > .monaco-action-bar .action-item .action-title { - text-overflow: ellipsis; - overflow: hidden; -} - -.settings-tabs-widget > .monaco-action-bar .action-item .action-details { - text-transform: none; - margin-left: 0.5em; - font-size: 10px; - opacity: 0.7; -} - -.settings-tabs-widget .monaco-action-bar .action-item .dropdown-icon { - padding-left: 0.3em; - padding-top: 8px; - font-size: 12px; -} - -.settings-tabs-widget .monaco-action-bar .action-item .dropdown-icon.hide { - display: none; -} - -.settings-tabs-widget > .monaco-action-bar .action-item .action-label { - color: var(--vscode-panelTitle-inactiveForeground); -} - -.settings-tabs-widget > .monaco-action-bar .action-item .action-label.checked, -.settings-tabs-widget > .monaco-action-bar .action-item .action-label:hover { - color: var(--vscode-panelTitle-activeForeground); - border-bottom: 1px solid var(--vscode-panelTitle-activeBorder); - outline: 1px solid var(--vscode-contrastActiveBorder, transparent); - outline-offset: -1px; -} - -.settings-tabs-widget > .monaco-action-bar .action-item .action-label:focus { - border-bottom: 1px solid var(--vscode-focusBorder); - outline: 1px solid transparent; - outline-offset: -1px; -} - -.settings-tabs-widget > .monaco-action-bar .action-item .action-label:not(.checked):hover { - outline-style: dashed; -} - -.preferences-header > .settings-header-widget { - flex: 1; - display: flex; - position: relative; - align-self: stretch; -} - -.settings-header-widget > .settings-search-controls > .settings-count-widget { - margin: 6px 0px; - padding: 0px 8px; - border-radius: 2px; - float: left; -} - -.settings-header-widget > .settings-search-controls { - position: absolute; - right: 10px; -} - -.settings-header-widget > .settings-search-controls > .settings-count-widget.hide { - display: none; -} - -.settings-header-widget > .settings-search-container { - flex: 1; -} - -.settings-header-widget > .settings-search-container > .settings-search-input { - vertical-align: middle; -} - -.settings-header-widget > .settings-search-container > .settings-search-input > .monaco-inputbox { - height: 30px; -} - -.monaco-workbench.vs .settings-header-widget > .settings-search-container > .settings-search-input > .monaco-inputbox { - border: 1px solid #ddd; -} - -.settings-header-widget > .settings-search-container > .settings-search-input > .monaco-inputbox .input { - font-size: 14px; - padding-left:10px; -} - -.monaco-editor .view-zones > .settings-header-widget { - z-index: 1; -} - -.monaco-editor .settings-header-widget .title-container { - display: flex; - user-select: none; - -webkit-user-select: none; -} - -.monaco-editor .settings-header-widget .title-container .title { - font-weight: bold; - white-space: nowrap; - text-transform: uppercase; -} - -.monaco-editor .settings-header-widget .title-container .message { - white-space: nowrap; -} - -.monaco-editor .settings-group-title-widget { - z-index: 1; -} - -.monaco-editor .settings-group-title-widget .title-container { - width: 100%; - cursor: pointer; - font-weight: bold; - user-select: none; - -webkit-user-select: none; - display: flex; -} - - -.monaco-editor .settings-group-title-widget .title-container .title { - white-space: nowrap; - overflow: hidden; -} - -.monaco-editor.vs-dark .settings-group-title-widget .title-container.focused, -.monaco-editor.vs .settings-group-title-widget .title-container.focused { - outline: none !important; -} - -.monaco-editor .settings-group-title-widget .title-container.focused, -.monaco-editor .settings-group-title-widget .title-container:hover { - background-color: rgba(153, 153, 153, 0.2); -} - -.monaco-editor.hc-black .settings-group-title-widget .title-container.focused { - outline: 1px dotted #f38518; -} - -.monaco-editor.hc-light .settings-group-title-widget .title-container.focused { - outline: 1px dotted #0F4A85; -} - -.monaco-editor .settings-group-title-widget .title-container .codicon { - margin: 0 2px; - width: 16px; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} - -.monaco-editor .dim-configuration { - color: #b1b1b1; -} - -.codicon-settings-edit:hover { - cursor: pointer; -} diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css index aa1fb1c0536..35647bd1aa8 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css @@ -64,12 +64,28 @@ } .settings-editor > .settings-header > .settings-header-controls { - height: 32px; display: flex; + flex-wrap: wrap; border-bottom: solid 1px; margin-top: 10px; } +.settings-editor > .settings-header > .settings-header-controls .settings-suggestions { + flex: 0 0 100%; + width: 100%; + min-height: 20px; + margin-bottom: 9px; +} + +.settings-editor > .settings-header > .settings-header-controls .settings-suggestions a { + color: var(--vscode-badge-foreground); + background: var(--vscode-badge-background); + cursor: pointer; + margin-right: 4px; + padding: 0px 4px 2px; + border-radius: 4px; +} + .settings-editor > .settings-header > .settings-header-controls .settings-target-container { flex: auto; } @@ -593,11 +609,15 @@ padding-bottom: 26px; } -.settings-editor > .settings-body .settings-tree-container .setting-item-bool .setting-item-value-description { +.settings-editor > .settings-body .settings-tree-container .setting-item-bool .setting-item-description { display: flex; cursor: pointer; } +.settings-editor > .settings-body .settings-tree-container .setting-item-bool .setting-item-description.disabled { + cursor: initial; +} + .settings-editor > .settings-body .settings-tree-container .setting-item-bool .setting-value-checkbox { height: 18px; width: 18px; diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css b/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css index 088783e156c..3f7611dd40b 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css @@ -227,3 +227,147 @@ .settings-editor > .settings-body .settings-tree-container .setting-list-widget .setting-list-object-list-row.select-container > select { width: inherit; } + +.settings-tabs-widget > .monaco-action-bar .action-item.disabled { + display: none; +} + +.settings-tabs-widget > .monaco-action-bar .action-item { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; +} + +.settings-tabs-widget > .monaco-action-bar .action-item .action-label { + text-transform: uppercase; + font-size: 11px; + margin-right: 5px; + cursor: pointer; + display: flex; + overflow: hidden; + text-overflow: ellipsis; +} + +.settings-tabs-widget > .monaco-action-bar .action-item .action-label { + display: block; + padding: 0px; + border-radius: initial; + background: none !important; +} + +.settings-tabs-widget > .monaco-action-bar .action-item .action-label.folder-settings { + display: flex; +} + +.settings-tabs-widget > .monaco-action-bar .action-item { + padding: 3px 0px; +} + +.settings-tabs-widget > .monaco-action-bar .action-item .action-title { + text-overflow: ellipsis; + overflow: hidden; +} + +.settings-tabs-widget > .monaco-action-bar .action-item .action-details { + text-transform: none; + margin-left: 0.5em; + font-size: 10px; + opacity: 0.7; +} + +.settings-tabs-widget .monaco-action-bar .action-item .dropdown-icon { + padding-left: 0.3em; + padding-top: 8px; + font-size: 12px; +} + +.settings-tabs-widget .monaco-action-bar .action-item .dropdown-icon.hide { + display: none; +} + +.settings-tabs-widget > .monaco-action-bar .action-item .action-label { + color: var(--vscode-panelTitle-inactiveForeground); +} + +.settings-tabs-widget > .monaco-action-bar .action-item .action-label.checked, +.settings-tabs-widget > .monaco-action-bar .action-item .action-label:hover { + color: var(--vscode-panelTitle-activeForeground); + border-bottom: 1px solid var(--vscode-panelTitle-activeBorder); + outline: 1px solid var(--vscode-contrastActiveBorder, transparent); + outline-offset: -1px; +} + +.settings-tabs-widget > .monaco-action-bar .action-item .action-label:focus { + border-bottom: 1px solid var(--vscode-focusBorder); + outline: 1px solid transparent; + outline-offset: -1px; +} + +.settings-tabs-widget > .monaco-action-bar .action-item .action-label:not(.checked):hover { + outline-style: dashed; +} + +.settings-header-widget > .settings-search-controls > .settings-count-widget { + margin: 6px 0px; + padding: 0px 8px; + border-radius: 2px; + float: left; +} + +.settings-header-widget > .settings-search-controls { + position: absolute; + right: 10px; +} + +.settings-header-widget > .settings-search-controls > .settings-count-widget.hide { + display: none; +} + +.settings-header-widget > .settings-search-container { + flex: 1; +} + +.settings-header-widget > .settings-search-container > .settings-search-input { + vertical-align: middle; +} + +.settings-header-widget > .settings-search-container > .settings-search-input > .monaco-inputbox { + height: 30px; +} + +.monaco-workbench.vs .settings-header-widget > .settings-search-container > .settings-search-input > .monaco-inputbox { + border: 1px solid #ddd; +} + +.settings-header-widget > .settings-search-container > .settings-search-input > .monaco-inputbox .input { + font-size: 14px; + padding-left:10px; +} + +.monaco-editor .view-zones > .settings-header-widget { + z-index: 1; +} + +.monaco-editor .settings-header-widget .title-container { + display: flex; + user-select: none; + -webkit-user-select: none; +} + +.monaco-editor .settings-header-widget .title-container .title { + font-weight: bold; + white-space: nowrap; + text-transform: uppercase; +} + +.monaco-editor .settings-header-widget .title-container .message { + white-space: nowrap; +} + +.monaco-editor .dim-configuration { + color: #b1b1b1; +} + +.codicon-settings-edit:hover { + cursor: pointer; +} diff --git a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts index d69acc0a677..f1a4ac3ff04 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts @@ -8,7 +8,6 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base import { Schemas } from '../../../../base/common/network.js'; import { isBoolean, isObject, isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; -import './media/preferences.css'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; import { Context as SuggestContext } from '../../../../editor/contrib/suggest/browser/suggest.js'; import * as nls from '../../../../nls.js'; @@ -31,7 +30,6 @@ import { ResourceContextKey, RemoteNameContext, WorkbenchStateContext } from '.. import { ExplorerFolderContext, ExplorerRootContext } from '../../files/common/files.js'; import { KeybindingsEditor } from './keybindingsEditor.js'; import { ConfigureLanguageBasedSettingsAction } from './preferencesActions.js'; -import { SettingsEditorContribution } from './preferencesEditor.js'; import { preferencesOpenSettingsIcon } from './preferencesIcons.js'; import { SettingsEditor2, SettingsFocusContext } from './settingsEditor2.js'; import { CONTEXT_KEYBINDINGS_EDITOR, CONTEXT_KEYBINDINGS_SEARCH_FOCUS, CONTEXT_KEYBINDING_FOCUS, CONTEXT_SETTINGS_EDITOR, CONTEXT_SETTINGS_JSON_EDITOR, CONTEXT_SETTINGS_ROW_FOCUS, CONTEXT_SETTINGS_SEARCH_FOCUS, CONTEXT_TOC_ROW_FOCUS, CONTEXT_WHEN_FOCUS, KEYBINDINGS_EDITOR_COMMAND_ACCEPT_WHEN, KEYBINDINGS_EDITOR_COMMAND_ADD, KEYBINDINGS_EDITOR_COMMAND_CLEAR_SEARCH_HISTORY, KEYBINDINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, KEYBINDINGS_EDITOR_COMMAND_COPY, KEYBINDINGS_EDITOR_COMMAND_COPY_COMMAND, KEYBINDINGS_EDITOR_COMMAND_COPY_COMMAND_TITLE, KEYBINDINGS_EDITOR_COMMAND_DEFINE, KEYBINDINGS_EDITOR_COMMAND_DEFINE_WHEN, KEYBINDINGS_EDITOR_COMMAND_FOCUS_KEYBINDINGS, KEYBINDINGS_EDITOR_COMMAND_RECORD_SEARCH_KEYS, KEYBINDINGS_EDITOR_COMMAND_REJECT_WHEN, KEYBINDINGS_EDITOR_COMMAND_REMOVE, KEYBINDINGS_EDITOR_COMMAND_RESET, KEYBINDINGS_EDITOR_COMMAND_SEARCH, KEYBINDINGS_EDITOR_COMMAND_SHOW_SIMILAR, KEYBINDINGS_EDITOR_COMMAND_SORTBY_PRECEDENCE, KEYBINDINGS_EDITOR_SHOW_DEFAULT_KEYBINDINGS, KEYBINDINGS_EDITOR_SHOW_EXTENSION_KEYBINDINGS, KEYBINDINGS_EDITOR_SHOW_USER_KEYBINDINGS, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, SETTINGS_EDITOR_COMMAND_SHOW_CONTEXT_MENU } from '../common/preferences.js'; @@ -44,11 +42,14 @@ import { DEFINE_KEYBINDING_EDITOR_CONTRIB_ID, IDefineKeybindingEditorContributio import { SettingsEditor2Input } from '../../../services/preferences/common/preferencesEditorInput.js'; import { IUserDataProfileService, CURRENT_PROFILE_CONTEXT } from '../../../services/userDataProfile/common/userDataProfile.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; -import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { ICodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { resolveCommandsContext } from '../../../browser/parts/editor/editorCommandsContext.js'; import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IListService } from '../../../../platform/list/browser/listService.js'; +import { SettingsEditorModel } from '../../../services/preferences/common/preferencesModels.js'; +import { IPreferencesRenderer, WorkspaceSettingsRenderer, UserSettingsRenderer } from './preferencesRenderers.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; const SETTINGS_EDITOR_COMMAND_SEARCH = 'settings.action.search'; @@ -1258,6 +1259,7 @@ class SettingsEditorTitleContribution extends Disposable implements IWorkbenchCo ResourceContextKey.Resource.isEqualTo(this.userDataProfileService.currentProfile.settingsResource.toString()), ResourceContextKey.Resource.isEqualTo(this.userDataProfilesService.defaultProfile.settingsResource.toString())), ContextKeyExpr.not('isInDiffEditor')); + registerOpenUserSettingsEditorFromJsonActionDisposables.clear(); registerOpenUserSettingsEditorFromJsonActionDisposables.value = registerAction2(class extends Action2 { constructor() { super({ @@ -1313,6 +1315,51 @@ class SettingsEditorTitleContribution extends Disposable implements IWorkbenchCo } } +class SettingsEditorContribution extends Disposable { + static readonly ID: string = 'editor.contrib.settings'; + + private currentRenderer: IPreferencesRenderer | undefined; + private readonly disposables = this._register(new DisposableStore()); + + constructor( + private readonly editor: ICodeEditor, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IPreferencesService private readonly preferencesService: IPreferencesService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService + ) { + super(); + this._createPreferencesRenderer(); + this._register(this.editor.onDidChangeModel(e => this._createPreferencesRenderer())); + this._register(this.workspaceContextService.onDidChangeWorkbenchState(() => this._createPreferencesRenderer())); + } + + private async _createPreferencesRenderer(): Promise { + this.disposables.clear(); + this.currentRenderer = undefined; + + const model = this.editor.getModel(); + if (model && /\.(json|code-workspace)$/.test(model.uri.path)) { + // Fast check: the preferences renderer can only appear + // in settings files or workspace files + const settingsModel = await this.preferencesService.createPreferencesEditorModel(model.uri); + if (settingsModel instanceof SettingsEditorModel && this.editor.getModel()) { + this.disposables.add(settingsModel); + switch (settingsModel.configurationTarget) { + case ConfigurationTarget.WORKSPACE: + this.currentRenderer = this.disposables.add(this.instantiationService.createInstance(WorkspaceSettingsRenderer, this.editor, settingsModel)); + break; + default: + this.currentRenderer = this.disposables.add(this.instantiationService.createInstance(UserSettingsRenderer, this.editor, settingsModel)); + break; + } + } + + this.currentRenderer?.render(); + } + } +} + + function getEditorGroupFromArguments(accessor: ServicesAccessor, args: unknown[]): IEditorGroup | undefined { const context = resolveCommandsContext(args, accessor.get(IEditorService), accessor.get(IEditorGroupsService), accessor.get(IListService)); return context.groupedEditors[0]?.group; diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts b/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts index 46fa025a87e..a9b88a30f57 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts @@ -19,7 +19,6 @@ import { MenuId, MenuRegistry, isIMenuItem } from '../../../../platform/actions/ import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { isLocalizedString } from '../../../../platform/action/common/action.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { KeybindingsRegistry } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; export class ConfigureLanguageBasedSettingsAction extends Action { @@ -120,26 +119,6 @@ CommandsRegistry.registerCommand('_getAllCommands', function (accessor, filterBy }); } } - for (const command of KeybindingsRegistry.getDefaultKeybindings()) { - if (filterByPrecondition && !contextKeyService.contextMatchesRules(command.when ?? undefined)) { - continue; - } - - const keybinding = keybindingService.lookupKeybinding(command.command ?? ''); - if (!keybinding) { - continue; - } - - if (actions.some(a => a.command === command.command)) { - continue; - } - actions.push({ - command: command.command ?? '', - label: command.command ?? '', - keybinding: keybinding?.getLabel() ?? 'Not set', - precondition: command.when?.serialize() - }); - } return actions; }); diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesEditor.ts b/src/vs/workbench/contrib/preferences/browser/preferencesEditor.ts deleted file mode 100644 index aed3a321c96..00000000000 --- a/src/vs/workbench/contrib/preferences/browser/preferencesEditor.ts +++ /dev/null @@ -1,57 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; -import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IPreferencesRenderer, UserSettingsRenderer, WorkspaceSettingsRenderer } from './preferencesRenderers.js'; -import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; -import { SettingsEditorModel } from '../../../services/preferences/common/preferencesModels.js'; - -export class SettingsEditorContribution extends Disposable { - static readonly ID: string = 'editor.contrib.settings'; - - private currentRenderer: IPreferencesRenderer | undefined; - private readonly disposables = this._register(new DisposableStore()); - - constructor( - private readonly editor: ICodeEditor, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IPreferencesService private readonly preferencesService: IPreferencesService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService - ) { - super(); - this._createPreferencesRenderer(); - this._register(this.editor.onDidChangeModel(e => this._createPreferencesRenderer())); - this._register(this.workspaceContextService.onDidChangeWorkbenchState(() => this._createPreferencesRenderer())); - } - - private async _createPreferencesRenderer(): Promise { - this.disposables.clear(); - this.currentRenderer = undefined; - - const model = this.editor.getModel(); - if (model && /\.(json|code-workspace)$/.test(model.uri.path)) { - // Fast check: the preferences renderer can only appear - // in settings files or workspace files - const settingsModel = await this.preferencesService.createPreferencesEditorModel(model.uri); - if (settingsModel instanceof SettingsEditorModel && this.editor.getModel()) { - this.disposables.add(settingsModel); - switch (settingsModel.configurationTarget) { - case ConfigurationTarget.WORKSPACE: - this.currentRenderer = this.disposables.add(this.instantiationService.createInstance(WorkspaceSettingsRenderer, this.editor, settingsModel)); - break; - default: - this.currentRenderer = this.disposables.add(this.instantiationService.createInstance(UserSettingsRenderer, this.editor, settingsModel)); - break; - } - } - - this.currentRenderer?.render(); - } - } -} diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts index 52ef9dbd446..833ba3ec78e 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts @@ -17,10 +17,10 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ExtensionType } from '../../../../platform/extensions/common/extensions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { IAiRelatedInformationService, RelatedInformationType, SettingInformationResult } from '../../../services/aiRelatedInformation/common/aiRelatedInformation.js'; import { TfIdfCalculator, TfIdfDocument } from '../../../../base/common/tfIdf.js'; import { IStringDictionary } from '../../../../base/common/collections.js'; import { nullRange } from '../../../services/preferences/common/preferencesModels.js'; +import { IAiSettingsSearchService } from '../../../services/aiSettingsSearch/common/aiSettingsSearch.js'; export interface IEndpointDetails { urlBase?: string; @@ -89,7 +89,7 @@ export class LocalSearchProvider implements ISearchProvider { this._filter = cleanFilter(this._filter); } - searchModel(preferencesModel: ISettingsEditorModel, token?: CancellationToken): Promise { + searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise { if (!this._filter) { return Promise.resolve(null); } @@ -99,7 +99,6 @@ export class LocalSearchProvider implements ISearchProvider { this._filter, setting, true, - (filter, setting) => preferencesModel.findValueMatches(filter, setting), this.configurationService ); if (matchType === SettingMatchType.None || matches.length === 0) { @@ -117,22 +116,15 @@ export class LocalSearchProvider implements ISearchProvider { }; const filterMatches = preferencesModel.filterSettings(this._filter, this.getGroupFilter(this._filter), settingMatcher); - const exactMatch = filterMatches.find(m => m.matchType === SettingMatchType.ExactMatch); - if (exactMatch) { - return Promise.resolve({ - filterMatches: [exactMatch], - exactMatch: true - }); - } // Check the top key match type. const topKeyMatchType = Math.max(...filterMatches.map(m => (m.matchType & SettingKeyMatchTypes))); // Always allow description matches as part of https://github.com/microsoft/vscode/issues/239936. const alwaysAllowedMatchTypes = SettingMatchType.DescriptionOrValueMatch | SettingMatchType.LanguageTagSettingMatch; - const filteredMatches = filterMatches.filter(m => (m.matchType & topKeyMatchType) || (m.matchType & alwaysAllowedMatchTypes)); + const filteredMatches = filterMatches.filter(m => (m.matchType & topKeyMatchType) || (m.matchType & alwaysAllowedMatchTypes) || m.matchType === SettingMatchType.ExactMatch); return Promise.resolve({ filterMatches: filteredMatches, - exactMatch: false + exactMatch: filteredMatches.some(m => m.matchType === SettingMatchType.ExactMatch) }); } @@ -157,7 +149,6 @@ export class SettingMatches { searchString: string, setting: ISetting, private searchDescription: boolean, - valuesMatcher: (filter: string, setting: ISetting) => IRange[], private readonly configurationService: IConfigurationService ) { this.matches = distinct(this._findMatchesInSetting(searchString, setting), (match) => `${match.startLineNumber}_${match.startColumn}_${match.endLineNumber}_${match.endColumn}_`); @@ -359,14 +350,11 @@ export class SettingMatches { } } -class AiRelatedInformationSearchKeysProvider { - private settingKeys: string[] = []; +class AiSettingsSearchKeysProvider { private settingsRecord: IStringDictionary = {}; private currentPreferencesModel: ISettingsEditorModel | undefined; - constructor( - private readonly aiRelatedInformationService: IAiRelatedInformationService - ) { } + constructor() { } updateModel(preferencesModel: ISettingsEditorModel) { if (preferencesModel === this.currentPreferencesModel) { @@ -378,13 +366,9 @@ class AiRelatedInformationSearchKeysProvider { } private refresh() { - this.settingKeys = []; this.settingsRecord = {}; - if ( - !this.currentPreferencesModel || - !this.aiRelatedInformationService.isEnabled() - ) { + if (!this.currentPreferencesModel) { return; } @@ -394,75 +378,66 @@ class AiRelatedInformationSearchKeysProvider { } for (const section of group.sections) { for (const setting of section.settings) { - this.settingKeys.push(setting.key); this.settingsRecord[setting.key] = setting; } } } } - getSettingKeys(): string[] { - return this.settingKeys; - } - getSettingsRecord(): IStringDictionary { return this.settingsRecord; } } -class AiRelatedInformationSearchProvider implements IRemoteSearchProvider { - private static readonly AI_RELATED_INFORMATION_MAX_PICKS = 5; +class AiSettingsSearchProvider implements IRemoteSearchProvider { + private static readonly AI_SETTINGS_SEARCH_MAX_PICKS = 5; - private readonly _keysProvider: AiRelatedInformationSearchKeysProvider; + private readonly _keysProvider: AiSettingsSearchKeysProvider; private _filter: string = ''; - constructor( - @IAiRelatedInformationService private readonly aiRelatedInformationService: IAiRelatedInformationService - ) { - this._keysProvider = new AiRelatedInformationSearchKeysProvider(aiRelatedInformationService); + constructor(private readonly aiSettingsSearchService: IAiSettingsSearchService) { + this._keysProvider = new AiSettingsSearchKeysProvider(); } setFilter(filter: string) { this._filter = cleanFilter(filter); } - async searchModel(preferencesModel: ISettingsEditorModel, token?: CancellationToken | undefined): Promise { + async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise { if ( !this._filter || - !this.aiRelatedInformationService.isEnabled() + !this.aiSettingsSearchService.isEnabled() ) { return null; } this._keysProvider.updateModel(preferencesModel); + this.aiSettingsSearchService.startSearch(this._filter, token); return { - filterMatches: await this.getAiRelatedInformationItems(token) + filterMatches: await this.getAiSettingsSearchItems(token), + exactMatch: false }; } - private async getAiRelatedInformationItems(token?: CancellationToken | undefined) { + private async getAiSettingsSearchItems(token: CancellationToken): Promise { const settingsRecord = this._keysProvider.getSettingsRecord(); - const filterMatches: ISettingMatch[] = []; - const relatedInformation = await this.aiRelatedInformationService.getRelatedInformation( - this._filter, - [RelatedInformationType.SettingInformation], - token ?? CancellationToken.None - ) as SettingInformationResult[]; - relatedInformation.sort((a, b) => b.weight - a.weight); + const settings = await this.aiSettingsSearchService.getEmbeddingsResults(this._filter, token); + if (!settings) { + return []; + } - for (const info of relatedInformation) { - if (filterMatches.length === AiRelatedInformationSearchProvider.AI_RELATED_INFORMATION_MAX_PICKS) { + for (const settingKey of settings) { + if (filterMatches.length === AiSettingsSearchProvider.AI_SETTINGS_SEARCH_MAX_PICKS) { break; } - const pick = info.setting; filterMatches.push({ - setting: settingsRecord[pick], - matches: [settingsRecord[pick].range], + setting: settingsRecord[settingKey], + matches: [settingsRecord[settingKey].range], matchType: SettingMatchType.RemoteMatch, keyMatchScore: 0, - score: info.weight + score: 0 // the results are sorted upstream. }); } @@ -504,7 +479,7 @@ class TfIdfSearchProvider implements IRemoteSearchProvider { return result; } - async searchModel(preferencesModel: ISettingsEditorModel, token?: CancellationToken | undefined): Promise { + async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise { if (!this._filter) { return null; } @@ -531,15 +506,16 @@ class TfIdfSearchProvider implements IRemoteSearchProvider { } return { - filterMatches: await this.getTfIdfItems(token) + filterMatches: await this.getTfIdfItems(token), + exactMatch: false }; } - private async getTfIdfItems(token?: CancellationToken | undefined): Promise { + private async getTfIdfItems(token: CancellationToken): Promise { const filterMatches: ISettingMatch[] = []; const tfIdfCalculator = new TfIdfCalculator(); tfIdfCalculator.updateDocuments(this._documents); - const tfIdfRankings = tfIdfCalculator.calculateScores(this._filter, token ?? CancellationToken.None); + const tfIdfRankings = tfIdfCalculator.calculateScores(this._filter, token); tfIdfRankings.sort((a, b) => b.score - a.score); const maxScore = tfIdfRankings[0].score; @@ -567,44 +543,43 @@ class TfIdfSearchProvider implements IRemoteSearchProvider { } class RemoteSearchProvider implements IRemoteSearchProvider { - private adaSearchProvider: AiRelatedInformationSearchProvider | undefined; - private tfIdfSearchProvider: TfIdfSearchProvider | undefined; + private aiSettingsSearchProvider: AiSettingsSearchProvider; + private tfIdfSearchProvider: TfIdfSearchProvider; private filter: string = ''; constructor( - @IAiRelatedInformationService private readonly aiRelatedInformationService: IAiRelatedInformationService + @IAiSettingsSearchService private readonly aiSettingsSearchService: IAiSettingsSearchService ) { - } - - private initializeSearchProviders() { - if (this.aiRelatedInformationService.isEnabled()) { - this.adaSearchProvider ??= new AiRelatedInformationSearchProvider(this.aiRelatedInformationService); - } - this.tfIdfSearchProvider ??= new TfIdfSearchProvider(); + this.aiSettingsSearchProvider = new AiSettingsSearchProvider(this.aiSettingsSearchService); + this.tfIdfSearchProvider = new TfIdfSearchProvider(); } setFilter(filter: string): void { - this.initializeSearchProviders(); this.filter = filter; - if (this.adaSearchProvider) { - this.adaSearchProvider.setFilter(filter); - } - this.tfIdfSearchProvider!.setFilter(filter); + this.tfIdfSearchProvider.setFilter(filter); + this.aiSettingsSearchProvider.setFilter(filter); } - searchModel(preferencesModel: ISettingsEditorModel, token?: CancellationToken): Promise { + async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise { if (!this.filter) { - return Promise.resolve(null); + return null; } - if (!this.adaSearchProvider) { - return this.tfIdfSearchProvider!.searchModel(preferencesModel, token); + if (!this.aiSettingsSearchService.isEnabled()) { + return this.tfIdfSearchProvider.searchModel(preferencesModel, token); } - // Use TF-IDF search as a fallback, ref https://github.com/microsoft/vscode/issues/224946 - return this.adaSearchProvider.searchModel(preferencesModel, token).then((results) => { - return results?.filterMatches.length ? results : this.tfIdfSearchProvider!.searchModel(preferencesModel, token); - }); + let results = await this.aiSettingsSearchProvider.searchModel(preferencesModel, token); + if (results?.filterMatches.length) { + return results; + } + if (!token.isCancellationRequested) { + results = await this.tfIdfSearchProvider.searchModel(preferencesModel, token); + if (results?.filterMatches.length) { + return results; + } + } + return null; } } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 5b1143c5dbf..7acbd9efb85 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -41,7 +41,7 @@ import { ITOCEntry, getCommonlyUsedData, tocData } from './settingsLayout.js'; import { AbstractSettingRenderer, HeightChangeParams, ISettingLinkClickEvent, resolveConfiguredUntrustedSettings, createTocTreeForExtensionSettings, resolveSettingsTree, SettingsTree, SettingTreeRenderers } from './settingsTree.js'; import { ISettingsEditorViewState, parseQuery, SearchResultIdx, SearchResultModel, SettingsTreeElement, SettingsTreeGroupChild, SettingsTreeGroupElement, SettingsTreeModel, SettingsTreeSettingElement } from './settingsTreeModels.js'; import { createTOCIterator, TOCTree, TOCTreeModel } from './tocTree.js'; -import { CONTEXT_SETTINGS_EDITOR, CONTEXT_SETTINGS_ROW_FOCUS, CONTEXT_SETTINGS_SEARCH_FOCUS, CONTEXT_TOC_ROW_FOCUS, ENABLE_LANGUAGE_FILTER, EXTENSION_FETCH_TIMEOUT_MS, EXTENSION_SETTING_TAG, FEATURE_SETTING_TAG, ID_SETTING_TAG, IPreferencesSearchService, ISearchProvider, LANGUAGE_SETTING_TAG, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, SETTINGS_EDITOR_COMMAND_SUGGEST_FILTERS, WORKSPACE_TRUST_SETTING_TAG, getExperimentalExtensionToggleData } from '../common/preferences.js'; +import { CONTEXT_SETTINGS_EDITOR, CONTEXT_SETTINGS_ROW_FOCUS, CONTEXT_SETTINGS_SEARCH_FOCUS, CONTEXT_TOC_ROW_FOCUS, ENABLE_LANGUAGE_FILTER, EXTENSION_FETCH_TIMEOUT_MS, EXTENSION_SETTING_TAG, FEATURE_SETTING_TAG, ID_SETTING_TAG, IPreferencesSearchService, ISearchProvider, LANGUAGE_SETTING_TAG, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, SETTINGS_EDITOR_COMMAND_SUGGEST_FILTERS, WORKSPACE_TRUST_SETTING_TAG, getExperimentalExtensionToggleData, wordifyKey } from '../common/preferences.js'; import { settingsHeaderBorder, settingsSashBorder, settingsTextInputBorder } from '../common/settingsEditorColorRegistry.js'; import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IOpenSettingsOptions, IPreferencesService, ISearchResult, ISetting, ISettingsEditorModel, ISettingsEditorOptions, ISettingsGroup, SettingMatchType, SettingValueType, validateSettingsEditorOptions } from '../../../services/preferences/common/preferences.js'; @@ -68,7 +68,7 @@ import { IEditorProgressService } from '../../../../platform/progress/common/pro import { IExtensionManifest } from '../../../../platform/extensions/common/extensions.js'; import { CodeWindow } from '../../../../base/browser/window.js'; import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; - +import { IAiSettingsSearchService } from '../../../services/aiSettingsSearch/common/aiSettingsSearch.js'; export const enum SettingsFocusContext { Search, @@ -168,6 +168,7 @@ export class SettingsEditor2 extends EditorPane { private countElement!: HTMLElement; private controlsElement!: HTMLElement; private settingsTargetsWidget!: SettingsTargetsWidget; + private suggestionsDiv!: HTMLElement; private splitView!: SplitView; @@ -226,6 +227,8 @@ export class SettingsEditor2 extends EditorPane { private readonly inputChangeListener: MutableDisposable; + private readonly searchSuggestionDisposables: DisposableStore = this._register(new DisposableStore()); + constructor( group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @@ -249,6 +252,7 @@ export class SettingsEditor2 extends EditorPane { @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, @IEditorProgressService private readonly editorProgressService: IEditorProgressService, @IUserDataProfileService userDataProfileService: IUserDataProfileService, + @IAiSettingsSearchService private readonly aiSettingsSearchService: IAiSettingsSearchService ) { super(SettingsEditor2.ID, group, telemetryService, themeService, storageService); this.searchDelayer = new Delayer(300); @@ -668,6 +672,11 @@ export class SettingsEditor2 extends EditorPane { const headerControlsContainer = DOM.append(this.headerContainer, $('.settings-header-controls')); headerControlsContainer.style.borderColor = asCssVariable(settingsHeaderBorder); + this.suggestionsDiv = DOM.append(headerControlsContainer, $('div.settings-suggestions')); + if (this.configurationService.getValue('workbench.settings.showExperimentalSuggestions') === false) { + this.suggestionsDiv.hidden = true; + } + const targetWidgetContainer = DOM.append(headerControlsContainer, $('.settings-target-container')); this.settingsTargetsWidget = this._register(this.instantiationService.createInstance(SettingsTargetsWidget, targetWidgetContainer, { enableRemoteSettings: true })); this.settingsTargetsWidget.settingsTarget = ConfigurationTarget.USER_LOCAL; @@ -1606,6 +1615,7 @@ export class SettingsEditor2 extends EditorPane { } private async triggerSearch(query: string): Promise { + this.clearSearchSuggestions(); const progressRunner = this.editorProgressService.show(true, 800); this.viewState.tagFilters = new Set(); this.viewState.extensionFilters = new Set(); @@ -1637,8 +1647,7 @@ export class SettingsEditor2 extends EditorPane { this.searchDelayer.cancel(); if (this.searchInProgress) { - this.searchInProgress.cancel(); - this.searchInProgress.dispose(); + this.searchInProgress.dispose(true); this.searchInProgress = null; } @@ -1673,7 +1682,8 @@ export class SettingsEditor2 extends EditorPane { const filterModel = this.instantiationService.createInstance(SearchResultModel, this.viewState, this.settingsOrderByTocIndex, this.workspaceTrustManagementService.isWorkspaceTrusted()); const fullResult: ISearchResult = { - filterMatches: [] + filterMatches: [], + exactMatch: false, }; for (const g of this.defaultSettingsEditorModel.settingsGroups.slice(1)) { for (const sect of g.sections) { @@ -1689,22 +1699,48 @@ export class SettingsEditor2 extends EditorPane { private async triggerFilterPreferences(query: string): Promise { if (this.searchInProgress) { - this.searchInProgress.cancel(); + this.searchInProgress.dispose(true); this.searchInProgress = null; } // Trigger the local search. If it didn't find an exact match, trigger the remote search. const searchInProgress = this.searchInProgress = new CancellationTokenSource(); return this.searchDelayer.trigger(async () => { - if (!searchInProgress.token.isCancellationRequested) { - const localResults = await this.localFilterPreferences(query, searchInProgress.token); - if (localResults && !localResults.exactMatch && !searchInProgress.token.isCancellationRequested) { - await this.remoteSearchPreferences(query, searchInProgress.token); - } + if (searchInProgress.token.isCancellationRequested) { + return; + } + const localResults = await this.localFilterPreferences(query, searchInProgress.token); + let remoteResults = null; + if ((!localResults || !localResults.exactMatch) && !searchInProgress.token.isCancellationRequested) { + remoteResults = await this.remoteSearchPreferences(query, searchInProgress.token); + } - // Update UI only after all the search results are in - // ref https://github.com/microsoft/vscode/issues/224946 - this.onDidFinishSearch(); + if (searchInProgress.token.isCancellationRequested) { + return; + } + + // Update UI only after all the search results are in + // ref https://github.com/microsoft/vscode/issues/224946 + this.onDidFinishSearch(); + + if (remoteResults?.filterMatches.length) { + if (this.aiSettingsSearchService.isEnabled() && !searchInProgress.token.isCancellationRequested) { + const rankedResults = await this.aiSettingsSearchService.getLLMRankedResults(query, searchInProgress.token); + if (!searchInProgress.token.isCancellationRequested) { + if (rankedResults === null) { + this.logService.trace('No ranked results found'); + } else { + this.logService.trace(`Got ranked results ${rankedResults.join(', ')}`); + const combinedResultsKeys = new Set([ + ...(localResults?.filterMatches.map(m => m.setting.key) ?? []), + ...(remoteResults.filterMatches.map(m => m.setting.key)) + ]); + const unlistedResults = rankedResults.filter(r => !combinedResultsKeys.has(r)); + this.logService.trace(`Got unlisted results ${unlistedResults.join(', ')}`); + this.setSearchSuggestions(unlistedResults); + } + } + } } }); } @@ -1720,19 +1756,59 @@ export class SettingsEditor2 extends EditorPane { this.renderTree(undefined, true); } - private localFilterPreferences(query: string, token?: CancellationToken): Promise { + private clearSearchSuggestions(): void { + this.searchSuggestionDisposables.clear(); + this.suggestionsDiv.innerText = ''; + } + + private setSearchSuggestions(suggestions: string[]): void { + this.clearSearchSuggestions(); + + if (suggestions.length === 0) { + return; + } + + this.suggestionsDiv.innerText = localize('suggestionsPrefix', "Did you mean: "); + suggestions.forEach((suggestion, idx) => { + const suggestionLink = document.createElement('a'); + suggestionLink.textContent = wordifyKey(suggestion); + suggestionLink.tabIndex = 0; + suggestionLink.setAttribute('aria-label', suggestion); + this.searchSuggestionDisposables.add(DOM.addDisposableListener(suggestionLink, 'click', (e) => { + e.preventDefault(); + this.searchWidget.setValue(suggestion); + this.focusSearch(); + })); + this.searchSuggestionDisposables.add(DOM.addDisposableListener(suggestionLink, 'keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.searchWidget.setValue(suggestion); + this.focusSearch(); + } + })); + this.suggestionsDiv.appendChild(suggestionLink); + if (idx < suggestions.length - 1) { + this.suggestionsDiv.appendChild(document.createTextNode(', ')); + } + }); + } + + private localFilterPreferences(query: string, token: CancellationToken): Promise { const localSearchProvider = this.preferencesSearchService.getLocalSearchProvider(query); - return this.filterOrSearchPreferences(query, SearchResultIdx.Local, localSearchProvider, token); + return this.searchWithProvider(SearchResultIdx.Local, localSearchProvider, token); } - private remoteSearchPreferences(query: string, token?: CancellationToken): Promise { + private remoteSearchPreferences(query: string, token: CancellationToken): Promise { const remoteSearchProvider = this.preferencesSearchService.getRemoteSearchProvider(query); - return this.filterOrSearchPreferences(query, SearchResultIdx.Remote, remoteSearchProvider, token); + if (!remoteSearchProvider) { + return Promise.resolve(null); + } + return this.searchWithProvider(SearchResultIdx.Remote, remoteSearchProvider, token); } - private async filterOrSearchPreferences(query: string, type: SearchResultIdx, searchProvider?: ISearchProvider, token?: CancellationToken): Promise { - const result = await this._filterOrSearchPreferencesModel(query, this.defaultSettingsEditorModel, searchProvider, token); - if (token?.isCancellationRequested) { + private async searchWithProvider(type: SearchResultIdx, searchProvider: ISearchProvider, token: CancellationToken): Promise { + const result = await this._searchPreferencesModel(this.defaultSettingsEditorModel, searchProvider, token); + if (token.isCancellationRequested) { // Handle cancellation like this because cancellation is lost inside the search provider due to async/await return null; } @@ -1785,31 +1861,16 @@ export class SettingsEditor2 extends EditorPane { } } - private _filterOrSearchPreferencesModel(filter: string, model: ISettingsEditorModel, provider?: ISearchProvider, token?: CancellationToken): Promise { - const searchP = provider ? provider.searchModel(model, token) : Promise.resolve(null); - return searchP - .then(undefined, err => { - if (isCancellationError(err)) { - return Promise.reject(err); - } else { - // type SettingsSearchErrorEvent = { - // 'message': string; - // }; - // type SettingsSearchErrorClassification = { - // owner: 'rzhao271'; - // comment: 'Helps understand when settings search errors out'; - // 'message': { 'classification': 'CallstackOrException'; 'purpose': 'FeatureInsight'; 'owner': 'rzhao271'; 'comment': 'The error message of the search error.' }; - // }; - - // const message = getErrorMessage(err).trim(); - // if (message && message !== 'Error') { - // // "Error" = any generic network error - // this.telemetryService.publicLogError2('settingsEditor.searchError', { message }); - // this.logService.info('Setting search error: ' + message); - // } - return null; - } - }); + private async _searchPreferencesModel(model: ISettingsEditorModel, provider: ISearchProvider, token: CancellationToken): Promise { + try { + return await provider.searchModel(model, token); + } catch (err) { + if (isCancellationError(err)) { + return Promise.reject(err); + } else { + return null; + } + } } private layoutSplitView(dimension: DOM.Dimension): void { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index c0b28234b7a..0733ae0ad50 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -236,7 +236,7 @@ export const tocData: ITOCEntry = { { id: 'features/chat', label: localize('chat', 'Chat'), - settings: ['chat.*', 'inlineChat.*'] + settings: ['chat.*', 'inlineChat.*', 'mcp'] }, { id: 'features/issueReporter', @@ -302,25 +302,3 @@ export const tocData: ITOCEntry = { } ] }; - -export const knownAcronyms = new Set(); -[ - 'css', - 'html', - 'scss', - 'less', - 'json', - 'js', - 'ts', - 'ie', - 'id', - 'php', - 'scm', -].forEach(str => knownAcronyms.add(str)); - -export const knownTermMappings = new Map(); -knownTermMappings.set('power shell', 'PowerShell'); -knownTermMappings.set('powershell', 'PowerShell'); -knownTermMappings.set('javascript', 'JavaScript'); -knownTermMappings.set('typescript', 'TypeScript'); -knownTermMappings.set('github', 'GitHub'); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts index 2f49546c8e4..caa55406a72 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts @@ -1989,7 +1989,7 @@ class SettingBoolRenderer extends AbstractSettingRenderer implements ITreeRender const categoryElement = DOM.append(titleElement, $('span.setting-item-category')); const labelElementContainer = DOM.append(titleElement, $('span.setting-item-label')); const labelElement = toDispose.add(new SimpleIconLabel(labelElementContainer)); - const indicatorsLabel = this._instantiationService.createInstance(SettingsTreeIndicatorsLabel, titleElement); + const indicatorsLabel = toDispose.add(this._instantiationService.createInstance(SettingsTreeIndicatorsLabel, titleElement)); const descriptionAndValueElement = DOM.append(container, $('.setting-item-value-description')); const controlElement = DOM.append(descriptionAndValueElement, $('.setting-item-bool-control')); @@ -2008,20 +2008,6 @@ class SettingBoolRenderer extends AbstractSettingRenderer implements ITreeRender template.onChange!(checkbox.checked); })); - // Need to listen for mouse clicks on description and toggle checkbox - use target ID for safety - // Also have to ignore embedded links - too buried to stop propagation - toDispose.add(DOM.addDisposableListener(descriptionElement, DOM.EventType.MOUSE_DOWN, (e) => { - const targetElement = e.target; - - // Toggle target checkbox - if (targetElement.tagName.toLowerCase() !== 'a') { - template.checkbox.checked = !template.checkbox.checked; - template.onChange!(checkbox.checked); - } - DOM.EventHelper.stop(e); - })); - - checkbox.domNode.classList.add(AbstractSettingRenderer.CONTROL_CLASS); const toolbarContainer = DOM.append(container, $('.setting-toolbar-container')); const toolbar = this.renderSettingToolbar(toolbarContainer); @@ -2059,6 +2045,26 @@ class SettingBoolRenderer extends AbstractSettingRenderer implements ITreeRender protected renderValue(dataElement: SettingsTreeSettingElement, template: ISettingBoolItemTemplate, onChange: (value: boolean) => void): void { template.onChange = undefined; template.checkbox.checked = dataElement.value; + if (dataElement.hasPolicyValue) { + template.checkbox.disable(); + template.descriptionElement.classList.add('disabled'); + } else { + template.checkbox.enable(); + template.descriptionElement.classList.remove('disabled'); + + // Need to listen for mouse clicks on description and toggle checkbox - use target ID for safety + // Also have to ignore embedded links - too buried to stop propagation + template.elementDisposables.add(DOM.addDisposableListener(template.descriptionElement, DOM.EventType.MOUSE_DOWN, (e) => { + const targetElement = e.target; + + // Toggle target checkbox + if (targetElement.tagName.toLowerCase() !== 'a') { + template.checkbox.checked = !template.checkbox.checked; + template.onChange!(template.checkbox.checked); + } + DOM.EventHelper.stop(e); + })); + } template.checkbox.setTitle(dataElement.setting.key); template.onChange = onChange; } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index c6377173789..2ae55f17398 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -9,8 +9,8 @@ import { isUndefinedOrNull } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { ConfigurationTarget, IConfigurationValue } from '../../../../platform/configuration/common/configuration.js'; import { SettingsTarget } from './preferencesWidgets.js'; -import { ITOCEntry, knownAcronyms, knownTermMappings, tocData } from './settingsLayout.js'; -import { ENABLE_EXTENSION_TOGGLE_SETTINGS, ENABLE_LANGUAGE_FILTER, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, compareTwoNullableNumbers } from '../common/preferences.js'; +import { ITOCEntry, tocData } from './settingsLayout.js'; +import { ENABLE_EXTENSION_TOGGLE_SETTINGS, ENABLE_LANGUAGE_FILTER, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, compareTwoNullableNumbers, wordifyKey } from '../common/preferences.js'; import { IExtensionSetting, ISearchResult, ISetting, ISettingMatch, SettingMatchType, SettingValueType } from '../../../services/preferences/common/preferences.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { FOLDER_SCOPES, WORKSPACE_SCOPES, REMOTE_MACHINE_SCOPES, LOCAL_MACHINE_SCOPES, IWorkbenchConfigurationService, APPLICATION_SCOPES } from '../../../services/configuration/common/configuration.js'; @@ -342,7 +342,7 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { // so we reset the default value source to the non-language-specific default value source for now. this.defaultValueSource = this.setting.nonLanguageSpecificDefaultValueSource; - if (inspected.policyValue) { + if (inspected.policyValue !== undefined) { this.hasPolicyValue = true; isConfigured = false; // The user did not manually configure the setting themselves. displayValue = inspected.policyValue; @@ -639,7 +639,7 @@ export class SettingsTreeModel implements IDisposable { this._userDataProfileService, this._configurationService); - const nameElements = this._treeElementsBySettingName.get(setting.key) || []; + const nameElements = this._treeElementsBySettingName.get(setting.key) ?? []; nameElements.push(element); this._treeElementsBySettingName.set(setting.key, nameElements); return element; @@ -727,24 +727,6 @@ export function settingKeyToDisplayFormat(key: string, groupId: string = '', isL return { category, label }; } -function wordifyKey(key: string): string { - key = key - .replace(/\.([a-z0-9])/g, (_, p1) => ` \u203A ${p1.toUpperCase()}`) // Replace dot with spaced '>' - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // Camel case to spacing, fooBar => foo Bar - .replace(/^[a-z]/g, match => match.toUpperCase()) // Upper casing all first letters, foo => Foo - .replace(/\b\w+\b/g, match => { // Upper casing known acronyms - return knownAcronyms.has(match.toLowerCase()) ? - match.toUpperCase() : - match; - }); - - for (const [k, v] of knownTermMappings) { - key = key.replace(new RegExp(`\\b${k}\\b`, 'gi'), v); - } - - return key; -} - /** * Removes redundant sections of the category label. * A redundant section is a section already reflected in the groupId. @@ -1026,30 +1008,26 @@ export class SearchResultModel extends SettingsTreeModel { this.cachedUniqueSearchResults = { filterMatches: combinedFilterMatches, - exactMatch: localResult?.exactMatch || remoteResult?.exactMatch + exactMatch: localResult.exactMatch // remote results should never have an exact match }; return this.cachedUniqueSearchResults; } getRawResults(): ISearchResult[] { - return this.rawSearchResults || []; + return this.rawSearchResults ?? []; } setResult(order: SearchResultIdx, result: ISearchResult | null): void { this.cachedUniqueSearchResults = null; this.newExtensionSearchResults = null; - this.rawSearchResults = this.rawSearchResults || []; + this.rawSearchResults ??= []; if (!result) { delete this.rawSearchResults[order]; return; } - if (result.exactMatch) { - this.rawSearchResults = []; - } - this.rawSearchResults[order] = result; this.updateChildren(); } diff --git a/src/vs/workbench/contrib/preferences/common/preferences.ts b/src/vs/workbench/contrib/preferences/common/preferences.ts index 10244f4354b..fb9655c44b8 100644 --- a/src/vs/workbench/contrib/preferences/common/preferences.ts +++ b/src/vs/workbench/contrib/preferences/common/preferences.ts @@ -43,7 +43,7 @@ export interface IPreferencesSearchService { } export interface ISearchProvider { - searchModel(preferencesModel: ISettingsEditorModel, token?: CancellationToken): Promise; + searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise; } export interface IRemoteSearchProvider extends ISearchProvider { @@ -178,3 +178,43 @@ export function compareTwoNullableNumbers(a: number | undefined, b: number | und export const PREVIEW_INDICATOR_DESCRIPTION = localize('previewIndicatorDescription', "Preview setting: this setting controls a new feature that is still under refinement yet ready to use. Feedback is welcome."); export const EXPERIMENTAL_INDICATOR_DESCRIPTION = localize('experimentalIndicatorDescription', "Experimental setting: this setting controls a new feature that is actively being developed and may be unstable. It is subject to change or removal."); + +export const knownAcronyms = new Set(); +[ + 'css', + 'html', + 'scss', + 'less', + 'json', + 'js', + 'ts', + 'ie', + 'id', + 'php', + 'scm', +].forEach(str => knownAcronyms.add(str)); + +export const knownTermMappings = new Map(); +knownTermMappings.set('power shell', 'PowerShell'); +knownTermMappings.set('powershell', 'PowerShell'); +knownTermMappings.set('javascript', 'JavaScript'); +knownTermMappings.set('typescript', 'TypeScript'); +knownTermMappings.set('github', 'GitHub'); + +export function wordifyKey(key: string): string { + key = key + .replace(/\.([a-z0-9])/g, (_, p1) => ` \u203A ${p1.toUpperCase()}`) // Replace dot with spaced '>' + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // Camel case to spacing, fooBar => foo Bar + .replace(/^[a-z]/g, match => match.toUpperCase()) // Upper casing all first letters, foo => Foo + .replace(/\b\w+\b/g, match => { // Upper casing known acronyms + return knownAcronyms.has(match.toLowerCase()) ? + match.toUpperCase() : + match; + }); + + for (const [k, v] of knownTermMappings) { + key = key.replace(new RegExp(`\\b${k}\\b`, 'gi'), v); + } + + return key; +} diff --git a/src/vs/workbench/contrib/preferences/common/settingsFilesystemProvider.ts b/src/vs/workbench/contrib/preferences/common/settingsFilesystemProvider.ts index b57328baabf..171b2458587 100644 --- a/src/vs/workbench/contrib/preferences/common/settingsFilesystemProvider.ts +++ b/src/vs/workbench/contrib/preferences/common/settingsFilesystemProvider.ts @@ -14,6 +14,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import * as JSONContributionRegistry from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { ILogService, LogLevel } from '../../../../platform/log/common/log.js'; +import { isEqual } from '../../../../base/common/resources.js'; const schemaRegistry = Registry.as(JSONContributionRegistry.Extensions.JSONContribution); @@ -25,6 +26,8 @@ export class SettingsFileSystemProvider extends Disposable implements IFileSyste protected readonly _onDidChangeFile = this._register(new Emitter()); readonly onDidChangeFile = this._onDidChangeFile.event; + private static SCHEMA_ASSOCIATIONS = URI.parse(`${Schemas.vscode}://schemas-associations/schemas-associations.json`); + constructor( @IPreferencesService private readonly preferencesService: IPreferencesService, @ILogService private readonly logService: ILogService @@ -33,6 +36,9 @@ export class SettingsFileSystemProvider extends Disposable implements IFileSyste this._register(schemaRegistry.onDidChangeSchema(schemaUri => { this._onDidChangeFile.fire([{ resource: URI.parse(schemaUri), type: FileChangeType.UPDATED }]); })); + this._register(schemaRegistry.onDidChangeSchemaAssociations(() => { + this._onDidChangeFile.fire([{ resource: SettingsFileSystemProvider.SCHEMA_ASSOCIATIONS, type: FileChangeType.UPDATED }]); + })); this._register(preferencesService.onDidDefaultSettingsContentChanged(uri => { this._onDidChangeFile.fire([{ resource: uri, type: FileChangeType.UPDATED }]); })); @@ -47,6 +53,8 @@ export class SettingsFileSystemProvider extends Disposable implements IFileSyste let content: string | undefined; if (uri.authority === 'schemas') { content = this.getSchemaContent(uri); + } else if (uri.authority === SettingsFileSystemProvider.SCHEMA_ASSOCIATIONS.authority) { + content = JSON.stringify(schemaRegistry.getSchemaAssociations()); } else if (uri.authority === 'defaultsettings') { content = this.preferencesService.getDefaultSettingsContent(uri); } @@ -67,6 +75,16 @@ export class SettingsFileSystemProvider extends Disposable implements IFileSyste size: 0 }; } + if (isEqual(uri, SettingsFileSystemProvider.SCHEMA_ASSOCIATIONS)) { + const currentTime = Date.now(); + return { + type: FileType.File, + permissions: FilePermission.Readonly, + mtime: currentTime, + ctime: currentTime, + size: 0 + }; + } throw FileSystemProviderErrorCode.FileNotFound; } diff --git a/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts b/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts index 3ae6694f964..2de14ff31ee 100644 --- a/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts +++ b/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts @@ -50,7 +50,7 @@ export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAcce // a chance to register so that the complete set of commands shows up as result // We do not want to delay functionality beyond that time though to keep the commands // functional. - private readonly extensionRegistrationRace = raceTimeout(this.extensionService.whenInstalledExtensionsRegistered(), 800); + private readonly extensionRegistrationRace: Promise; private useAiRelatedInfo = false; @@ -67,7 +67,7 @@ export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAcce constructor( @IEditorService private readonly editorService: IEditorService, @IMenuService private readonly menuService: IMenuService, - @IExtensionService private readonly extensionService: IExtensionService, + @IExtensionService extensionService: IExtensionService, @IInstantiationService instantiationService: IInstantiationService, @IKeybindingService keybindingService: IKeybindingService, @ICommandService commandService: ICommandService, @@ -88,6 +88,7 @@ export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAcce }), }, instantiationService, keybindingService, commandService, telemetryService, dialogService); + this.extensionRegistrationRace = raceTimeout(extensionService.whenInstalledExtensionsRegistered(), 800); this._register(configurationService.onDidChangeConfiguration((e) => this.updateOptions(e))); this.updateOptions(); } diff --git a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts index d167e5391ce..3e97a0e93a3 100644 --- a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts @@ -31,10 +31,10 @@ interface IConfiguration extends IWindowsConfiguration { security?: { workspace?: { trust?: { enabled?: boolean } }; restrictUNCAccess?: boolean }; window: IWindowSettings; workbench?: { enableExperiments?: boolean }; - telemetry?: { disableFeedback?: boolean }; + telemetry?: { feedback?: { enabled?: boolean } }; _extensionsGallery?: { enablePPE?: boolean }; accessibility?: { verbosity?: { debug?: boolean } }; - chat?: { experimental?: { unifiedChatView?: boolean }; useFileStorage?: boolean }; + chat?: { useFileStorage?: boolean }; } export class SettingsChangeRelauncher extends Disposable implements IWorkbenchContribution { @@ -52,9 +52,8 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo '_extensionsGallery.enablePPE', 'security.restrictUNCAccess', 'accessibility.verbosity.debug', - ChatConfiguration.UnifiedChatView, ChatConfiguration.UseFileStorage, - 'telemetry.disableFeedback' + 'telemetry.feedback.enabled' ]; private readonly titleBarStyle = new ChangeObserver('string'); @@ -69,9 +68,8 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo private readonly enablePPEExtensionsGallery = new ChangeObserver('boolean'); private readonly restrictUNCAccess = new ChangeObserver('boolean'); private readonly accessibilityVerbosityDebug = new ChangeObserver('boolean'); - private readonly unifiedChatView = new ChangeObserver('boolean'); private readonly useFileStorage = new ChangeObserver('boolean'); - private readonly telemetryDisableFeedback = new ChangeObserver('boolean'); + private readonly telemetryFeedbackEnabled = new ChangeObserver('boolean'); constructor( @IHostService private readonly hostService: IHostService, @@ -151,7 +149,6 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo // Debug accessibility verbosity processChanged(this.accessibilityVerbosityDebug.handleChange(config?.accessibility?.verbosity?.debug)); - processChanged(this.unifiedChatView.handleChange(config.chat?.experimental?.unifiedChatView)); processChanged(this.useFileStorage.handleChange(config.chat?.useFileStorage)); } @@ -161,8 +158,8 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo // Profiles processChanged(this.productService.quality !== 'stable' && this.enablePPEExtensionsGallery.handleChange(config._extensionsGallery?.enablePPE)); - // Disable Feedback - processChanged(this.telemetryDisableFeedback.handleChange(config.telemetry?.disableFeedback)); + // Enable Feedback + processChanged(this.telemetryFeedbackEnabled.handleChange(config.telemetry?.feedback?.enabled)); if (askToRelaunch && changed && this.hostService.hasFocus) { this.doConfirm( diff --git a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts index a3cab6f679a..2784bfce7f0 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts @@ -41,7 +41,6 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { DomEmitter } from '../../../../base/browser/event.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { infoIcon } from '../../extensions/browser/extensionsIcons.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -51,6 +50,11 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import Severity from '../../../../base/common/severity.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; +import { toErrorMessage } from '../../../../base/common/errorMessage.js'; +import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; type ActionGroup = [string, Array]; @@ -149,6 +153,9 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr @ITelemetryService private readonly telemetryService: ITelemetryService, @IProductService private readonly productService: IProductService, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, + @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, + @IDialogService private readonly dialogService: IDialogService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, @IOpenerService private readonly openerService: IOpenerService, @IConfigurationService private readonly configurationService: IConfigurationService, ) { @@ -625,14 +632,27 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr return markdownTooltip; } - private async installExtension(extensionId: string) { - const galleryExtension = (await this.extensionGalleryService.getExtensions([{ id: extensionId }], CancellationToken.None))[0]; - - await this.extensionManagementService.installFromGallery(galleryExtension, { - isMachineScoped: false, - donotIncludePackAndDependencies: false, - context: { [EXTENSION_INSTALL_SKIP_WALKTHROUGH_CONTEXT]: true } - }); + private async installExtension(extensionId: string, remoteLabel: string): Promise { + try { + await this.extensionsWorkbenchService.install(extensionId, { + isMachineScoped: false, + donotIncludePackAndDependencies: false, + context: { [EXTENSION_INSTALL_SKIP_WALKTHROUGH_CONTEXT]: true } + }); + } catch (error) { + if (!this.lifecycleService.willShutdown) { + const { confirmed } = await this.dialogService.confirm({ + type: Severity.Error, + message: nls.localize('unknownSetupError', "An error occurred while setting up {0}. Would you like to try again?", remoteLabel), + detail: error && !isCancellationError(error) ? toErrorMessage(error) : undefined, + primaryButton: nls.localize('retry', "Retry") + }); + if (confirmed) { + return this.installExtension(extensionId, remoteLabel); + } + } + throw error; + } } private async runRemoteStartCommand(extensionId: string, startCommand: string) { @@ -794,8 +814,13 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr quickPick.busy = true; quickPick.placeholder = nls.localize('remote.startActions.installingExtension', 'Installing extension... '); - await this.installExtension(remoteExtension.id); - quickPick.hide(); + try { + await this.installExtension(remoteExtension.id, selectedItems[0].label); + } catch (error) { + return; + } finally { + quickPick.hide(); + } await this.runRemoteStartCommand(remoteExtension.id, remoteExtension.startCommand); } else { diff --git a/src/vs/workbench/contrib/remote/common/remote.contribution.ts b/src/vs/workbench/contrib/remote/common/remote.contribution.ts index f2bb13dad7f..fb3edaaa9b5 100644 --- a/src/vs/workbench/contrib/remote/common/remote.contribution.ts +++ b/src/vs/workbench/contrib/remote/common/remote.contribution.ts @@ -27,6 +27,7 @@ import { IDownloadService } from '../../../../platform/download/common/download. import { DownloadServiceChannel } from '../../../../platform/download/common/downloadIpc.js'; import { RemoteLoggerChannelClient } from '../../../../platform/log/common/logIpc.js'; import { REMOTE_DEFAULT_IF_LOCAL_EXTENSIONS } from '../../../../platform/remote/common/remote.js'; +import product from '../../../../platform/product/common/product.js'; const EXTENSION_IDENTIFIER_PATTERN = '([a-z0-9A-Z][a-z0-9-A-Z]*)\\.([a-z0-9A-Z][a-z0-9-A-Z]*)$'; @@ -362,11 +363,8 @@ Registry.as(ConfigurationExtensions.Configuration) }, [REMOTE_DEFAULT_IF_LOCAL_EXTENSIONS]: { type: 'array', - markdownDescription: localize('remote.defaultExtensionsIfInstalledLocally.markdownDescription', 'List of extensions to install automatically on all remotes if already installed locally.'), - default: [ - 'GitHub.copilot', - 'GitHub.copilot-chat' - ], + markdownDescription: localize('remote.defaultExtensionsIfInstalledLocally.markdownDescription', 'List of extensions to install upon connection to a remote when already installed locally.'), + default: product?.remoteDefaultExtensionsIfInstalledLocally || [], items: { type: 'string', pattern: EXTENSION_IDENTIFIER_PATTERN, diff --git a/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts b/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts index b716bcd1d0c..5de04851541 100644 --- a/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts +++ b/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts @@ -20,7 +20,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; -import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { INotificationService, NotificationPriority, Severity } from '../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IProgress, IProgressService, IProgressStep, ProgressLocation } from '../../../../platform/progress/common/progress.js'; @@ -188,6 +188,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo } this.notificationService.notify({ severity: Severity.Info, + priority: NotificationPriority.OPTIONAL, message: localize( { diff --git a/src/vs/workbench/contrib/scm/browser/activity.ts b/src/vs/workbench/contrib/scm/browser/activity.ts index c8a7faebab6..bd29ee8c06d 100644 --- a/src/vs/workbench/contrib/scm/browser/activity.ts +++ b/src/vs/workbench/contrib/scm/browser/activity.ts @@ -108,6 +108,7 @@ export class SCMActiveRepositoryController extends Disposable implements IWorkbe })); this._register(autorunWithStore((reader, store) => { + this._repositories.read(reader); const repository = this.scmViewService.activeRepository.read(reader); const commands = repository?.provider.statusBarCommands.read(reader); @@ -175,6 +176,19 @@ export class SCMActiveRepositoryController extends Disposable implements IWorkbe this.statusbarService.addEntry(statusbarEntry, `status.scm.${index}`, MainThreadStatusBarAlignment.LEFT, { location: { id: `status.scm.${index - 1}`, priority: 10000 }, alignment: MainThreadStatusBarAlignment.RIGHT, compact: true }) ); } + + // Source control provider status bar entry + if (this.scmService.repositoryCount > 1) { + const repositoryStatusbarEntry: IStatusbarEntry = { + name: localize('status.scm.provider', "Source Control Provider"), + text: `$(repo) ${repository.provider.name}`, + ariaLabel: label, + tooltip: label, + command: 'scm.setActiveProvider' + }; + + store.add(this.statusbarService.addEntry(repositoryStatusbarEntry, 'status.scm.provider', MainThreadStatusBarAlignment.LEFT, { location: { id: `status.scm.0`, priority: 10000 }, alignment: MainThreadStatusBarAlignment.LEFT, compact: true })); + } } private _updateActiveRepositoryContextKeys(repositoryName: string | undefined, branchName: string | undefined): void { diff --git a/src/vs/workbench/contrib/scm/browser/media/dirtydiffDecorator.css b/src/vs/workbench/contrib/scm/browser/media/dirtydiffDecorator.css index 5a9e4a2536e..d1490f77723 100644 --- a/src/vs/workbench/contrib/scm/browser/media/dirtydiffDecorator.css +++ b/src/vs/workbench/contrib/scm/browser/media/dirtydiffDecorator.css @@ -25,55 +25,91 @@ display: none; } -.monaco-editor .dirty-diff-added { - border-left-color: var(--vscode-editorGutter-addedBackground); +.monaco-editor .dirty-diff-added:not(.pattern) { border-left-style: solid; } -.monaco-editor .dirty-diff-added:before { +.monaco-editor .dirty-diff-added.primary { + border-left-color: var(--vscode-editorGutter-addedBackground); +} + +.monaco-editor .dirty-diff-added.primary:before { background: var(--vscode-editorGutter-addedBackground); } -.monaco-editor .dirty-diff-added-pattern { - background-image: linear-gradient(-45deg, var(--vscode-editorGutter-addedBackground) 25%, var(--vscode-editorGutter-background) 25%, var(--vscode-editorGutter-background) 50%, var(--vscode-editorGutter-addedBackground) 50%, var(--vscode-editorGutter-addedBackground) 75%, var(--vscode-editorGutter-background) 75%, var(--vscode-editorGutter-background)); +.monaco-editor .dirty-diff-added.secondary { + border-left-color: var(--vscode-editorGutter-addedSecondaryBackground); +} + +.monaco-editor .dirty-diff-added.secondary:before { + background: var(--vscode-editorGutter-addedSecondaryBackground); +} + +.monaco-editor .dirty-diff-added.pattern { background-repeat: repeat-y; } -.monaco-editor .dirty-diff-added-pattern:before { - background-image: linear-gradient(-45deg, var(--vscode-editorGutter-addedBackground) 25%, var(--vscode-editorGutter-background) 25%, var(--vscode-editorGutter-background) 50%, var(--vscode-editorGutter-addedBackground) 50%, var(--vscode-editorGutter-addedBackground) 75%, var(--vscode-editorGutter-background) 75%, var(--vscode-editorGutter-background)); +.monaco-editor .dirty-diff-added.pattern:before { transform: translateX(3px); } -.monaco-editor .dirty-diff-modified { - border-left-color: var(--vscode-editorGutter-modifiedBackground); +.monaco-editor .dirty-diff-added.pattern.primary, +.monaco-editor .dirty-diff-added.pattern.primary:before { + background-image: linear-gradient(-45deg, var(--vscode-editorGutter-addedBackground) 25%, var(--vscode-editorGutter-background) 25%, var(--vscode-editorGutter-background) 50%, var(--vscode-editorGutter-addedBackground) 50%, var(--vscode-editorGutter-addedBackground) 75%, var(--vscode-editorGutter-background) 75%, var(--vscode-editorGutter-background)); +} + +.monaco-editor .dirty-diff-added.pattern.secondary, +.monaco-editor .dirty-diff-added.pattern.secondary:before { + background-image: linear-gradient(45deg, var(--vscode-editorGutter-addedSecondaryBackground) 25%, var(--vscode-editorGutter-background) 25%, var(--vscode-editorGutter-background) 50%, var(--vscode-editorGutter-addedSecondaryBackground) 50%, var(--vscode-editorGutter-addedSecondaryBackground) 75%, var(--vscode-editorGutter-background) 75%, var(--vscode-editorGutter-background)); +} + +.monaco-editor .dirty-diff-modified:not(.pattern) { border-left-style: solid; } -.monaco-editor .dirty-diff-modified:before { +.monaco-editor .dirty-diff-modified.primary { + border-left-color: var(--vscode-editorGutter-modifiedBackground); +} + +.monaco-editor .dirty-diff-modified.primary:before { background: var(--vscode-editorGutter-modifiedBackground); } -.monaco-editor .dirty-diff-modified-pattern { - background-image: linear-gradient(-45deg, var(--vscode-editorGutter-modifiedBackground) 25%, var(--vscode-editorGutter-background) 25%, var(--vscode-editorGutter-background) 50%, var(--vscode-editorGutter-modifiedBackground) 50%, var(--vscode-editorGutter-modifiedBackground) 75%, var(--vscode-editorGutter-background) 75%, var(--vscode-editorGutter-background)); +.monaco-editor .dirty-diff-modified.secondary { + border-left-color: var(--vscode-editorGutter-modifiedSecondaryBackground); +} + +.monaco-editor .dirty-diff-modified.secondary:before { + background: var(--vscode-editorGutter-modifiedSecondaryBackground); +} + +.monaco-editor .dirty-diff-modified.pattern { background-repeat: repeat-y; } -.monaco-workbench:not(.reduce-motion) .monaco-editor .dirty-diff-added, -.monaco-workbench:not(.reduce-motion) .monaco-editor .dirty-diff-added-pattern, -.monaco-workbench:not(.reduce-motion) .monaco-editor .dirty-diff-modified, -.monaco-workbench:not(.reduce-motion) .monaco-editor .dirty-diff-modified-pattern { - transition: opacity 0.5s; -} - -.monaco-editor .dirty-diff-modified-pattern:before { - background-image: linear-gradient(-45deg, var(--vscode-editorGutter-modifiedBackground) 25%, var(--vscode-editorGutter-background) 25%, var(--vscode-editorGutter-background) 50%, var(--vscode-editorGutter-modifiedBackground) 50%, var(--vscode-editorGutter-modifiedBackground) 75%, var(--vscode-editorGutter-background) 75%, var(--vscode-editorGutter-background)); +.monaco-editor .dirty-diff-modified.pattern:before { transform: translateX(3px); } +.monaco-editor .dirty-diff-modified.pattern.primary, +.monaco-editor .dirty-diff-modified.pattern.primary:before { + background-image: linear-gradient(-45deg, var(--vscode-editorGutter-modifiedBackground) 25%, var(--vscode-editorGutter-background) 25%, var(--vscode-editorGutter-background) 50%, var(--vscode-editorGutter-modifiedBackground) 50%, var(--vscode-editorGutter-modifiedBackground) 75%, var(--vscode-editorGutter-background) 75%, var(--vscode-editorGutter-background)); +} + +.monaco-editor .dirty-diff-modified.pattern.secondary, +.monaco-editor .dirty-diff-modified.pattern.secondary:before { + background-image: linear-gradient(45deg, var(--vscode-editorGutter-modifiedSecondaryBackground) 25%, var(--vscode-editorGutter-background) 25%, var(--vscode-editorGutter-background) 50%, var(--vscode-editorGutter-modifiedSecondaryBackground) 50%, var(--vscode-editorGutter-modifiedSecondaryBackground) 75%, var(--vscode-editorGutter-background) 75%, var(--vscode-editorGutter-background)); +} + +.monaco-workbench:not(.reduce-motion) .monaco-editor .dirty-diff-added, +.monaco-workbench:not(.reduce-motion) .monaco-editor .dirty-diff-modified, +.monaco-workbench:not(.reduce-motion) .monaco-editor .dirty-diff-deleted { + transition: opacity 0.5s; +} + .monaco-editor .margin:hover .dirty-diff-added, -.monaco-editor .margin:hover .dirty-diff-added-pattern, .monaco-editor .margin:hover .dirty-diff-modified, -.monaco-editor .margin:hover .dirty-diff-modified-pattern { +.monaco-editor .margin:hover .dirty-diff-deleted { opacity: 1; } @@ -87,10 +123,17 @@ z-index: 9; border-top: 4px solid transparent; border-bottom: 4px solid transparent; - border-left: 4px solid var(--vscode-editorGutter-deletedBackground); pointer-events: none; } +.monaco-editor .dirty-diff-deleted.primary:after { + border-left: 4px solid var(--vscode-editorGutter-deletedBackground); +} + +.monaco-editor .dirty-diff-deleted.secondary:after { + border-left: 4px solid var(--vscode-editorGutter-deletedSecondaryBackground); +} + .monaco-workbench:not(.reduce-motion) .monaco-editor .dirty-diff-deleted:after { transition: border-top-width 80ms linear, border-bottom-width 80ms linear, bottom 80ms linear, opacity 0.5s; } @@ -102,6 +145,14 @@ bottom: 0; } +.monaco-editor .dirty-diff-deleted.primary:before { + background: var(--vscode-editorGutter-deletedBackground); +} + +.monaco-editor .dirty-diff-deleted.secondary:before { + background: var(--vscode-editorGutter-deletedSecondaryBackground); +} + .monaco-workbench:not(.reduce-motion) .monaco-editor .dirty-diff-deleted:before { transition: height 80ms linear; } diff --git a/src/vs/workbench/contrib/scm/browser/quickDiffDecorator.ts b/src/vs/workbench/contrib/scm/browser/quickDiffDecorator.ts index ac52c163d58..5dd8d643aa9 100644 --- a/src/vs/workbench/contrib/scm/browser/quickDiffDecorator.ts +++ b/src/vs/workbench/contrib/scm/browser/quickDiffDecorator.ts @@ -13,17 +13,19 @@ import { ModelDecorationOptions } from '../../../../editor/common/model/textMode import { themeColorFromId } from '../../../../platform/theme/common/themeService.js'; import { ICodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { IEditorDecorationsCollection } from '../../../../editor/common/editorCommon.js'; -import { OverviewRulerLane, IModelDecorationOptions, MinimapPosition } from '../../../../editor/common/model.js'; +import { OverviewRulerLane, IModelDecorationOptions, MinimapPosition, IModelDeltaDecoration } from '../../../../editor/common/model.js'; import * as domStylesheetsJs from '../../../../base/browser/domStylesheets.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { ChangeType, getChangeType, minimapGutterAddedBackground, minimapGutterDeletedBackground, minimapGutterModifiedBackground, overviewRulerAddedForeground, overviewRulerDeletedForeground, overviewRulerModifiedForeground } from '../common/quickDiff.js'; +import { ChangeType, getChangeType, IQuickDiffService, QuickDiffProvider, minimapGutterAddedBackground, minimapGutterDeletedBackground, minimapGutterModifiedBackground, overviewRulerAddedForeground, overviewRulerDeletedForeground, overviewRulerModifiedForeground } from '../common/quickDiff.js'; import { QuickDiffModel, IQuickDiffModelService } from './quickDiffModel.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyTrueExpr, ContextKeyFalseExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { autorun, autorunWithStore, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; +import { registerAction2, Action2, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; export const quickDiffDecorationCount = new RawContextKey('quickDiffDecorationCount', 0); @@ -58,16 +60,22 @@ class QuickDiffDecorator extends Disposable { } private addedOptions: ModelDecorationOptions; + private addedSecondaryOptions: ModelDecorationOptions; private addedPatternOptions: ModelDecorationOptions; + private addedSecondaryPatternOptions: ModelDecorationOptions; private modifiedOptions: ModelDecorationOptions; + private modifiedSecondaryOptions: ModelDecorationOptions; private modifiedPatternOptions: ModelDecorationOptions; + private modifiedSecondaryPatternOptions: ModelDecorationOptions; private deletedOptions: ModelDecorationOptions; + private deletedSecondaryOptions: ModelDecorationOptions; private decorationsCollection: IEditorDecorationsCollection | undefined; constructor( private readonly codeEditor: ICodeEditor, private readonly quickDiffModelRef: IReference, - @IConfigurationService private readonly configurationService: IConfigurationService + @IConfigurationService private readonly configurationService: IConfigurationService, + @IQuickDiffService private readonly quickDiffService: IQuickDiffService ) { super(); @@ -77,37 +85,38 @@ class QuickDiffDecorator extends Disposable { const minimap = decorations === 'all' || decorations === 'minimap'; const diffAdded = nls.localize('diffAdded', 'Added lines'); - this.addedOptions = QuickDiffDecorator.createDecoration('dirty-diff-added', diffAdded, { + const diffAddedOptions = { gutter, overview: { active: overview, color: overviewRulerAddedForeground }, minimap: { active: minimap, color: minimapGutterAddedBackground }, isWholeLine: true - }); - this.addedPatternOptions = QuickDiffDecorator.createDecoration('dirty-diff-added-pattern', diffAdded, { - gutter, - overview: { active: overview, color: overviewRulerAddedForeground }, - minimap: { active: minimap, color: minimapGutterAddedBackground }, - isWholeLine: true - }); + }; + this.addedOptions = QuickDiffDecorator.createDecoration('dirty-diff-added primary', diffAdded, diffAddedOptions); + this.addedPatternOptions = QuickDiffDecorator.createDecoration('dirty-diff-added primary pattern', diffAdded, diffAddedOptions); + this.addedSecondaryOptions = QuickDiffDecorator.createDecoration('dirty-diff-added secondary', diffAdded, diffAddedOptions); + this.addedSecondaryPatternOptions = QuickDiffDecorator.createDecoration('dirty-diff-added secondary pattern', diffAdded, diffAddedOptions); + const diffModified = nls.localize('diffModified', 'Changed lines'); - this.modifiedOptions = QuickDiffDecorator.createDecoration('dirty-diff-modified', diffModified, { + const diffModifiedOptions = { gutter, overview: { active: overview, color: overviewRulerModifiedForeground }, minimap: { active: minimap, color: minimapGutterModifiedBackground }, isWholeLine: true - }); - this.modifiedPatternOptions = QuickDiffDecorator.createDecoration('dirty-diff-modified-pattern', diffModified, { - gutter, - overview: { active: overview, color: overviewRulerModifiedForeground }, - minimap: { active: minimap, color: minimapGutterModifiedBackground }, - isWholeLine: true - }); - this.deletedOptions = QuickDiffDecorator.createDecoration('dirty-diff-deleted', nls.localize('diffDeleted', 'Removed lines'), { + }; + this.modifiedOptions = QuickDiffDecorator.createDecoration('dirty-diff-modified primary', diffModified, diffModifiedOptions); + this.modifiedPatternOptions = QuickDiffDecorator.createDecoration('dirty-diff-modified primary pattern', diffModified, diffModifiedOptions); + this.modifiedSecondaryOptions = QuickDiffDecorator.createDecoration('dirty-diff-modified secondary', diffModified, diffModifiedOptions); + this.modifiedSecondaryPatternOptions = QuickDiffDecorator.createDecoration('dirty-diff-modified secondary pattern', diffModified, diffModifiedOptions); + + const diffDeleted = nls.localize('diffDeleted', 'Removed lines'); + const diffDeletedOptions = { gutter, overview: { active: overview, color: overviewRulerDeletedForeground }, minimap: { active: minimap, color: minimapGutterDeletedBackground }, isWholeLine: false - }); + }; + this.deletedOptions = QuickDiffDecorator.createDecoration('dirty-diff-deleted primary', diffDeleted, diffDeletedOptions); + this.deletedSecondaryOptions = QuickDiffDecorator.createDecoration('dirty-diff-deleted secondary', diffDeleted, diffDeletedOptions); this._register(configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('scm.diffDecorationsGutterPattern')) { @@ -123,44 +132,66 @@ class QuickDiffDecorator extends Disposable { return; } - const visibleQuickDiffs = this.quickDiffModelRef.object.quickDiffs.filter(quickDiff => quickDiff.visible); const pattern = this.configurationService.getValue<{ added: boolean; modified: boolean }>('scm.diffDecorationsGutterPattern'); - const decorations = this.quickDiffModelRef.object.changes - .filter(labeledChange => visibleQuickDiffs.some(quickDiff => quickDiff.label === labeledChange.label)) - .map((labeledChange) => { - const change = labeledChange.change; - const changeType = getChangeType(change); - const startLineNumber = change.modifiedStartLineNumber; - const endLineNumber = change.modifiedEndLineNumber || startLineNumber; + const primaryQuickDiff = this.quickDiffModelRef.object.quickDiffs.find(quickDiff => quickDiff.kind === 'primary'); + const primaryQuickDiffChanges = this.quickDiffModelRef.object.changes.filter(change => change.providerId === primaryQuickDiff?.id); - switch (changeType) { - case ChangeType.Add: - return { - range: { - startLineNumber: startLineNumber, startColumn: 1, - endLineNumber: endLineNumber, endColumn: 1 - }, - options: pattern.added ? this.addedPatternOptions : this.addedOptions - }; - case ChangeType.Delete: - return { - range: { - startLineNumber: startLineNumber, startColumn: Number.MAX_VALUE, - endLineNumber: startLineNumber, endColumn: Number.MAX_VALUE - }, - options: this.deletedOptions - }; - case ChangeType.Modify: - return { - range: { - startLineNumber: startLineNumber, startColumn: 1, - endLineNumber: endLineNumber, endColumn: 1 - }, - options: pattern.modified ? this.modifiedPatternOptions : this.modifiedOptions - }; - } - }); + const decorations: IModelDeltaDecoration[] = []; + for (const change of this.quickDiffModelRef.object.changes) { + const quickDiff = this.quickDiffModelRef.object.quickDiffs + .find(quickDiff => quickDiff.id === change.providerId); + + // Skip quick diffs that are not visible + if (!quickDiff || !this.quickDiffService.isQuickDiffProviderVisible(quickDiff.id)) { + continue; + } + + if (quickDiff.kind !== 'primary' && primaryQuickDiffChanges.some(c => c.change2.modified.overlapOrTouch(change.change2.modified))) { + // Overlap with primary quick diff changes + continue; + } + + const changeType = getChangeType(change.change); + const startLineNumber = change.change.modifiedStartLineNumber; + const endLineNumber = change.change.modifiedEndLineNumber || startLineNumber; + + switch (changeType) { + case ChangeType.Add: + decorations.push({ + range: { + startLineNumber: startLineNumber, startColumn: 1, + endLineNumber: endLineNumber, endColumn: 1 + }, + options: quickDiff.kind === 'primary' || quickDiff.kind === 'contributed' + ? pattern.added ? this.addedPatternOptions : this.addedOptions + : pattern.added ? this.addedSecondaryPatternOptions : this.addedSecondaryOptions + }); + break; + case ChangeType.Delete: + decorations.push({ + range: { + startLineNumber: startLineNumber, startColumn: Number.MAX_VALUE, + endLineNumber: startLineNumber, endColumn: Number.MAX_VALUE + }, + options: quickDiff.kind === 'primary' || quickDiff.kind === 'contributed' + ? this.deletedOptions + : this.deletedSecondaryOptions + }); + break; + case ChangeType.Modify: + decorations.push({ + range: { + startLineNumber: startLineNumber, startColumn: 1, + endLineNumber: endLineNumber, endColumn: 1 + }, + options: quickDiff.kind === 'primary' || quickDiff.kind === 'contributed' + ? pattern.modified ? this.modifiedPatternOptions : this.modifiedOptions + : pattern.modified ? this.modifiedSecondaryPatternOptions : this.modifiedSecondaryOptions + }); + break; + } + } if (!this.decorationsCollection) { this.decorationsCollection = this.codeEditor.createDecorationsCollection(decorations); @@ -190,6 +221,7 @@ export class QuickDiffWorkbenchController extends Disposable implements IWorkben private readonly quickDiffDecorationCount: IContextKey; private readonly activeEditor: IObservable; + private readonly quickDiffProviders: IObservable; // Resource URI -> Code Editor Id -> Decoration (Disposable) private readonly decorators = new ResourceMap>(); @@ -201,6 +233,7 @@ export class QuickDiffWorkbenchController extends Disposable implements IWorkben @IEditorService private readonly editorService: IEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, @IQuickDiffModelService private readonly quickDiffModelService: IQuickDiffModelService, + @IQuickDiffService private readonly quickDiffService: IQuickDiffService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IContextKeyService contextKeyService: IContextKeyService, ) { @@ -212,6 +245,9 @@ export class QuickDiffWorkbenchController extends Disposable implements IWorkben this.activeEditor = observableFromEvent(this, this.editorService.onDidActiveEditorChange, () => this.editorService.activeEditor); + this.quickDiffProviders = observableFromEvent(this, + this.quickDiffService.onDidChangeQuickDiffProviders, () => this.quickDiffService.providers); + const onDidChangeConfiguration = Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.diffDecorations')); this._register(onDidChangeConfiguration(this.onDidChangeConfiguration, this)); this.onDidChangeConfiguration(); @@ -257,16 +293,14 @@ export class QuickDiffWorkbenchController extends Disposable implements IWorkben .monaco-editor .dirty-diff-modified { border-left-width:${state.width}px; } - .monaco-editor .dirty-diff-added-pattern, - .monaco-editor .dirty-diff-added-pattern:before, - .monaco-editor .dirty-diff-modified-pattern, - .monaco-editor .dirty-diff-modified-pattern:before { + .monaco-editor .dirty-diff-added.pattern, + .monaco-editor .dirty-diff-added.pattern:before, + .monaco-editor .dirty-diff-modified.pattern, + .monaco-editor .dirty-diff-modified.pattern:before { background-size: ${state.width}px ${state.width}px; } .monaco-editor .dirty-diff-added, - .monaco-editor .dirty-diff-added-pattern, .monaco-editor .dirty-diff-modified, - .monaco-editor .dirty-diff-modified-pattern, .monaco-editor .dirty-diff-deleted { opacity: ${state.visibility === 'always' ? 1 : 0}; } @@ -282,6 +316,7 @@ export class QuickDiffWorkbenchController extends Disposable implements IWorkben this.onEditorsChanged(); this.onDidActiveEditorChange(); + this.onDidChangeQuickDiffProviders(); this.enabled = true; } @@ -322,8 +357,8 @@ export class QuickDiffWorkbenchController extends Disposable implements IWorkben const visibleDecorationCount = observableFromEvent(this, quickDiffModelRef.object.onDidChange, () => { - const visibleQuickDiffs = quickDiffModelRef.object.quickDiffs.filter(quickDiff => quickDiff.visible); - return quickDiffModelRef.object.changes.filter(labeledChange => visibleQuickDiffs.some(quickDiff => quickDiff.label === labeledChange.label)).length; + const visibleQuickDiffs = quickDiffModelRef.object.quickDiffs.filter(quickDiff => this.quickDiffService.isQuickDiffProviderVisible(quickDiff.id)); + return quickDiffModelRef.object.changes.filter(change => visibleQuickDiffs.some(quickDiff => quickDiff.id === change.providerId)).length; }); store.add(autorun(reader => { @@ -333,6 +368,37 @@ export class QuickDiffWorkbenchController extends Disposable implements IWorkben })); } + private onDidChangeQuickDiffProviders(): void { + this.transientDisposables.add(autorunWithStore((reader, store) => { + const providers = this.quickDiffProviders.read(reader); + + for (let index = 0; index < providers.length; index++) { + const provider = providers[index]; + const visible = this.quickDiffService.isQuickDiffProviderVisible(provider.id); + const group = provider.kind !== 'contributed' ? '0_scm' : '1_contributed'; + const order = index + 1; + + store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: `workbench.scm.action.toggleQuickDiffVisibility.${provider.id}`, + title: provider.label, + toggled: visible ? ContextKeyTrueExpr.INSTANCE : ContextKeyFalseExpr.INSTANCE, + menu: { + id: MenuId.SCMQuickDiffDecorations, group, order + }, + f1: false + }); + } + override run(accessor: ServicesAccessor): void { + const quickDiffService = accessor.get(IQuickDiffService); + quickDiffService.toggleQuickDiffProviderVisibility(provider.id); + } + })); + } + })); + } + private onEditorsChanged(): void { for (const editor of this.editorService.visibleTextEditorControls) { if (!isCodeEditor(editor)) { @@ -358,7 +424,7 @@ export class QuickDiffWorkbenchController extends Disposable implements IWorkben this.decorators.set(textModel.uri, new DisposableMap()); } - this.decorators.get(textModel.uri)!.set(editorId, new QuickDiffDecorator(editor, quickDiffModelRef, this.configurationService)); + this.decorators.get(textModel.uri)!.set(editorId, new QuickDiffDecorator(editor, quickDiffModelRef, this.configurationService, this.quickDiffService)); } // Dispose decorators for editors that are no longer visible. diff --git a/src/vs/workbench/contrib/scm/browser/quickDiffModel.ts b/src/vs/workbench/contrib/scm/browser/quickDiffModel.ts index ce0f83a212f..2a88b551251 100644 --- a/src/vs/workbench/contrib/scm/browser/quickDiffModel.ts +++ b/src/vs/workbench/contrib/scm/browser/quickDiffModel.ts @@ -26,7 +26,7 @@ import { LineRangeMapping } from '../../../../editor/common/diff/rangeMapping.js import { IDiffEditorModel } from '../../../../editor/common/editorCommon.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; -import { IChatEditingService, WorkingSetEntryState } from '../../chat/common/chatEditingService.js'; +import { IChatEditingService, ModifiedFileEntryState } from '../../chat/common/chatEditingService.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { autorun, autorunWithStore } from '../../../../base/common/observable.js'; @@ -178,15 +178,14 @@ export class QuickDiffModel extends Disposable { public getQuickDiffResults(): QuickDiffResult[] { return this._quickDiffs.map(quickDiff => { const changes = this.changes - .filter(change => change.label === quickDiff.label); + .filter(change => change.providerId === quickDiff.id); return { - label: quickDiff.label, original: quickDiff.originalResource, modified: this._model.resource, changes: changes.map(change => change.change), changes2: changes.map(change => change.change2) - }; + } satisfies QuickDiffResult; }); } @@ -245,22 +244,52 @@ export class QuickDiffModel extends Disposable { return Promise.resolve({ changes: [], mapChanges: new Map() }); // disposed } - const filteredToDiffable = originalURIs.filter(quickDiff => this.editorWorkerService.canComputeDirtyDiff(quickDiff.originalResource, this._model.resource)); - if (filteredToDiffable.length === 0) { + const quickDiffs = originalURIs + .filter(quickDiff => this.editorWorkerService.canComputeDirtyDiff(quickDiff.originalResource, this._model.resource)); + if (quickDiffs.length === 0) { return Promise.resolve({ changes: [], mapChanges: new Map() }); // All files are too large } + const quickDiffPrimary = quickDiffs.find(quickDiff => quickDiff.kind === 'primary'); + const ignoreTrimWhitespaceSetting = this.configurationService.getValue<'true' | 'false' | 'inherit'>('scm.diffDecorationsIgnoreTrimWhitespace'); const ignoreTrimWhitespace = ignoreTrimWhitespaceSetting === 'inherit' ? this.configurationService.getValue('diffEditor.ignoreTrimWhitespace') : ignoreTrimWhitespaceSetting !== 'false'; const allDiffs: QuickDiffChange[] = []; - for (const quickDiff of filteredToDiffable) { + for (const quickDiff of quickDiffs) { const diff = await this._diff(quickDiff.originalResource, this._model.resource, ignoreTrimWhitespace); if (diff.changes && diff.changes2 && diff.changes.length === diff.changes2.length) { for (let index = 0; index < diff.changes.length; index++) { + const change2 = diff.changes2[index]; + + // The secondary diffs are complimentary to the primary diffs, and + // they overlap. We need to remove the secondary quick diffs that + // overlap with primary quick diffs that are already in the array. + if (quickDiffPrimary && quickDiff.kind === 'secondary') { + // Check whether the: + // 1. the modified line range is equal + // 2. the original line range length is equal + const primaryQuickDiffChange = allDiffs + .find(d => d.change2.modified.equals(change2.modified) && + d.change2.original.length === change2.original.length); + + if (primaryQuickDiffChange) { + // Check whether the original content matches + const primaryModel = this._originalEditorModels.get(quickDiffPrimary.originalResource)?.textEditorModel; + const primaryContent = primaryModel?.getValueInRange(primaryQuickDiffChange.change2.toRangeMapping().originalRange); + + const secondaryModel = this._originalEditorModels.get(quickDiff.originalResource)?.textEditorModel; + const secondaryContent = secondaryModel?.getValueInRange(change2.toRangeMapping().originalRange); + if (primaryContent === secondaryContent) { + continue; + } + } + } + allDiffs.push({ + providerId: quickDiff.id, label: quickDiff.label, original: quickDiff.originalResource, modified: this._model.resource, @@ -270,6 +299,7 @@ export class QuickDiffModel extends Disposable { } } } + const sorted = allDiffs.sort((a, b) => compareChanges(a.change, b.change)); const map: Map = new Map(); for (let i = 0; i < sorted.length; i++) { @@ -309,7 +339,11 @@ export class QuickDiffModel extends Disposable { return []; } - if (equals(this._quickDiffs, quickDiffs, (a, b) => a.originalResource.toString() === b.originalResource.toString() && a.label === b.label)) { + if (equals(this._quickDiffs, quickDiffs, (a, b) => + a.id === b.id && + a.originalResource.toString() === b.originalResource.toString() && + this.quickDiffService.isQuickDiffProviderVisible(a.id) === this.quickDiffService.isQuickDiffProviderVisible(b.id)) + ) { return quickDiffs; } @@ -358,7 +392,7 @@ export class QuickDiffModel extends Disposable { // disable dirty diff when doing chat edits const isBeingModifiedByChatEdits = this._chatEditingService.editingSessionsObs.get() - .some(session => session.getEntry(uri)?.state.get() === WorkingSetEntryState.Modified); + .some(session => session.getEntry(uri)?.state.get() === ModifiedFileEntryState.Modified); if (isBeingModifiedByChatEdits) { return Promise.resolve([]); } @@ -368,44 +402,38 @@ export class QuickDiffModel extends Disposable { } findNextClosestChange(lineNumber: number, inclusive = true, provider?: string): number { - let preferredProvider: string | undefined; - if (!provider && inclusive) { - preferredProvider = this.quickDiffs.find(value => value.isSCM)?.label; + const visibleQuickDiffLabels = this.quickDiffs + .filter(quickDiff => (!provider || quickDiff.label === provider) && + this.quickDiffService.isQuickDiffProviderVisible(quickDiff.id)) + .map(quickDiff => quickDiff.label); + + if (!inclusive) { + // Next visible change + const nextChange = this.changes + .findIndex(change => visibleQuickDiffLabels.includes(change.label) && + change.change.modifiedStartLineNumber > lineNumber); + + return nextChange !== -1 ? nextChange : 0; } - const possibleChanges: number[] = []; - for (let i = 0; i < this.changes.length; i++) { - if (provider && this.changes[i].label !== provider) { - continue; - } + const primaryQuickDiffId = this.quickDiffs + .find(quickDiff => quickDiff.kind === 'primary')?.id; - // Skip quick diffs that are not visible - if (!this.quickDiffs.find(quickDiff => quickDiff.label === this.changes[i].label)?.visible) { - continue; - } + const primaryInclusiveChangeIndex = this.changes + .findIndex(change => change.providerId === primaryQuickDiffId && + change.change.modifiedStartLineNumber <= lineNumber && + getModifiedEndLineNumber(change.change) >= lineNumber); - const change = this.changes[i]; - const possibleChangesLength = possibleChanges.length; - - if (inclusive) { - if (getModifiedEndLineNumber(change.change) >= lineNumber) { - if (preferredProvider && change.label !== preferredProvider) { - possibleChanges.push(i); - } else { - return i; - } - } - } else { - if (change.change.modifiedStartLineNumber > lineNumber) { - return i; - } - } - if ((possibleChanges.length > 0) && (possibleChanges.length === possibleChangesLength)) { - return possibleChanges[0]; - } + if (primaryInclusiveChangeIndex !== -1) { + return primaryInclusiveChangeIndex; } - return possibleChanges.length > 0 ? possibleChanges[0] : 0; + const inclusiveChangeIndex = this.changes + .findIndex(change => visibleQuickDiffLabels.includes(change.label) && + change.change.modifiedStartLineNumber <= lineNumber && + getModifiedEndLineNumber(change.change) >= lineNumber); + + return inclusiveChangeIndex !== -1 ? inclusiveChangeIndex : 0; } findPreviousClosestChange(lineNumber: number, inclusive = true, provider?: string): number { @@ -415,7 +443,8 @@ export class QuickDiffModel extends Disposable { } // Skip quick diffs that are not visible - if (!this.quickDiffs.find(quickDiff => quickDiff.label === this.changes[i].label)?.visible) { + const quickDiff = this.quickDiffs.find(quickDiff => quickDiff.id === this.changes[i].providerId); + if (!quickDiff || !this.quickDiffService.isQuickDiffProviderVisible(quickDiff.id)) { continue; } diff --git a/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts b/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts index de01355fb54..1c6bebfe09b 100644 --- a/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts +++ b/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts @@ -28,7 +28,7 @@ import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from ' import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { rot } from '../../../../base/common/numbers.js'; import { ISplice } from '../../../../base/common/sequence.js'; -import { ChangeType, getChangeHeight, getChangeType, getChangeTypeColor, getModifiedEndLineNumber, lineIntersectsChange, QuickDiffChange } from '../common/quickDiff.js'; +import { ChangeType, getChangeHeight, getChangeType, getChangeTypeColor, getModifiedEndLineNumber, IQuickDiffService, lineIntersectsChange, QuickDiff, QuickDiffChange } from '../common/quickDiff.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; @@ -58,33 +58,26 @@ export interface IQuickDiffSelectItem extends ISelectOptionItem { } export class QuickDiffPickerViewItem extends SelectActionViewItem { - private readonly optionsItems: IQuickDiffSelectItem[]; + private optionsItems: IQuickDiffSelectItem[] = []; constructor( action: IAction, - providers: string[], - selected: string, @IContextViewService contextViewService: IContextViewService, @IThemeService themeService: IThemeService ) { - const items = providers.map(provider => ({ provider, text: provider })); - let startingSelection = providers.indexOf(selected); - if (startingSelection === -1) { - startingSelection = 0; - } const styles = { ...defaultSelectBoxStyles }; const theme = themeService.getColorTheme(); const editorBackgroundColor = theme.getColor(editorBackground); const peekTitleColor = theme.getColor(peekViewTitleBackground); const opaqueTitleColor = peekTitleColor?.makeOpaque(editorBackgroundColor!) ?? editorBackgroundColor!; styles.selectBackground = opaqueTitleColor.lighten(.6).toString(); - super(null, action, items, startingSelection, contextViewService, styles, { ariaLabel: nls.localize('remotes', 'Switch quick diff base') }); - this.optionsItems = items; + super(null, action, [], 0, contextViewService, styles, { ariaLabel: nls.localize('remotes', 'Switch quick diff base') }); } - public setSelection(provider: string) { + public setSelection(providers: string[], provider: string) { + this.optionsItems = providers.map(provider => ({ provider, text: provider })); const index = this.optionsItems.findIndex(item => item.provider === provider); - this.select(index); + this.setOptions(this.optionsItems, index); } protected override getActionContext(_: string, index: number): IQuickDiffSelectItem { @@ -168,11 +161,12 @@ class QuickDiffWidget extends PeekViewWidget { @IThemeService private readonly themeService: IThemeService, @IInstantiationService instantiationService: IInstantiationService, @IMenuService private readonly menuService: IMenuService, - @IContextKeyService private contextKeyService: IContextKeyService + @IContextKeyService private contextKeyService: IContextKeyService, + @IQuickDiffService private readonly quickDiffService: IQuickDiffService ) { super(editor, { isResizeable: true, frameWidth: 1, keepEditorSelection: true, className: 'dirty-diff' }, instantiationService); - this._disposables.add(themeService.onDidColorThemeChange(e => this._applyTheme(e.theme))); + this._disposables.add(themeService.onDidColorThemeChange(this._applyTheme, this)); this._applyTheme(themeService.getColorTheme()); if (!Iterable.isEmpty(this.model.originalTextModels)) { @@ -207,6 +201,7 @@ class QuickDiffWidget extends PeekViewWidget { const labeledChange = this.model.changes[index]; const change = labeledChange.change; this._index = index; + this.contextKeyService.createKey('originalResource', this.model.changes[index].original.toString()); this.contextKeyService.createKey('originalResourceScheme', this.model.changes[index].original.scheme); this.updateActions(); @@ -228,7 +223,6 @@ class QuickDiffWidget extends PeekViewWidget { return; } this.diffEditor.setModel(diffEditorModel); - this.dropdown?.setSelection(labeledChange.label); const position = new Position(getModifiedEndLineNumber(change), 1); @@ -237,6 +231,7 @@ class QuickDiffWidget extends PeekViewWidget { const editorHeightInLines = Math.floor(editorHeight / lineHeight); const height = Math.min(getChangeHeight(change) + /* padding */ 8, Math.floor(editorHeightInLines / 3)); + this.updateDropdown(labeledChange.label); this.renderTitle(labeledChange.label); const changeType = getChangeType(change); @@ -294,7 +289,7 @@ class QuickDiffWidget extends PeekViewWidget { } } let closestLesserIndex = this._index > 0 ? this._index - 1 : this.model.changes.length - 1; - for (let i = closestLesserIndex; i !== this._index; i >= 0 ? i-- : i = this.model.changes.length - 1) { + for (let i = closestLesserIndex; i !== this._index; i > 0 ? i-- : i = this.model.changes.length - 1) { if (this.model.changes[i].label === newProvider) { closestLesserIndex = i; break; @@ -307,12 +302,8 @@ class QuickDiffWidget extends PeekViewWidget { } private shouldUseDropdown(): boolean { - const visibleQuickDiffs = this.model.quickDiffs.filter(quickDiff => quickDiff.visible); - const visibleQuickDiffResults = this.model.getQuickDiffResults() - .filter(result => visibleQuickDiffs.some(quickDiff => quickDiff.label === result.label)); - - return visibleQuickDiffResults - .filter(quickDiff => quickDiff.changes.length > 0).length > 1; + const quickDiffs = this.getQuickDiffsContainingChange(); + return quickDiffs.length > 1; } private updateActions(): void { @@ -336,17 +327,31 @@ class QuickDiffWidget extends PeekViewWidget { this._actionbarWidget.push(this._disposables.add(new Action('peekview.close', nls.localize('label.close', "Close"), ThemeIcon.asClassName(Codicon.close), true, () => this.dispose())), { label: false, icon: true }); } + private updateDropdown(label: string): void { + const quickDiffs = this.getQuickDiffsContainingChange(); + this.dropdown?.setSelection(quickDiffs.map(quickDiff => quickDiff.label), label); + } + + private getQuickDiffsContainingChange(): QuickDiff[] { + const change = this.model.changes[this._index]; + + const quickDiffsWithChange = this.model.changes + .filter(c => change.change2.modified.overlapOrTouch(c.change2.modified)) + .map(c => c.providerId); + + return this.model.quickDiffs + .filter(quickDiff => quickDiffsWithChange.includes(quickDiff.id) && + this.quickDiffService.isQuickDiffProviderVisible(quickDiff.id)); + } + protected override _fillHead(container: HTMLElement): void { super._fillHead(container, true); - const visibleQuickDiffs = this.model.quickDiffs.filter(quickDiff => quickDiff.visible); - + // Render an empty picker which will be populated later this.dropdownContainer = dom.prepend(this._titleElement!, dom.$('.dropdown')); this.dropdown = this.instantiationService.createInstance(QuickDiffPickerViewItem, - new QuickDiffPickerBaseAction((event?: IQuickDiffSelectItem) => this.switchQuickDiff(event)), - visibleQuickDiffs.map(quickDiff => quickDiff.label), this.model.changes[this._index].label); + new QuickDiffPickerBaseAction((event?: IQuickDiffSelectItem) => this.switchQuickDiff(event))); this.dropdown.render(this.dropdownContainer); - this.updateActions(); } protected override _getActionBarOptions(): IActionBarOptions { diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index 2995f5ccacf..22199e56e6d 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -25,9 +25,9 @@ import { ModesRegistry } from '../../../../editor/common/languages/modesRegistry import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ContextKeys, SCMViewPane } from './scmViewPane.js'; -import { SCMViewService } from './scmViewService.js'; +import { RepositoryPicker, SCMViewService } from './scmViewService.js'; import { SCMRepositoriesViewPane } from './scmRepositoriesViewPane.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { Context as SuggestContext } from '../../../../editor/contrib/suggest/browser/suggest.js'; import { MANAGE_TRUST_COMMAND_ID, WorkspaceTrustContext } from '../../workspace/common/workspace.js'; import { IQuickDiffService } from '../common/quickDiff.js'; @@ -534,6 +534,21 @@ CommandsRegistry.registerCommand('scm.openInTerminal', async (accessor, provider await commandService.executeCommand('openInTerminal', provider.rootUri); }); +CommandsRegistry.registerCommand('scm.setActiveProvider', async (accessor) => { + const instantiationService = accessor.get(IInstantiationService); + const scmViewService = accessor.get(ISCMViewService); + + const placeHolder = localize('scmActiveRepositoryPlaceHolder', "Select the active repository, type to filter all repositories"); + const autoQuickItemDescription = localize('scmActiveRepositoryAutoDescription', "The active repository is updated based on focused repository/active editor"); + const repositoryPicker = instantiationService.createInstance(RepositoryPicker, placeHolder, autoQuickItemDescription); + + const result = await repositoryPicker.pickRepository(); + if (result?.repository) { + const repository = result.repository !== 'auto' ? result.repository : undefined; + scmViewService.pinActiveRepository(repository); + } +}); + MenuRegistry.appendMenuItem(MenuId.SCMSourceControl, { group: '100_end', command: { @@ -611,6 +626,15 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ } }); +MenuRegistry.appendMenuItem(MenuId.EditorLineNumberContext, { + title: localize('quickDiffDecoration', "Diff Decorations"), + submenu: MenuId.SCMQuickDiffDecorations, + when: ContextKeyExpr.or( + ContextKeyExpr.equals('config.scm.diffDecorations', 'all'), + ContextKeyExpr.equals('config.scm.diffDecorations', 'gutter')), + group: '9_quickDiffDecorations' +}); + registerSingleton(ISCMService, SCMService, InstantiationType.Delayed); registerSingleton(ISCMViewService, SCMViewService, InstantiationType.Delayed); registerSingleton(IQuickDiffService, QuickDiffService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/scm/browser/scmAccessibilityHelp.ts b/src/vs/workbench/contrib/scm/browser/scmAccessibilityHelp.ts index 3126416fcfd..18908231a3c 100644 --- a/src/vs/workbench/contrib/scm/browser/scmAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/scm/browser/scmAccessibilityHelp.ts @@ -76,23 +76,23 @@ class SCMAccessibilityHelpContentProvider extends Disposable implements IAccessi content.push(localize('state-msg1', "Visible repositories: {0}", repositoryList)); } - const activeRepository = this._scmViewService.activeRepository.get(); - if (activeRepository) { - content.push(localize('state-msg2', "Repository: {0}", activeRepository.provider.name)); + const focusedRepository = this._scmViewService.focusedRepository; + if (focusedRepository) { + content.push(localize('state-msg2', "Repository: {0}", focusedRepository.provider.name)); // History Item Reference - const currentHistoryItemRef = activeRepository.provider.historyProvider.get()?.historyItemRef.get(); + const currentHistoryItemRef = focusedRepository.provider.historyProvider.get()?.historyItemRef.get(); if (currentHistoryItemRef) { content.push(localize('state-msg3', "History item reference: {0}", currentHistoryItemRef.name)); } // Commit Message - if (activeRepository.input.visible && activeRepository.input.enabled && activeRepository.input.value !== '') { - content.push(localize('state-msg4', "Commit message: {0}", activeRepository.input.value)); + if (focusedRepository.input.visible && focusedRepository.input.enabled && focusedRepository.input.value !== '') { + content.push(localize('state-msg4', "Commit message: {0}", focusedRepository.input.value)); } // Action Button - const actionButton = activeRepository.provider.actionButton.get(); + const actionButton = focusedRepository.provider.actionButton.get(); if (actionButton) { const label = actionButton.command.tooltip ?? actionButton.command.title; const enablementLabel = actionButton.enabled ? localize('enabled', "enabled") : localize('disabled', "disabled"); @@ -101,11 +101,11 @@ class SCMAccessibilityHelpContentProvider extends Disposable implements IAccessi // Resource Groups const resourceGroups: string[] = []; - for (const resourceGroup of activeRepository.provider.groups) { + for (const resourceGroup of focusedRepository.provider.groups) { resourceGroups.push(`${resourceGroup.label} (${resourceGroup.resources.length} resource(s))`); } - activeRepository.provider.groups.map(g => g.label).join(', '); + focusedRepository.provider.groups.map(g => g.label).join(', '); content.push(localize('state-msg6', "Resource groups: {0}", resourceGroups.join(', '))); } diff --git a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts index 501e99edf7e..75ca8982fcd 100644 --- a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts @@ -497,7 +497,7 @@ class HistoryItemRenderer implements ITreeRenderer { const picks: (RepositoryQuickPickItem | IQuickPickSeparator)[] = [ diff --git a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts index 254f02be73e..755aed69a01 100644 --- a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts @@ -92,6 +92,7 @@ export class SCMRepositoriesViewPane extends ViewPane { this._register(this.list); this._register(this.list.onDidChangeSelection(this.onListSelectionChange, this)); + this._register(this.list.onDidChangeFocus(this.onDidChangeFocus, this)); this._register(this.list.onContextMenu(this.onListContextMenu, this)); this._register(this.scmViewService.onDidChangeRepositories(this.onDidChangeRepositories, this)); @@ -169,6 +170,12 @@ export class SCMRepositoriesViewPane extends ViewPane { } } + private onDidChangeFocus(e: IListEvent): void { + if (e.browserEvent && e.elements.length > 0) { + this.scmViewService.focus(e.elements[0]); + } + } + private updateListSelection(): void { const oldSelection = this.list.getSelection(); const oldSet = new Set(Iterable.map(oldSelection, i => this.list.element(i))); diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 013eeeac063..83f8a15401d 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -1498,6 +1498,8 @@ class SCMInputWidgetEditorOptions { e => { return e.affectsConfiguration('editor.accessibilitySupport') || e.affectsConfiguration('editor.cursorBlinking') || + e.affectsConfiguration('editor.cursorStyle') || + e.affectsConfiguration('editor.cursorWidth') || e.affectsConfiguration('editor.emptySelectionClipboard') || e.affectsConfiguration('editor.fontFamily') || e.affectsConfiguration('editor.rulers') || @@ -1515,7 +1517,6 @@ class SCMInputWidgetEditorOptions { return { ...getSimpleEditorOptions(this.configurationService), ...this.getEditorOptions(), - cursorWidth: 1, dragAndDrop: true, dropIntoEditor: { enabled: true }, formatOnType: true, @@ -1539,9 +1540,11 @@ class SCMInputWidgetEditorOptions { const lineHeight = this._getEditorLineHeight(fontSize); const accessibilitySupport = this.configurationService.getValue<'auto' | 'off' | 'on'>('editor.accessibilitySupport'); const cursorBlinking = this.configurationService.getValue<'blink' | 'smooth' | 'phase' | 'expand' | 'solid'>('editor.cursorBlinking'); + const cursorStyle = this.configurationService.getValue('editor.cursorStyle'); + const cursorWidth = this.configurationService.getValue('editor.cursorWidth') ?? 1; const emptySelectionClipboard = this.configurationService.getValue('editor.emptySelectionClipboard') === true; - return { ...this._getEditorLanguageConfiguration(), accessibilitySupport, cursorBlinking, fontFamily, fontSize, lineHeight, emptySelectionClipboard }; + return { ...this._getEditorLanguageConfiguration(), accessibilitySupport, cursorBlinking, cursorStyle, cursorWidth, fontFamily, fontSize, lineHeight, emptySelectionClipboard }; } private _getEditorFontFamily(): string { diff --git a/src/vs/workbench/contrib/scm/browser/scmViewService.ts b/src/vs/workbench/contrib/scm/browser/scmViewService.ts index 3e4d5f73507..e5c16a6e0e3 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewService.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewService.ts @@ -18,10 +18,14 @@ import { binarySearch } from '../../../../base/common/arrays.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; -import { derivedObservableWithCache, derivedOpts, IObservable, latestChangedValue, observableFromEventOpts } from '../../../../base/common/observable.js'; +import { derivedObservableWithCache, derivedOpts, IObservable, ISettableObservable, latestChangedValue, observableFromEventOpts, observableValue } from '../../../../base/common/observable.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorResourceAccessor } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; function getProviderStorageKey(provider: ISCMProvider): string { return `${provider.contextValue}:${provider.label}${provider.rootUri ? `:${provider.rootUri.toString()}` : ''}`; @@ -40,6 +44,41 @@ export const RepositoryContextKeys = { RepositorySortKey: new RawContextKey('scmRepositorySortKey', ISCMRepositorySortKey.DiscoveryTime), }; +export type RepositoryQuickPickItem = IQuickPickItem & { repository: 'auto' | ISCMRepository }; + +export class RepositoryPicker { + private readonly _autoQuickPickItem: RepositoryQuickPickItem; + + constructor( + private readonly _placeHolder: string, + private readonly _autoQuickItemDescription: string, + @IQuickInputService private readonly _quickInputService: IQuickInputService, + @ISCMViewService private readonly _scmViewService: ISCMViewService + ) { + this._autoQuickPickItem = { + label: localize('auto', "Auto"), + description: this._autoQuickItemDescription, + repository: 'auto' + } satisfies RepositoryQuickPickItem; + } + + async pickRepository(): Promise { + const picks: (RepositoryQuickPickItem | IQuickPickSeparator)[] = [ + this._autoQuickPickItem, + { type: 'separator' } + ]; + + picks.push(...this._scmViewService.repositories.map(r => ({ + label: r.provider.name, + description: r.provider.rootUri?.fsPath, + iconClass: ThemeIcon.asClassName(Codicon.repo), + repository: r + }))); + + return this._quickInputService.pick(picks, { placeHolder: this._placeHolder }); + } +} + interface ISCMRepositoryView { readonly repository: ISCMRepository; readonly discoveryTime: number; @@ -167,6 +206,7 @@ export class SCMViewService implements ISCMViewService { * values are updated in the same transaction (or during the initial read of the observable value). */ private readonly _activeRepositoryObs: IObservable; + private readonly _activeRepositoryPinnedObs: ISettableObservable; private readonly _focusedRepositoryObs: IObservable; private _repositoriesSortKey: ISCMRepositorySortKey; @@ -214,12 +254,18 @@ export class SCMViewService implements ISCMViewService { return Object.create(repository); }); + this._activeRepositoryPinnedObs = observableValue(this, undefined); this._activeRepositoryObs = latestChangedValue(this, [this._activeEditorRepositoryObs, this._focusedRepositoryObs]); this.activeRepository = derivedOpts({ owner: this, equalsFn: (r1, r2) => r1?.id === r2?.id - }, reader => this._activeRepositoryObs.read(reader)); + }, reader => { + const activeRepository = this._activeRepositoryObs.read(reader); + const activeRepositoryPinned = this._activeRepositoryPinnedObs.read(reader); + + return activeRepositoryPinned ?? activeRepository; + }); try { this.previousState = JSON.parse(storageService.get('scm:view:visibleRepositories', StorageScope.WORKSPACE, '')); @@ -384,6 +430,10 @@ export class SCMViewService implements ISCMViewService { } } + pinActiveRepository(repository: ISCMRepository | undefined): void { + this._activeRepositoryPinnedObs.set(repository, undefined); + } + private compareRepositories(op1: ISCMRepositoryView, op2: ISCMRepositoryView): number { // Sort by discovery time if (this._repositoriesSortKey === ISCMRepositorySortKey.DiscoveryTime) { diff --git a/src/vs/workbench/contrib/scm/common/quickDiff.ts b/src/vs/workbench/contrib/scm/common/quickDiff.ts index 60dd34b399e..e1c0b0bf437 100644 --- a/src/vs/workbench/contrib/scm/common/quickDiff.ts +++ b/src/vs/workbench/contrib/scm/common/quickDiff.ts @@ -16,7 +16,8 @@ import { IColorTheme } from '../../../../platform/theme/common/themeService.js'; import { Color } from '../../../../base/common/color.js'; import { darken, editorBackground, editorForeground, listInactiveSelectionBackground, opaque, - editorErrorForeground, registerColor, transparent + editorErrorForeground, registerColor, transparent, + lighten } from '../../../../platform/theme/common/colorRegistry.js'; export const IQuickDiffService = createDecorator('quickDiff'); @@ -25,13 +26,24 @@ const editorGutterModifiedBackground = registerColor('editorGutter.modifiedBackg dark: '#1B81A8', light: '#2090D3', hcDark: '#1B81A8', hcLight: '#2090D3' }, nls.localize('editorGutterModifiedBackground', "Editor gutter background color for lines that are modified.")); +registerColor('editorGutter.modifiedSecondaryBackground', + { dark: darken(editorGutterModifiedBackground, 0.5), light: lighten(editorGutterModifiedBackground, 0.7), hcDark: '#1B81A8', hcLight: '#2090D3' }, + nls.localize('editorGutterModifiedSecondaryBackground', "Editor gutter secondary background color for lines that are modified.")); + const editorGutterAddedBackground = registerColor('editorGutter.addedBackground', { dark: '#487E02', light: '#48985D', hcDark: '#487E02', hcLight: '#48985D' }, nls.localize('editorGutterAddedBackground', "Editor gutter background color for lines that are added.")); +registerColor('editorGutter.addedSecondaryBackground', + { dark: darken(editorGutterAddedBackground, 0.5), light: lighten(editorGutterAddedBackground, 0.7), hcDark: '#487E02', hcLight: '#48985D' }, + nls.localize('editorGutterAddedSecondaryBackground', "Editor gutter secondary background color for lines that are added.")); + const editorGutterDeletedBackground = registerColor('editorGutter.deletedBackground', editorErrorForeground, nls.localize('editorGutterDeletedBackground', "Editor gutter background color for lines that are deleted.")); +registerColor('editorGutter.deletedSecondaryBackground', + { dark: darken(editorGutterDeletedBackground, 0.4), light: lighten(editorGutterDeletedBackground, 0.3), hcDark: '#F48771', hcLight: '#B5200D' }, + nls.localize('editorGutterDeletedSecondaryBackground', "Editor gutter secondary background color for lines that are deleted.")); export const minimapGutterModifiedBackground = registerColor('minimapGutter.modifiedBackground', editorGutterModifiedBackground, nls.localize('minimapGutterModifiedBackground', "Minimap gutter background color for lines that are modified.")); @@ -55,22 +67,23 @@ export const editorGutterItemGlyphForeground = registerColor('editorGutter.itemG export const editorGutterItemBackground = registerColor('editorGutter.itemBackground', { dark: opaque(listInactiveSelectionBackground, editorBackground), light: darken(opaque(listInactiveSelectionBackground, editorBackground), .05), hcDark: Color.white, hcLight: Color.black }, nls.localize('editorGutterItemBackground', 'Editor gutter decoration color for gutter item background. This color should be opaque.')); export interface QuickDiffProvider { - label: string; - rootUri: URI | undefined; - selector?: LanguageSelector; - isSCM: boolean; - visible: boolean; + readonly id: string; + readonly label: string; + readonly rootUri: URI | undefined; + readonly selector?: LanguageSelector; + readonly kind: 'primary' | 'secondary' | 'contributed'; getOriginalResource(uri: URI): Promise; } export interface QuickDiff { - label: string; - originalResource: URI; - isSCM: boolean; - visible: boolean; + readonly id: string; + readonly label: string; + readonly originalResource: URI; + readonly kind: 'primary' | 'secondary' | 'contributed'; } export interface QuickDiffChange { + readonly providerId: string; readonly label: string; readonly original: URI; readonly modified: URI; @@ -79,7 +92,6 @@ export interface QuickDiffChange { } export interface QuickDiffResult { - readonly label: string; readonly original: URI; readonly modified: URI; readonly changes: IChange[]; @@ -90,8 +102,11 @@ export interface IQuickDiffService { readonly _serviceBrand: undefined; readonly onDidChangeQuickDiffProviders: Event; + readonly providers: readonly QuickDiffProvider[]; addQuickDiffProvider(quickDiff: QuickDiffProvider): IDisposable; getQuickDiffs(uri: URI, language?: string, isSynchronized?: boolean): Promise; + toggleQuickDiffProviderVisibility(id: string): void; + isQuickDiffProviderVisible(id: string): boolean; } export enum ChangeType { diff --git a/src/vs/workbench/contrib/scm/common/quickDiffService.ts b/src/vs/workbench/contrib/scm/common/quickDiffService.ts index 5b872ae5722..97811516760 100644 --- a/src/vs/workbench/contrib/scm/common/quickDiffService.ts +++ b/src/vs/workbench/contrib/scm/common/quickDiffService.ts @@ -10,6 +10,7 @@ import { isEqualOrParent } from '../../../../base/common/resources.js'; import { score } from '../../../../editor/common/languageSelector.js'; import { Emitter } from '../../../../base/common/event.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; function createProviderComparer(uri: URI): (a: QuickDiffProvider, b: QuickDiffProvider) => number { return (a, b) => { @@ -25,7 +26,7 @@ function createProviderComparer(uri: URI): (a: QuickDiffProvider, b: QuickDiffPr const bIsParent = isEqualOrParent(uri, b.rootUri!); if (aIsParent && bIsParent) { - return a.rootUri!.fsPath.length - b.rootUri!.fsPath.length; + return providerComparer(a, b); } else if (aIsParent) { return -1; } else if (bIsParent) { @@ -36,48 +37,120 @@ function createProviderComparer(uri: URI): (a: QuickDiffProvider, b: QuickDiffPr }; } +function providerComparer(a: QuickDiffProvider, b: QuickDiffProvider): number { + if (a.kind === 'primary') { + return -1; + } else if (b.kind === 'primary') { + return 1; + } else if (a.kind === 'secondary') { + return -1; + } else if (b.kind === 'secondary') { + return 1; + } + return 0; +} + export class QuickDiffService extends Disposable implements IQuickDiffService { declare readonly _serviceBrand: undefined; + private static readonly STORAGE_KEY = 'workbench.scm.quickDiffProviders.hidden'; + + private quickDiffProviders: Map = new Map(); + get providers(): readonly QuickDiffProvider[] { + return Array.from(this.quickDiffProviders.values()).sort(providerComparer); + } - private quickDiffProviders: Set = new Set(); private readonly _onDidChangeQuickDiffProviders = this._register(new Emitter()); readonly onDidChangeQuickDiffProviders = this._onDidChangeQuickDiffProviders.event; - constructor(@IUriIdentityService private readonly uriIdentityService: IUriIdentityService) { + private hiddenQuickDiffProviders = new Set(); + + constructor( + @IStorageService private readonly storageService: IStorageService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService + ) { super(); + + this.loadState(); } addQuickDiffProvider(quickDiff: QuickDiffProvider): IDisposable { - this.quickDiffProviders.add(quickDiff); + this.quickDiffProviders.set(quickDiff.id, quickDiff); this._onDidChangeQuickDiffProviders.fire(); return { dispose: () => { - this.quickDiffProviders.delete(quickDiff); + this.quickDiffProviders.delete(quickDiff.id); this._onDidChangeQuickDiffProviders.fire(); } }; } - private isQuickDiff(diff: { originalResource?: URI; label?: string; isSCM?: boolean }): diff is QuickDiff { - return !!diff.originalResource && (typeof diff.label === 'string') && (typeof diff.isSCM === 'boolean'); - } - async getQuickDiffs(uri: URI, language: string = '', isSynchronized: boolean = false): Promise { - const providers = Array.from(this.quickDiffProviders) + const providers = Array.from(this.quickDiffProviders.values()) .filter(provider => !provider.rootUri || this.uriIdentityService.extUri.isEqualOrParent(uri, provider.rootUri)) .sort(createProviderComparer(uri)); - const diffs = await Promise.all(providers.map(async provider => { + const quickDiffOriginalResources = await Promise.all(providers.map(async provider => { const scoreValue = provider.selector ? score(provider.selector, uri, language, isSynchronized, undefined, undefined) : 10; - const diff: Partial = { - originalResource: scoreValue > 0 ? await provider.getOriginalResource(uri) ?? undefined : undefined, - label: provider.label, - isSCM: provider.isSCM, - visible: provider.visible - }; - return diff; + const originalResource = scoreValue > 0 ? await provider.getOriginalResource(uri) ?? undefined : undefined; + return { id: provider.id, originalResource }; })); - return diffs.filter(this.isQuickDiff); + + const quickDiffs: QuickDiff[] = []; + for (const { id, originalResource } of quickDiffOriginalResources) { + if (!originalResource) { + continue; + } + + const provider = this.quickDiffProviders.get(id); + if (!provider) { + continue; + } + + quickDiffs.push({ + id: provider.id, + label: provider.label, + kind: provider.kind, + originalResource, + } satisfies QuickDiff); + } + + return quickDiffs; + } + + toggleQuickDiffProviderVisibility(id: string): void { + if (!this.quickDiffProviders.has(id)) { + return; + } + + if (this.isQuickDiffProviderVisible(id)) { + this.hiddenQuickDiffProviders.add(id); + } else { + this.hiddenQuickDiffProviders.delete(id); + } + + this.saveState(); + this._onDidChangeQuickDiffProviders.fire(); + } + + isQuickDiffProviderVisible(id: string): boolean { + return !this.hiddenQuickDiffProviders.has(id); + } + + private loadState(): void { + const raw = this.storageService.get(QuickDiffService.STORAGE_KEY, StorageScope.PROFILE); + if (raw) { + try { + this.hiddenQuickDiffProviders = new Set(JSON.parse(raw)); + } catch { } + } + } + + private saveState(): void { + if (this.hiddenQuickDiffProviders.size === 0) { + this.storageService.remove(QuickDiffService.STORAGE_KEY, StorageScope.PROFILE); + } else { + this.storageService.store(QuickDiffService.STORAGE_KEY, JSON.stringify(Array.from(this.hiddenQuickDiffProviders)), StorageScope.PROFILE, StorageTarget.USER); + } } } diff --git a/src/vs/workbench/contrib/scm/common/scm.ts b/src/vs/workbench/contrib/scm/common/scm.ts index d04c2b09a02..1987feddc55 100644 --- a/src/vs/workbench/contrib/scm/common/scm.ts +++ b/src/vs/workbench/contrib/scm/common/scm.ts @@ -233,6 +233,7 @@ export interface ISCMViewService { * Focused repository or the repository for the active editor */ readonly activeRepository: IObservable; + pinActiveRepository(repository: ISCMRepository | undefined): void; } export const SCM_CHANGES_EDITOR_ID = 'workbench.editor.scmChangesEditor'; diff --git a/src/vs/workbench/contrib/search/browser/AISearch/aiSearchModel.ts b/src/vs/workbench/contrib/search/browser/AISearch/aiSearchModel.ts index df9d8a5dccd..c630a13882c 100644 --- a/src/vs/workbench/contrib/search/browser/AISearch/aiSearchModel.ts +++ b/src/vs/workbench/contrib/search/browser/AISearch/aiSearchModel.ts @@ -23,14 +23,18 @@ import { TextSearchHeadingImpl } from '../searchTreeModel/textSearchHeading.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { textSearchResultToMatches } from '../searchTreeModel/match.js'; import { ISearchTreeAIFileMatch } from './aiSearchModelBase.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; export class AITextSearchHeadingImpl extends TextSearchHeadingImpl { + public override hidden: boolean; constructor( parent: ISearchResult, @IInstantiationService instantiationService: IInstantiationService, @IUriIdentityService uriIdentityService: IUriIdentityService ) { super(false, parent, instantiationService, uriIdentityService); + + this.hidden = true; } override name(): string { @@ -64,6 +68,20 @@ export class AITextSearchHeadingImpl extends TextSearchHeadingImpl this._query = query; } + override fileCount(): number { + const uniqueFileUris = new ResourceSet(); + for (const folderMatch of this.folderMatches()) { + if (folderMatch.isEmpty()) { + continue; + } + for (const fileMatch of folderMatch.allDownstreamFileMatches()) { + uniqueFileUris.add(fileMatch.resource); + } + } + + return uniqueFileUris.size; + } + private _createBaseFolderMatch(resource: URI, id: string, index: number, query: IAITextQuery): ISearchTreeFolderMatch { const folderMatch: ISearchTreeFolderMatch = this._register(this.createWorkspaceRootWithResourceImpl(resource, id, index, query)); const disposable = folderMatch.onChange((event) => this._onChange.fire(event)); diff --git a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts index bcb65cdddfd..acbecc9773b 100644 --- a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts +++ b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts @@ -71,6 +71,28 @@ function isEditorSymbolQuickPickItem(pick?: IAnythingQuickPickItem): pick is IEd return !!candidate?.range && !!candidate.resource; } +interface IAnythingPickState extends IDisposable { + picker: IQuickPick | undefined; + editorViewState: PickerEditorState; + + scorerCache: FuzzyScorerCache; + fileQueryCache: FileQueryCacheState | undefined; + + lastOriginalFilter: string | undefined; + lastFilter: string | undefined; + lastRange: IRange | undefined; + + lastGlobalPicks: PicksWithActive | undefined; + + isQuickNavigating: boolean | undefined; + + /** + * Sets the picker for this pick state. + */ + set(picker: IQuickPick): void; +} + + export class AnythingQuickAccessProvider extends PickerQuickAccessProvider { static PREFIX = ''; @@ -85,56 +107,7 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider | undefined = undefined; - - editorViewState = this._register(this.instantiationService.createInstance(PickerEditorState)); - - scorerCache: FuzzyScorerCache = Object.create(null); - fileQueryCache: FileQueryCacheState | undefined = undefined; - - lastOriginalFilter: string | undefined = undefined; - lastFilter: string | undefined = undefined; - lastRange: IRange | undefined = undefined; - - lastGlobalPicks: PicksWithActive | undefined = undefined; - - isQuickNavigating: boolean | undefined = undefined; - - constructor( - private readonly provider: AnythingQuickAccessProvider, - private readonly instantiationService: IInstantiationService - ) { - super(); - } - - set(picker: IQuickPick): void { - - // Picker for this run - this.picker = picker; - Event.once(picker.onDispose)(() => { - if (picker === this.picker) { - this.picker = undefined; // clear the picker when disposed to not keep it in memory for too long - } - }); - - // Caches - const isQuickNavigating = !!picker.quickNavigate; - if (!isQuickNavigating) { - this.fileQueryCache = this.provider.createFileQueryCache(); - this.scorerCache = Object.create(null); - } - - // Other - this.isQuickNavigating = isQuickNavigating; - this.lastOriginalFilter = undefined; - this.lastFilter = undefined; - this.lastRange = undefined; - this.lastGlobalPicks = undefined; - this.editorViewState.reset(); - } - }(this, this.instantiationService)); + private readonly pickState: IAnythingPickState; get defaultFilterValue(): DefaultQuickAccessFilterValue | undefined { if (this.configuration.preserveInput) { @@ -171,6 +144,62 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider | undefined = undefined; + + editorViewState: PickerEditorState; + + scorerCache: FuzzyScorerCache = Object.create(null); + fileQueryCache: FileQueryCacheState | undefined = undefined; + + lastOriginalFilter: string | undefined = undefined; + lastFilter: string | undefined = undefined; + lastRange: IRange | undefined = undefined; + + lastGlobalPicks: PicksWithActive | undefined = undefined; + + isQuickNavigating: boolean | undefined = undefined; + + constructor( + private readonly provider: AnythingQuickAccessProvider, + instantiationService: IInstantiationService + ) { + super(); + this.editorViewState = this._register(instantiationService.createInstance(PickerEditorState)); + } + + set(picker: IQuickPick): void { + + // Picker for this run + this.picker = picker; + Event.once(picker.onDispose)(() => { + if (picker === this.picker) { + this.picker = undefined; // clear the picker when disposed to not keep it in memory for too long + } + }); + + // Caches + const isQuickNavigating = !!picker.quickNavigate; + if (!isQuickNavigating) { + this.fileQueryCache = this.provider.createFileQueryCache(); + this.scorerCache = Object.create(null); + } + + // Other + this.isQuickNavigating = isQuickNavigating; + this.lastOriginalFilter = undefined; + this.lastFilter = undefined; + this.lastRange = undefined; + this.lastGlobalPicks = undefined; + this.editorViewState.reset(); + } + }(this, instantiationService)); + + this.fileQueryBuilder = this.instantiationService.createInstance(QueryBuilder); + this.workspaceSymbolsQuickAccess = this._register(instantiationService.createInstance(SymbolsQuickAccessProvider)); + this.editorSymbolsQuickAccess = this.instantiationService.createInstance(GotoSymbolQuickAccessProvider); } private get configuration() { @@ -519,7 +548,7 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider(AnythingQuickAccessProvider.TYPING_SEARCH_DELAY)); - private readonly fileQueryBuilder = this.instantiationService.createInstance(QueryBuilder); + private readonly fileQueryBuilder: QueryBuilder; private createFileQueryCache(): FileQueryCacheState { return new FileQueryCacheState( @@ -825,7 +854,7 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider> { if ( @@ -850,7 +879,7 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider> | null { const filterSegments = query.original.split(GotoSymbolQuickAccessProvider.PREFIX); diff --git a/src/vs/workbench/contrib/search/browser/media/searchview.css b/src/vs/workbench/contrib/search/browser/media/searchview.css index ddab0a2e10b..000d9a09f49 100644 --- a/src/vs/workbench/contrib/search/browser/media/searchview.css +++ b/src/vs/workbench/contrib/search/browser/media/searchview.css @@ -169,6 +169,18 @@ overflow-wrap: break-word; } +.search-view .message.ai-keywords { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + margin: 0 22px 8px; + padding: 0px; +} + .search-view .message p:first-child { margin-top: 0px; margin-bottom: 0px; @@ -186,6 +198,18 @@ color: var(--vscode-textLink-activeForeground); } +.search-view .message .keyword-refresh { + vertical-align: sub; + margin-right: 4px; + cursor: pointer; +} + +.search-view .message .keyword-refresh:hover, +.search-view .message .keyword-refresh:active { + color: var(--vscode-textLink-activeForeground); +} + + .search-view .foldermatch, .search-view .filematch { display: flex; diff --git a/src/vs/workbench/contrib/search/browser/searchActionsCopy.ts b/src/vs/workbench/contrib/search/browser/searchActionsCopy.ts index ab63b6ec4d2..ab06c9be1b9 100644 --- a/src/vs/workbench/contrib/search/browser/searchActionsCopy.ts +++ b/src/vs/workbench/contrib/search/browser/searchActionsCopy.ts @@ -99,6 +99,35 @@ registerAction2(class CopyAllCommandAction extends Action2 { } }); +registerAction2(class GetSearchResultsAction extends Action2 { + constructor() { + super({ + id: Constants.SearchCommandIds.GetSearchResultsActionId, + title: nls.localize2('getSearchResultsLabel', "Get Search Results"), + category, + f1: false + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const labelService = accessor.get(ILabelService); + + const searchView = getSearchView(viewsService); + if (searchView) { + const root = searchView.searchResult; + const textSearchResult = allFolderMatchesToString(root.folderMatches(), labelService); + const aiSearchResult = allFolderMatchesToString(root.folderMatches(true), labelService); + + const text = `${textSearchResult}${lineDelimiter}${lineDelimiter}${aiSearchResult}`; + + return text; + } + + return undefined; + } +}); + //#endregion //#region Helpers diff --git a/src/vs/workbench/contrib/search/browser/searchActionsTopBar.ts b/src/vs/workbench/contrib/search/browser/searchActionsTopBar.ts index 9b862134d4f..7a0f2a2152b 100644 --- a/src/vs/workbench/contrib/search/browser/searchActionsTopBar.ts +++ b/src/vs/workbench/contrib/search/browser/searchActionsTopBar.ts @@ -225,6 +225,10 @@ registerAction2(class SearchWithAIAction extends Action2 { const searchView = getSearchView(accessor.get(IViewsService)); if (searchView) { const viewer = searchView.getControl(); + searchView.model.searchResult.aiTextSearchResult.hidden = false; + searchView.model.cancelAISearch(true); + searchView.model.clearAiSearchResults(); + await searchView.queueRefreshTree(); await forcedExpandRecursively(viewer, searchView.model.searchResult.aiTextSearchResult); } } @@ -298,7 +302,7 @@ function cancelSearch(accessor: ServicesAccessor) { function refreshSearch(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const searchView = getSearchView(viewsService); - searchView?.triggerQueryChange({ preserveFocus: false }); + searchView?.triggerQueryChange({ preserveFocus: false, shouldUpdateAISearch: !searchView.model.searchResult.aiTextSearchResult.hidden }); } function collapseDeepestExpandedLevel(accessor: ServicesAccessor) { diff --git a/src/vs/workbench/contrib/search/browser/searchResultsView.ts b/src/vs/workbench/contrib/search/browser/searchResultsView.ts index 55c599ca307..f94b4227bca 100644 --- a/src/vs/workbench/contrib/search/browser/searchResultsView.ts +++ b/src/vs/workbench/contrib/search/browser/searchResultsView.ts @@ -144,7 +144,13 @@ export class TextSearchResultRenderer extends Disposable implements ICompressibl SearchContext.FileFocusKey.bindTo(templateData.contextKeyService).set(false); SearchContext.FolderFocusKey.bindTo(templateData.contextKeyService).set(false); } else { - const aiName = await node.element.parent().searchModel.getAITextResultProviderName(); + let aiName = 'Copilot'; + try { + aiName = (await node.element.parent().searchModel.getAITextResultProviderName()) || 'Copilot'; + } catch { + // ignore + } + const localizedLabel = nls.localize({ key: 'searchFolderMatch.aiText.label', comment: ['This is displayed before the AI text search results, where {0} will be in the place of the AI name (ie: Copilot)'] diff --git a/src/vs/workbench/contrib/search/browser/searchTreeModel/searchModel.ts b/src/vs/workbench/contrib/search/browser/searchTreeModel/searchModel.ts index 104a7ef8193..df9c61ce2e8 100644 --- a/src/vs/workbench/contrib/search/browser/searchTreeModel/searchModel.ts +++ b/src/vs/workbench/contrib/search/browser/searchTreeModel/searchModel.ts @@ -398,6 +398,11 @@ export class SearchModelImpl extends Disposable implements ISearchModel { } return false; } + clearAiSearchResults(): void { + this._aiResultQueue.length = 0; + // it's not clear all as we are only clearing the AI results + this._searchResult.aiTextSearchResult.clear(false); + } override dispose(): void { this.cancelSearch(); this.cancelAISearch(); diff --git a/src/vs/workbench/contrib/search/browser/searchTreeModel/searchResult.ts b/src/vs/workbench/contrib/search/browser/searchTreeModel/searchResult.ts index 6bdb676fb0d..f2e0fa7ee3b 100644 --- a/src/vs/workbench/contrib/search/browser/searchTreeModel/searchResult.ts +++ b/src/vs/workbench/contrib/search/browser/searchTreeModel/searchResult.ts @@ -197,7 +197,9 @@ export class SearchResultImpl extends Disposable implements ISearchResult { add(allRaw: IFileMatch[], searchInstanceID: string, ai: boolean, silent: boolean = false): void { this._plainTextSearchResult.hidden = false; - this._aiTextSearchResult.hidden = false; + if (ai) { + this._aiTextSearchResult.hidden = false; + } if (ai) { this._aiTextSearchResult.add(allRaw, searchInstanceID, silent); diff --git a/src/vs/workbench/contrib/search/browser/searchTreeModel/searchTreeCommon.ts b/src/vs/workbench/contrib/search/browser/searchTreeModel/searchTreeCommon.ts index d473600c55f..bb05360e4d3 100644 --- a/src/vs/workbench/contrib/search/browser/searchTreeModel/searchTreeCommon.ts +++ b/src/vs/workbench/contrib/search/browser/searchTreeModel/searchTreeCommon.ts @@ -107,6 +107,7 @@ export interface ISearchModel { }; cancelSearch(cancelledForNewSearch?: boolean): boolean; cancelAISearch(cancelledForNewSearch?: boolean): boolean; + clearAiSearchResults(): void; dispose(): void; } @@ -168,7 +169,7 @@ export interface ITextSearchHeading { rangeHighlightDecorations: RangeHighlightDecorations; fileCount(): number; count(): number; - clear(): void; + clear(clearAll: boolean): void; dispose(): void; } @@ -335,6 +336,13 @@ export function isSearchTreeMatch(obj: any): obj is ISearchTreeMatch { obj.id().startsWith(MATCH_PREFIX); } +export function isSearchHeader(obj: any): boolean { + return typeof obj === 'object' && + obj !== null && + typeof obj.id === 'function' && + obj.id().startsWith(TEXT_SEARCH_HEADING_PREFIX); +} + export function getFileMatches(matches: (ISearchTreeFileMatch | ISearchTreeFolderMatchWithResource)[]): ISearchTreeFileMatch[] { const folderMatches: ISearchTreeFolderMatchWithResource[] = []; diff --git a/src/vs/workbench/contrib/search/browser/searchTreeModel/textSearchHeading.ts b/src/vs/workbench/contrib/search/browser/searchTreeModel/textSearchHeading.ts index ce7f9670de7..c301606b1a5 100644 --- a/src/vs/workbench/contrib/search/browser/searchTreeModel/textSearchHeading.ts +++ b/src/vs/workbench/contrib/search/browser/searchTreeModel/textSearchHeading.ts @@ -249,9 +249,9 @@ export abstract class TextSearchHeadingImpl return this.matches().reduce((prev, match) => prev + match.count(), 0); } - clear(): void { + clear(clearAll: boolean = true): void { this.cachedSearchComplete = undefined; - this.folderMatches().forEach((folderMatch) => folderMatch.clear(true)); + this.folderMatches().forEach((folderMatch) => folderMatch.clear(clearAll)); this.disposeMatches(); this._folderMatches = []; this._otherFilesMatch = null; diff --git a/src/vs/workbench/contrib/search/browser/searchView.ts b/src/vs/workbench/contrib/search/browser/searchView.ts index feb18420c0f..79f9a4c7dfc 100644 --- a/src/vs/workbench/contrib/search/browser/searchView.ts +++ b/src/vs/workbench/contrib/search/browser/searchView.ts @@ -72,7 +72,7 @@ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/edit import { IPreferencesService, ISettingsEditorOptions } from '../../../services/preferences/common/preferences.js'; import { ITextQueryBuilderOptions, QueryBuilder } from '../../../services/search/common/queryBuilder.js'; import { IPatternInfo, ISearchComplete, ISearchConfiguration, ISearchConfigurationProperties, ISearchService, ITextQuery, SearchCompletionExitCode, SearchSortOrder, TextSearchCompleteMessageType, ViewMode } from '../../../services/search/common/search.js'; -import { TextSearchCompleteMessage } from '../../../services/search/common/searchExtTypes.js'; +import { AISearchKeyword, TextSearchCompleteMessage } from '../../../services/search/common/searchExtTypes.js'; import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; import { INotebookService } from '../../notebook/common/notebookService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -80,7 +80,7 @@ import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../pl import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { ISearchViewModelWorkbenchService } from './searchTreeModel/searchViewModelWorkbenchService.js'; -import { ISearchTreeMatch, isSearchTreeMatch, RenderableMatch, SearchModelLocation, IChangeEvent, FileMatchOrMatch, ISearchTreeFileMatch, ISearchTreeFolderMatch, ISearchModel, ISearchResult, isSearchTreeFileMatch, isSearchTreeFolderMatch, isSearchTreeFolderMatchNoRoot, isSearchTreeFolderMatchWithResource, isSearchTreeFolderMatchWorkspaceRoot, isSearchResult, isTextSearchHeading, ITextSearchHeading } from './searchTreeModel/searchTreeCommon.js'; +import { ISearchTreeMatch, isSearchTreeMatch, RenderableMatch, SearchModelLocation, IChangeEvent, FileMatchOrMatch, ISearchTreeFileMatch, ISearchTreeFolderMatch, ISearchModel, ISearchResult, isSearchTreeFileMatch, isSearchTreeFolderMatch, isSearchTreeFolderMatchNoRoot, isSearchTreeFolderMatchWithResource, isSearchTreeFolderMatchWorkspaceRoot, isSearchResult, isTextSearchHeading, ITextSearchHeading, isSearchHeader } from './searchTreeModel/searchTreeCommon.js'; import { INotebookFileInstanceMatch, isIMatchInNotebook } from './notebookSearch/notebookSearchModelBase.js'; import { searchMatchComparer } from './searchCompare.js'; import { AIFolderMatchWorkspaceRootImpl } from './AISearch/aiSearchModel.js'; @@ -117,6 +117,7 @@ export class SearchView extends ViewPane { private folderMatchFocused: IContextKey; private folderMatchWithResourceFocused: IContextKey; private matchFocused: IContextKey; + private searchResultHeaderFocused: IContextKey; private isEditableItem: IContextKey; private hasSearchResultsKey: IContextKey; private lastFocusState: 'input' | 'tree' = 'input'; @@ -215,6 +216,7 @@ export class SearchView extends ViewPane { this.fileMatchFocused = Constants.SearchContext.FileFocusKey.bindTo(this.contextKeyService); this.folderMatchFocused = Constants.SearchContext.FolderFocusKey.bindTo(this.contextKeyService); this.folderMatchWithResourceFocused = Constants.SearchContext.ResourceFolderFocusKey.bindTo(this.contextKeyService); + this.searchResultHeaderFocused = Constants.SearchContext.SearchResultHeaderFocused.bindTo(this.contextKeyService); this.hasSearchResultsKey = Constants.SearchContext.HasSearchResults.bindTo(this.contextKeyService); this.matchFocused = Constants.SearchContext.MatchFocusKey.bindTo(this.contextKeyService); this.searchStateKey = SearchStateKey.bindTo(this.contextKeyService); @@ -941,6 +943,7 @@ export class SearchView extends ViewPane { this.fileMatchOrFolderMatchFocus.set(isSearchTreeFileMatch(focus) || isSearchTreeFolderMatch(focus)); this.fileMatchOrFolderMatchWithResourceFocus.set(isSearchTreeFileMatch(focus) || isSearchTreeFolderMatchWithResource(focus)); this.folderMatchWithResourceFocused.set(isSearchTreeFolderMatchWithResource(focus)); + this.searchResultHeaderFocused.set(isSearchHeader(focus)); this.lastFocusState = 'tree'; } @@ -964,6 +967,7 @@ export class SearchView extends ViewPane { this.fileMatchOrFolderMatchFocus.reset(); this.fileMatchOrFolderMatchWithResourceFocus.reset(); this.folderMatchWithResourceFocused.reset(); + this.searchResultHeaderFocused.reset(); this.isEditableItem.reset(); })); } @@ -1311,7 +1315,7 @@ export class SearchView extends ViewPane { } cancelSearch(focus: boolean = true): boolean { - if (this.viewModel.cancelSearch()) { + if (this.viewModel.cancelSearch() && this.viewModel.cancelAISearch()) { if (focus) { this.searchWidget.focus(); } return true; } @@ -1464,7 +1468,7 @@ export class SearchView extends ViewPane { this.searchWidget.focus(false); } - triggerQueryChange(_options?: { preserveFocus?: boolean; triggeredOnType?: boolean; delay?: number; shouldKeepAIResults?: boolean }): void { + triggerQueryChange(_options?: { preserveFocus?: boolean; triggeredOnType?: boolean; delay?: number; shouldKeepAIResults?: boolean; shouldUpdateAISearch?: boolean }): void { const options = { preserveFocus: true, triggeredOnType: false, delay: 0, ..._options }; if (options.triggeredOnType && !this.searchConfig.searchOnType) { return; } @@ -1473,7 +1477,7 @@ export class SearchView extends ViewPane { const delay = options.triggeredOnType ? options.delay : 0; this.triggerQueryDelayer.trigger(() => { - this._onQueryChanged(options.preserveFocus, options.triggeredOnType, options.shouldKeepAIResults); + this._onQueryChanged(options.preserveFocus, options.triggeredOnType, options.shouldKeepAIResults, options.shouldUpdateAISearch); }, delay); } } @@ -1486,7 +1490,7 @@ export class SearchView extends ViewPane { return this.inputPatternIncludes.getValue().trim(); } - private _onQueryChanged(preserveFocus: boolean, triggeredOnType = false, shouldKeepAIResults = false): void { + private _onQueryChanged(preserveFocus: boolean, triggeredOnType = false, shouldKeepAIResults = false, shouldUpdateAISearch = false): void { if (!(this.searchWidget.searchInput?.inputBox.isInputValid())) { return; } @@ -1565,11 +1569,11 @@ export class SearchView extends ViewPane { this.validateQuery(query).then(() => { // ensure that the node is closed when a new search is triggered - if (!shouldKeepAIResults && this.tree.hasNode(this.searchResult.aiTextSearchResult)) { + if (!shouldKeepAIResults && !shouldUpdateAISearch && this.tree.hasNode(this.searchResult.aiTextSearchResult)) { this.tree.collapse(this.searchResult.aiTextSearchResult); } - this.onQueryTriggered(query, options, excludePatternText, includePatternText, triggeredOnType, shouldKeepAIResults); + this.onQueryTriggered(query, options, excludePatternText, includePatternText, triggeredOnType, shouldKeepAIResults, shouldUpdateAISearch); if (!preserveFocus) { this.searchWidget.focus(false, undefined, true); // focus back to input field @@ -1599,7 +1603,7 @@ export class SearchView extends ViewPane { }); } - private onQueryTriggered(query: ITextQuery, options: ITextQueryBuilderOptions, excludePatternText: string, includePatternText: string, triggeredOnType: boolean, shouldKeepAIResults: boolean): void { + private onQueryTriggered(query: ITextQuery, options: ITextQueryBuilderOptions, excludePatternText: string, includePatternText: string, triggeredOnType: boolean, shouldKeepAIResults: boolean, shouldUpdateAISearch: boolean): void { this.addToSearchHistoryDelayer.trigger(() => { this.searchWidget.searchInput?.onSearchSubmit(); this.inputPatternExcludes.onSearchSubmit(); @@ -1610,7 +1614,7 @@ export class SearchView extends ViewPane { this.viewModel.cancelAISearch(true); this.currentSearchQ = this.currentSearchQ - .then(() => this.doSearch(query, excludePatternText, includePatternText, triggeredOnType, shouldKeepAIResults)) + .then(() => this.doSearch(query, excludePatternText, includePatternText, triggeredOnType, shouldKeepAIResults, shouldUpdateAISearch)) .then(() => undefined, () => undefined); } @@ -1664,27 +1668,32 @@ export class SearchView extends ViewPane { } // Special case for when we have an AI provider registered + Constants.SearchContext.AIResultsRequested.bindTo(this.contextKeyService).set(this.shouldShowAIResults() && !!aiResults); + if (this.shouldShowAIResults() && !allResults) { - Constants.SearchContext.AIResultsRequested.bindTo(this.contextKeyService).set(!!aiResults); const messageEl = this.clearMessage(); const noResultsMessage = nls.localize('noResultsFallback', "No results found. "); dom.append(messageEl, noResultsMessage); - const aiName = await this.searchService.getAIName(); + let aiName = 'Copilot'; + try { + aiName = (await this.searchService.getAIName()) || aiName; + } catch (e) { + // ignore + } if (aiName) { - const kb = this.keybindingService.lookupKeybinding(Constants.SearchCommandIds.SearchWithAIActionId); - const searchWithAIButtonTooltip = kb ? nls.localize('searchWithAIButtonTooltipWithKB', "{0} to search", kb.getLabel()) - : nls.localize('searchWithAIButtonTooltip', "Search"); + const searchWithAIButtonTooltip = appendKeyBindingLabel( + nls.localize('triggerAISearch.tooltip', "Search with {0}", aiName), + this.keybindingService.lookupKeybinding(Constants.SearchCommandIds.SearchWithAIActionId) + ); + const searchWithAIButtonText = nls.localize('searchWithAIButtonTooltip', "Search with {0}.", aiName); const searchWithAIButton = this.messageDisposables.add(new SearchLinkButton( - searchWithAIButtonTooltip, + searchWithAIButtonText, () => { this.commandService.executeCommand(Constants.SearchCommandIds.SearchWithAIActionId); - }, this.hoverService)); + }, this.hoverService, searchWithAIButtonTooltip)); dom.append(messageEl, searchWithAIButton.element); - - const message = nls.localize('triggerAISearch', " with {0}.", aiName); - dom.append(messageEl, message); } if (!aiResults) { @@ -1811,6 +1820,11 @@ export class SearchView extends ViewPane { const result = this.viewModel.addAIResults(); return result.then((complete) => { clearTimeout(slowTimer); + if (complete.aiKeywords && complete.aiKeywords.length > 0) { + this.updateKeywordSuggestion(complete.aiKeywords); + } else { + this.updateSearchResultCount(this.viewModel.searchResult.query?.userDisabledExcludesAndIgnoreFiles, this.viewModel.searchResult.query?.onlyOpenEditors, false); + } return this.onSearchComplete(progressComplete, excludePatternText, includePatternText, complete, false); }, (e) => { clearTimeout(slowTimer); @@ -1818,7 +1832,7 @@ export class SearchView extends ViewPane { }); } - private doSearch(query: ITextQuery, excludePatternText: string, includePatternText: string, triggeredOnType: boolean, shouldKeepAIResults: boolean): Thenable { + private doSearch(query: ITextQuery, excludePatternText: string, includePatternText: string, triggeredOnType: boolean, shouldKeepAIResults: boolean, shouldUpdateAISearch: boolean): Thenable { let progressComplete: () => void; this.progressService.withProgress({ location: this.getProgressLocation(), delay: triggeredOnType ? 300 : 0 }, _progress => { return new Promise(resolve => progressComplete = resolve); @@ -1827,6 +1841,7 @@ export class SearchView extends ViewPane { this.searchWidget.searchInput?.clearMessage(); this.state = SearchUIState.Searching; this.showEmptyStage(); + this.model.searchResult.aiTextSearchResult.hidden = !shouldKeepAIResults && !shouldUpdateAISearch; const slowTimer = setTimeout(() => { this.state = SearchUIState.SlowSearch; @@ -1844,9 +1859,14 @@ export class SearchView extends ViewPane { this.viewModel.replaceString = this.searchWidget.getReplaceValue(); const result = this.viewModel.search(query); - if (!shouldKeepAIResults) { + if (!shouldKeepAIResults || shouldUpdateAISearch) { this.viewModel.searchResult.setAIQueryUsingTextQuery(query); } + + if (shouldUpdateAISearch) { + this.tree.updateChildren(this.searchResult.aiTextSearchResult); + } + return result.asyncResults.then((complete) => { clearTimeout(slowTimer); return this.onSearchComplete(progressComplete, excludePatternText, includePatternText, complete); @@ -1932,6 +1952,38 @@ export class SearchView extends ViewPane { } } + private handleKeywordClick(keyword: string) { + this.searchWidget.searchInput?.setValue(keyword); + this.triggerQueryChange({ preserveFocus: false, triggeredOnType: false, shouldKeepAIResults: false }); + } + + private updateKeywordSuggestion(keywords: AISearchKeyword[]) { + const messageEl = this.clearMessage(); + messageEl.classList.add('ai-keywords'); + + if (keywords.length === 0) { + // Do not display anything if there are no keywords + return; + } + + // Add unclickable message + const resultMsg = nls.localize('keywordSuggestion.message', "Search instead for: "); + dom.append(messageEl, resultMsg); + + const topKeywords = keywords.slice(0, 3); + topKeywords.forEach((keyword, index) => { + if (index > 0 && index < topKeywords.length) { + dom.append(messageEl, ', '); + } + const button = this.messageDisposables.add(new SearchLinkButton( + keyword.keyword, + () => this.handleKeywordClick(keyword.keyword), + this.hoverService + )); + dom.append(messageEl, button.element); + }); + } + private addMessage(message: TextSearchCompleteMessage) { const messageBox = this.messagesElement.firstChild as HTMLDivElement; if (!messageBox) { return; } @@ -2362,8 +2414,13 @@ class SearchViewDataSource implements IAsyncDataSource('folderMatchWithResourceFocus', false), IsEditableItemKey: new RawContextKey('isEditableItem', true), MatchFocusKey: new RawContextKey('matchFocus', false), + SearchResultHeaderFocused: new RawContextKey('searchResultHeaderFocused', false), ViewHasSearchPatternKey: new RawContextKey('viewHasSearchPattern', false), ViewHasReplacePatternKey: new RawContextKey('viewHasReplacePattern', false), ViewHasFilePatternKey: new RawContextKey('viewHasFilePattern', false), diff --git a/src/vs/workbench/contrib/share/browser/shareService.ts b/src/vs/workbench/contrib/share/browser/shareService.ts index 9a94bd370be..02e7fa04361 100644 --- a/src/vs/workbench/contrib/share/browser/shareService.ts +++ b/src/vs/workbench/contrib/share/browser/shareService.ts @@ -15,7 +15,7 @@ import { ILabelService } from '../../../../platform/label/common/label.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { ToggleTitleBarConfigAction } from '../../../browser/parts/titlebar/titlebarActions.js'; -import { WorkspaceFolderCountContext } from '../../../common/contextkeys.js'; +import { IsCompactTitleBarContext, WorkspaceFolderCountContext } from '../../../common/contextkeys.js'; import { IShareProvider, IShareService, IShareableItem } from '../common/share.js'; export const ShareProviderCountContext = new RawContextKey('shareProviderCount', 0, localize('shareProviderCount', "The number of available share providers")); @@ -89,6 +89,6 @@ export class ShareService implements IShareService { registerAction2(class ToggleShareControl extends ToggleTitleBarConfigAction { constructor() { - super('workbench.experimental.share.enabled', localize('toggle.share', 'Share'), localize('toggle.shareDescription', "Toggle visibility of the Share action in title bar"), 3, false, ContextKeyExpr.and(ContextKeyExpr.has('config.window.commandCenter'), ContextKeyExpr.and(ShareProviderCountContext.notEqualsTo(0), WorkspaceFolderCountContext.notEqualsTo(0)))); + super('workbench.experimental.share.enabled', localize('toggle.share', 'Share'), localize('toggle.shareDescription', "Toggle visibility of the Share action in title bar"), 3, ContextKeyExpr.and(IsCompactTitleBarContext.toNegated(), ContextKeyExpr.has('config.window.commandCenter'), ContextKeyExpr.and(ShareProviderCountContext.notEqualsTo(0), WorkspaceFolderCountContext.notEqualsTo(0)))); } }); diff --git a/src/vs/workbench/contrib/surveys/browser/languageSurveys.contribution.ts b/src/vs/workbench/contrib/surveys/browser/languageSurveys.contribution.ts index e7df0b22037..a69a499fcb5 100644 --- a/src/vs/workbench/contrib/surveys/browser/languageSurveys.contribution.ts +++ b/src/vs/workbench/contrib/surveys/browser/languageSurveys.contribution.ts @@ -13,7 +13,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IProductService } from '../../../../platform/product/common/productService.js'; import { ISurveyData } from '../../../../base/common/product.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; -import { Severity, INotificationService } from '../../../../platform/notification/common/notification.js'; +import { Severity, INotificationService, NotificationPriority } from '../../../../platform/notification/common/notification.js'; import { ITextFileService, ITextFileEditorModel } from '../../../services/textfile/common/textfiles.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { URI } from '../../../../base/common/uri.js'; @@ -119,7 +119,7 @@ class LanguageSurvey extends Disposable { storageService.store(SKIP_VERSION_KEY, productService.version, StorageScope.APPLICATION, StorageTarget.USER); } }], - { sticky: true } + { sticky: true, priority: NotificationPriority.OPTIONAL } ); } } diff --git a/src/vs/workbench/contrib/surveys/browser/nps.contribution.ts b/src/vs/workbench/contrib/surveys/browser/nps.contribution.ts index 8d5e219219e..0602ca9ce64 100644 --- a/src/vs/workbench/contrib/surveys/browser/nps.contribution.ts +++ b/src/vs/workbench/contrib/surveys/browser/nps.contribution.ts @@ -33,7 +33,7 @@ class NPSContribution implements IWorkbenchContribution { @IProductService productService: IProductService, @IConfigurationService configurationService: IConfigurationService ) { - if (!productService.npsSurveyUrl || configurationService.getValue('telemetry.disableFeedback')) { + if (!productService.npsSurveyUrl || !configurationService.getValue('telemetry.feedback.enabled')) { return; } diff --git a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts index 25385286aad..78c67ee4a15 100644 --- a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +++ b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts @@ -2249,7 +2249,6 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer configuringTask.configures.type, JSON.stringify(configuringTask._source.config.element, undefined, 4) )); - this._showOutput(); } }); diff --git a/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts b/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts index 4781331772e..d00ad24ca2a 100644 --- a/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts +++ b/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts @@ -94,7 +94,7 @@ export class TaskTerminalStatus extends Disposable { } terminalData.taskRunEnded = true; terminalData.terminal.statusList.remove(terminalData.status); - if ((event.exitCode === 0) && (terminalData.problemMatcher.numberOfMatches === 0)) { + if ((event.exitCode === 0) && (!terminalData.problemMatcher.maxMarkerSeverity || terminalData.problemMatcher.maxMarkerSeverity < MarkerSeverity.Warning)) { this._accessibilitySignalService.playSignal(AccessibilitySignal.taskCompleted); if (terminalData.task.configurationProperties.isBackground) { for (const status of terminalData.terminal.statusList.statuses) { @@ -103,7 +103,7 @@ export class TaskTerminalStatus extends Disposable { } else { terminalData.terminal.statusList.add(SUCCEEDED_TASK_STATUS); } - } else if (event.exitCode || terminalData.problemMatcher.maxMarkerSeverity === MarkerSeverity.Error) { + } else if (event.exitCode || (terminalData.problemMatcher.maxMarkerSeverity !== undefined && terminalData.problemMatcher.maxMarkerSeverity >= MarkerSeverity.Warning)) { this._accessibilitySignalService.playSignal(AccessibilitySignal.taskFailed); terminalData.terminal.statusList.add(FAILED_TASK_STATUS); } else if (terminalData.problemMatcher.maxMarkerSeverity === MarkerSeverity.Warning) { diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 2fcb98375e2..96bd7ef8e9a 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -833,7 +833,6 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { eventCounter++; this._busyTasks[mapKey] = task; this._fireTaskEvent(TaskEvent.general(TaskEventKind.Active, task, terminal?.instanceId)); - this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherStarted, task, terminal?.instanceId)); } else if (event.kind === ProblemCollectorEventKind.BackgroundProcessingEnds) { eventCounter--; if (this._busyTasks[mapKey]) { @@ -843,7 +842,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { if (eventCounter === 0) { if ((watchingProblemMatcher.numberOfMatches > 0) && watchingProblemMatcher.maxMarkerSeverity && (watchingProblemMatcher.maxMarkerSeverity >= MarkerSeverity.Error)) { - this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherFoundErrors, task, terminal?.instanceId)); + // this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherFoundErrors, task, terminal?.instanceId)); const reveal = task.command.presentation!.reveal; const revealProblems = task.command.presentation!.revealProblems; if (revealProblems === RevealProblemKind.OnProblem) { @@ -853,7 +852,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { this._terminalGroupService.showPanel(false); } } else { - this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherEnded, task, terminal?.instanceId)); + // this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherEnded, task, terminal?.instanceId)); } } } @@ -882,6 +881,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { this._fireTaskEvent(TaskEvent.start(task, terminal.instanceId, resolver.values)); let onData: IDisposable | undefined; if (problemMatchers.length) { + // this._fireTaskEvent(TaskEvent.general(TaskEventKind.ProblemMatcherStarted, task, terminal.instanceId)); // prevent https://github.com/microsoft/vscode/issues/174511 from happening onData = terminal.onLineData((line) => { watchingProblemMatcher.processLine(line); diff --git a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts index 3cb325d21a7..594086eed7c 100644 --- a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts +++ b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts @@ -362,6 +362,8 @@ export class StartStopProblemCollector extends AbstractProblemCollector implemen private currentOwner: string | undefined; private currentResource: string | undefined; + private _hasStarted: boolean = false; + constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, _strategy: ProblemHandlingStrategy = ProblemHandlingStrategy.Clean, fileService?: IFileService) { super(problemMatchers, markerService, modelService, fileService); const ownerSet: { [key: string]: boolean } = Object.create(null); @@ -373,6 +375,10 @@ export class StartStopProblemCollector extends AbstractProblemCollector implemen } protected async processLineInternal(line: string): Promise { + if (!this._hasStarted) { + this._hasStarted = true; + this._onDidStateChange.fire(IProblemCollectorEvent.create(ProblemCollectorEventKind.BackgroundProcessingBegins)); + } const markerMatch = this.tryFindMarker(line); if (!markerMatch) { return; diff --git a/src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts b/src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts index 15ffde581b3..a93582cc1eb 100644 --- a/src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts +++ b/src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts @@ -39,6 +39,7 @@ import { localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { IOutputService } from '../../../services/output/common/output.js'; import { ILoggerResource, ILoggerService, LogLevel } from '../../../../platform/log/common/log.js'; +import { VerifyExtensionSignatureConfigKey } from '../../../../platform/extensionManagement/common/extensionManagement.js'; type TelemetryData = { mimeType: TelemetryTrustedValue; @@ -382,7 +383,7 @@ class ConfigurationTelemetryContribution extends Disposable implements IWorkbenc }>('window.titleBarStyle', { settingValue: this.getValueToReport(key, target), source }); return; - case 'extensions.verifySignature': + case VerifyExtensionSignatureConfigKey: this.telemetryService.publicLog2; - runCommand(command: string, shouldExecute?: boolean): void; + runCommand(command: string, shouldExecute?: boolean): Promise; /** * Takes a path and returns the properly escaped path to send to a given shell. On Windows, this diff --git a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts index 6008c2b6dd7..ff14b2b4af7 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts @@ -42,7 +42,6 @@ import { IPreferencesService } from '../../../services/preferences/common/prefer import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { isAbsolute } from '../../../../base/common/path.js'; -import { AbstractVariableResolverService } from '../../../services/configurationResolver/common/variableResolver.js'; import { ITerminalQuickPickItem } from './terminalProfileQuickpick.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { getIconId, getColorClass, getUriClasses } from './terminalIcon.js'; @@ -62,6 +61,7 @@ import { editorGroupToColumn } from '../../../services/editor/common/editorGroup import { InstanceContext } from './terminalContextMenu.js'; import { AccessibleViewProviderId } from '../../../../platform/accessibility/browser/accessibleView.js'; import { TerminalTabList } from './terminalTabsList.js'; +import { ConfigurationResolverExpression } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; export const switchTerminalActionViewItemSeparator = '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500'; export const switchTerminalShowTabsTitle = localize('showTerminalTabs', "Show Tabs"); @@ -1682,7 +1682,7 @@ async function resolveWorkspaceFolderCwd(folder: IWorkspaceFolder, configuration } const resolvedCwdConfig = await configurationResolverService.resolveAsync(folder, cwdConfig); - return isAbsolute(resolvedCwdConfig) || resolvedCwdConfig.startsWith(AbstractVariableResolverService.VARIABLE_LHS) + return isAbsolute(resolvedCwdConfig) || resolvedCwdConfig.startsWith(ConfigurationResolverExpression.VARIABLE_LHS) ? { folder, isAbsolute: true, isOverridden: true, cwd: URI.from({ ...folder.uri, path: resolvedCwdConfig }) } : { folder, isAbsolute: false, isOverridden: true, cwd: URI.joinPath(folder.uri, resolvedCwdConfig) }; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index b458567ae4c..498535ab9c0 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -46,7 +46,7 @@ import { IMarkProperties, TerminalCapability } from '../../../../platform/termin import { TerminalCapabilityStoreMultiplexer } from '../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; import { IEnvironmentVariableCollection, IMergedEnvironmentVariableCollection } from '../../../../platform/terminal/common/environmentVariable.js'; import { deserializeEnvironmentVariableCollections } from '../../../../platform/terminal/common/environmentVariableShared.js'; -import { GeneralShellType, IProcessDataEvent, IProcessPropertyMap, IReconnectionProperties, IShellLaunchConfig, ITerminalDimensionsOverride, ITerminalLaunchError, ITerminalLogService, PosixShellType, ProcessPropertyType, ShellIntegrationStatus, TerminalExitReason, TerminalIcon, TerminalLocation, TerminalSettingId, TerminalShellType, TitleEventSource, WindowsShellType } from '../../../../platform/terminal/common/terminal.js'; +import { GeneralShellType, IProcessDataEvent, IProcessPropertyMap, IReconnectionProperties, IShellLaunchConfig, ITerminalDimensionsOverride, ITerminalLaunchError, ITerminalLogService, PosixShellType, ProcessPropertyType, ShellIntegrationStatus, TerminalExitReason, TerminalIcon, TerminalLocation, TerminalSettingId, TerminalShellType, TitleEventSource, WindowsShellType, type ShellIntegrationInjectionFailureReason } from '../../../../platform/terminal/common/terminal.js'; import { formatMessageForTerminal } from '../../../../platform/terminal/common/terminalStrings.js'; import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; import { getIconRegistry } from '../../../../platform/theme/common/iconRegistry.js'; @@ -193,6 +193,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private _hasScrollBar?: boolean; private _usedShellIntegrationInjection: boolean = false; get usedShellIntegrationInjection(): boolean { return this._usedShellIntegrationInjection; } + private _shellIntegrationInjectionInfo: ShellIntegrationInjectionFailureReason | undefined; + get shellIntegrationInjectionFailureReason(): ShellIntegrationInjectionFailureReason | undefined { return this._shellIntegrationInjectionInfo; } private _lineDataEventAddon: LineDataEventAddon | undefined; private readonly _scopedContextKeyService: IContextKeyService; private _resizeDebouncer?: TerminalResizeDebouncer; @@ -470,7 +472,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { if (capability === TerminalCapability.CommandDetection) { const commandDetection = this.capabilities.get(capability); if (commandDetection) { + commandDetection.promptInputModel.setShellType(this.shellType); capabilityListeners.set(capability, Event.any( + commandDetection.onPromptTypeChanged, commandDetection.promptInputModel.onDidStartInput, commandDetection.promptInputModel.onDidChangeInput, commandDetection.promptInputModel.onDidFinishInput @@ -1456,6 +1460,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { case ProcessPropertyType.UsedShellIntegrationInjection: this._usedShellIntegrationInjection = true; break; + case ProcessPropertyType.ShellIntegrationInjectionFailureReason: + this._shellIntegrationInjectionInfo = value; + break; } })); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 42b3f065762..c998d9c0505 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -1171,13 +1171,16 @@ export class TerminalService extends Disposable implements ITerminalService { } protected _showBackgroundTerminal(instance: ITerminalInstance): void { + const index = this._backgroundedTerminalInstances.indexOf(instance); + if (index === -1) { + return; + } this._backgroundedTerminalInstances.splice(this._backgroundedTerminalInstances.indexOf(instance), 1); const disposables = this._backgroundedTerminalDisposables.get(instance.instanceId); if (disposables) { dispose(disposables); } this._backgroundedTerminalDisposables.delete(instance.instanceId); - instance.shellLaunchConfig.hideFromUser = false; this._terminalGroupService.createGroup(instance); // Make active automatically if it's the first instance diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTelemetry.ts b/src/vs/workbench/contrib/terminal/browser/terminalTelemetry.ts new file mode 100644 index 00000000000..e321b696b4b --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalTelemetry.ts @@ -0,0 +1,273 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { timeout } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { basename } from '../../../../base/common/path.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; +import type { IShellLaunchConfig, ShellIntegrationInjectionFailureReason } from '../../../../platform/terminal/common/terminal.js'; +import type { IWorkbenchContribution } from '../../../common/contributions.js'; +import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; +import { ITerminalService, type ITerminalInstance } from './terminal.js'; + +export class TerminalTelemetryContribution extends Disposable implements IWorkbenchContribution { + static ID = 'terminalTelemetry'; + + constructor( + @ILifecycleService lifecycleService: ILifecycleService, + @ITerminalService terminalService: ITerminalService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { + super(); + + this._register(terminalService.onDidCreateInstance(async instance => { + const store = new DisposableStore(); + this._store.add(store); + + await Promise.race([ + // Wait for process ready so the shell launch config is fully resolved, then + // allow another 10 seconds for the shell integration to be fully initialized + instance.processReady.then(() => { + return timeout(10000); + }), + // If the terminal is disposed, it's ready to report on immediately + Event.toPromise(instance.onDisposed, store), + // If the app is shutting down, flush + Event.toPromise(lifecycleService.onWillShutdown, store), + ]); + + this._logCreateInstance(instance); + this._store.delete(store); + })); + } + + private _logCreateInstance(instance: ITerminalInstance): void { + const slc = instance.shellLaunchConfig; + const commandDetection = instance.capabilities.get(TerminalCapability.CommandDetection); + + type TerminalCreationTelemetryData = { + shellType: string; + promptType: string | undefined; + + isCustomPtyImplementation: boolean; + isExtensionOwnedTerminal: boolean; + isLoginShell: boolean; + isReconnect: boolean; + + shellIntegrationQuality: number; + shellIntegrationInjected: boolean; + shellIntegrationInjectionFailureReason: ShellIntegrationInjectionFailureReason | undefined; + }; + type TerminalCreationTelemetryClassification = { + owner: 'tyriar'; + comment: 'Track details about terminal creation, such as the shell type'; + + shellType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The detected shell type for the terminal.' }; + promptType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The detected prompt type for the terminal.' }; + + isCustomPtyImplementation: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the terminal was using a custom PTY implementation.' }; + isExtensionOwnedTerminal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the terminal was created by an extension.' }; + isLoginShell: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the arguments contain -l or --login.' }; + isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the terminal is reconnecting to an existing instance.' }; + + shellIntegrationQuality: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The shell integration quality (rich=2, basic=1 or none=0).' }; + shellIntegrationInjected: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the shell integration script was injected.' }; + shellIntegrationInjectionFailureReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Info about shell integration injection.' }; + }; + this._telemetryService.publicLog2('terminal/createInstance', { + shellType: getSanitizedShellType(slc), + promptType: commandDetection?.promptType, + + isCustomPtyImplementation: !!slc.customPtyImplementation, + isExtensionOwnedTerminal: !!slc.isExtensionOwnedTerminal, + isLoginShell: (typeof slc.args === 'string' ? slc.args.split(' ') : slc.args)?.some(arg => arg === '-l' || arg === '--login') ?? false, + isReconnect: !!slc.attachPersistentProcess, + + shellIntegrationQuality: commandDetection?.hasRichCommandDetection ? 2 : commandDetection ? 1 : 0, + shellIntegrationInjected: instance.usedShellIntegrationInjection, + shellIntegrationInjectionFailureReason: instance.shellIntegrationInjectionFailureReason, + }); + } +} + +// #region Shell Type + +const enum AllowedShellType { + Unknown = 'unknown', + + // Windows only + CommandPrompt = 'cmd', + Cygwin = 'cygwin-bash', + GitBash = 'git-bash', + Msys2 = 'msys2-bash', + WindowsPowerShell = 'windows-powershell', + Wsl = 'wsl', + + + // Common Unix shells + Bash = 'bash', + Fish = 'fish', + Pwsh = 'pwsh', + PwshPreview = 'pwsh-preview', + Sh = 'sh', + Ssh = 'ssh', + Tmux = 'tmux', + Zsh = 'zsh', + + // More shells + Amm = 'amm', + Ash = 'ash', + Csh = 'csh', + Dash = 'dash', + Elvish = 'elvish', + Ion = 'ion', + Ksh = 'ksh', + Mksh = 'mksh', + Msh = 'msh', + NuShell = 'nu', + Plan9Shell = 'rc', + SchemeShell = 'scsh', + Tcsh = 'tcsh', + Termux = 'termux', + Xonsh = 'xonsh', + + // Lanugage REPLs + // These are expected to be very low since they are not typically the default shell + Clojure = 'clj', + CommonLispSbcl = 'sbcl', + Crystal = 'crystal', + Deno = 'deno', + Elixir = 'iex', + Erlang = 'erl', + FSharp = 'fsi', + Go = 'go', + HaskellGhci = 'ghci', + Java = 'jshell', + Julia = 'julia', + Lua = 'lua', + Node = 'node', + Ocaml = 'ocaml', + Perl = 'perl', + Php = 'php', + PrologSwipl = 'swipl', + Python = 'python', + R = 'R', + RubyIrb = 'irb', + Scala = 'scala', + SchemeRacket = 'racket', + SmalltalkGnu = 'gst', + SmalltalkPharo = 'pharo', + Tcl = 'tclsh', + TsNode = 'ts-node', +} + +// Types that match the executable name directly +const shellTypeExecutableAllowList: Set = new Set([ + // Windows only + AllowedShellType.CommandPrompt, + AllowedShellType.Wsl, + + // Common Unix shells + AllowedShellType.Bash, + AllowedShellType.Fish, + AllowedShellType.Pwsh, + AllowedShellType.Sh, + AllowedShellType.Ssh, + AllowedShellType.Tmux, + AllowedShellType.Zsh, + + // More shells + AllowedShellType.Amm, + AllowedShellType.Ash, + AllowedShellType.Csh, + AllowedShellType.Dash, + AllowedShellType.Elvish, + AllowedShellType.Ion, + AllowedShellType.Ksh, + AllowedShellType.Mksh, + AllowedShellType.Msh, + AllowedShellType.NuShell, + AllowedShellType.Plan9Shell, + AllowedShellType.SchemeShell, + AllowedShellType.Tcsh, + AllowedShellType.Termux, + AllowedShellType.Xonsh, + + // Lanugage REPLs + AllowedShellType.Clojure, + AllowedShellType.CommonLispSbcl, + AllowedShellType.Crystal, + AllowedShellType.Deno, + AllowedShellType.Elixir, + AllowedShellType.Erlang, + AllowedShellType.FSharp, + AllowedShellType.Go, + AllowedShellType.HaskellGhci, + AllowedShellType.Java, + AllowedShellType.Julia, + AllowedShellType.Lua, + AllowedShellType.Node, + AllowedShellType.Ocaml, + AllowedShellType.Perl, + AllowedShellType.Php, + AllowedShellType.PrologSwipl, + AllowedShellType.Python, + AllowedShellType.R, + AllowedShellType.RubyIrb, + AllowedShellType.Scala, + AllowedShellType.SchemeRacket, + AllowedShellType.SmalltalkGnu, + AllowedShellType.SmalltalkPharo, + AllowedShellType.Tcl, + AllowedShellType.TsNode, +]) satisfies Set; + +// Dynamic executables that map to a single type +const shellTypeExecutableRegexAllowList: { regex: RegExp; type: AllowedShellType }[] = [ + { regex: /^(?:pwsh|powershell)-preview$/i, type: AllowedShellType.PwshPreview }, + { regex: /^python(?:\d+(?:\.\d+)?)?$/i, type: AllowedShellType.Python }, +]; + +// Path-based look ups +const shellTypePathRegexAllowList: { regex: RegExp; type: AllowedShellType }[] = [ + // Cygwin uses bash.exe, so look up based on the path + { regex: /\\Cygwin(?:64)?\\.+\\bash\.exe$/i, type: AllowedShellType.Cygwin }, + // Git bash uses bash.exe, so look up based on the path + { regex: /\\Git\\.+\\bash\.exe$/i, type: AllowedShellType.GitBash }, + // Msys2 uses bash.exe, so look up based on the path + { regex: /\\msys(?:32|64)\\.+\\(?:bash|msys2)\.exe$/i, type: AllowedShellType.Msys2 }, + // WindowsPowerShell should always be installed on this path, we cannot just look at the + // executable name since powershell is the CLI on other platforms sometimes (eg. snap package) + { regex: /\\WindowsPowerShell\\v1.0\\powershell.exe$/i, type: AllowedShellType.WindowsPowerShell }, + // WSL executables will represent some other shell in the end, but it's difficult to determine + // when we log + { regex: /\\Windows\\(?:System32|SysWOW64|Sysnative)\\(?:bash|wsl)\.exe$/i, type: AllowedShellType.Wsl }, +]; + +function getSanitizedShellType(slc: IShellLaunchConfig): AllowedShellType { + if (!slc.executable) { + return AllowedShellType.Unknown; + } + const executableFile = basename(slc.executable); + const executableFileWithoutExt = executableFile.replace(/\.[^\.]+$/, ''); + for (const entry of shellTypePathRegexAllowList) { + if (entry.regex.test(slc.executable)) { + return entry.type; + } + } + for (const entry of shellTypeExecutableRegexAllowList) { + if (entry.regex.test(executableFileWithoutExt)) { + return entry.type; + } + } + if ((shellTypeExecutableAllowList).has(executableFileWithoutExt)) { + return executableFileWithoutExt as AllowedShellType; + } + return AllowedShellType.Unknown; +} + +// #endregion Shell Type diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts b/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts index 3c232558048..9874708eb42 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts @@ -96,6 +96,10 @@ export function refreshShellIntegrationInfoStatus(instance: ITerminalInstance) { if (seenSequences.length > 0) { detailedAdditions.push(`Seen sequences: ${seenSequences.map(e => `\`${e}\``).join(', ')}`); } + const promptType = instance.capabilities.get(TerminalCapability.CommandDetection)?.promptType; + if (promptType) { + detailedAdditions.push(`Prompt type: \`${promptType}\``); + } const combinedString = instance.capabilities.get(TerminalCapability.CommandDetection)?.promptInputModel.getCombinedString(); if (combinedString !== undefined) { detailedAdditions.push(`Prompt input: \`${combinedString}\``); diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index b1a9006bf1f..44c8a117b9b 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -225,6 +225,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach fastScrollModifier: 'alt', fastScrollSensitivity: config.fastScrollSensitivity, scrollSensitivity: config.mouseWheelScrollSensitivity, + scrollOnEraseInDisplay: true, wordSeparator: config.wordSeparators, overviewRuler: { width: 14, @@ -256,7 +257,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } })); - this._register(this._themeService.onDidColorThemeChange(e => this._updateTheme(e.theme))); + this._register(this._themeService.onDidColorThemeChange(theme => this._updateTheme(theme))); this._register(this._logService.onDidChangeLogLevel(e => this.raw.options.logLevel = vscodeToXtermLogLevel(e))); // Refire events diff --git a/src/vs/workbench/contrib/terminal/common/basePty.ts b/src/vs/workbench/contrib/terminal/common/basePty.ts index 9a8c11ce3ff..a851f56225e 100644 --- a/src/vs/workbench/contrib/terminal/common/basePty.ts +++ b/src/vs/workbench/contrib/terminal/common/basePty.ts @@ -25,7 +25,8 @@ export abstract class BasePty extends Disposable implements Partial | undefined = undefined; + const expr = ConfigurationResolverExpression.parse({ shellLaunchConfig, configuration }); try { - allResolvedVariables = (await this._resolverService.resolveAnyMap(lastActiveWorkspace, { - shellLaunchConfig, - configuration - })).resolvedVariables; + await this._resolverService.resolveAsync(lastActiveWorkspace, expr); } catch (err) { this._logService.error(err); } - if (allResolvedVariables) { - for (const [name, value] of allResolvedVariables.entries()) { - if (/^config:/.test(name) || name === 'selectedText' || name === 'lineNumber') { - resolvedVariables[name] = value; - } + for (const [{ inner }, resolved] of expr.resolved()) { + if (/^config:/.test(inner) || inner === 'selectedText' || inner === 'lineNumber') { + resolvedVariables[inner] = resolved.value; } } diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh index 86015654e38..7f6f5bdab7c 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh @@ -18,6 +18,9 @@ bash_major_version=${BASH_VERSINFO[0]} __vscode_shell_env_reporting="$VSCODE_SHELL_ENV_REPORTING" unset VSCODE_SHELL_ENV_REPORTING +envVarsToReport=() +IFS=',' read -ra envVarsToReport <<< "$__vscode_shell_env_reporting" + if (( BASH_VERSINFO[0] >= 4 )); then use_associative_array=1 # Associative arrays are only available in bash 4.0+ @@ -63,8 +66,8 @@ fi if [ -n "${VSCODE_ENV_REPLACE:-}" ]; then IFS=':' read -ra ADDR <<< "$VSCODE_ENV_REPLACE" for ITEM in "${ADDR[@]}"; do - VARNAME="$(echo $ITEM | cut -d "=" -f 1)" - VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2-)" + VARNAME="${ITEM%%=*}" + VALUE="${ITEM#*=}" export $VARNAME="$VALUE" done builtin unset VSCODE_ENV_REPLACE @@ -72,8 +75,8 @@ fi if [ -n "${VSCODE_ENV_PREPEND:-}" ]; then IFS=':' read -ra ADDR <<< "$VSCODE_ENV_PREPEND" for ITEM in "${ADDR[@]}"; do - VARNAME="$(echo $ITEM | cut -d "=" -f 1)" - VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2-)" + VARNAME="${ITEM%%=*}" + VALUE="${ITEM#*=}" export $VARNAME="$VALUE${!VARNAME}" done builtin unset VSCODE_ENV_PREPEND @@ -81,8 +84,8 @@ fi if [ -n "${VSCODE_ENV_APPEND:-}" ]; then IFS=':' read -ra ADDR <<< "$VSCODE_ENV_APPEND" for ITEM in "${ADDR[@]}"; do - VARNAME="$(echo $ITEM | cut -d "=" -f 1)" - VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2-)" + VARNAME="${ITEM%%=*}" + VALUE="${ITEM#*=}" export $VARNAME="${!VARNAME}$VALUE" done builtin unset VSCODE_ENV_APPEND @@ -198,6 +201,12 @@ if [ "$__vsc_stable" = "0" ]; then builtin printf "\e]633;P;ContinuationPrompt=$(echo "$PS2" | sed 's/\x1b/\\\\x1b/g')\a" fi +if [ -n "$STARSHIP_SESSION_KEY" ]; then + builtin printf '\e]633;P;PromptType=starship\a' +elif [ -n "$POSH_SESSION_ID" ]; then + builtin printf '\e]633;P;PromptType=oh-my-posh\a' +fi + # Report this shell supports rich command detection builtin printf '\e]633;P;HasRichCommandDetection=True\a' @@ -242,22 +251,6 @@ __updateEnvCacheAA() { fi } -__trackMissingEnvVarsAA() { - if [ "$use_associative_array" = 1 ]; then - declare -A currentEnvMap - while IFS='=' read -r key value; do - currentEnvMap["$key"]="$value" - done < <(env) - - for key in "${!vsc_aa_env[@]}"; do - if [ -z "${currentEnvMap[$key]}" ]; then - builtin printf '\e]633;EnvSingleDelete;%s;%s;%s\a' "$key" "$(__vsc_escape_value "${vsc_aa_env[$key]}")" "$__vsc_nonce" - builtin unset "vsc_aa_env[$key]" - fi - done - fi -} - __updateEnvCache() { local key="$1" local value="$2" @@ -277,69 +270,52 @@ __updateEnvCache() { builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" } -__trackMissingEnvVars() { - local current_env_keys=() - - while IFS='=' read -r key value; do - current_env_keys+=("$key") - done < <(env) - - # Compare vsc_env_keys with user's current_env_keys - for key in "${vsc_env_keys[@]}"; do - local found=0 - for env_key in "${current_env_keys[@]}"; do - if [[ "$key" == "$env_key" ]]; then - found=1 - break - fi - done - if [ "$found" = 0 ]; then - builtin printf '\e]633;EnvSingleDelete;%s;%s;%s\a' "${vsc_env_keys[i]}" "$(__vsc_escape_value "${vsc_env_values[i]}")" "$__vsc_nonce" - builtin unset 'vsc_env_keys[i]' - builtin unset 'vsc_env_values[i]' - fi - done - - # Remove gaps from unset - vsc_env_keys=("${vsc_env_keys[@]}") - vsc_env_values=("${vsc_env_values[@]}") -} - __vsc_update_env() { - if [[ "$__vscode_shell_env_reporting" == "1" ]]; then + if [[ ${#envVarsToReport[@]} -gt 0 ]]; then builtin printf '\e]633;EnvSingleStart;%s;%s\a' 0 $__vsc_nonce if [ "$use_associative_array" = 1 ]; then if [ ${#vsc_aa_env[@]} -eq 0 ]; then # Associative array is empty, do not diff, just add - while IFS='=' read -r key value; do - vsc_aa_env["$key"]="$value" - builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" - done < <(env) + for key in "${envVarsToReport[@]}"; do + if [ -n "${!key+x}" ]; then + local value="${!key}" + vsc_aa_env["$key"]="$value" + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + done else # Diff approach for associative array - while IFS='=' read -r key value; do - __updateEnvCacheAA "$key" "$value" - done < <(env) - __trackMissingEnvVarsAA + for key in "${envVarsToReport[@]}"; do + if [ -n "${!key+x}" ]; then + local value="${!key}" + __updateEnvCacheAA "$key" "$value" + fi + done + # Track missing env vars not needed for now, as we are only tracking pre-defined env var from terminalEnvironment. fi else if [[ -z ${vsc_env_keys[@]} ]] && [[ -z ${vsc_env_values[@]} ]]; then - # Non associative arrays are both empty, do not diff, just add - while IFS='=' read -r key value; do - vsc_env_keys+=("$key") - vsc_env_values+=("$value") - builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" - done < <(env) + # Non associative arrays are both empty, do not diff, just add + for key in "${envVarsToReport[@]}"; do + if [ -n "${!key+x}" ]; then + local value="${!key}" + vsc_env_keys+=("$key") + vsc_env_values+=("$value") + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + done else # Diff approach for non-associative arrays - while IFS='=' read -r key value; do - __updateEnvCache "$key" "$value" - done < <(env) - __trackMissingEnvVars + for key in "${envVarsToReport[@]}"; do + if [ -n "${!key+x}" ]; then + local value="${!key}" + __updateEnvCache "$key" "$value" + fi + done + # Track missing env vars not needed for now, as we are only tracking pre-defined env var from terminalEnvironment. fi - fi builtin printf '\e]633;EnvSingleEnd;%s;\a' $__vsc_nonce fi diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh index de3eba1ef27..74536cbd328 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh @@ -80,18 +80,19 @@ __vsc_escape_value() { builtin emulate -L zsh # Process text byte by byte, not by codepoint. - builtin local LC_ALL=C str="$1" i byte token out='' + builtin local LC_ALL=C str="$1" i byte token out='' val for (( i = 0; i < ${#str}; ++i )); do + # Escape backslashes, semi-colons specially, then special ASCII chars below space (0x20). byte="${str:$i:1}" - - # Escape backslashes, semi-colons and newlines - if [ "$byte" = "\\" ]; then + val=$(printf "%d" "'$byte") + if (( val < 31 )); then + # For control characters, use hex encoding + token=$(printf "\\\\x%02x" "'$byte") + elif [ "$byte" = "\\" ]; then token="\\\\" elif [ "$byte" = ";" ]; then token="\\x3b" - elif [ "$byte" = $'\n' ]; then - token="\x0a" else token="$byte" fi @@ -112,8 +113,20 @@ unset VSCODE_NONCE __vscode_shell_env_reporting="$VSCODE_SHELL_ENV_REPORTING" unset VSCODE_SHELL_ENV_REPORTING +envVarsToReport=() +IFS=',' read -rA envVarsToReport <<< "$__vscode_shell_env_reporting" + builtin printf "\e]633;P;ContinuationPrompt=%s\a" "$(echo "$PS2" | sed 's/\x1b/\\\\x1b/g')" +# Report prompt type +if [ -n "$ZSH" ] && [ -n "$ZSH_VERSION" ] && (( ${+functions[omz]} )) ; then + builtin printf '\e]633;P;PromptType=oh-my-zsh\a' +elif [ -n "$STARSHIP_SESSION_KEY" ]; then + builtin printf '\e]633;P;PromptType=starship\a' +elif [ -n "$P9K_SSH" ] || [ -n "$P9K_TTY" ]; then + builtin printf '\e]633;P;PromptType=p10k\a' +fi + # Report this shell supports rich command detection builtin printf '\e]633;P;HasRichCommandDetection=True\a' @@ -140,23 +153,6 @@ __update_env_cache_aa() { fi } -__track_missing_env_vars_aa() { - if [ $__vsc_use_aa -eq 1 ]; then - typeset -A currentEnvMap - while IFS='=' read -r key value; do - currentEnvMap["$key"]="$value" - done < <(env) - - for k in "${(@k)vsc_aa_env}"; do - # if currentEnvMap does not have the key, then it is missing - if ! [[ -v currentEnvMap[$k] ]]; then - builtin printf '\e]633;EnvSingleDelete;%s;%s;%s\a' "${(Q)k}" "$(__vsc_escape_value "${vsc_aa_env[$k]}")" "$__vsc_nonce" - builtin unset "vsc_aa_env[$k]" - fi - done - fi -} - __update_env_cache() { local key="$1" local value="$2" @@ -177,69 +173,49 @@ __update_env_cache() { builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" } -__track_missing_env_vars() { - local currentEnvKeys=(); - - while IFS='=' read -r key value; do - currentEnvKeys+=("$key"); - done < <(env); - - # Compare __vsc_env_keys with user's currentEnvKeys - for ((i = 1; i <= ${#__vsc_env_keys[@]}; i++)); do - local found=0; - for envKey in "${currentEnvKeys[@]}"; do - if [[ "${__vsc_env_keys[$i]}" == "$envKey" ]]; then - found=1; - break; - fi; - done; - if [ "$found" = 0 ]; then - builtin printf '\e]633;EnvSingleDelete;%s;%s;%s\a' "${__vsc_env_keys[$i]}" "$(__vsc_escape_value "${__vsc_env_values[$i]}")" "$__vsc_nonce"; - unset "__vsc_env_keys[$i]"; - unset "__vsc_env_values[$i]"; - fi; - done; - - # Remove gaps from unset - __vsc_env_keys=("${(@)__vsc_env_keys}"); - __vsc_env_values=("${(@)__vsc_env_values}"); -} - - __vsc_update_env() { - if [[ "$__vscode_shell_env_reporting" == "1" ]]; then + if [[ ${#envVarsToReport[@]} -gt 0 ]]; then builtin printf '\e]633;EnvSingleStart;%s;%s;\a' 0 $__vsc_nonce if [ $__vsc_use_aa -eq 1 ]; then if [[ ${#vsc_aa_env[@]} -eq 0 ]]; then # Associative array is empty, do not diff, just add - while IFS='=' read -r key value; do - vsc_aa_env["$key"]="$value" - builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" - done < <(env) + for key in "${envVarsToReport[@]}"; do + if [[ -v $key ]]; then + vsc_aa_env["$key"]="${(P)key}" + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "${(P)key}")" "$__vsc_nonce" + fi + done else # Diff approach for associative array - while IFS='=' read -r key value; do - __update_env_cache_aa "$key" "$value" - done < <(env) - __track_missing_env_vars_aa - + for var in "${envVarsToReport[@]}"; do + if [[ -v $var ]]; then + value="${(P)var}" + __update_env_cache_aa "$var" "$value" + fi + done + # Track missing env vars not needed for now, as we are only tracking pre-defined env var from terminalEnvironment. fi else # Two arrays approach if [[ ${#__vsc_env_keys[@]} -eq 0 ]] && [[ ${#__vsc_env_values[@]} -eq 0 ]]; then # Non-associative arrays are both empty, do not diff, just add - while IFS='=' read -r key value; do - __vsc_env_keys+=("$key") - __vsc_env_values+=("$value") - builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" - done < <(env) + for key in "${envVarsToReport[@]}"; do + if [[ -v $key ]]; then + value="${(P)key}" + __vsc_env_keys+=("$key") + __vsc_env_values+=("$value") + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + done else # Diff approach for non-associative arrays - while IFS='=' read -r key value; do - __update_env_cache "$key" "$value" - done < <(env) - __track_missing_env_vars - + for var in "${envVarsToReport[@]}"; do + if [[ -v $var ]]; then + value="${(P)var}" + __update_env_cache "$var" "$value" + fi + done + # Track missing env vars not needed for now, as we are only tracking pre-defined env var from terminalEnvironment. fi fi diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish index f1ef2bd6c6e..9df1ecd8a3f 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish @@ -23,6 +23,10 @@ or exit set --global VSCODE_SHELL_INTEGRATION 1 set --global __vscode_shell_env_reporting $VSCODE_SHELL_ENV_REPORTING set -e VSCODE_SHELL_ENV_REPORTING +set -g envVarsToReport +if test -n "$__vscode_shell_env_reporting" + set envVarsToReport (string split "," "$__vscode_shell_env_reporting") +end # Apply any explicit path prefix (see #99878) # On fish, '$fish_user_paths' is always prepended to the PATH, for both login and non-login shells, so we need @@ -35,6 +39,9 @@ set -e VSCODE_PATH_PREFIX set -g vsc_env_keys set -g vsc_env_values +# Tracks if the shell has been initialized, this prevents +set -g vsc_initialized 0 + set -g __vsc_applied_env_vars 0 function __vsc_apply_env_vars if test $__vsc_applied_env_vars -eq 1; @@ -109,6 +116,11 @@ end # Sent when a command line is cleared or reset, but no command was run. # Marks the cleared line with neither success nor failure. function __vsc_cmd_clear --on-event fish_cancel + if test $vsc_initialized -eq 0; + return + end + __vsc_esc E "" $__vsc_nonce + __vsc_esc C __vsc_esc D end @@ -151,15 +163,20 @@ function __vsc_update_cwd --on-event fish_prompt end end -if test "$__vscode_shell_env_reporting" = "1" +if test -n "$__vscode_shell_env_reporting" function __vsc_update_env --on-event fish_prompt - __vsc_esc EnvSingleStart 1 - for line in (env) - set myVar (echo $line | awk -F= '{print $1}') - set myVal (echo $line | awk -F= '{print $2}') - __vsc_esc EnvSingleEntry $myVar (__vsc_escape_value "$myVal") + if test (count $envVarsToReport) -gt 0 + __vsc_esc EnvSingleStart 1 + + for key in $envVarsToReport + if set -q $key + set -l value $$key + __vsc_esc EnvSingleEntry $key (__vsc_escape_value "$value") + end + end + + __vsc_esc EnvSingleEnd end - __vsc_esc EnvSingleEnd end end @@ -170,6 +187,7 @@ function __vsc_fish_prompt_start # evaluated __vsc_apply_env_vars __vsc_esc A + set -g vsc_initialized 1 end # Sent at the end of the prompt. @@ -208,6 +226,11 @@ function __init_vscode_shell_integration end end +# Report prompt type +if set -q POSH_SESSION_ID + __vsc_esc P PromptType=oh-my-posh +end + # Report this shell supports rich command detection __vsc_esc P HasRichCommandDetection=True diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 index f3d92e9c3d9..c29140f2ea9 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 @@ -16,6 +16,7 @@ if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") { $Global:__VSCodeOriginalPrompt = $function:Prompt $Global:__LastHistoryId = -1 +$Global:__VSCodeIsInExecution = $false # Store the nonce in script scope and unset the global $Nonce = $env:VSCODE_NONCE @@ -26,6 +27,10 @@ $env:VSCODE_STABLE = $null $__vscode_shell_env_reporting = $env:VSCODE_SHELL_ENV_REPORTING $env:VSCODE_SHELL_ENV_REPORTING = $null +$Global:envVarsToReport = @() +if ($__vscode_shell_env_reporting) { + $Global:envVarsToReport = $__vscode_shell_env_reporting.Split(',') +} $osVersion = [System.Environment]::OSVersion.Version $isWindows10 = $IsWindows -and $osVersion.Major -eq 10 -and $osVersion.Minor -eq 0 -and $osVersion.Build -lt 22000 @@ -73,8 +78,10 @@ function Global:Prompt() { Set-StrictMode -Off $LastHistoryEntry = Get-History -Count 1 $Result = "" - # Skip finishing the command if the first command has not yet started - if ($Global:__LastHistoryId -ne -1) { + # Skip finishing the command if the first command has not yet started or an execution has not + # yet begun + if ($Global:__LastHistoryId -ne -1 -and $Global:__VSCodeIsInExecution -eq $true) { + $Global:__VSCodeIsInExecution = $false if ($LastHistoryEntry.Id -eq $Global:__LastHistoryId) { # Don't provide a command line or exit code if there was no history entry (eg. ctrl+c, enter on no command) $Result += "$([char]0x1b)]633;D`a" @@ -94,11 +101,15 @@ function Global:Prompt() { # Send current environment variables as JSON # OSC 633 ; EnvJson ; ; - if ($__vscode_shell_env_reporting -eq "1") { + if ($Global:envVarsToReport.Count -gt 0) { $envMap = @{} - Get-ChildItem Env: | ForEach-Object { $envMap[$_.Name] = $_.Value } - $envJson = $envMap | ConvertTo-Json -Compress - $Result += "$([char]0x1b)]633;EnvJson;$(__VSCode-Escape-Value $envJson);$Nonce`a" + foreach ($varName in $envVarsToReport) { + if (Test-Path "env:$varName") { + $envMap[$varName] = (Get-Item "env:$varName").Value + } + } + $envJson = $envMap | ConvertTo-Json -Compress + $Result += "$([char]0x1b)]633;EnvJson;$(__VSCode-Escape-Value $envJson);$Nonce`a" } # Before running the original prompt, put $? back to what it was: @@ -121,13 +132,26 @@ function Global:Prompt() { return $Result } +# Report prompt type +if ($env:STARSHIP_SESSION_KEY) { + [Console]::Write("$([char]0x1b)]633;P;PromptType=starship`a") +} +elseif ($env:POSH_SESSION_ID) { + [Console]::Write("$([char]0x1b)]633;P;PromptType=oh-my-posh`a") +} +elseif ($Global:GitPromptSettings) { + [Console]::Write("$([char]0x1b)]633;P;PromptType=posh-git`a") +} + # Only send the command executed sequence when PSReadLine is loaded, if not shell integration should # still work thanks to the command line sequence if (Get-Module -Name PSReadLine) { [Console]::Write("$([char]0x1b)]633;P;HasRichCommandDetection=True`a") + $__VSCodeOriginalPSConsoleHostReadLine = $function:PSConsoleHostReadLine function Global:PSConsoleHostReadLine { $CommandLine = $__VSCodeOriginalPSConsoleHostReadLine.Invoke() + $Global:__VSCodeIsInExecution = $true # Command line # OSC 633 ; E [; [; ]] ST diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts index 7e67399b2d6..fbf6c29bf3b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts @@ -68,7 +68,8 @@ export class TerminalChatController extends Disposable implements ITerminalContr element: editor, code: editor.getValue(), codeBlockIndex: 0, - languageId: editor.getModel()!.getLanguageId() + languageId: editor.getModel()!.getLanguageId(), + chatSessionId: this._terminalChatWidget.value.inlineChatWidget.chatWidget.viewModel?.sessionId }; } }, 'terminal')); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatEnabler.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatEnabler.ts index b0b1e25e6c3..cbdeafdd383 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatEnabler.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatEnabler.ts @@ -3,13 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IChatAgentService } from '../../../chat/common/chatAgents.js'; import { ChatAgentLocation } from '../../../chat/common/constants.js'; import { TerminalChatContextKeys } from './terminalChat.js'; - export class TerminalChatEnabler { static Id = 'terminalChat.enabler'; @@ -23,7 +23,7 @@ export class TerminalChatEnabler { @IContextKeyService contextKeyService: IContextKeyService, ) { this._ctxHasProvider = TerminalChatContextKeys.hasChatAgent.bindTo(contextKeyService); - this._store.add(chatAgentService.onDidChangeAgents(() => { + this._store.add(Event.runAndSubscribe(chatAgentService.onDidChangeAgents, () => { const hasTerminalAgent = Boolean(chatAgentService.getDefaultAgent(ChatAgentLocation.Terminal)); this._ctxHasProvider.set(hasTerminalAgent); })); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalInitialHint.test.ts b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalInitialHint.test.ts index ddf420a3539..19cea45b430 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalInitialHint.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalInitialHint.test.ts @@ -15,7 +15,7 @@ import { strictEqual } from 'assert'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IChatAgent } from '../../../../chat/common/chatAgents.js'; import { importAMDNodeModule } from '../../../../../../amdX.js'; -import { ChatAgentLocation } from '../../../../chat/common/constants.js'; +import { ChatAgentLocation, ChatMode } from '../../../../chat/common/constants.js'; suite('Terminal Initial Hint Addon', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -34,6 +34,7 @@ suite('Terminal Initial Hint Addon', () => { slashCommands: [{ name: 'test', description: 'test' }], disambiguation: [], locations: [ChatAgentLocation.fromRaw('terminal')], + modes: [ChatMode.Ask], invoke: async () => { return {}; } }; const editorAgent: IChatAgent = { @@ -45,6 +46,7 @@ suite('Terminal Initial Hint Addon', () => { metadata: {}, slashCommands: [{ name: 'test', description: 'test' }], locations: [ChatAgentLocation.fromRaw('editor')], + modes: [ChatMode.Ask], disambiguation: [], invoke: async () => { return {}; } }; diff --git a/src/vs/workbench/contrib/terminalContrib/history/common/history.ts b/src/vs/workbench/contrib/terminalContrib/history/common/history.ts index 29709a50e0c..8eaac217b45 100644 --- a/src/vs/workbench/contrib/terminalContrib/history/common/history.ts +++ b/src/vs/workbench/contrib/terminalContrib/history/common/history.ts @@ -299,7 +299,8 @@ export async function fetchZshHistory(accessor: ServicesAccessor): Promise = new Set(); for (let i = 0; i < fileLines.length; i++) { const sanitized = fileLines[i].replace(/\\\n/g, '\n').trim(); diff --git a/src/vs/workbench/contrib/terminalContrib/history/test/common/history.test.ts b/src/vs/workbench/contrib/terminalContrib/history/test/common/history.test.ts index 2fffe8c043b..c9db0ef431f 100644 --- a/src/vs/workbench/contrib/terminalContrib/history/test/common/history.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/history/test/common/history.test.ts @@ -216,94 +216,117 @@ suite('Terminal history', () => { suite('fetchZshHistory', () => { let fileScheme: string; let filePath: string; - const fileContent: string = [ - ': 1655252330:0;single line command', - ': 1655252330:0;git commit -m "A wrapped line in pwsh history\\', - '\\', - 'Some commit description\\', - '\\', - 'Fixes #xyz"', - ': 1655252330:0;git status', - ': 1655252330:0;two "\\', - 'line"' - ].join('\n'); + const fileContentType = [ + { + type: 'simple', + content: [ + 'single line command', + 'git commit -m "A wrapped line in pwsh history\\', + '\\', + 'Some commit description\\', + '\\', + 'Fixes #xyz"', + 'git status', + 'two "\\', + 'line"' + ].join('\n') + }, + { + type: 'extended', + content: [ + ': 1655252330:0;single line command', + ': 1655252330:0;git commit -m "A wrapped line in pwsh history\\', + '\\', + 'Some commit description\\', + '\\', + 'Fixes #xyz"', + ': 1655252330:0;git status', + ': 1655252330:0;two "\\', + 'line"' + ].join('\n') + }, + ]; let instantiationService: TestInstantiationService; let remoteConnection: Pick | null = null; let remoteEnvironment: Pick | null = null; - setup(() => { - instantiationService = new TestInstantiationService(); - instantiationService.stub(IFileService, { - async readFile(resource: URI) { - const expected = URI.from({ scheme: fileScheme, path: filePath }); - strictEqual(resource.scheme, expected.scheme); - strictEqual(resource.path, expected.path); - return { value: VSBuffer.fromString(fileContent) }; - } - } as Pick); - instantiationService.stub(IRemoteAgentService, { - async getEnvironment() { return remoteEnvironment; }, - getConnection() { return remoteConnection; } - } as Pick); - }); - - teardown(() => { - instantiationService.dispose(); - }); - - if (!isWindows) { - suite('local', () => { - let originalEnvValues: { HOME: string | undefined }; + for (const { type, content } of fileContentType) { + suite(type, () => { setup(() => { - originalEnvValues = { HOME: env['HOME'] }; - env['HOME'] = '/home/user'; - remoteConnection = { remoteAuthority: 'some-remote' }; - fileScheme = Schemas.vscodeRemote; - filePath = '/home/user/.bash_history'; + instantiationService = new TestInstantiationService(); + instantiationService.stub(IFileService, { + async readFile(resource: URI) { + const expected = URI.from({ scheme: fileScheme, path: filePath }); + strictEqual(resource.scheme, expected.scheme); + strictEqual(resource.path, expected.path); + return { value: VSBuffer.fromString(content) }; + } + } as Pick); + instantiationService.stub(IRemoteAgentService, { + async getEnvironment() { return remoteEnvironment; }, + getConnection() { return remoteConnection; } + } as Pick); }); + teardown(() => { - if (originalEnvValues['HOME'] === undefined) { - delete env['HOME']; - } else { - env['HOME'] = originalEnvValues['HOME']; - } + instantiationService.dispose(); }); - test('current OS', async () => { - filePath = '/home/user/.zsh_history'; - deepStrictEqual((await instantiationService.invokeFunction(fetchZshHistory))!.commands, expectedCommands); + + if (!isWindows) { + suite('local', () => { + let originalEnvValues: { HOME: string | undefined }; + setup(() => { + originalEnvValues = { HOME: env['HOME'] }; + env['HOME'] = '/home/user'; + remoteConnection = { remoteAuthority: 'some-remote' }; + fileScheme = Schemas.vscodeRemote; + filePath = '/home/user/.bash_history'; + }); + teardown(() => { + if (originalEnvValues['HOME'] === undefined) { + delete env['HOME']; + } else { + env['HOME'] = originalEnvValues['HOME']; + } + }); + test('current OS', async () => { + filePath = '/home/user/.zsh_history'; + deepStrictEqual((await instantiationService.invokeFunction(fetchZshHistory))!.commands, expectedCommands); + }); + }); + } + suite('remote', () => { + let originalEnvValues: { HOME: string | undefined }; + setup(() => { + originalEnvValues = { HOME: env['HOME'] }; + env['HOME'] = '/home/user'; + remoteConnection = { remoteAuthority: 'some-remote' }; + fileScheme = Schemas.vscodeRemote; + filePath = '/home/user/.zsh_history'; + }); + teardown(() => { + if (originalEnvValues['HOME'] === undefined) { + delete env['HOME']; + } else { + env['HOME'] = originalEnvValues['HOME']; + } + }); + test('Windows', async () => { + remoteEnvironment = { os: OperatingSystem.Windows }; + strictEqual(await instantiationService.invokeFunction(fetchZshHistory), undefined); + }); + test('macOS', async () => { + remoteEnvironment = { os: OperatingSystem.Macintosh }; + deepStrictEqual((await instantiationService.invokeFunction(fetchZshHistory))!.commands, expectedCommands); + }); + test('Linux', async () => { + remoteEnvironment = { os: OperatingSystem.Linux }; + deepStrictEqual((await instantiationService.invokeFunction(fetchZshHistory))!.commands, expectedCommands); + }); }); }); } - suite('remote', () => { - let originalEnvValues: { HOME: string | undefined }; - setup(() => { - originalEnvValues = { HOME: env['HOME'] }; - env['HOME'] = '/home/user'; - remoteConnection = { remoteAuthority: 'some-remote' }; - fileScheme = Schemas.vscodeRemote; - filePath = '/home/user/.zsh_history'; - }); - teardown(() => { - if (originalEnvValues['HOME'] === undefined) { - delete env['HOME']; - } else { - env['HOME'] = originalEnvValues['HOME']; - } - }); - test('Windows', async () => { - remoteEnvironment = { os: OperatingSystem.Windows }; - strictEqual(await instantiationService.invokeFunction(fetchZshHistory), undefined); - }); - test('macOS', async () => { - remoteEnvironment = { os: OperatingSystem.Macintosh }; - deepStrictEqual((await instantiationService.invokeFunction(fetchZshHistory))!.commands, expectedCommands); - }); - test('Linux', async () => { - remoteEnvironment = { os: OperatingSystem.Linux }; - deepStrictEqual((await instantiationService.invokeFunction(fetchZshHistory))!.commands, expectedCommands); - }); - }); }); suite('fetchPwshHistory', () => { let fileScheme: string; diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts index e9014e9ad05..a862d2146db 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts @@ -33,6 +33,7 @@ import { ITerminalLogService } from '../../../../../platform/terminal/common/ter import { TerminalMultiLineLinkDetector } from './terminalMultiLineLinkDetector.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; import type { IHoverAction } from '../../../../../base/browser/ui/hover/hover.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; export type XtermLinkMatcherHandler = (event: MouseEvent | undefined, link: string) => Promise; @@ -56,6 +57,7 @@ export class TerminalLinkManager extends DisposableStore { @IConfigurationService private readonly _configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @INotificationService notificationService: INotificationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, @ITerminalConfigurationService terminalConfigurationService: ITerminalConfigurationService, @ITerminalLogService private readonly _logService: ITerminalLogService, @ITunnelService private readonly _tunnelService: ITunnelService, @@ -191,6 +193,13 @@ export class TerminalLinkManager extends DisposableStore { if (!opener) { throw new Error(`No matching opener for link type "${link.type}"`); } + this._telemetryService.publicLog2<{ + linkType: TerminalBuiltinLinkType | string; + }, { + owner: 'tyriar'; + comment: 'When the user opens a link in the terminal'; + linkType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of link being opened' }; + }>('terminal/openLink', { linkType: typeof link.type === 'string' ? link.type : `extension:${link.type.id}` }); await opener.open(link); } diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing.ts index d93b62aa938..ea69ddc1154 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing.ts @@ -314,6 +314,23 @@ function detectLinksViaSuffix(line: string): IParsedLink[] { prefix, suffix }); + + // If the path contains an opening bracket, provide the path starting immediately after + // the opening bracket as an additional result + const openingBracketMatch = path.matchAll(/(?[\[\(])(?![\]\)])/g); + for (const match of openingBracketMatch) { + const bracket = match.groups?.bracket; + if (bracket) { + results.push({ + path: { + index: linkStartIndex + (prefix?.text.length || 0) + match.index + 1, + text: path.substring(match.index + bracket.length) + }, + prefix, + suffix + }); + } + } } } diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkParsing.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkParsing.test.ts index 9d4684a66fa..2109f3d52a3 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkParsing.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkParsing.test.ts @@ -322,6 +322,48 @@ suite('TerminalLinkParsing', () => { ); }); + test('should detect multiple links when opening brackets are in the text', () => { + deepStrictEqual( + detectLinks('notlink[foo:45]', OperatingSystem.Linux), + [ + { + path: { + index: 0, + text: 'notlink[foo' + }, + prefix: undefined, + suffix: { + col: undefined, + row: 45, + rowEnd: undefined, + colEnd: undefined, + suffix: { + index: 11, + text: ':45' + } + } + }, + { + path: { + index: 8, + text: 'foo' + }, + prefix: undefined, + suffix: { + col: undefined, + row: 45, + rowEnd: undefined, + colEnd: undefined, + suffix: { + index: 11, + text: ':45' + } + } + }, + ] as IParsedLink[] + ); + }); + test('should extract the link prefix', () => { deepStrictEqual( detectLinks('"foo", line 5, col 6', OperatingSystem.Linux), diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts index a2275a18a59..04d0dfa7415 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts @@ -251,6 +251,13 @@ suite('Workbench - TerminalLocalLinkDetector', () => { { range: [[1, 1], [16, 1]], uri: URI.file('/parent/cwd/foo') } ]); }); + + test('should support finding links after brackets', async () => { + validResources = [URI.file('/parent/cwd/foo')]; + await assertLinks(TerminalBuiltinLinkType.LocalFile, 'bar[foo:5', [ + { range: [[5, 1], [9, 1]], uri: URI.file('/parent/cwd/foo') } + ]); + }); }); suite('macOS/Linux', () => { diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/media/terminalSymbolIcons.css b/src/vs/workbench/contrib/terminalContrib/suggest/browser/media/terminalSymbolIcons.css index 6d918851bad..515978e5bbb 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/media/terminalSymbolIcons.css +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/media/terminalSymbolIcons.css @@ -3,7 +3,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.monaco-editor .codicon.codicon-symbol-method-arrow, -.monaco-workbench .codicon.codicon-symbol-method-arrow { color: var(--vscode-terminalSymbolIcon-aliasForeground); } -.monaco-editor .codicon.codicon-flag, -.monaco-workbench .codicon.codicon-flag { color: var(--vscode-terminalSymbolIcon-flagForeground); } +.monaco-editor .codicon.codicon-terminal-symbol-alias, +.monaco-workbench .codicon.codicon-terminal-symbol-alias { color: var(--vscode-terminalSymbolIcon-aliasForeground); } + +.monaco-editor .codicon.codicon-terminal-symbol-flag, +.monaco-workbench .codicon.codicon-terminal-symbol-flag { color: var(--vscode-terminalSymbolIcon-flagForeground); } + +.monaco-editor .codicon.codicon-terminal-symbol-option-value, +.monaco-workbench .codicon.codicon-terminal-symbol-option-value { color: var(--vscode-terminalSymbolIcon-optionValueForeground); } + +.monaco-editor .codicon.codicon-terminal-symbol-method, +.monaco-workbench .codicon.codicon-terminal-symbol-method { color: var(--vscode-terminalSymbolIcon-methodForeground); } + +.monaco-editor .codicon.codicon-terminal-symbol-argument, +.monaco-workbench .codicon.codicon-terminal-symbol-argument { color: var(--vscode-terminalSymbolIcon-argumentForeground); } + +.monaco-editor .codicon.codicon-terminal-symbol-option, +.monaco-workbench .codicon.codicon-terminal-symbol-option { color: var(--vscode-terminalSymbolIcon-optionForeground); } + +.monaco-editor .codicon.codicon-terminal-symbol-inline-suggestion, +.monaco-workbench .codicon.codicon-terminal-symbol-inline-suggestion { color: var(--vscode-terminalSymbolIcon-inlineSuggestionForeground); } + +.monaco-editor .codicon.codicon-terminal-symbol-file, +.monaco-workbench .codicon.codicon-terminal-symbol-file { color: var(--vscode-terminalSymbolIcon-fileForeground); } + +.monaco-editor .codicon.codicon-terminal-symbol-folder, +.monaco-workbench .codicon.codicon-terminal-symbol-folder { color: var(--vscode-terminalSymbolIcon-folderForeground); } diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/pwshCompletionProviderAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/pwshCompletionProviderAddon.ts index 9928faaff89..1b3429c304c 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/pwshCompletionProviderAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/pwshCompletionProviderAddon.ts @@ -9,23 +9,21 @@ import type { ITerminalAddon, Terminal } from '@xterm/xterm'; import { Event, Emitter } from '../../../../../base/common/event.js'; import { ShellIntegrationOscPs } from '../../../../../platform/terminal/common/xterm/shellIntegrationAddon.js'; import * as dom from '../../../../../base/browser/dom.js'; -import { IPromptInputModel, IPromptInputModelState } from '../../../../../platform/terminal/common/capabilities/commandDetection/promptInputModel.js'; +import { IPromptInputModel } from '../../../../../platform/terminal/common/capabilities/commandDetection/promptInputModel.js'; import { sep } from '../../../../../base/common/path.js'; import { SuggestAddon } from './terminalSuggestAddon.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { ITerminalSuggestConfiguration, terminalSuggestConfigSection, TerminalSuggestSettingId } from '../common/terminalSuggestConfiguration.js'; +import { ITerminalSuggestConfiguration, terminalSuggestConfigSection } from '../common/terminalSuggestConfiguration.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { GeneralShellType } from '../../../../../platform/terminal/common/terminal.js'; import { ITerminalCapabilityStore, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { ITerminalCompletion, TerminalCompletionItemKind } from './terminalCompletionItem.js'; export const enum VSCodeSuggestOscPt { Completions = 'Completions', - CompletionsPwshCommands = 'CompletionsPwshCommands', } export type CompressedPwshCompletion = [ @@ -42,13 +40,8 @@ export type PwshCompletion = { CustomIcon?: string; }; -const enum Constants { - CachedPwshCommandsStorageKey = 'terminal.suggest.pwshCommands' -} - const enum RequestCompletionsSequence { Contextual = '\x1b[24~e', // F12,e - Global = '\x1b[24~f', // F12,f } export class PwshCompletionProviderAddon extends Disposable implements ITerminalAddon, ITerminalCompletionProvider { @@ -56,13 +49,11 @@ export class PwshCompletionProviderAddon extends Disposable implements ITerminal triggerCharacters?: string[] | undefined; isBuiltin?: boolean = true; static readonly ID = 'pwsh-shell-integration'; - static cachedPwshCommands: Set; readonly shellTypes = [GeneralShellType.PowerShell]; private _lastUserDataTimestamp: number = 0; private _terminal?: Terminal; private _mostRecentCompletion?: ITerminalCompletion; private _promptInputModel?: IPromptInputModel; - private _currentPromptInputState?: IPromptInputModelState; private _enableWidget: boolean = true; isPasting: boolean = false; private _completionsDeferred: DeferredPromise | null = null; @@ -73,10 +64,8 @@ export class PwshCompletionProviderAddon extends Disposable implements ITerminal readonly onDidRequestSendText = this._onDidRequestSendText.event; constructor( - providedPwshCommands: Set | undefined, capabilities: ITerminalCapabilityStore, @IConfigurationService private readonly _configurationService: IConfigurationService, - @IStorageService private readonly _storageService: IStorageService ) { super(); this._register(Event.runAndSubscribe(Event.any( @@ -92,29 +81,6 @@ export class PwshCompletionProviderAddon extends Disposable implements ITerminal this._promptInputModel = undefined; } })); - PwshCompletionProviderAddon.cachedPwshCommands = providedPwshCommands || new Set(); - - // Attempt to load cached pwsh commands if not already loaded - if (PwshCompletionProviderAddon.cachedPwshCommands.size === 0) { - const config = this._storageService.get(Constants.CachedPwshCommandsStorageKey, StorageScope.APPLICATION, undefined); - if (config !== undefined) { - const completions = JSON.parse(config); - for (const c of completions) { - PwshCompletionProviderAddon.cachedPwshCommands.add(c); - } - } - } - - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(TerminalSuggestSettingId.Enabled)) { - this.clearSuggestCache(); - } - })); - } - - clearSuggestCache(): void { - PwshCompletionProviderAddon.cachedPwshCommands.clear(); - this._storageService.remove(Constants.CachedPwshCommandsStorageKey, StorageScope.APPLICATION); } activate(xterm: Terminal): void { @@ -143,8 +109,6 @@ export class PwshCompletionProviderAddon extends Disposable implements ITerminal case VSCodeSuggestOscPt.Completions: this._handleCompletionsSequence(this._terminal, data, command, args); return true; - case VSCodeSuggestOscPt.CompletionsPwshCommands: - return this._handleCompletionsPwshCommandsSequence(this._terminal, data, command, args); } // Unrecognized sequence @@ -175,38 +139,14 @@ export class PwshCompletionProviderAddon extends Disposable implements ITerminal let replacementIndex = 0; let replacementLength = this._promptInputModel.cursorIndex; - this._currentPromptInputState = { - value: this._promptInputModel.value, - prefix: this._promptInputModel.prefix, - suffix: this._promptInputModel.suffix, - cursorIndex: this._promptInputModel.cursorIndex, - ghostTextIndex: this._promptInputModel.ghostTextIndex - }; - - let leadingLineContent = this._currentPromptInputState.prefix.substring(replacementIndex, replacementIndex + replacementLength); - - const firstChar = leadingLineContent.length === 0 ? '' : leadingLineContent[0]; - const isGlobalCommand = !leadingLineContent.includes(' ') && firstChar !== '['; - // This is a TabExpansion2 result - if (!isGlobalCommand) { - replacementIndex = parseInt(args[0]); - replacementLength = parseInt(args[1]); - leadingLineContent = this._promptInputModel.prefix; - } + replacementIndex = parseInt(args[0]); + replacementLength = parseInt(args[1]); + const payload = data.slice(command.length + args[0].length + args[1].length + args[2].length + 4/*semi-colons*/); const rawCompletions: PwshCompletion | PwshCompletion[] | CompressedPwshCompletion[] | CompressedPwshCompletion = args.length === 0 || payload.length === 0 ? undefined : JSON.parse(payload); const completions = parseCompletionsFromShell(rawCompletions, replacementIndex, replacementLength); - // This is a global command, add cached commands list to completions - if (isGlobalCommand) { - for (const c of PwshCompletionProviderAddon.cachedPwshCommands) { - c.replacementIndex = replacementIndex; - c.replacementLength = replacementLength; - completions.push(c); - } - } - if (this._mostRecentCompletion?.kind === TerminalCompletionItemKind.Folder && completions.every(c => c.kind === TerminalCompletionItemKind.Folder)) { completions.push(this._mostRecentCompletion); } @@ -214,22 +154,6 @@ export class PwshCompletionProviderAddon extends Disposable implements ITerminal this._resolveCompletions(completions); } - private async _handleCompletionsPwshCommandsSequence(terminal: Terminal, data: string, command: string, args: string[]): Promise { - const type = args[0]; - const rawCompletions: PwshCompletion | PwshCompletion[] | CompressedPwshCompletion[] | CompressedPwshCompletion = JSON.parse(data.slice(command.length + type.length + 2/*semi-colons*/)); - const completions = parseCompletionsFromShell(rawCompletions, 0, 0); - - const set = PwshCompletionProviderAddon.cachedPwshCommands; - set.clear(); - for (const c of completions) { - set.add(c); - } - - this._storageService.store(Constants.CachedPwshCommandsStorageKey, JSON.stringify(Array.from(set.values())), StorageScope.APPLICATION, StorageTarget.MACHINE); - - return true; - } - private _resolveCompletions(result: ITerminalCompletion[] | undefined) { if (!this._completionsDeferred) { return; @@ -251,11 +175,6 @@ export class PwshCompletionProviderAddon extends Disposable implements ITerminal return Promise.resolve(undefined); } - // Request global pwsh completions if there are none cached - if (PwshCompletionProviderAddon.cachedPwshCommands.size === 0) { - this._onDidRequestSendText.fire(RequestCompletionsSequence.Global); - } - // Ensure that a key has been pressed since the last accepted completion in order to prevent // completions being requested again right after accepting a completion if (this._lastUserDataTimestamp > SuggestAddon.lastAcceptedCompletionTimestamp) { diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts index fce7396cf0a..ca79d22a37e 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts @@ -93,8 +93,12 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo } private async _loadPwshCompletionAddon(xterm: RawXtermTerminal): Promise { - // Disable when shell type is not powershell - if (this._ctx.instance.shellType !== GeneralShellType.PowerShell) { + // Disable when shell type is not powershell. A naive check is done for Windows PowerShell + // as we don't differentiate it in shellType + if ( + this._ctx.instance.shellType !== GeneralShellType.PowerShell || + this._ctx.instance.shellLaunchConfig.executable?.endsWith('WindowsPowerShell\\v1.0\\powershell.exe') + ) { this._pwshAddon.clear(); return; } @@ -106,11 +110,10 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo return; } - const pwshCompletionProviderAddon = this._pwshAddon.value = this._instantiationService.createInstance(PwshCompletionProviderAddon, undefined, this._ctx.instance.capabilities); + const pwshCompletionProviderAddon = this._pwshAddon.value = this._instantiationService.createInstance(PwshCompletionProviderAddon, this._ctx.instance.capabilities); xterm.loadAddon(pwshCompletionProviderAddon); this.add(pwshCompletionProviderAddon); this.add(pwshCompletionProviderAddon.onDidRequestSendText(text => { - this._ctx.instance.focus(); this._ctx.instance.sendText(text, false); })); this.add(this._terminalCompletionService.registerTerminalCompletionProvider('builtinPwsh', pwshCompletionProviderAddon.id, pwshCompletionProviderAddon)); @@ -394,11 +397,4 @@ registerActiveInstanceAction({ } }); -registerActiveInstanceAction({ - id: TerminalSuggestCommandId.ClearSuggestCache, - title: localize2('workbench.action.terminal.clearSuggestCache', 'Clear Suggest Cache'), - f1: true, - run: (activeInstance) => TerminalSuggestContribution.get(activeInstance)?.pwshAddon?.clearSuggestCache() -}); - // #endregion diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionModel.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionModel.ts index 4cb466f933c..b6559e2a45d 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionModel.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionModel.ts @@ -104,6 +104,22 @@ const compareCompletionsFn = (leadingLineContent: string, a: TerminalCompletionI } } + if (a.completion.kind !== b.completion.kind) { + // Sort by kind + if ((a.completion.kind === TerminalCompletionItemKind.Method || a.completion.kind === TerminalCompletionItemKind.Alias) && (b.completion.kind !== TerminalCompletionItemKind.Method && b.completion.kind !== TerminalCompletionItemKind.Alias)) { + return -1; // Methods and aliases should come first + } + if ((b.completion.kind === TerminalCompletionItemKind.Method || b.completion.kind === TerminalCompletionItemKind.Alias) && (a.completion.kind !== TerminalCompletionItemKind.Method && a.completion.kind !== TerminalCompletionItemKind.Alias)) { + return 1; // Methods and aliases should come first + } + if ((a.completion.kind === TerminalCompletionItemKind.File || a.completion.kind === TerminalCompletionItemKind.Folder) && (b.completion.kind !== TerminalCompletionItemKind.File && b.completion.kind !== TerminalCompletionItemKind.Folder)) { + return 1; // Resources should come last + } + if ((b.completion.kind === TerminalCompletionItemKind.File || b.completion.kind === TerminalCompletionItemKind.Folder) && (a.completion.kind !== TerminalCompletionItemKind.File && a.completion.kind !== TerminalCompletionItemKind.Folder)) { + return -1; // Resources should come last + } + } + // Sort alphabetically, ignoring punctuation causes dot files to be mixed in rather than // all at the top return a.labelLow.localeCompare(b.labelLow, undefined, { ignorePunctuation: true }); diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index 61fafbb8000..7757d450c45 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -5,7 +5,6 @@ import type { ITerminalAddon, Terminal } from '@xterm/xterm'; import * as dom from '../../../../../base/browser/dom.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { sep } from '../../../../../base/common/path.js'; @@ -38,6 +37,7 @@ import { TerminalCompletionItem, TerminalCompletionItemKind, type ITerminalCompl import { IntervalTimer, TimeoutTimer } from '../../../../../base/common/async.js'; import { localize } from '../../../../../nls.js'; import { TerminalSuggestTelemetry } from './terminalSuggestTelemetry.js'; +import { terminalSymbolAliasIcon, terminalSymbolArgumentIcon, terminalSymbolEnumMember, terminalSymbolFileIcon, terminalSymbolFlagIcon, terminalSymbolInlineSuggestionIcon, terminalSymbolMethodIcon, terminalSymbolOptionIcon, terminalSymbolFolderIcon } from './terminalSymbolIcons.js'; export interface ISuggestController { isPasting: boolean; @@ -91,19 +91,19 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest readonly onDidFontConfigurationChange = this._onDidFontConfigurationChange.event; private _kindToIconMap = new Map([ - [TerminalCompletionItemKind.File, Codicon.file], - [TerminalCompletionItemKind.Folder, Codicon.folder], - [TerminalCompletionItemKind.Method, Codicon.symbolMethod], - [TerminalCompletionItemKind.Alias, Codicon.symbolMethodArrow], - [TerminalCompletionItemKind.Argument, Codicon.symbolVariable], - [TerminalCompletionItemKind.Option, Codicon.symbolEnum], - [TerminalCompletionItemKind.OptionValue, Codicon.symbolEnumMember], - [TerminalCompletionItemKind.Flag, Codicon.flag], - [TerminalCompletionItemKind.InlineSuggestion, Codicon.star], - [TerminalCompletionItemKind.InlineSuggestionAlwaysOnTop, Codicon.star], + [TerminalCompletionItemKind.File, terminalSymbolFileIcon], + [TerminalCompletionItemKind.Folder, terminalSymbolFolderIcon], + [TerminalCompletionItemKind.Method, terminalSymbolMethodIcon], + [TerminalCompletionItemKind.Alias, terminalSymbolAliasIcon], + [TerminalCompletionItemKind.Argument, terminalSymbolArgumentIcon], + [TerminalCompletionItemKind.Option, terminalSymbolOptionIcon], + [TerminalCompletionItemKind.OptionValue, terminalSymbolEnumMember], + [TerminalCompletionItemKind.Flag, terminalSymbolFlagIcon], + [TerminalCompletionItemKind.InlineSuggestion, terminalSymbolInlineSuggestionIcon], + [TerminalCompletionItemKind.InlineSuggestionAlwaysOnTop, terminalSymbolInlineSuggestionIcon], ]); - private _kindToTypeMap = new Map([ + private _kindToKindLabelMap = new Map([ [TerminalCompletionItemKind.File, localize('file', 'File')], [TerminalCompletionItemKind.Folder, localize('folder', 'Folder')], [TerminalCompletionItemKind.Method, localize('method', 'Method')], @@ -220,6 +220,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._lastUserData = e.key; this._lastUserDataTimestamp = Date.now(); })); + this._register(xterm.onScroll(() => this.hideSuggestWidget(true))); } private async _handleCompletionProviders(terminal: Terminal | undefined, token: CancellationToken, explicitlyInvoked?: boolean): Promise { @@ -302,13 +303,13 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest // Add any "ghost text" suggestion suggested by the shell. This aligns with behavior of the // editor and how it interacts with inline completions. This object is tracked and reused as // it may change on input. - this._refreshInlineCompletion(); + this._refreshInlineCompletion(completions); // Add any missing icons based on the completion item kind for (const completion of completions) { if (!completion.icon && completion.kind !== undefined) { completion.icon = this._kindToIconMap.get(completion.kind); - completion.kindLabel = this._kindToTypeMap.get(completion.kind); + completion.kindLabel = this._kindToKindLabelMap.get(completion.kind); } } @@ -368,6 +369,22 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest await this._handleCompletionProviders(this._terminal, token, explicitlyInvoked); } + private _addPropertiesToInlineCompletionItem(completions: ITerminalCompletion[]): void { + const inlineCompletionLabel = (typeof this._inlineCompletionItem.completion.label === 'string' ? this._inlineCompletionItem.completion.label : this._inlineCompletionItem.completion.label.label).trim(); + const inlineCompletionMatchIndex = completions.findIndex(c => typeof c.label === 'string' ? c.label === inlineCompletionLabel : c.label.label === inlineCompletionLabel); + if (inlineCompletionMatchIndex !== -1) { + // Remove the existing inline completion item from the completions list + const richCompletionMatchingInline = completions.splice(inlineCompletionMatchIndex, 1)[0]; + // Apply its properties to the inline completion item + this._inlineCompletionItem.completion.label = richCompletionMatchingInline.label; + this._inlineCompletionItem.completion.detail = richCompletionMatchingInline.detail; + this._inlineCompletionItem.completion.documentation = richCompletionMatchingInline.documentation; + } else if (this._inlineCompletionItem.completion) { + this._inlineCompletionItem.completion.detail = undefined; + this._inlineCompletionItem.completion.documentation = undefined; + } + } + private _requestTriggerCharQuickSuggestCompletions(): boolean { if (!this._wasLastInputVerticalArrowKey()) { // Only request on trigger character when it's a regular input, or on an arrow if the widget @@ -518,7 +535,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._suggestWidget.setLineContext(lineContext); } - this._refreshInlineCompletion(); + this._refreshInlineCompletion(this._model?.items.map(i => i.completion) || []); // Hide and clear model if there are no more items if (!this._suggestWidget.hasCompletions()) { @@ -538,7 +555,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest }); } - private _refreshInlineCompletion() { + private _refreshInlineCompletion(completions: ITerminalCompletion[]): void { const oldIsInvalid = this._inlineCompletionItem.isInvalid; if (!this._currentPromptInputState || this._currentPromptInputState.ghostTextIndex === -1) { this._inlineCompletionItem.isInvalid = true; @@ -556,6 +573,8 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest // Reset the completion item as the object reference must remain the same but its // contents will differ across syncs. This is done so we don't need to reassign the // model and the slowdown/flickering that could potentially cause. + this._addPropertiesToInlineCompletionItem(completions); + const x = new TerminalCompletionItem(this._inlineCompletion); this._inlineCompletionItem.idx = x.idx; this._inlineCompletionItem.score = x.score; diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestTelemetry.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestTelemetry.ts index be5d761ebcc..4eef8c24066 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestTelemetry.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestTelemetry.ts @@ -7,10 +7,23 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ICommandDetectionCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { IPromptInputModel } from '../../../../../platform/terminal/common/capabilities/commandDetection/promptInputModel.js'; -import { ITerminalCompletion } from './terminalCompletionItem.js'; +import { ITerminalCompletion, TerminalCompletionItemKind } from './terminalCompletionItem.js'; export class TerminalSuggestTelemetry extends Disposable { - private _acceptedCompletions: Array<{ label: string; kindLabel?: string }> | undefined; + private _acceptedCompletions: Array<{ label: string; kind?: string }> | undefined; + + private _kindMap = new Map([ + [TerminalCompletionItemKind.File, 'File'], + [TerminalCompletionItemKind.Folder, 'Folder'], + [TerminalCompletionItemKind.Method, 'Method'], + [TerminalCompletionItemKind.Alias, 'Alias'], + [TerminalCompletionItemKind.Argument, 'Argument'], + [TerminalCompletionItemKind.Option, 'Option'], + [TerminalCompletionItemKind.OptionValue, 'Option Value'], + [TerminalCompletionItemKind.Flag, 'Flag'], + [TerminalCompletionItemKind.InlineSuggestion, 'Inline Suggestion'], + [TerminalCompletionItemKind.InlineSuggestionAlwaysOnTop, 'Inline Suggestion'], + ]); constructor( commandDetection: ICommandDetectionCapability, @@ -33,18 +46,19 @@ export class TerminalSuggestTelemetry extends Disposable { return; } this._acceptedCompletions = this._acceptedCompletions || []; - this._acceptedCompletions.push({ label: typeof completion.label === 'string' ? completion.label : completion.label.label, kindLabel: completion.kindLabel }); + this._acceptedCompletions.push({ label: typeof completion.label === 'string' ? completion.label : completion.label.label, kind: this._kindMap.get(completion.kind!) }); } private _sendTelemetryInfo(fromInterrupt?: boolean, exitCode?: number): void { const commandLine = this._promptInputModel?.value; for (const completion of this._acceptedCompletions || []) { const label = completion?.label; - const kind = completion?.kindLabel; + const kind = completion?.kind; + if (label === undefined || commandLine === undefined || kind === undefined) { return; } - let outcome: CompletionOutcome; + let outcome: string; if (fromInterrupt) { outcome = CompletionOutcome.Interrupted; } else if (commandLine.trim() && commandLine.includes(label)) { @@ -56,7 +70,7 @@ export class TerminalSuggestTelemetry extends Disposable { } this._telemetryService.publicLog2<{ kind: string | undefined; - outcome: CompletionOutcome; + outcome: string; exitCode: number | undefined; }, { owner: 'meganrogge'; @@ -85,13 +99,14 @@ export class TerminalSuggestTelemetry extends Disposable { } } -function inputContainsFirstHalfOfLabel(commandLine: string, label: string): boolean { - return commandLine.includes(label.substring(0, Math.ceil(label.length / 2))); -} - const enum CompletionOutcome { Accepted = 'Accepted', Deleted = 'Deleted', AcceptedWithEdit = 'AcceptedWithEdit', Interrupted = 'Interrupted' } + +function inputContainsFirstHalfOfLabel(commandLine: string, label: string): boolean { + return commandLine.includes(label.substring(0, Math.ceil(label.length / 2))); +} + diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSymbolIcons.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSymbolIcons.ts index a2039d2cd57..5f216f62014 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSymbolIcons.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSymbolIcons.ts @@ -4,10 +4,28 @@ *--------------------------------------------------------------------------------------------*/ import './media/terminalSymbolIcons.css'; -import { SYMBOL_ICON_ENUMERATOR_FOREGROUND, SYMBOL_ICON_METHOD_FOREGROUND } from '../../../../../editor/contrib/symbolIcons/browser/symbolIcons.js'; +import { SYMBOL_ICON_ENUMERATOR_FOREGROUND, SYMBOL_ICON_ENUMERATOR_MEMBER_FOREGROUND, SYMBOL_ICON_METHOD_FOREGROUND, SYMBOL_ICON_VARIABLE_FOREGROUND, SYMBOL_ICON_FILE_FOREGROUND, SYMBOL_ICON_FOLDER_FOREGROUND } from '../../../../../editor/contrib/symbolIcons/browser/symbolIcons.js'; import { registerColor } from '../../../../../platform/theme/common/colorUtils.js'; import { localize } from '../../../../../nls.js'; +import { registerIcon } from '../../../../../platform/theme/common/iconRegistry.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; export const TERMINAL_SYMBOL_ICON_FLAG_FOREGROUND = registerColor('terminalSymbolIcon.flagForeground', SYMBOL_ICON_ENUMERATOR_FOREGROUND, localize('terminalSymbolIcon.flagForeground', 'The foreground color for an flag icon. These icons will appear in the terminal suggest widget.')); - export const TERMINAL_SYMBOL_ICON_ALIAS_FOREGROUND = registerColor('terminalSymbolIcon.aliasForeground', SYMBOL_ICON_METHOD_FOREGROUND, localize('terminalSymbolIcon.aliasForeground', 'The foreground color for an alias icon. These icons will appear in the terminal suggest widget.')); +export const TERMINAL_SYMBOL_ICON_OPTION_VALUE_FOREGROUND = registerColor('terminalSymbolIcon.optionValueForeground', SYMBOL_ICON_ENUMERATOR_MEMBER_FOREGROUND, localize('terminalSymbolIcon.enumMemberForeground', 'The foreground color for an enum member icon. These icons will appear in the terminal suggest widget.')); +export const TERMINAL_SYMBOL_ICON_METHOD_FOREGROUND = registerColor('terminalSymbolIcon.methodForeground', SYMBOL_ICON_METHOD_FOREGROUND, localize('terminalSymbolIcon.methodForeground', 'The foreground color for a method icon. These icons will appear in the terminal suggest widget.')); +export const TERMINAL_SYMBOL_ICON_ARGUMENT_FOREGROUND = registerColor('terminalSymbolIcon.argumentForeground', SYMBOL_ICON_VARIABLE_FOREGROUND, localize('terminalSymbolIcon.argumentForeground', 'The foreground color for an argument icon. These icons will appear in the terminal suggest widget.')); +export const TERMINAL_SYMBOL_ICON_OPTION_FOREGROUND = registerColor('terminalSymbolIcon.optionForeground', SYMBOL_ICON_ENUMERATOR_FOREGROUND, localize('terminalSymbolIcon.optionForeground', 'The foreground color for an option icon. These icons will appear in the terminal suggest widget.')); +export const TERMINAL_SYMBOL_ICON_INLINE_SUGGESTION_FOREGROUND = registerColor('terminalSymbolIcon.inlineSuggestionForeground', null, localize('terminalSymbolIcon.inlineSuggestionForeground', 'The foreground color for an inline suggestion icon. These icons will appear in the terminal suggest widget.')); +export const TERMINAL_SYMBOL_ICON_FILE_FOREGROUND = registerColor('terminalSymbolIcon.fileForeground', SYMBOL_ICON_FILE_FOREGROUND, localize('terminalSymbolIcon.fileForeground', 'The foreground color for a file icon. These icons will appear in the terminal suggest widget.')); +export const TERMINAL_SYMBOL_ICON_FOLDER_FOREGROUND = registerColor('terminalSymbolIcon.folderForeground', SYMBOL_ICON_FOLDER_FOREGROUND, localize('terminalSymbolIcon.folderForeground', 'The foreground color for a folder icon. These icons will appear in the terminal suggest widget.')); + +export const terminalSymbolFlagIcon = registerIcon('terminal-symbol-flag', Codicon.flag, localize('terminalSymbolFlagIcon', 'Icon for flags in the terminal suggest widget.'), TERMINAL_SYMBOL_ICON_FLAG_FOREGROUND); +export const terminalSymbolAliasIcon = registerIcon('terminal-symbol-alias', Codicon.symbolMethod, localize('terminalSymbolAliasIcon', 'Icon for aliases in the terminal suggest widget.'), TERMINAL_SYMBOL_ICON_ALIAS_FOREGROUND); +export const terminalSymbolEnumMember = registerIcon('terminal-symbol-option-value', Codicon.symbolEnumMember, localize('terminalSymbolOptionValue', 'Icon for enum members in the terminal suggest widget.'), TERMINAL_SYMBOL_ICON_OPTION_VALUE_FOREGROUND); +export const terminalSymbolMethodIcon = registerIcon('terminal-symbol-method', Codicon.symbolMethod, localize('terminalSymbolMethodIcon', 'Icon for methods in the terminal suggest widget.'), TERMINAL_SYMBOL_ICON_METHOD_FOREGROUND); +export const terminalSymbolArgumentIcon = registerIcon('terminal-symbol-argument', Codicon.symbolVariable, localize('terminalSymbolArgumentIcon', 'Icon for arguments in the terminal suggest widget.'), TERMINAL_SYMBOL_ICON_ARGUMENT_FOREGROUND); +export const terminalSymbolOptionIcon = registerIcon('terminal-symbol-option', Codicon.symbolEnum, localize('terminalSymbolOptionIcon', 'Icon for options in the terminal suggest widget.'), TERMINAL_SYMBOL_ICON_OPTION_FOREGROUND); +export const terminalSymbolInlineSuggestionIcon = registerIcon('terminal-symbol-inline-suggestion', Codicon.star, localize('terminalSymbolInlineSuggestionIcon', 'Icon for inline suggestions in the terminal suggest widget.'), TERMINAL_SYMBOL_ICON_INLINE_SUGGESTION_FOREGROUND); +export const terminalSymbolFileIcon = registerIcon('terminal-symbol-file', Codicon.symbolFile, localize('terminalSymbolFileIcon', 'Icon for files in the terminal suggest widget.'), TERMINAL_SYMBOL_ICON_FILE_FOREGROUND); +export const terminalSymbolFolderIcon = registerIcon('terminal-symbol-folder', Codicon.symbolFolder, localize('terminalSymbolFolderIcon', 'Icon for folders in the terminal suggest widget.'), TERMINAL_SYMBOL_ICON_FOLDER_FOREGROUND); diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/common/terminal.suggest.ts b/src/vs/workbench/contrib/terminalContrib/suggest/common/terminal.suggest.ts index f472ffd7673..f9d66c0aa48 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/common/terminal.suggest.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/common/terminal.suggest.ts @@ -12,7 +12,6 @@ export const enum TerminalSuggestCommandId { AcceptSelectedSuggestionEnter = 'workbench.action.terminal.acceptSelectedSuggestionEnter', HideSuggestWidget = 'workbench.action.terminal.hideSuggestWidget', HideSuggestWidgetAndNavigateHistory = 'workbench.action.terminal.hideSuggestWidgetAndNavigateHistory', - ClearSuggestCache = 'workbench.action.terminal.clearSuggestCache', RequestCompletions = 'workbench.action.terminal.requestCompletions', ResetWidgetSize = 'workbench.action.terminal.resetSuggestWidgetSize', ToggleDetails = 'workbench.action.terminal.suggestToggleDetails', @@ -28,7 +27,6 @@ export const defaultTerminalSuggestCommandsToSkipShell = [ TerminalSuggestCommandId.AcceptSelectedSuggestion, TerminalSuggestCommandId.AcceptSelectedSuggestionEnter, TerminalSuggestCommandId.HideSuggestWidget, - TerminalSuggestCommandId.ClearSuggestCache, TerminalSuggestCommandId.RequestCompletions, TerminalSuggestCommandId.ToggleDetails, TerminalSuggestCommandId.ToggleDetailsFocus, diff --git a/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts b/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts index e286592242d..4dfe8aec91b 100644 --- a/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts @@ -1290,8 +1290,8 @@ export const enum CharPredictState { export class TypeAheadAddon extends Disposable implements ITerminalAddon { private _typeaheadStyle?: TypeAheadStyle; - private _typeaheadThreshold = this._configurationService.getValue(TERMINAL_CONFIG_SECTION).localEchoLatencyThreshold; - private _excludeProgramRe = compileExcludeRegexp(this._configurationService.getValue(TERMINAL_CONFIG_SECTION).localEchoExcludePrograms); + private _typeaheadThreshold: number; + private _excludeProgramRe: RegExp; protected _lastRow?: { y: number; startingX: number; endingX: number; charState: CharPredictState }; protected _timeline?: PredictionTimeline; private _terminalTitle = ''; @@ -1308,6 +1308,8 @@ export class TypeAheadAddon extends Disposable implements ITerminalAddon { @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); + this._typeaheadThreshold = this._configurationService.getValue(TERMINAL_CONFIG_SECTION).localEchoLatencyThreshold; + this._excludeProgramRe = compileExcludeRegexp(this._configurationService.getValue(TERMINAL_CONFIG_SECTION).localEchoExcludePrograms); this._register(toDisposable(() => this._clearPredictionDebounce?.dispose())); } diff --git a/src/vs/workbench/contrib/terminalContrib/wslRecommendation/browser/terminal.wslRecommendation.contribution.ts b/src/vs/workbench/contrib/terminalContrib/wslRecommendation/browser/terminal.wslRecommendation.contribution.ts index 09aa79c7320..5ed1692b724 100644 --- a/src/vs/workbench/contrib/terminalContrib/wslRecommendation/browser/terminal.wslRecommendation.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/wslRecommendation/browser/terminal.wslRecommendation.contribution.ts @@ -9,7 +9,7 @@ import { isWindows } from '../../../../../base/common/platform.js'; import { localize } from '../../../../../nls.js'; import { IExtensionManagementService } from '../../../../../platform/extensionManagement/common/extensionManagement.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { INotificationService, NeverShowAgainScope, Severity } from '../../../../../platform/notification/common/notification.js'; +import { INotificationService, NeverShowAgainScope, NotificationPriority, Severity } from '../../../../../platform/notification/common/notification.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribution } from '../../../../common/contributions.js'; import { InstallRecommendedExtensionAction } from '../../../extensions/browser/extensionsActions.js'; @@ -67,6 +67,7 @@ export class TerminalWslRecommendationContribution extends Disposable implements ], { sticky: true, + priority: NotificationPriority.OPTIONAL, neverShowAgain: { id: 'terminalConfigHelper/launchRecommendationsIgnore', scope: NeverShowAgainScope.APPLICATION }, onCancel: () => { } } diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts index 552bf1a2298..b781d5b157a 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts @@ -74,7 +74,7 @@ export abstract class TestItemTreeElement { /** * Depth of the element in the tree. */ - public depth: number = this.parent ? this.parent.depth + 1 : 0; + public depth: number; /** * Whether the node's test result is 'retired' -- from an outdated test run. @@ -104,7 +104,9 @@ export abstract class TestItemTreeElement { * in a 'flat' projection. */ public readonly parent: TestItemTreeElement | null = null, - ) { } + ) { + this.depth = parent ? parent.depth + 1 : 0; + } public toJSON() { if (this.depth === 0) { diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index 6ba098b83f1..160800155fc 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -1246,10 +1246,33 @@ abstract class ExecuteTestsInCurrentFile extends Action2 { }); } + private async _runByUris(accessor: ServicesAccessor, files: URI[]): Promise<{ completedAt: number | undefined }> { + const uriIdentity = accessor.get(IUriIdentityService); + const testService = accessor.get(ITestService); + const discovered: InternalTestItem[] = []; + for (const uri of files) { + for await (const file of testsInFile(testService, uriIdentity, uri, undefined, true)) { + discovered.push(file); + } + } + + if (discovered.length) { + const r = await testService.runTests({ tests: discovered, group: this.group }); + return { completedAt: r.completedAt }; + } + + return { completedAt: undefined }; + } + /** * @override */ - public run(accessor: ServicesAccessor) { + public run(accessor: ServicesAccessor, files?: URI[]) { + if (files?.length) { + return this._runByUris(accessor, files); + } + + const uriIdentity = accessor.get(IUriIdentityService); let editor = accessor.get(ICodeEditorService).getActiveCodeEditor(); if (!editor) { return; @@ -1264,7 +1287,6 @@ abstract class ExecuteTestsInCurrentFile extends Action2 { } const testService = accessor.get(ITestService); - const demandedUri = model.uri.toString(); // Iterate through the entire collection and run any tests that are in the // uri. See #138007. @@ -1273,7 +1295,7 @@ abstract class ExecuteTestsInCurrentFile extends Action2 { while (queue.length) { for (const id of queue.pop()!) { const node = testService.collection.getNodeById(id)!; - if (node.item.uri?.toString() === demandedUri) { + if (uriIdentity.extUri.isEqual(node.item.uri, model.uri)) { discovered.push(node); } else { queue.push(node.children); diff --git a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput.ts b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput.ts index 4f06c5ee9ca..ce9d447356f 100644 --- a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput.ts +++ b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput.ts @@ -41,17 +41,20 @@ import { ITaskRawOutput, ITestResult, ITestRunTaskResults, LiveTestResult, TestR import { ITestMessage, TestMessageType, getMarkId } from '../../common/testTypes.js'; import { ScrollEvent } from '../../../../../base/common/scrollable.js'; import { CALL_STACK_WIDGET_HEADER_HEIGHT } from '../../../debug/browser/callStackWidget.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; class SimpleDiffEditorModel extends EditorModel { - public readonly original = this._original.object.textEditorModel; - public readonly modified = this._modified.object.textEditorModel; + public readonly original: ITextModel; + public readonly modified: ITextModel; constructor( private readonly _original: IReference, private readonly _modified: IReference, ) { super(); + this.original = this._original.object.textEditorModel; + this.modified = this._modified.object.textEditorModel; } public override dispose() { diff --git a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts index 4915553ce61..1895434ddba 100644 --- a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts +++ b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts @@ -49,6 +49,7 @@ import { TestingContextKeys } from '../../common/testingContextKeys.js'; import { cmpPriority } from '../../common/testingStates.js'; import { TestUriType, buildTestUri } from '../../common/testingUri.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { TestId } from '../../common/testId.js'; interface ITreeElement { @@ -79,9 +80,9 @@ class TestResultElement implements ITreeElement { public readonly changeEmitter = new Emitter(); public readonly onDidChange = this.changeEmitter.event; public readonly type = 'result'; - public readonly context = this.value.id; - public readonly id = this.value.id; - public readonly label = this.value.name; + public readonly context: string; + public readonly id: string; + public readonly label: string; public get icon() { return icons.testingStatesToIcons.get( @@ -91,7 +92,11 @@ class TestResultElement implements ITreeElement { ); } - constructor(public readonly value: ITestResult) { } + constructor(public readonly value: ITestResult) { + this.id = value.id; + this.context = value.id; + this.label = value.name; + } } const openCoverageLabel = localize('openTestCoverage', 'View Test Coverage'); @@ -100,7 +105,7 @@ const closeCoverageLabel = localize('closeTestCoverage', 'Close Test Coverage'); class CoverageElement implements ITreeElement { public readonly type = 'coverage'; public readonly context: undefined; - public readonly id = `coverage-${this.results.id}/${this.task.id}`; + public readonly id: string; public readonly onDidChange: Event; public get label() { @@ -116,10 +121,11 @@ class CoverageElement implements ITreeElement { } constructor( - private readonly results: ITestResult, + results: ITestResult, public readonly task: ITestRunTaskResults, private readonly coverageService: ITestCoverageService, ) { + this.id = `coverage-${results.id}/${task.id}`; this.onDidChange = Event.fromObservableLight(coverageService.selected); } } @@ -127,23 +133,23 @@ class CoverageElement implements ITreeElement { class OlderResultsElement implements ITreeElement { public readonly type = 'older'; public readonly context: undefined; - public readonly id = `older-${this.n}`; + public readonly id: string; public readonly onDidChange = Event.None; public readonly label: string; constructor(private readonly n: number) { - this.label = localize('nOlderResults', '{0} older results', n); + this.label = n === 1 + ? localize('oneOlderResult', '1 older result') + : localize('nOlderResults', '{0} older results', n); + this.id = `older-${this.n}`; } } class TestCaseElement implements ITreeElement { public readonly type = 'test'; - public readonly context: ITestItemContext = { - $mid: MarshalledId.TestItemContext, - tests: [InternalTestItem.serialize(this.test)], - }; - public readonly id = `${this.results.id}/${this.test.item.extId}`; + public readonly context: ITestItemContext; + public readonly id: string; public readonly description?: string; public get onDidChange() { @@ -179,7 +185,29 @@ class TestCaseElement implements ITreeElement { public readonly results: ITestResult, public readonly test: TestResultItem, public readonly taskIndex: number, - ) { } + ) { + this.id = `${results.id}/${test.item.extId}`; + + const parentId = TestId.fromString(test.item.extId).parentId; + if (parentId) { + this.description = ''; + for (const part of parentId.idsToRoot()) { + if (part.isRoot) { break; } + const test = results.getStateById(part.toString()); + if (!test) { break; } + if (this.description.length) { + this.description += ' \u2039 '; + } + + this.description += test.item.label; + } + } + + this.context = { + $mid: MarshalledId.TestItemContext, + tests: [InternalTestItem.serialize(test)], + }; + } } class TaskElement implements ITreeElement { @@ -832,6 +860,14 @@ class TreeActionsProvider { ...getTestItemContextOverlay(element.test, capabilities), ); + primary.push(new Action( + 'testing.outputPeek.goToTest', + localize('testing.goToTest', "Go to Test"), + ThemeIcon.asClassName(Codicon.goToFile), + undefined, + () => this.commandService.executeCommand('vscode.revealTest', element.test.item.extId), + )); + const extId = element.test.item.extId; if (element.test.tasks[element.taskIndex].messages.some(m => m.type === TestMessageType.Output)) { primary.push(new Action( @@ -877,14 +913,6 @@ class TreeActionsProvider { id = MenuId.TestMessageContext; contextKeys.push([TestingContextKeys.testMessageContext.key, element.contextValue]); - primary.push(new Action( - 'testing.outputPeek.goToTest', - localize('testing.goToTest', "Go to Test"), - ThemeIcon.asClassName(Codicon.goToFile), - undefined, - () => this.commandService.executeCommand('vscode.revealTest', element.test.item.extId), - )); - if (this.showRevealLocationOnMessages && element.location) { primary.push(new Action( 'testing.outputPeek.goToError', diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts index 0894eeca52b..6e08a8843f4 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts @@ -37,11 +37,7 @@ export class TestingExplorerFilter extends BaseActionViewItem { private wrapper!: HTMLDivElement; private readonly focusEmitter = this._register(new Emitter()); public readonly onDidFocus = this.focusEmitter.event; - private readonly history: StoredValue<{ values: string[]; lastValue: string } | string[]> = this._register(this.instantiationService.createInstance(StoredValue, { - key: 'testing.filterHistory2', - scope: StorageScope.WORKSPACE, - target: StorageTarget.MACHINE - })); + private readonly history: StoredValue<{ values: string[]; lastValue: string } | string[]>; private readonly filtersAction = new Action('markersFiltersAction', localize('testing.filters.menu', "More Filters..."), 'testing-filter-button ' + ThemeIcon.asClassName(testingFilterIcon)); @@ -53,6 +49,11 @@ export class TestingExplorerFilter extends BaseActionViewItem { @ITestService private readonly testService: ITestService, ) { super(null, action, options); + this.history = this._register(instantiationService.createInstance(StoredValue, { + key: 'testing.filterHistory2', + scope: StorageScope.WORKSPACE, + target: StorageTarget.MACHINE + })); this.updateFilterActiveState(); this._register(testService.excluded.onTestExclusionsChanged(this.updateFilterActiveState, this)); } diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index 421f9578df8..6083f5719c6 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -19,6 +19,7 @@ import { mapFindFirst } from '../../../../base/common/arraysFind.js'; import { RunOnceScheduler, disposableTimeout } from '../../../../base/common/async.js'; import { groupBy } from '../../../../base/common/collections.js'; import { Color, RGBA } from '../../../../base/common/color.js'; +import { compareFileNames } from '../../../../base/common/comparers.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { FuzzyScore } from '../../../../base/common/filters.js'; import { Iterable } from '../../../../base/common/iterator.js'; @@ -36,7 +37,7 @@ import { MenuEntryActionViewItem, createActionViewItem, getActionBarActions, get import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -701,15 +702,11 @@ class TestingExplorerViewModel extends Disposable { public readonly projection = this._register(new MutableDisposable()); private readonly revealTimeout = new MutableDisposable(); - private readonly _viewMode = TestingContextKeys.viewMode.bindTo(this.contextKeyService); - private readonly _viewSorting = TestingContextKeys.viewSorting.bindTo(this.contextKeyService); + private readonly _viewMode: IContextKey; + private readonly _viewSorting: IContextKey; private readonly welcomeVisibilityEmitter = new Emitter(); private readonly actionRunner = this._register(new TestExplorerActionRunner(() => this.tree.getSelection().filter(isDefined))); - private readonly lastViewState = this._register(new StoredValue({ - key: 'testing.treeState', - scope: StorageScope.WORKSPACE, - target: StorageTarget.MACHINE, - }, this.storageService)); + private readonly lastViewState: StoredValue; private readonly noTestForDocumentWidget: NoTestsForDocumentWidget; /** @@ -781,6 +778,13 @@ class TestingExplorerViewModel extends Disposable { this.hasPendingReveal = !!filterState.reveal.get(); this.noTestForDocumentWidget = this._register(instantiationService.createInstance(NoTestsForDocumentWidget, listContainer)); + this.lastViewState = this._register(new StoredValue({ + key: 'testing.treeState', + scope: StorageScope.WORKSPACE, + target: StorageTarget.MACHINE, + }, this.storageService)); + this._viewMode = TestingContextKeys.viewMode.bindTo(contextKeyService); + this._viewSorting = TestingContextKeys.viewSorting.bindTo(contextKeyService); this._viewMode.set(this.storageService.get('testing.viewMode', StorageScope.WORKSPACE, TestExplorerViewMode.Tree) as TestExplorerViewMode); this._viewSorting.set(this.storageService.get('testing.viewSorting', StorageScope.WORKSPACE, TestExplorerViewSorting.ByLocation) as TestExplorerViewSorting); @@ -1348,7 +1352,9 @@ class TreeSorter implements ITreeSorter { const sb = b.test.item.sortText; // If tests are in the same location and there's no preferred sortText, // keep the extension's insertion order (#163449). - return inSameLocation && !sa && !sb ? 0 : (sa || a.test.item.label).localeCompare(sb || b.test.item.label); + return inSameLocation && !sa && !sb + ? 0 + : compareFileNames(sa || a.test.item.label, sb || b.test.item.label); } } @@ -1545,6 +1551,12 @@ class TestItemRenderer extends Disposable : undefined })); + disposable.add(this.profiles.onDidChange(() => { + if (templateData.current) { + this.fillActionBar(templateData.current, templateData); + } + })); + disposable.add(this.crService.onDidChange(changed => { const id = templateData.current?.test.item.extId; if (id && (!changed || changed === id || TestId.isChild(id, changed))) { diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index bb40133b638..788ed44ccd0 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -116,11 +116,7 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener private lastUri?: TestUriWithDocument; /** @inheritdoc */ - public readonly historyVisible = this._register(MutableObservableValue.stored(new StoredValue({ - key: 'testHistoryVisibleInPeek', - scope: StorageScope.PROFILE, - target: StorageTarget.USER, - }, this.storageService), false)); + public readonly historyVisible: MutableObservableValue; constructor( @IConfigurationService private readonly configuration: IConfigurationService, @@ -128,13 +124,18 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @ITestResultService private readonly testResults: ITestResultService, @ITestService private readonly testService: ITestService, - @IStorageService private readonly storageService: IStorageService, + @IStorageService storageService: IStorageService, @IViewsService private readonly viewsService: IViewsService, @ICommandService private readonly commandService: ICommandService, @INotificationService private readonly notificationService: INotificationService, ) { super(); this._register(testResults.onTestChanged(this.openPeekOnFailure, this)); + this.historyVisible = this._register(MutableObservableValue.stored(new StoredValue({ + key: 'testHistoryVisibleInPeek', + scope: StorageScope.PROFILE, + target: StorageTarget.USER, + }, storageService), false)); } /** @inheritdoc */ diff --git a/src/vs/workbench/contrib/testing/common/testExclusions.ts b/src/vs/workbench/contrib/testing/common/testExclusions.ts index 5ac06c15fc1..408c8975b90 100644 --- a/src/vs/workbench/contrib/testing/common/testExclusions.ts +++ b/src/vs/workbench/contrib/testing/common/testExclusions.ts @@ -12,26 +12,28 @@ import { StoredValue } from './storedValue.js'; import { InternalTestItem } from './testTypes.js'; export class TestExclusions extends Disposable { - private readonly excluded = this._register( - MutableObservableValue.stored(new StoredValue>({ - key: 'excludedTestItems', - scope: StorageScope.WORKSPACE, - target: StorageTarget.MACHINE, - serialization: { - deserialize: v => new Set(JSON.parse(v)), - serialize: v => JSON.stringify([...v]) - }, - }, this.storageService), new Set()) - ); + private readonly excluded: MutableObservableValue>; constructor(@IStorageService private readonly storageService: IStorageService) { super(); + this.excluded = this._register( + MutableObservableValue.stored(new StoredValue>({ + key: 'excludedTestItems', + scope: StorageScope.WORKSPACE, + target: StorageTarget.MACHINE, + serialization: { + deserialize: v => new Set(JSON.parse(v)), + serialize: v => JSON.stringify([...v]) + }, + }, this.storageService), new Set()) + ); + this.onTestExclusionsChanged = this.excluded.onDidChange; } /** * Event that fires when the excluded tests change. */ - public readonly onTestExclusionsChanged: Event = this.excluded.onDidChange; + public readonly onTestExclusionsChanged: Event; /** * Gets whether there's any excluded tests. diff --git a/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts b/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts index 1d865c3a37d..9c187e33e76 100644 --- a/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts +++ b/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts @@ -99,11 +99,7 @@ export class TestExplorerFilterState extends Disposable implements ITestExplorer public readonly text = this._register(new MutableObservableValue('')); /** @inheritdoc */ - public readonly fuzzy = this._register(MutableObservableValue.stored(new StoredValue({ - key: 'testHistoryFuzzy', - scope: StorageScope.PROFILE, - target: StorageTarget.USER, - }, this.storageService), false)); + public readonly fuzzy: MutableObservableValue; public readonly reveal: ISettableObservable = observableValue('TestExplorerFilterState.reveal', undefined); @@ -113,9 +109,14 @@ export class TestExplorerFilterState extends Disposable implements ITestExplorer public readonly onDidSelectTestInExplorer = this.selectTestInExplorerEmitter.event; constructor( - @IStorageService private readonly storageService: IStorageService, + @IStorageService storageService: IStorageService, ) { super(); + this.fuzzy = this._register(MutableObservableValue.stored(new StoredValue({ + key: 'testHistoryFuzzy', + scope: StorageScope.PROFILE, + target: StorageTarget.USER, + }, storageService), false)); } /** @inheritdoc */ diff --git a/src/vs/workbench/contrib/testing/common/testResultStorage.ts b/src/vs/workbench/contrib/testing/common/testResultStorage.ts index 2253712efb8..6b8ee0d1a63 100644 --- a/src/vs/workbench/contrib/testing/common/testResultStorage.ts +++ b/src/vs/workbench/contrib/testing/common/testResultStorage.ts @@ -49,18 +49,19 @@ const currentRevision = 1; export abstract class BaseTestResultStorage extends Disposable implements ITestResultStorage { declare readonly _serviceBrand: undefined; - protected readonly stored = this._register(new StoredValue>({ - key: 'storedTestResults', - scope: StorageScope.WORKSPACE, - target: StorageTarget.MACHINE - }, this.storageService)); + protected readonly stored: StoredValue>; constructor( @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, - @IStorageService private readonly storageService: IStorageService, + @IStorageService storageService: IStorageService, @ILogService private readonly logService: ILogService, ) { super(); + this.stored = this._register(new StoredValue>({ + key: 'storedTestResults', + scope: StorageScope.WORKSPACE, + target: StorageTarget.MACHINE + }, storageService)); } /** diff --git a/src/vs/workbench/contrib/testing/common/testService.ts b/src/vs/workbench/contrib/testing/common/testService.ts index 4f9fbbd8e9a..643a04d2697 100644 --- a/src/vs/workbench/contrib/testing/common/testService.ts +++ b/src/vs/workbench/contrib/testing/common/testService.ts @@ -172,7 +172,7 @@ const waitForTestToBeIdle = (testService: ITestService, test: IncrementalTestCol * Iterator that expands to and iterates through tests in the file. Iterates * in strictly descending order. */ -export const testsInFile = async function* (testService: ITestService, ident: IUriIdentityService, uri: URI, waitForIdle = true): AsyncIterable { +export const testsInFile = async function* (testService: ITestService, ident: IUriIdentityService, uri: URI, waitForIdle = true, descendInFile = true): AsyncIterable { const queue = new LinkedList>(); const existing = [...testService.collection.getNodeByUrl(uri)]; @@ -194,6 +194,10 @@ export const testsInFile = async function* (testService: ITestService, ident: IU if (ident.extUri.isEqual(uri, test.item.uri)) { yield test; + + if (!descendInFile) { + continue; + } } if (ident.extUri.isEqualOrParent(uri, test.item.uri)) { diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 00ea40df0f7..67febf8cb4a 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -72,7 +72,7 @@ export class TestService extends Disposable implements ITestService { /** * @inheritdoc */ - public readonly collection = new MainThreadTestCollection(this.uriIdentityService, this.expandTest.bind(this)); + public readonly collection: MainThreadTestCollection; /** * @inheritdoc @@ -82,17 +82,13 @@ export class TestService extends Disposable implements ITestService { /** * @inheritdoc */ - public readonly showInlineOutput = this._register(MutableObservableValue.stored(new StoredValue({ - key: 'inlineTestOutputVisible', - scope: StorageScope.WORKSPACE, - target: StorageTarget.USER - }, this.storage), true)); + public readonly showInlineOutput: MutableObservableValue; constructor( @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, - @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, - @IStorageService private readonly storage: IStorageService, + @IUriIdentityService uriIdentityService: IUriIdentityService, + @IStorageService storage: IStorageService, @IEditorService private readonly editorService: IEditorService, @ITestProfileService private readonly testProfiles: ITestProfileService, @INotificationService private readonly notificationService: INotificationService, @@ -101,6 +97,13 @@ export class TestService extends Disposable implements ITestService { @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); + this.collection = new MainThreadTestCollection(uriIdentityService, this.expandTest.bind(this)); + this.showInlineOutput = this._register(MutableObservableValue.stored(new StoredValue({ + key: 'inlineTestOutputVisible', + scope: StorageScope.WORKSPACE, + target: StorageTarget.USER + }, storage), true)); + this.excluded = instantiationService.createInstance(TestExclusions); this.isRefreshingTests = TestingContextKeys.isRefreshingTests.bindTo(contextKeyService); this.activeEditorHasTests = TestingContextKeys.activeEditorHasTests.bindTo(contextKeyService); diff --git a/src/vs/workbench/contrib/testing/common/testingContinuousRunService.ts b/src/vs/workbench/contrib/testing/common/testingContinuousRunService.ts index 6e0e6168111..fec6792ad2d 100644 --- a/src/vs/workbench/contrib/testing/common/testingContinuousRunService.ts +++ b/src/vs/workbench/contrib/testing/common/testingContinuousRunService.ts @@ -7,8 +7,7 @@ import * as arrays from '../../../../base/common/arrays.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { ISettableObservable, observableValue } from '../../../../base/common/observable.js'; -import { autorunIterableDelta } from '../../../../base/common/observableInternal/autorun.js'; +import { autorunIterableDelta, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { WellDefinedPrefixTree } from '../../../../base/common/prefixTree.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; diff --git a/src/vs/workbench/contrib/testing/test/common/testStubs.ts b/src/vs/workbench/contrib/testing/test/common/testStubs.ts index d484c5e4f62..f121bd9cd83 100644 --- a/src/vs/workbench/contrib/testing/test/common/testStubs.ts +++ b/src/vs/workbench/contrib/testing/test/common/testStubs.ts @@ -7,7 +7,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { MainThreadTestCollection } from '../../common/mainThreadTestCollection.js'; import { ITestItem, TestsDiff } from '../../common/testTypes.js'; import { TestId } from '../../common/testId.js'; -import { createTestItemChildren, ITestItemApi, ITestItemLike, TestItemCollection, TestItemEventOp } from '../../common/testItemCollection.js'; +import { createTestItemChildren, ITestItemApi, ITestItemChildren, ITestItemLike, TestItemCollection, TestItemEventOp } from '../../common/testItemCollection.js'; export class TestTestItem implements ITestItemLike { private readonly props: ITestItem; @@ -39,15 +39,17 @@ export class TestTestItem implements ITestItemLike { return this._extId.localId; } - public api: ITestItemApi = { controllerId: this._extId.controllerId }; + public api: ITestItemApi; - public children = createTestItemChildren(this.api, i => i.api, TestTestItem); + public children: ITestItemChildren; constructor( private readonly _extId: TestId, label: string, uri?: URI, ) { + this.api = { controllerId: this._extId.controllerId }; + this.children = createTestItemChildren(this.api, i => i.api, TestTestItem); this.props = { extId: _extId.toString(), busy: false, diff --git a/src/vs/workbench/contrib/themes/browser/themes.contribution.ts b/src/vs/workbench/contrib/themes/browser/themes.contribution.ts index 678d939d9c3..e64fcc3e374 100644 --- a/src/vs/workbench/contrib/themes/browser/themes.contribution.ts +++ b/src/vs/workbench/contrib/themes/browser/themes.contribution.ts @@ -41,7 +41,7 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; import { Toggle } from '../../../../base/browser/ui/toggle/toggle.js'; import { defaultToggleStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; export const manageExtensionIcon = registerIcon('theme-selection-manage-extension', Codicon.gear, localize('manageExtensionIcon', 'Icon for the \'Manage\' action in the theme selection quick pick.')); @@ -53,7 +53,7 @@ enum ConfigureItem { CUSTOM_TOP_ENTRY = 'customTopEntry' } -class MarketplaceThemesPicker { +class MarketplaceThemesPicker implements IDisposable { private readonly _installedExtensions: Promise>; private readonly _marketplaceExtensions: Set = new Set(); private readonly _marketplaceThemes: ThemeItem[] = []; @@ -275,6 +275,7 @@ class MarketplaceThemesPicker { this._queryDelayer.dispose(); this._marketplaceExtensions.clear(); this._marketplaceThemes.length = 0; + this._onDidChange.dispose(); } } @@ -306,7 +307,7 @@ class InstalledThemesPicker { let marketplaceThemePicker: MarketplaceThemesPicker | undefined; if (this.extensionGalleryService.isEnabled()) { - if (this.extensionResourceLoaderService.supportsExtensionGalleryResources && this.options.browseMessage) { + if (await this.extensionResourceLoaderService.supportsExtensionGalleryResources() && this.options.browseMessage) { marketplaceThemePicker = this.instantiationService.createInstance(MarketplaceThemesPicker, this.getMarketplaceColorThemes.bind(this), this.options.marketplaceTag); picks = [configurationEntry(this.options.browseMessage, ConfigureItem.BROWSE_GALLERY), ...picks]; } else { @@ -772,7 +773,7 @@ registerAction2(class extends Action2 { const extensionResourceLoaderService = accessor.get(IExtensionResourceLoaderService); const instantiationService = accessor.get(IInstantiationService); - if (!extensionGalleryService.isEnabled() || !extensionResourceLoaderService.supportsExtensionGalleryResources) { + if (!extensionGalleryService.isEnabled() || !await extensionResourceLoaderService.supportsExtensionGalleryResources()) { return; } const currentTheme = themeService.getColorTheme(); diff --git a/src/vs/workbench/contrib/themes/browser/themes.test.contribution.ts b/src/vs/workbench/contrib/themes/browser/themes.test.contribution.ts index fd1cd59d469..47a41a6d9a4 100644 --- a/src/vs/workbench/contrib/themes/browser/themes.test.contribution.ts +++ b/src/vs/workbench/contrib/themes/browser/themes.test.contribution.ts @@ -21,8 +21,11 @@ import { IFileService } from '../../../../platform/files/common/files.js'; import { basename } from '../../../../base/common/resources.js'; import { Schemas } from '../../../../base/common/network.js'; import { splitLines } from '../../../../base/common/strings.js'; -import { ITreeSitterParserService } from '../../../../editor/common/services/treeSitterParserService.js'; +import { ITextModelTreeSitter, ITreeSitterParserService } from '../../../../editor/common/services/treeSitterParserService.js'; import { ColorThemeData, findMetadata } from '../../../services/themes/common/colorThemeData.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { Event } from '../../../../base/common/event.js'; +import { Range } from '../../../../editor/common/core/range.js'; interface IToken { c: string; // token @@ -97,6 +100,7 @@ class Snapper { @IWorkbenchThemeService private readonly themeService: IWorkbenchThemeService, @ITextMateTokenizationService private readonly textMateService: ITextMateTokenizationService, @ITreeSitterParserService private readonly treeSitterParserService: ITreeSitterParserService, + @IModelService private readonly modelService: IModelService, ) { } @@ -274,35 +278,69 @@ class Snapper { } } - private _treeSitterTokenize(tree: Parser.Tree, languageId: string): IToken[] { + private _moveInjectionCursorToRange(cursor: Parser.TreeCursor, injectionRange: { startIndex: number; endIndex: number }): void { + let continueCursor = cursor.gotoFirstChild(); + // Get into the first "real" child node, as the root nodes can extend outside the range. + while (((cursor.startIndex < injectionRange.startIndex) || (cursor.endIndex > injectionRange.endIndex)) && continueCursor) { + if (cursor.endIndex < injectionRange.startIndex) { + continueCursor = cursor.gotoNextSibling(); + } else { + continueCursor = cursor.gotoFirstChild(); + } + } + } + + private _treeSitterTokenize(textModelTreeSitter: ITextModelTreeSitter, tree: Parser.Tree, languageId: string): IToken[] { const cursor = tree.walk(); cursor.gotoFirstChild(); let cursorResult: boolean = true; const tokens: IToken[] = []; const tokenizationSupport = TreeSitterTokenizationRegistry.get(languageId); + const cursors: { cursor: Parser.TreeCursor; languageId: string; startOffset: number; endOffset: number }[] = [{ cursor, languageId, startOffset: 0, endOffset: textModelTreeSitter.textModel.getValueLength() }]; do { - if (cursor.currentNode.childCount === 0) { - const capture = tokenizationSupport?.captureAtPositionTree(cursor.currentNode.startPosition.row + 1, cursor.currentNode.startPosition.column + 1, tree); - tokens.push({ - c: cursor.currentNode.text.replace(/\r/g, ''), - t: capture?.map(cap => cap.name).join(' ') ?? '', - r: { - dark_plus: undefined, - light_plus: undefined, - dark_vs: undefined, - light_vs: undefined, - hc_black: undefined, - } - }); + const current = cursors[cursors.length - 1]; + const currentCursor = current.cursor; + const currentLanguageId = current.languageId; + const isOutsideRange: boolean = (currentCursor.currentNode.endIndex > current.endOffset); - while (!(cursorResult = cursor.gotoNextSibling())) { - if (!(cursorResult = cursor.gotoParent())) { - break; + if (!isOutsideRange && (currentCursor.currentNode.childCount === 0)) { + const range = new Range(currentCursor.currentNode.startPosition.row + 1, currentCursor.currentNode.startPosition.column + 1, currentCursor.currentNode.endPosition.row + 1, currentCursor.currentNode.endPosition.column + 1); + const injection = textModelTreeSitter.getInjection(currentCursor.currentNode.startIndex, currentLanguageId); + const treeSitterRange = injection?.ranges!.find(r => r.startIndex <= currentCursor.currentNode.startIndex && r.endIndex >= currentCursor.currentNode.endIndex); + if (injection?.tree && treeSitterRange && (treeSitterRange.startIndex === currentCursor.currentNode.startIndex)) { + const injectionLanguageId = injection.languageId; + const injectionTree = injection.tree; + const injectionCursor = injectionTree.walk(); + this._moveInjectionCursorToRange(injectionCursor, treeSitterRange); + cursors.push({ cursor: injectionCursor, languageId: injectionLanguageId, startOffset: treeSitterRange.startIndex, endOffset: treeSitterRange.endIndex }); + while ((currentCursor.endIndex <= treeSitterRange.endIndex) && (currentCursor.gotoNextSibling() || currentCursor.gotoParent())) { } + } else { + const capture = tokenizationSupport?.captureAtRangeTree(range, tree, textModelTreeSitter); + tokens.push({ + c: currentCursor.currentNode.text.replace(/\r/g, ''), + t: capture?.map(cap => cap.name).join(' ') ?? '', + r: { + dark_plus: undefined, + light_plus: undefined, + dark_vs: undefined, + light_vs: undefined, + hc_black: undefined, + } + }); + while (!(cursorResult = currentCursor.gotoNextSibling())) { + if (!(cursorResult = currentCursor.gotoParent())) { + break; + } } } + } else { - cursorResult = cursor.gotoFirstChild(); + cursorResult = currentCursor.gotoFirstChild(); + } + if (cursors.length > 1 && ((!cursorResult && currentCursor === cursors[cursors.length - 1].cursor) || isOutsideRange)) { + cursors.pop(); + cursorResult = true; } } while (cursorResult); return tokens; @@ -324,14 +362,32 @@ class Snapper { }); } - public async captureTreeSitterSyntaxTokens(fileName: string, content: string): Promise { - const languageId = this.languageService.guessLanguageIdByFilepathOrFirstLine(URI.file(fileName)); + public async captureTreeSitterSyntaxTokens(resource: URI, content: string): Promise { + const languageId = this.languageService.guessLanguageIdByFilepathOrFirstLine(resource); if (languageId) { - const tree = await this.treeSitterParserService.getTree(content, languageId!); + const hasLanguage = TreeSitterTokenizationRegistry.get(languageId); + if (!hasLanguage) { + return []; + } + const model = this.modelService.getModel(resource) ?? this.modelService.createModel(content, { languageId, onDidChange: Event.None }, resource); + let textModelTreeSitter = this.treeSitterParserService.getParseResult(model); + let tree = textModelTreeSitter?.parseResult?.tree; + if (!textModelTreeSitter) { + return []; + } + if (!tree) { + let e = await Event.toPromise(this.treeSitterParserService.onDidUpdateTree); + // Once more for injections + if (e.hasInjections) { + e = await Event.toPromise(this.treeSitterParserService.onDidUpdateTree); + } + textModelTreeSitter = e.tree; + tree = textModelTreeSitter.parseResult?.tree; + } if (!tree) { return []; } - const result = (await this._treeSitterTokenize(tree, languageId)).filter(t => t.c.length > 0); + const result = (await this._treeSitterTokenize(textModelTreeSitter, tree, languageId)).filter(t => t.c.length > 0); const themeTokens = await this._getTreeSitterThemesResult(result, languageId); this._enrichResult(result, themeTokens); return result; @@ -348,7 +404,7 @@ async function captureTokens(accessor: ServicesAccessor, resource: URI | undefin return fileService.readFile(resource).then(content => { if (treeSitter) { - return snapper.captureTreeSitterSyntaxTokens(fileName, content.value.toString()); + return snapper.captureTreeSitterSyntaxTokens(resource, content.value.toString()); } else { return snapper.captureSyntaxTokens(fileName, content.value.toString()); } @@ -377,6 +433,12 @@ CommandsRegistry.registerCommand('_workbench.captureSyntaxTokens', function (acc return captureTokens(accessor, resource); }); -CommandsRegistry.registerCommand('_workbench.captureTreeSitterSyntaxTokens', function (accessor: ServicesAccessor, resource: URI) { +CommandsRegistry.registerCommand('_workbench.captureTreeSitterSyntaxTokens', function (accessor: ServicesAccessor, resource?: URI) { + // If no resource is provided, use the active editor's resource + // This is useful for testing the command + if (!resource) { + const editorService = accessor.get(IEditorService); + resource = editorService.activeEditor?.resource; + } return captureTokens(accessor, resource, true); }); diff --git a/src/vs/workbench/contrib/timeline/common/timelineService.ts b/src/vs/workbench/contrib/timeline/common/timelineService.ts index 42193a625d4..a2ef6a1cc31 100644 --- a/src/vs/workbench/contrib/timeline/common/timelineService.ts +++ b/src/vs/workbench/contrib/timeline/common/timelineService.ts @@ -5,7 +5,7 @@ import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ITimelineService, TimelineChangeEvent, TimelineOptions, TimelineProvidersChangeEvent, TimelineProvider, TimelinePaneId } from './timeline.js'; @@ -16,6 +16,7 @@ import { IContextKey, IContextKeyService, RawContextKey } from '../../../../plat export const TimelineHasProviderContext = new RawContextKey('timelineHasProvider', false); export class TimelineService extends Disposable implements ITimelineService { + declare readonly _serviceBrand: undefined; private readonly _onDidChangeProviders = this._register(new Emitter()); @@ -23,12 +24,13 @@ export class TimelineService extends Disposable implements ITimelineService { private readonly _onDidChangeTimeline = this._register(new Emitter()); readonly onDidChangeTimeline = this._onDidChangeTimeline.event; + private readonly _onDidChangeUri = this._register(new Emitter()); readonly onDidChangeUri = this._onDidChangeUri.event; private readonly hasProviderContext: IContextKey; private readonly providers = new Map(); - private readonly providerSubscriptions = new Map(); + private readonly providerSubscriptions = this._register(new DisposableMap()); constructor( @ILogService private readonly logService: ILogService, @@ -122,8 +124,7 @@ export class TimelineService extends Disposable implements ITimelineService { } this.providers.delete(id); - this.providerSubscriptions.get(id)?.dispose(); - this.providerSubscriptions.delete(id); + this.providerSubscriptions.deleteAndDispose(id); this.updateHasProviderContext(); diff --git a/src/vs/workbench/contrib/typeHierarchy/browser/typeHierarchyPeek.ts b/src/vs/workbench/contrib/typeHierarchy/browser/typeHierarchyPeek.ts index df75f745155..0af4bb62f51 100644 --- a/src/vs/workbench/contrib/typeHierarchy/browser/typeHierarchyPeek.ts +++ b/src/vs/workbench/contrib/typeHierarchy/browser/typeHierarchyPeek.ts @@ -97,7 +97,7 @@ export class TypeHierarchyTreePeekWidget extends peekView.PeekViewWidget { this.create(); this._peekViewService.addExclusiveWidget(editor, this); this._applyTheme(themeService.getColorTheme()); - this._disposables.add(themeService.onDidColorThemeChange(e => this._applyTheme(e.theme), this)); + this._disposables.add(themeService.onDidColorThemeChange(this._applyTheme, this)); this._disposables.add(this._previewDisposable); } diff --git a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts index d8c9fbd63bb..beedeb01f6e 100644 --- a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts +++ b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts @@ -340,7 +340,6 @@ export class ReleaseNotesManager { padding-right: 3px; padding-top: 1px; padding-bottom: 1px; - margin-left: -5px; margin-top: -3px; } .codesetting:hover { @@ -363,7 +362,7 @@ export class ReleaseNotesManager { display: inline-block; background-color: var(--vscode-editor-background); font-size: 12px; - margin-right: 8px; + margin-right: 4px; } header { display: flex; align-items: center; padding-top: 1em; } diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index 3def92a9336..a20d7aea012 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -13,7 +13,7 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUpdateService, State as UpdateState, StateType, IUpdate, DisablementReason } from '../../../../platform/update/common/update.js'; -import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { INotificationService, NotificationPriority, Severity } from '../../../../platform/notification/common/notification.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IBrowserWorkbenchEnvironmentService } from '../../../services/environment/browser/environmentService.js'; import { ReleaseNotesManager } from './releaseNotesEditor.js'; @@ -146,7 +146,8 @@ export class ProductContribution implements IWorkbenchContribution { const uri = URI.parse(releaseNotesUrl); openerService.open(uri); } - }] + }], + { priority: NotificationPriority.OPTIONAL } ); }); } @@ -319,7 +320,8 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu run: () => { this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, productVersion)); } - }] + }], + { priority: NotificationPriority.OPTIONAL } ); } @@ -355,7 +357,8 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu run: () => { this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, productVersion)); } - }] + }], + { priority: NotificationPriority.OPTIONAL } ); } @@ -388,7 +391,10 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu severity.Info, nls.localize('updateAvailableAfterRestart', "Restart {0} to apply the latest update.", this.productService.nameLong), actions, - { sticky: true } + { + sticky: true, + priority: NotificationPriority.OPTIONAL + } ); } diff --git a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfilesEditorModel.ts b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfilesEditorModel.ts index 5ef8a898c17..9a937a500f9 100644 --- a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfilesEditorModel.ts +++ b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfilesEditorModel.ts @@ -527,7 +527,7 @@ export class UserDataProfileElement extends AbstractUserDataProfileElement { const extensions = await this.extensionManagementService.getInstalled(undefined, this.profile.extensionsResource); const extension = extensions.find(e => areSameExtensions(e.identifier, child.identifier)); if (extension) { - await this.extensionManagementService.toggleAppliationScope(extension, this.profile.extensionsResource); + await this.extensionManagementService.toggleApplicationScope(extension, this.profile.extensionsResource); } } }] diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts index 5ab93b68536..21d1c9b2065 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts @@ -804,7 +804,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo constructor() { super({ id: 'workbench.userData.actions.turningOn', - title: localize('turnin on sync', "Turning on Settings Sync..."), + title: localize('turning on sync', "Turning on Settings Sync..."), precondition: ContextKeyExpr.false(), menu: [{ group: '3_configuration', diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index 88f65d9c90e..40108569ae9 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -75,7 +75,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { private _isDisposed = false; private readonly _onDidDispose = this._register(new Emitter()); - public onDidDispose = this._onDidDispose.event; + public readonly onDidDispose = this._onDidDispose.event; override dispose() { this._isDisposed = true; diff --git a/src/vs/workbench/contrib/webview/browser/pre/index-no-csp.html b/src/vs/workbench/contrib/webview/browser/pre/index-no-csp.html index ae571f8c84d..309df85a98b 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index-no-csp.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index-no-csp.html @@ -230,7 +230,7 @@ return reject(new Error('Service Workers are not enabled. Webviews will not work. Try disabling private/incognito mode.')); } - const swPath = encodeURI(`service-worker.js?v=${expectedWorkerVersion}&vscode-resource-base-authority=${searchParams.get('vscode-resource-base-authority')}&remoteAuthority=${searchParams.get('remoteAuthority') ?? ''}`); + const swPath = encodeURI(`service-worker.js?v=${expectedWorkerVersion}&vscode-resource-base-authority=${searchParams.get('vscode-resource-base-authority')}&id=${ID}&remoteAuthority=${searchParams.get('remoteAuthority') ?? ''}`); navigator.serviceWorker.register(swPath) .then(async registration => { /** diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index 6a1f3d459ab..e123bb92432 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-nlLyDpnjtftJG2xvXh2vuy77l7xFTjfOz7Jnj1iXNmA=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> @@ -236,7 +236,7 @@ return reject(new Error('Service Workers are not enabled. Webviews will not work. Try disabling private/incognito mode.')); } - const swPath = encodeURI(`service-worker.js?v=${expectedWorkerVersion}&vscode-resource-base-authority=${searchParams.get('vscode-resource-base-authority')}&remoteAuthority=${searchParams.get('remoteAuthority') ?? ''}`); + const swPath = encodeURI(`service-worker.js?v=${expectedWorkerVersion}&vscode-resource-base-authority=${searchParams.get('vscode-resource-base-authority')}&id=${ID}&remoteAuthority=${searchParams.get('remoteAuthority') ?? ''}`); navigator.serviceWorker.register(swPath) .then(async registration => { /** diff --git a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js index e5fa674ea82..3d74103ec24 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +++ b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js @@ -18,6 +18,8 @@ const searchParams = new URL(location.toString()).searchParams; const remoteAuthority = searchParams.get('remoteAuthority'); +const ID = searchParams.get('id'); + /** * Origin used for resources */ @@ -233,12 +235,19 @@ sw.addEventListener('activate', (event) => { */ async function processResourceRequest(event, requestUrlComponents) { const client = await sw.clients.get(event.clientId); + let webviewId; if (!client) { - console.error('Could not find inner client for request'); - return notFound(); + const workerClient = await getWorkerClientForId(event.clientId); + if (!workerClient) { + console.error('Could not find inner client for request'); + return notFound(); + } else { + webviewId = getWebviewIdForClient(workerClient); + } + } else { + webviewId = getWebviewIdForClient(client); } - const webviewId = getWebviewIdForClient(client); if (!webviewId) { console.error('Could not resolve webview id'); return notFound(); @@ -439,6 +448,15 @@ async function processLocalhostRequest(event, requestUrl) { * @returns {string | null} */ function getWebviewIdForClient(client) { + // Refs https://github.com/microsoft/vscode/issues/244143 + // With PlzDedicatedWorker, worker subresources and blob wokers + // will use clients different from the window client. + // Since we cannot different a worker main resource from a worker subresource + // we will use the global webview ID passed in at the time of + // service worker registration. + if (client.type === 'worker' || client.type === 'sharedworker') { + return ID; + } const requesterClientUrl = new URL(client.url); return requesterClientUrl.searchParams.get('id'); } @@ -455,3 +473,16 @@ async function getOuterIframeClient(webviewId) { return hasExpectedPathName && clientUrl.searchParams.get('id') === webviewId; }); } + +/** + * @param {string} clientId + * @returns {Promise} + */ +async function getWorkerClientForId(clientId) { + const allDedicatedWorkerClients = await sw.clients.matchAll({ type: 'worker' }); + const allSharedWorkerClients = await sw.clients.matchAll({ type: 'sharedworker' }); + const allWorkerClients = [...allDedicatedWorkerClients, ...allSharedWorkerClients]; + return allWorkerClients.find(client => { + return client.id === clientId; + }); +} diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 1273b8c7a9a..84b5eacefa3 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -419,6 +419,7 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD // The extensionId and purpose in the URL are used for filtering in js-debug: const params: { [key: string]: string } = { id: this.id, + parentId: targetWindow.vscodeWindowId.toString(), origin: this.origin, swVersion: String(this._expectedServiceWorkerVersion), extensionId: extension?.id.value ?? '', diff --git a/src/vs/workbench/contrib/welcomeDialog/browser/media/welcomeWidget.css b/src/vs/workbench/contrib/welcomeDialog/browser/media/welcomeWidget.css deleted file mode 100644 index 79dade268d9..00000000000 --- a/src/vs/workbench/contrib/welcomeDialog/browser/media/welcomeWidget.css +++ /dev/null @@ -1,23 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -.monaco-dialog-box { - border-radius: 6px; -} - -.dialog-message-detail-title > div > p > .codicon[class*='codicon-']::before{ - position: relative; - color: var(--vscode-textLink-foreground); - padding-right: 10px; - font-size: larger; -} - -.dialog-message-detail-title { - height: 22px; - font-size: large; -} - -.monaco-dialog-box .monaco-action-bar .actions-container { - justify-content: flex-end; -} diff --git a/src/vs/workbench/contrib/welcomeDialog/browser/welcomeDialog.contribution.ts b/src/vs/workbench/contrib/welcomeDialog/browser/welcomeDialog.contribution.ts deleted file mode 100644 index ece6304fe9c..00000000000 --- a/src/vs/workbench/contrib/welcomeDialog/browser/welcomeDialog.contribution.ts +++ /dev/null @@ -1,111 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry, IWorkbenchContribution } from '../../../common/contributions.js'; -import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; -import { IBrowserWorkbenchEnvironmentService } from '../../../services/environment/browser/environmentService.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { WelcomeWidget } from './welcomeWidget.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { localize } from '../../../../nls.js'; -import { applicationConfigurationNodeBase } from '../../../common/configuration.js'; -import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; - -const configurationKey = 'workbench.welcome.experimental.dialog'; - -class WelcomeDialogContribution extends Disposable implements IWorkbenchContribution { - - private isRendered = false; - - constructor( - @IStorageService storageService: IStorageService, - @IBrowserWorkbenchEnvironmentService environmentService: IBrowserWorkbenchEnvironmentService, - @IConfigurationService configurationService: IConfigurationService, - @IContextKeyService contextService: IContextKeyService, - @ICodeEditorService codeEditorService: ICodeEditorService, - @IInstantiationService instantiationService: IInstantiationService, - @ICommandService commandService: ICommandService, - @ITelemetryService telemetryService: ITelemetryService, - @IOpenerService openerService: IOpenerService, - @IEditorService editorService: IEditorService - ) { - super(); - - if (!storageService.isNew(StorageScope.APPLICATION)) { - return; // do not show if this is not the first session - } - - const setting = configurationService.inspect(configurationKey); - if (!setting.value) { - return; - } - - const welcomeDialog = environmentService.options?.welcomeDialog; - if (!welcomeDialog) { - return; - } - - this._register(editorService.onDidActiveEditorChange(() => { - if (!this.isRendered) { - - const codeEditor = codeEditorService.getActiveCodeEditor(); - if (codeEditor?.hasModel()) { - const scheduler = new RunOnceScheduler(() => { - const notificationsVisible = contextService.contextMatchesRules(ContextKeyExpr.deserialize('notificationCenterVisible')) || - contextService.contextMatchesRules(ContextKeyExpr.deserialize('notificationToastsVisible')); - if (codeEditor === codeEditorService.getActiveCodeEditor() && !notificationsVisible) { - this.isRendered = true; - - const welcomeWidget = new WelcomeWidget( - codeEditor, - instantiationService, - commandService, - telemetryService, - openerService); - - welcomeWidget.render(welcomeDialog.title, - welcomeDialog.message, - welcomeDialog.buttonText, - welcomeDialog.buttonCommand); - } - }, 3000); - - this._register(codeEditor.onDidChangeModelContent((e) => { - if (!this.isRendered) { - scheduler.schedule(); - } - })); - } - } - })); - } -} - -Registry.as(WorkbenchExtensions.Workbench) - .registerWorkbenchContribution(WelcomeDialogContribution, LifecyclePhase.Eventually); - -const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); -configurationRegistry.registerConfiguration({ - ...applicationConfigurationNodeBase, - properties: { - 'workbench.welcome.experimental.dialog': { - scope: ConfigurationScope.APPLICATION, - type: 'boolean', - default: false, - tags: ['experimental'], - description: localize('workbench.welcome.dialog', "When enabled, a welcome widget is shown in the editor") - } - } -}); diff --git a/src/vs/workbench/contrib/welcomeDialog/browser/welcomeWidget.ts b/src/vs/workbench/contrib/welcomeDialog/browser/welcomeWidget.ts deleted file mode 100644 index c33e006b35e..00000000000 --- a/src/vs/workbench/contrib/welcomeDialog/browser/welcomeWidget.ts +++ /dev/null @@ -1,218 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/welcomeWidget.css'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPositionPreference } from '../../../../editor/browser/editorBrowser.js'; -import { $, append, hide } from '../../../../base/browser/dom.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { MarkdownRenderer } from '../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ButtonBar } from '../../../../base/browser/ui/button/button.js'; -import { mnemonicButtonLabel } from '../../../../base/common/labels.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { Action, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../base/common/actions.js'; -import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; -import { localize } from '../../../../nls.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { LinkedText, parseLinkedText } from '../../../../base/common/linkedText.js'; -import { Link } from '../../../../platform/opener/browser/link.js'; -import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { renderFormattedText } from '../../../../base/browser/formattedTextRenderer.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; -import { Color } from '../../../../base/common/color.js'; -import { contrastBorder, editorWidgetBackground, editorWidgetForeground, widgetBorder, widgetShadow } from '../../../../platform/theme/common/colorRegistry.js'; - -export class WelcomeWidget extends Disposable implements IOverlayWidget { - - private readonly _rootDomNode: HTMLElement; - private readonly element: HTMLElement; - private readonly messageContainer: HTMLElement; - private readonly markdownRenderer = this.instantiationService.createInstance(MarkdownRenderer, {}); - - constructor( - private readonly _editor: ICodeEditor, - private readonly instantiationService: IInstantiationService, - private readonly commandService: ICommandService, - private readonly telemetryService: ITelemetryService, - private readonly openerService: IOpenerService - ) { - super(); - this._rootDomNode = document.createElement('div'); - this._rootDomNode.className = 'welcome-widget'; - - this.element = this._rootDomNode.appendChild($('.monaco-dialog-box')); - this.element.setAttribute('role', 'dialog'); - - hide(this._rootDomNode); - - this.messageContainer = this.element.appendChild($('.dialog-message-container')); - } - - async executeCommand(commandId: string, ...args: string[]) { - try { - await this.commandService.executeCommand(commandId, ...args); - this.telemetryService.publicLog2('workbenchActionExecuted', { - id: commandId, - from: 'welcomeWidget' - }); - } - catch (ex) { - } - } - - public async render(title: string, message: string, buttonText: string, buttonAction: string) { - if (!this._editor._getViewModel()) { - return; - } - - await this.buildWidgetContent(title, message, buttonText, buttonAction); - this._editor.addOverlayWidget(this); - this._show(); - this.telemetryService.publicLog2('workbenchActionExecuted', { - id: 'welcomeWidgetRendered', - from: 'welcomeWidget' - }); - } - - private async buildWidgetContent(title: string, message: string, buttonText: string, buttonAction: string) { - - const actionBar = this._register(new ActionBar(this.element, {})); - - const action = this._register(new Action('dialog.close', localize('dialogClose', "Close Dialog"), ThemeIcon.asClassName(Codicon.dialogClose), true, async () => { - this._hide(); - })); - actionBar.push(action, { icon: true, label: false }); - - const renderBody = (message: string, icon: string): MarkdownString => { - const mds = new MarkdownString(undefined, { supportThemeIcons: true, supportHtml: true }); - mds.appendMarkdown(`$(${icon})`); - mds.appendMarkdown(message); - return mds; - }; - - const titleElement = this.messageContainer.appendChild($('#monaco-dialog-message-detail.dialog-message-detail-title')); - const titleElementMdt = this.markdownRenderer.render(renderBody(title, 'zap')); - titleElement.appendChild(titleElementMdt.element); - - this.buildStepMarkdownDescription(this.messageContainer, message.split('\n').filter(x => x).map(text => parseLinkedText(text))); - - const buttonsRowElement = this.messageContainer.appendChild($('.dialog-buttons-row')); - const buttonContainer = buttonsRowElement.appendChild($('.dialog-buttons')); - - const buttonBar = this._register(new ButtonBar(buttonContainer)); - const primaryButton = this._register(buttonBar.addButtonWithDescription({ title: true, secondary: false, ...defaultButtonStyles })); - primaryButton.label = mnemonicButtonLabel(buttonText, true); - - this._register(primaryButton.onDidClick(async () => { - await this.executeCommand(buttonAction); - })); - - buttonBar.buttons[0].focus(); - } - - private buildStepMarkdownDescription(container: HTMLElement, text: LinkedText[]) { - for (const linkedText of text) { - const p = append(container, $('p')); - for (const node of linkedText.nodes) { - if (typeof node === 'string') { - const labelWithIcon = renderLabelWithIcons(node); - for (const element of labelWithIcon) { - if (typeof element === 'string') { - p.appendChild(renderFormattedText(element, { inline: true, renderCodeSegments: true })); - } else { - p.appendChild(element); - } - } - } else { - const link = this.instantiationService.createInstance(Link, p, node, { - opener: (href: string) => { - this.telemetryService.publicLog2('workbenchActionExecuted', { - id: 'welcomeWidetLinkAction', - from: 'welcomeWidget' - }); - this.openerService.open(href, { allowCommands: true }); - } - }); - this._register(link); - } - } - } - return container; - } - - getId(): string { - return 'editor.contrib.welcomeWidget'; - } - - getDomNode(): HTMLElement { - return this._rootDomNode; - } - - getPosition(): IOverlayWidgetPosition | null { - return { - preference: OverlayWidgetPositionPreference.TOP_RIGHT_CORNER - }; - } - - private _isVisible: boolean = false; - - private _show(): void { - if (this._isVisible) { - return; - } - this._isVisible = true; - this._rootDomNode.style.display = 'block'; - } - - private _hide(): void { - if (!this._isVisible) { - return; - } - - this._isVisible = true; - this._rootDomNode.style.display = 'none'; - this._editor.removeOverlayWidget(this); - this.telemetryService.publicLog2('workbenchActionExecuted', { - id: 'welcomeWidgetDismissed', - from: 'welcomeWidget' - }); - } -} - -registerThemingParticipant((theme, collector) => { - const addBackgroundColorRule = (selector: string, color: Color | undefined): void => { - if (color) { - collector.addRule(`.monaco-editor ${selector} { background-color: ${color}; }`); - } - }; - - const widgetBackground = theme.getColor(editorWidgetBackground); - addBackgroundColorRule('.welcome-widget', widgetBackground); - - const widgetShadowColor = theme.getColor(widgetShadow); - if (widgetShadowColor) { - collector.addRule(`.welcome-widget { box-shadow: 0 0 8px 2px ${widgetShadowColor}; }`); - } - - const widgetBorderColor = theme.getColor(widgetBorder); - if (widgetBorderColor) { - collector.addRule(`.welcome-widget { border-left: 1px solid ${widgetBorderColor}; border-right: 1px solid ${widgetBorderColor}; border-bottom: 1px solid ${widgetBorderColor}; }`); - } - - const hcBorder = theme.getColor(contrastBorder); - if (hcBorder) { - collector.addRule(`.welcome-widget { border: 1px solid ${hcBorder}; }`); - } - - const foreground = theme.getColor(editorWidgetForeground); - if (foreground) { - collector.addRule(`.welcome-widget { color: ${foreground}; }`); - } -}); diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts index e5b58995fd2..6aa706f7bf5 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts @@ -57,13 +57,16 @@ registerAction2(class extends Action2 { public run( accessor: ServicesAccessor, walkthroughID: string | { category: string; step: string } | undefined, - toSide: boolean | undefined + optionsOrToSide: { toSide?: boolean; inactive?: boolean } | boolean | undefined ) { const editorGroupsService = accessor.get(IEditorGroupsService); const instantiationService = accessor.get(IInstantiationService); const editorService = accessor.get(IEditorService); const commandService = accessor.get(ICommandService); + const toSide = typeof optionsOrToSide === 'object' ? optionsOrToSide.toSide : optionsOrToSide; + const inactive = typeof optionsOrToSide === 'object' ? optionsOrToSide.inactive : false; + if (walkthroughID) { const selectedCategory = typeof walkthroughID === 'string' ? walkthroughID : walkthroughID.category; let selectedStep: string | undefined; @@ -77,7 +80,7 @@ registerAction2(class extends Action2 { if (!selectedCategory && !selectedStep) { editorService.openEditor({ resource: GettingStartedInput.RESOURCE, - options: { preserveFocus: toSide ?? false } + options: { preserveFocus: toSide ?? false, inactive, forceReload: true } }, toSide ? SIDE_GROUP : undefined); return; } @@ -87,6 +90,10 @@ registerAction2(class extends Action2 { if (group.activeEditor instanceof GettingStartedInput) { const activeEditor = group.activeEditor as GettingStartedInput; activeEditor.showWelcome = false; + if (activeEditor.selectedCategory && activeEditor.selectedStep) { + // currently in a walkthrough. + return; + } (group.activeEditorPane as GettingStartedPage).makeCategoryVisibleWhenAvailable(selectedCategory, selectedStep); return; } @@ -101,7 +108,7 @@ registerAction2(class extends Action2 { editor.selectedCategory = selectedCategory; editor.selectedStep = selectedStep; editor.showWelcome = false; - group.openEditor(editor, { revealIfOpened: true }); + group.openEditor(editor, { revealIfOpened: true, inactive }); return; } } @@ -124,7 +131,7 @@ registerAction2(class extends Action2 { }]); } else { // else open respecting toSide - const options: GettingStartedEditorOptions = { selectedCategory: selectedCategory, selectedStep: selectedStep, showWelcome: false, preserveFocus: toSide ?? false }; + const options: GettingStartedEditorOptions = { selectedCategory: selectedCategory, selectedStep: selectedStep, showWelcome: false, preserveFocus: toSide ?? false, inactive }; editorService.openEditor({ resource: GettingStartedInput.RESOURCE, options @@ -136,7 +143,7 @@ registerAction2(class extends Action2 { } else { editorService.openEditor({ resource: GettingStartedInput.RESOURCE, - options: { preserveFocus: toSide ?? false } + options: { preserveFocus: toSide ?? false, inactive } }, toSide ? SIDE_GROUP : undefined); } } @@ -233,6 +240,11 @@ registerAction2(class extends Action2 { title: localize2('welcome.showAllWalkthroughs', 'Open Walkthrough...'), category, f1: true, + menu: { + id: MenuId.MenubarHelpMenu, + group: '1_welcome', + order: 3, + }, }); } @@ -275,14 +287,43 @@ registerAction2(class extends Action2 { })); disposables.add(quickPick.onDidHide(() => disposables.dispose())); await extensionService.whenInstalledExtensionsRegistered(); - gettingStartedService.onDidAddWalkthrough(async () => { + disposables.add(gettingStartedService.onDidAddWalkthrough(async () => { quickPick.items = await this.getQuickPickItems(contextService, gettingStartedService); - }); + })); quickPick.show(); quickPick.busy = false; } }); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'welcome.showNewWelcome', + title: localize2('welcome.showNewWelcome', 'Open New Welcome Experience'), + f1: true, + }); + } + + async run(accessor: ServicesAccessor) { + const editorService = accessor.get(IEditorService); + const options: GettingStartedEditorOptions = { selectedCategory: 'NewWelcomeExperience', forceReload: true, showTelemetryNotice: true }; + + editorService.openEditor({ + resource: GettingStartedInput.RESOURCE, + options + }); + } +}); + +CommandsRegistry.registerCommand({ + id: 'welcome.newWorkspaceChat', + handler: (accessor, stepID: string) => { + const commandService = accessor.get(ICommandService); + commandService.executeCommand('workbench.action.chat.open', { mode: 'agent', query: '#new ', isPartialQuery: true }); + } +}); + export const WorkspacePlatform = new RawContextKey<'mac' | 'linux' | 'windows' | 'webworker' | undefined>('workspacePlatform', undefined, localize('workspacePlatform', "The platform of the current workspace, which in remote or serverless contexts may be different from the platform of the UI")); class WorkspacePlatformContribution { diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts index 18322a787ac..65dac267bbd 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts @@ -60,7 +60,7 @@ import { IWebviewElement, IWebviewService } from '../../webview/browser/webview. import './gettingStartedColors.js'; import { GettingStartedDetailsRenderer } from './gettingStartedDetailsRenderer.js'; import { gettingStartedCheckedCodicon, gettingStartedUncheckedCodicon } from './gettingStartedIcons.js'; -import { GettingStartedInput } from './gettingStartedInput.js'; +import { GettingStartedEditorOptions, GettingStartedInput } from './gettingStartedInput.js'; import { IResolvedWalkthrough, IResolvedWalkthroughStep, IWalkthroughsService, hiddenEntriesConfigurationKey, parseDescription } from './gettingStartedService.js'; import { RestoreWalkthroughsConfigurationValue, restoreWalkthroughsConfigurationKey } from './startupPage.js'; import { startEntries } from '../common/gettingStartedContent.js'; @@ -280,12 +280,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)); } 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)); } }); } @@ -341,6 +343,7 @@ export class GettingStartedPage extends EditorPane { override async setInput(newInput: GettingStartedInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken) { this.container.classList.remove('animatable'); this.editorInput = newInput; + this.editorInput.showTelemetryNotice = (options as GettingStartedEditorOptions)?.showTelemetryNotice ?? true; await super.setInput(newInput, options, context, token); await this.buildCategoriesSlide(); if (this.shouldAnimate()) { @@ -917,19 +920,28 @@ export class GettingStartedPage extends EditorPane { this.registerDispatchListeners(); if (this.editorInput.selectedCategory) { + const showNewExperience = this.editorInput.selectedCategory === 'NewWelcomeExperience'; this.currentWalkthrough = this.gettingStartedCategories.find(category => category.id === this.editorInput.selectedCategory); if (!this.currentWalkthrough) { this.gettingStartedCategories = this.gettingStartedService.getWalkthroughs(); - this.currentWalkthrough = this.gettingStartedCategories.find(category => category.id === this.editorInput.selectedCategory); + this.currentWalkthrough = showNewExperience ? this.gettingStartedService.getWalkthrough(this.editorInput.selectedCategory) : this.gettingStartedCategories.find(category => category.id === this.editorInput.selectedCategory); if (this.currentWalkthrough) { - this.buildCategorySlide(this.editorInput.selectedCategory, this.editorInput.selectedStep); + if (showNewExperience) { + this.buildNewCategorySlide(this.editorInput.selectedCategory, this.editorInput.selectedStep); + } else { + this.buildCategorySlide(this.editorInput.selectedCategory, this.editorInput.selectedStep); + } this.setSlide('details'); return; } } else { - this.buildCategorySlide(this.editorInput.selectedCategory, this.editorInput.selectedStep); + if (showNewExperience) { + this.buildNewCategorySlide(this.editorInput.selectedCategory, this.editorInput.selectedStep); + } else { + this.buildCategorySlide(this.editorInput.selectedCategory, this.editorInput.selectedStep); + } this.setSlide('details'); return; } @@ -1178,7 +1190,7 @@ export class GettingStartedPage extends EditorPane { this.container.classList.toggle('height-constrained', size.height <= 600); this.container.classList.toggle('width-constrained', size.width <= 400); - this.container.classList.toggle('width-semi-constrained', size.width <= 800); + this.container.classList.toggle('width-semi-constrained', size.width <= 950); this.categoriesPageScrollbar?.scanDomNode(); this.detailsPageScrollbar?.scanDomNode(); @@ -1354,7 +1366,7 @@ export class GettingStartedPage extends EditorPane { if (isCommand) { const keybinding = this.getKeyBinding(command); - if (keybinding) { + if (keybinding && this.editorInput.selectedCategory !== 'NewWelcomeExperience') { const shortcutMessage = $('span.shortcut-message', {}, localize('gettingStarted.keyboardTip', 'Tip: Use keyboard shortcut ')); container.appendChild(shortcutMessage); const label = new KeybindingLabel(shortcutMessage, OS, { ...defaultKeybindingLabelStyles }); @@ -1392,7 +1404,385 @@ export class GettingStartedPage extends EditorPane { super.clearInput(); } + + private selectStepByIndex(newIndex: number, steps: IResolvedWalkthroughStep[], direction: number) { + const currentIndex = steps.findIndex(step => step.id === this.editorInput.selectedStep); + const slidesContainer = this.stepsContent.querySelector('.step-slides-container') as HTMLElement; + + if (slidesContainer) { + // Apply the transform to move the slides + const slides = slidesContainer.querySelectorAll('.step-slide'); + + // First make all slides visible for the animation + slides.forEach((slide, index) => { + const slideElement = slide as HTMLElement; + // Position all slides in their starting positions + if (index === currentIndex) { + slideElement.style.display = 'block'; + slideElement.style.transform = 'translateX(0)'; + } else if (index === newIndex) { + slideElement.style.display = 'block'; + slideElement.style.transform = `translateX(${direction < 0 ? '-100%' : '100%'})`; + } else { + slideElement.style.display = 'none'; + } + }); + + // Force a reflow to ensure the initial positions are applied + slidesContainer.getBoundingClientRect(); + + // Now animate to the final positions + setTimeout(() => { + slides.forEach((slide, index) => { + const slideElement = slide as HTMLElement; + if (index === currentIndex) { + slideElement.style.transform = `translateX(${direction > 0 ? '-100%' : '100%'})`; + setTimeout(() => { + slideElement.style.display = 'none'; + }, SLIDE_TRANSITION_TIME_MS); + } else if (index === newIndex) { + slideElement.style.transform = 'translateX(0)'; + } + }); + }, 20); + + // Update the active dot + const dots = this.stepsContent.querySelectorAll('.step-dot'); + dots.forEach((dot, index) => { + if (index === newIndex) { + dot.classList.add('active'); + } else { + dot.classList.remove('active'); + } + }); + + // Update the selected step and build its media + this.selectSlide(steps[newIndex].id); + } + } + + private buildNewCategorySlide(categoryID: string, selectedStep?: string) { + this.container.classList.add('newSlide'); + if (this.detailsScrollbar) { this.detailsScrollbar.dispose(); } + + this.detailsPageDisposables.clear(); + this.mediaDisposables.clear(); + + const category = this.gettingStartedService.getWalkthrough(categoryID); + if (!category) { + throw Error('could not find category with ID ' + categoryID); + } + + // Filter steps based on when context + const steps = category.steps.filter(step => this.contextService.contextMatchesRules(step.when)); + + const groupedSteps = new Map(); + steps.forEach(step => { + const prefixMatch = step.id.match(/^([^.]+)\./); + const prefix = prefixMatch ? prefixMatch[1] : step.id; + if (!groupedSteps.has(prefix)) { + groupedSteps.set(prefix, []); + } + groupedSteps.get(prefix)?.push(step); + }); + + // Create the slide container that will hold all step slides + const slidesContainer = $('.step-slides-container'); + + const navigationContainer = $('.step-dots-container'); + + // Add back button + const prevButton = $('button.button-link.navigation.back', { + 'aria-label': localize('previousStep', "Previous Step"), + 'tabindex': '0' + }, $('span.codicon.codicon-arrow-left'), localize('back', "Back")); + + const dotsContainer = $('.dots-centered'); + navigationContainer.appendChild(prevButton); + navigationContainer.appendChild(dotsContainer); + + const allSlides: { id: string; steps: IResolvedWalkthroughStep[] }[] = []; + groupedSteps.forEach((stepsInGroup, prefix) => { + if (stepsInGroup.length === 1) { + allSlides.push({ id: stepsInGroup[0].id, steps: [stepsInGroup[0]] }); + } else { + // For multi-steps, group them into a single slide + allSlides.push({ id: prefix, steps: stepsInGroup }); + } + }); + + allSlides.forEach((slide, index) => { + // Create the slide element + const slideElement = $('.step-slide', { 'data-step': slide.id }); + + // Create the content container with flex layout + const slideContent = $('.step-slide-content'); + + // Text content column + const textContent = $('.step-text-content'); + + if (slide.steps.length === 1) { + // Single step case + const step = slide.steps[0]; + + // Create step title + const titleElement = $('h3.step-title', { 'x-step-title-for': step.id }); + reset(titleElement, ...renderLabelWithIcons(step.title)); + textContent.appendChild(titleElement); + + // Create step description container + const descriptionContainer = $('.step-description', { 'x-step-description-for': step.id }); + this.buildMarkdownDescription(descriptionContainer, step.description); + textContent.appendChild(descriptionContainer); + } else { + // Multi-step case - group steps with same prefix into a single slide + const multiStepContainer = $('.multi-step-container'); + + slide.steps.forEach((step, i) => { + const subStep = $('.sub-step', { 'data-sub-step-id': step.id }); + + this.detailsPageDisposables.add(addDisposableListener(subStep, 'click', () => { + this.selectSubStep(slide.steps, step.id); + })); + + this.detailsPageDisposables.add(addDisposableListener(subStep, 'keydown', (e) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) { + this.selectSubStep(slide.steps, step.id); + e.preventDefault(); + } + })); + + const subStepTitleEl = $('.sub-step-title', {}, ...renderLabelWithIcons(step.title)); + subStep.appendChild(subStepTitleEl); + + const subStepDesc = $('.sub-step-description'); + this.buildMarkdownDescription(subStepDesc, [step.description[0]]); + subStep.appendChild(subStepDesc); + + if (i === 0 || step.id === this.editorInput.selectedStep) { + subStep.classList.add('active'); + } else { + subStep.classList.remove('active'); + } + + multiStepContainer.appendChild(subStep); + }); + + // Get the linkedText of the lastStep + const lastStep = slide.steps[slide.steps.length - 1]; + const linkedText = lastStep.description.length > 1 ? lastStep.description[1] : undefined; + if (linkedText) { + const descElement = $('.multi-step-action'); + this.buildMarkdownDescription(descElement, [linkedText]); + multiStepContainer.appendChild(descElement); + } + + textContent.appendChild(multiStepContainer); + } + + // Add actions container for buttons + const actionsContainer = $('.step-actions'); + textContent.appendChild(actionsContainer); + + // Append text content to the slide + slideContent.appendChild(textContent); + slideElement.appendChild(slideContent); + slidesContainer.appendChild(slideElement); + + // Create dot for this slide + const dot = $('button.step-dot', { + 'data-step-dot-index': `${index}`, + 'role': 'button' + }); + + // Set the initial active dot + if (index === 0) { + dot.classList.add('active'); + } + + dotsContainer.appendChild(dot); + + this.detailsPageDisposables.add(addDisposableListener(dot, 'click', () => { + const currentIndex = this.getCurrentSlideIndex(allSlides); + if (currentIndex === index) { + return; + } + this.selectStepByIndex(index, allSlides.map(s => s.steps[0]), index > currentIndex ? 1 : -1); + })); + }); + + // Add next button + const nextButton = $('button.button-link.navigation.next', { + 'aria-label': localize('nextStep', "Next"), + 'tabindex': '0' + }, localize('next', "Next"), $('span.codicon.codicon-arrow-right')); + + navigationContainer.appendChild(nextButton); + this.detailsPageDisposables.add(addDisposableListener(prevButton, 'click', () => { + const currentIndex = this.getCurrentSlideIndex(allSlides); + if (currentIndex > 0) { + this.selectStepByIndex(currentIndex - 1, allSlides.map(s => s.steps[0]), -1); + } + })); + + this.detailsPageDisposables.add(addDisposableListener(nextButton, 'click', () => { + const currentIndex = this.getCurrentSlideIndex(allSlides); + if (currentIndex < allSlides.length - 1) { + this.selectStepByIndex(currentIndex + 1, allSlides.map(s => s.steps[0]), 1); + } + })); + + // Set the current walkthrough and step + this.currentWalkthrough = category; + this.editorInput.selectedCategory = categoryID; + this.editorInput.selectedStep = this.currentWalkthrough.steps[0].id; + const stepId = this.editorInput.selectedStep.match(/^([^.]+)\./)?.[1] ?? this.editorInput.selectedStep; + + const selectedSlide = slidesContainer.querySelector(`.step-slide[data-step="${stepId}"]`); + if (selectedSlide) { + const selectedSlideContent = selectedSlide.querySelector('.step-slide-content'); + this.buildMediaComponent(this.editorInput.selectedStep); + selectedSlideContent?.appendChild(this.stepMediaComponent); + } + + // Category title and description + const categoryHeader = $('.category-header'); + const categoryTitle = $('h2.category-title', { 'x-category-title-for': category.id }); + reset(categoryTitle, ...renderLabelWithIcons(category.title)); + categoryHeader.appendChild(categoryTitle); + + const categoryFooter = $('.getting-started-footer'); + if (this.editorInput.showTelemetryNotice && getTelemetryLevel(this.configurationService) !== TelemetryLevel.NONE && this.productService.enableTelemetry) { + this.buildTelemetryFooter(categoryFooter); + } + + // Build the container for the whole slide deck + const stepsContainer = $('.getting-started-steps-container', {}, + categoryHeader, + slidesContainer, + navigationContainer, + categoryFooter, + ); + + // Set up the scroll container + this.detailsScrollbar = this._register(new DomScrollableElement(stepsContainer, { className: 'steps-container' })); + const stepListComponent = this.detailsScrollbar.getDomNode(); + + // Append to the content area + reset(this.stepsContent, stepListComponent); + + // Add keyboard navigation + this.detailsPageDisposables.add(addDisposableListener(stepListComponent, 'keydown', (e) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.RightArrow) { + const currentIndex = this.getCurrentSlideIndex(allSlides); + if (currentIndex < allSlides.length - 1) { + this.selectStepByIndex(currentIndex + 1, allSlides.map(s => s.steps[0]), 1); + } + } else if (event.keyCode === KeyCode.LeftArrow) { + const currentIndex = this.getCurrentSlideIndex(allSlides); + if (currentIndex > 0) { + this.selectStepByIndex(currentIndex - 1, allSlides.map(s => s.steps[0]), -1); + } + } + })); + + // Register listeners for step selection + this.registerDispatchListeners(); + + this.detailsScrollbar.scanDomNode(); + this.detailsPageScrollbar?.scanDomNode(); + } + + private selectSubStep(steps: IResolvedWalkthroughStep[], selectedStepId: string) { + this.editorInput.selectedStep = selectedStepId; + + const multiStepContainer = this.container.querySelector('.multi-step-container'); + if (!multiStepContainer) { return; } + + const subSteps = multiStepContainer.querySelectorAll('.sub-step'); + subSteps.forEach(subStepEl => { + const stepId = subStepEl.getAttribute('data-sub-step-id'); + if (stepId === selectedStepId) { + subStepEl.classList.add('active'); + } else { + subStepEl.classList.remove('active'); + } + }); + + const prefixMatch = selectedStepId.match(/^([^.]+)\./); + const prefix = prefixMatch ? prefixMatch[1] : selectedStepId; + this.selectSlideWithPrefix(selectedStepId, prefix); + + this.gettingStartedService.progressByEvent('stepSelected:' + selectedStepId); + } + + private selectSlideWithPrefix(stepId: string, prefix: string) { + this.editorInput.selectedStep = stepId; + + const step = this.currentWalkthrough?.steps.find(step => step.id === stepId); + if (!step) { return; } + + const selectedSlide = this.stepsContent.querySelector(`.step-slide[data-step="${prefix}"]`); + if (selectedSlide) { + const selectedSlideContent = selectedSlide.querySelector('.step-slide-content'); + this.mediaDisposables.clear(); + this.stepDisposables.clear(); + this.buildMediaComponent(this.editorInput.selectedStep); + selectedSlideContent?.appendChild(this.stepMediaComponent); + setTimeout(() => (selectedSlideContent as HTMLElement).focus(), 0); + } + + this.gettingStartedService.progressByEvent('stepSelected:' + stepId); + this.detailsPageScrollbar?.scanDomNode(); + this.detailsScrollbar?.scanDomNode(); + } + + private getCurrentSlideIndex(allSlides: { id: string; steps: IResolvedWalkthroughStep[] }[]): number { + if (!this.editorInput.selectedStep) { + return 0; + } + + // Check if the selected step is directly a slide ID + const directMatch = allSlides.findIndex(slide => slide.id === this.editorInput.selectedStep); + if (directMatch !== -1) { + return directMatch; + } + + // Otherwise, find which slide contains the step as a sub-step + return allSlides.findIndex(slide => + slide.steps.some(step => step.id === this.editorInput.selectedStep) + ); + } + + private selectSlide(stepId: string) { + this.editorInput.selectedStep = stepId; + + const step = this.currentWalkthrough?.steps.find(step => step.id === stepId); + if (!step) { return; } + + + const effectiveStepId = stepId.match(/^([^.]+)\./)?.[1] ?? stepId; + const selectedSlide = this.stepsContent.querySelector(`.step-slide[data-step="${effectiveStepId}"]`); + + if (selectedSlide) { + const selectedSlideContent = selectedSlide.querySelector('.step-slide-content'); + this.mediaDisposables.clear(); + this.stepDisposables.clear(); + this.buildMediaComponent(this.editorInput.selectedStep); + selectedSlideContent?.appendChild(this.stepMediaComponent); + setTimeout(() => (selectedSlideContent as HTMLElement).focus(), 0); + } + + this.gettingStartedService.progressByEvent('stepSelected:' + stepId); + this.detailsPageScrollbar?.scanDomNode(); + this.detailsScrollbar?.scanDomNode(); + } + private buildCategorySlide(categoryID: string, selectedStep?: string) { + this.container.classList.remove('newSlide'); + if (this.detailsScrollbar) { this.detailsScrollbar.dispose(); } this.extensionService.whenInstalledExtensionsRegistered().then(() => { @@ -1462,7 +1852,10 @@ export class GettingStartedPage extends EditorPane { 'data-done-step-id': step.id, 'x-dispatch': 'toggleStepCompletion:' + step.id, 'role': 'checkbox', - 'aria-checked': step.done ? 'true' : 'false' + '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), }); const container = $('.step-description-container', { 'x-step-description-for': step.id }); @@ -1622,8 +2015,12 @@ export class GettingStartedPage extends EditorPane { const prevButton = this.container.querySelector('.prev-button.button-link'); prevButton!.style.display = this.editorInput.showWelcome || this.prevWalkthrough ? 'block' : 'none'; - const moreTextElement = prevButton!.querySelector('.moreText'); - moreTextElement!.textContent = firstLaunch ? localize('welcome', "Welcome") : localize('goBack', "Go Back"); + if (this.editorInput.selectedCategory === 'NewWelcomeExperience') { + prevButton!.style.display = 'none'; + } else { + const moreTextElement = prevButton!.querySelector('.moreText'); + moreTextElement!.textContent = firstLaunch ? localize('welcome', "Welcome") : localize('goBack', "Go Back"); + } this.container.querySelector('.gettingStartedSlideDetails')!.querySelectorAll('button').forEach(button => button.disabled = false); this.container.querySelector('.gettingStartedSlideCategories')!.querySelectorAll('button').forEach(button => button.disabled = true); diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedInput.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedInput.ts index 7375366e5c6..cb40a3ddafa 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedInput.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedInput.ts @@ -19,6 +19,7 @@ export interface GettingStartedEditorOptions extends IEditorOptions { showTelemetryNotice?: boolean; showWelcome?: boolean; walkthroughPageTitle?: string; + showNewExperience?: boolean; } export class GettingStartedInput extends EditorInput { @@ -29,6 +30,7 @@ export class GettingStartedInput extends EditorInput { private _selectedStep: string | undefined; private _showTelemetryNotice: boolean; private _showWelcome: boolean; + private _walkthroughPageTitle: string | undefined; override get typeId(): string { diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts index 4b2800fe031..630a26f614c 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts @@ -36,6 +36,7 @@ import { CancellationTokenSource } from '../../../../base/common/cancellation.js import { DefaultIconPath } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { asWebviewUri } from '../../webview/common/webview.js'; +import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; export const HasMultipleNewFileEntries = new RawContextKey('hasMultipleNewFileEntries', false); @@ -158,7 +159,8 @@ export class WalkthroughsService extends Disposable implements IWalkthroughsServ @IViewsService private readonly viewsService: IViewsService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IWorkbenchAssignmentService private readonly tasExperimentService: IWorkbenchAssignmentService, - @IProductService private readonly productService: IProductService + @IProductService private readonly productService: IProductService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { super(); @@ -444,7 +446,7 @@ export class WalkthroughsService extends Disposable implements IWalkthroughsServ if (hadLastFoucs && sectionToOpen && this.configurationService.getValue('workbench.welcomePage.walkthroughs.openOnInstall')) { type GettingStartedAutoOpenClassification = { owner: 'lramos15'; - comment: 'When a walkthrthrough is opened upon extension installation'; + comment: 'When a walkthrough is opened upon extension installation'; id: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; owner: 'lramos15'; @@ -455,7 +457,9 @@ export class WalkthroughsService extends Disposable implements IWalkthroughsServ id: string; }; this.telemetryService.publicLog2('gettingStarted.didAutoOpenWalkthrough', { id: sectionToOpen }); - this.commandService.executeCommand('workbench.action.openWalkthrough', sectionToOpen); + this.commandService.executeCommand('workbench.action.openWalkthrough', sectionToOpen, { + inactive: this.layoutService.hasFocus(Parts.EDITOR_PART) // do not steal the active editor away + }); } } @@ -496,6 +500,7 @@ export class WalkthroughsService extends Disposable implements IWalkthroughsServ }; }) .filter(category => category.content.type !== 'steps' || category.content.steps.length) + .filter(category => category.id !== 'NewWelcomeExperience') .map(category => this.resolveWalkthrough(category)); return categoriesWithCompletion; diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css index cd312dfbe33..bc6c1d813a0 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css @@ -134,7 +134,8 @@ grid-template-areas: "left-column" "right-column" "footer"; } -.monaco-workbench .part.editor > .content .gettingStartedContainer.width-constrained .gettingStartedSlideCategories > .gettingStartedCategoriesContainer > .header, .monaco-workbench .part.editor > .content .gettingStartedContainer.height-constrained .gettingStartedSlideCategories > .gettingStartedCategoriesContainer > .header { +.monaco-workbench .part.editor > .content .gettingStartedContainer.width-constrained .gettingStartedSlideCategories > .gettingStartedCategoriesContainer > .header, +.monaco-workbench .part.editor > .content .gettingStartedContainer.height-constrained .gettingStartedSlideCategories > .gettingStartedCategoriesContainer > .header { display: none; } @@ -142,7 +143,8 @@ display: none; } -.monaco-workbench .part.editor > .content .gettingStartedContainer.noWalkthroughs .gettingStartedSlideCategories li.showWalkthroughsEntry, .gettingStartedContainer.noExtensions { +.monaco-workbench .part.editor > .content .gettingStartedContainer.noWalkthroughs .gettingStartedSlideCategories li.showWalkthroughsEntry, +.gettingStartedContainer.noExtensions { display: unset; } @@ -299,7 +301,7 @@ font-size: 16px; } -.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .description-content:not(:empty){ +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .description-content:not(:empty) { margin-bottom: 8px; } @@ -360,6 +362,7 @@ position: relative; top: auto; } + .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideDetails .getting-started-category img.category-icon { margin-right: 10px; margin-left: 10px; @@ -571,6 +574,7 @@ .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideDetails .gettingStartedDetailsContent > .steps-container { height: 100%; + align-self: center; grid-area: steps; } @@ -583,7 +587,7 @@ grid-area: steps-start / media-start / footer-start / media-end; align-self: self-start; display: flex; - justify-content:center ; + justify-content: center; height: 100%; width: 100%; } @@ -655,7 +659,7 @@ display: inline; } -.monaco-workbench .part.editor > .content .gettingStartedContainer.noWalkthroughs .index-list.getting-started { +.monaco-workbench .part.editor > .content .gettingStartedContainer.noWalkthroughs .index-list.getting-started { display: none; } @@ -810,7 +814,8 @@ background: transparent; } -.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .openAWalkthrough > button, .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .showOnStartup { +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .openAWalkthrough > button, +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .showOnStartup { text-align: center; display: flex; justify-content: center; @@ -829,7 +834,7 @@ } -.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-checkbox.codicon:not(.checked)::before { +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-checkbox.codicon:not(.checked)::before { opacity: 0; } @@ -867,7 +872,8 @@ line-height: 1.3em; } -.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideDetails .getting-started-step .step-description-container .monaco-button, .monaco-workbench .part.editor > .content .gettingStartedContainer .max-lines-3 { +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideDetails .getting-started-step .step-description-container .monaco-button, +.monaco-workbench .part.editor > .content .gettingStartedContainer .max-lines-3 { /* Supported everywhere: https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-line-clamp#browser_compatibility */ -webkit-line-clamp: 3; display: -webkit-box; @@ -936,7 +942,7 @@ outline-color: var(--vscode-contrastActiveBorder, var(--vscode-focusBorder)); } -.monaco-workbench .part.editor > .content .gettingStartedContainer button.expanded:hover { +.monaco-workbench .part.editor > .content .gettingStartedContainer button.expanded:hover { background: var(--vscode-welcomePage-tileBackground); } @@ -974,7 +980,7 @@ color: var(--vscode-textLink-activeForeground); } -.monaco-workbench .part.editor > .content .gettingStartedContainer a:not(.hide-category-button):active { +.monaco-workbench .part.editor > .content .gettingStartedContainer a:not(.hide-category-button):active { color: var(--vscode-textLink-activeForeground); } @@ -994,7 +1000,7 @@ border: 1px solid var(--vscode-contrastBorder); } -.monaco-workbench .part.editor > .content .gettingStartedContainer button.button-link { +.monaco-workbench .part.editor > .content .gettingStartedContainer button.button-link { border: inherit; } @@ -1029,3 +1035,371 @@ .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-checkbox { border-color: var(--vscode-checkbox-border) !important; } + +/* Full width layout for the new slide design */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .gettingStartedSlideDetails .gettingStartedDetailsContent { + height: 100%; + max-width: 100%; + margin: 0 auto; + padding: 0 32px; + display: flex; +} + +/* Back button position */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .gettingStartedSlideDetails .gettingStartedDetailsContent > .prev-button { + padding: 16px 32px 0; + position: static; + margin: 0; +} + +/* Title area */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .gettingStartedSlideDetails .gettingStartedDetailsContent > .getting-started-category, +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .category-header { + grid-area: header; + text-align: left; + align-self: end; +} + +/* Steps container - takes most of the space */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .gettingStartedSlideDetails .gettingStartedDetailsContent > .steps-container { + flex: 1; + flex-direction: column; +} + +/* Hide the default media container */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .gettingStartedSlideDetails .gettingStartedDetailsContent > .getting-started-media { + display: none; +} + +/* Getting Started Steps Container */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .getting-started-steps-container { + max-width: 1000px; + margin: 0 auto; + display: grid; + padding-left: 10%; + padding-right: 10%; + grid-template-rows: 25% 50% 5% auto; + grid-template-areas: + "header" + "slides" + "dots" + "footer"; + height: 100%; + width: 100%; + align-self: center; + /* Center vertically in parent */ +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideDetails h2 { + font-size: 40px; +} + +/* Step slides container */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-slides-container { + grid-area: slides; + margin: 0; + overflow: hidden; + flex: 1; + width: 100%; + transition: transform 0.6s ease; +} + +/* Individual slide styling */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-slide { + min-width: 100%; + height: 100%; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + transition: transform 0.6s ease; +} + +/* Two-column layout for slide content */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-slide-content { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-areas: "text media"; + max-width: 1200px; + width: 100%; + height: 100%; + gap: 60px; + /* Increased from 40px */ +} + +/* Left column - text content only */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-text-content { + grid-area: text; + display: flex; + flex-direction: column; + justify-content: center; + padding-right: 16px; + min-height: 300px; + height: 100%; +} + +/* Right column - for media content */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-media-content { + grid-area: media; + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; + height: 100%; +} + +/* Navigation buttons in dots container for newSlide scenario */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dots-container { + width: 75%; + /* Increased from 60% */ + max-width: 900px; + /* Increased from 600px */ + margin: 12px auto; + /* Increased from 8px */ + display: flex; + justify-content: space-between; + align-items: center; + grid-area: dots; + height: max-content; +} + +/* Center dots between navigation buttons */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dots-container .dots-centered { + display: flex; + gap: 48px; + justify-content: center; + align-items: center; + width: max-content; + flex: 1; + margin: 0 8px; +} + +/* Navigation buttons styling */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dots-container .button-link.navigation { + display: flex; + align-items: center; + color: var(--vscode-textLink-foreground); + cursor: pointer; + padding: 3px 12px; + font-size: 16px; + line-height: 1.5; + border-radius: 4px; + white-space: nowrap; + flex-shrink: 0; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dots-container .button-link.navigation:hover { + color: var(--vscode-textLink-activeForeground); + background: var(--vscode-toolbar-hoverBackground); +} + +/* Make the back/next button icons larger */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dots-container .button-link.navigation .codicon { + font-size: 18px; + padding-left: 4px; + padding-right: 4px; +} + +/* Remove auto margins that spread things out */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dots-container .button-link.back { + margin-right: 0; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dots-container .button-link.next { + margin-left: 0; +} + +/* Left alignment for back button */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dots-container .button-link.back { + margin-right: auto; +} + +/* Right alignment for next button */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dots-container .button-link.next { + margin-left: auto; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dot { + width: 19px; + /* Increased from 13px */ + height: 19px; + /* Increased from 13px */ + background-color: var(--vscode-button-secondaryBackground); + border: none; + border-radius: 50%; + cursor: pointer; + padding: 0; + transition: transform 0.2s ease, background-color 0.2s ease; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dot:hover { + transform: scale(1.2); + background-color: var(--vscode-button-secondaryHoverBackground); +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-dot.active { + background-color: var(--vscode-button-background); + width: 18px; + /* Increased from 14px */ + height: 18px; + /* Increased from 14px */ +} + +/* Footer area */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .gettingStartedSlideDetails .gettingStartedDetailsContent > .getting-started-footer, +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .getting-started-footer { + grid-area: footer; + align-self: flex-end; + justify-self: center; + text-align: center; + flex-direction: column; + align-items: center; + border-top: 1px solid var(--vscode-welcomePage-tileBorder); +} + +/* Step title styling */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide h3.step-title { + font-size: 2.25em; + /* Increased from 1.5em */ + margin: 0 0 24px 0; + /* Increased from 16px bottom margin */ + padding: 0; + line-height: 1.5; +} + +/* Increase description text size */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-description { + font-size: 16px; + /* Increased from default */ + line-height: 1.5; +} + +/* Buttons in description */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-description .button-container .monaco-button { + height: 36px; + /* Increased from 24px */ + padding: 0 16px; + /* Increased from 0 11px */ + font-size: 15px; + /* Increased from default */ +} + +/* Multi-step container buttons */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .multi-step-container .button-container .monaco-button { + height: 36px; + /* Increased from 24px */ + padding: 0 16px; + /* Increased from 0 11px */ + font-size: 15px; + /* Increased from default */ +} + +/* Responsive design - stack on smaller screens */ +@media (max-width: 900px) { + .monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-slide-content { + grid-template-columns: 1fr; + grid-template-areas: + "text" + "media"; + gap: 24px; + } + + .monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-text-content { + padding-right: 0; + } + + .monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-media-content { + min-height: 200px; + } + + .monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-slide { + padding: 0 16px; + } + + .monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-text-content, + .monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-media-content { + min-height: unset; + height: auto; + } +} + +/* Animation for slide transitions */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide.animatable .step-slides-container { + transition: transform 0.25s ease; +} + +/* Low motion preference */ +.monaco-workbench.reduce-motion .part.editor > .content .gettingStartedContainer.newSlide .step-slides-container { + transition: none; +} + +/* Hide moreText and prev-button in newSlide scenarios */ +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .moreText, +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .prev-button, +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .prev-button.button-link, +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .gettingStartedSlideDetails .prev-button { + display: none; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-description .button-container { + margin-top: 50px; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .step-description .button-container .monaco-button { + height: 40px; + width: fit-content; + display: flex; + padding: 0 10%; + align-items: center; + font-size: 24px; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .multi-step-container { + display: flex; + flex-direction: column; + margin-top: 10px; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .sub-step:hover { + background: var(--vscode-welcomePage-tileHoverBackground); + border-radius: 6px; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .sub-step { + display: flex; + flex-direction: column; + padding: 8px 12px 8px 12px; + cursor: pointer; + transition: background-color 0.1s ease; + border-left: 3px solid transparent; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .sub-step-title { + font-size: 24px; + margin: 0; + padding: 16px 0 4px 16px; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .sub-step-description { + padding: 0 0 0 16px +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .sub-step.active { + border: 1px solid var(--vscode-welcomePage-tileBorder); + border-radius: 6px; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .multi-step-container .button-container { + margin-top: 50px; + margin-left: 16px +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer.newSlide .multi-step-container .button-container .monaco-button { + height: 40px; + width: fit-content; + display: flex; + padding: 0 10%; + align-items: center; + min-width: max-content; + font-size: 24px; +} diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts index 9f54e5df21e..e728e1941f8 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts @@ -14,7 +14,7 @@ import { IWorkspaceContextService, UNKNOWN_EMPTY_WINDOW_WORKSPACE, WorkbenchStat import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IWorkingCopyBackupService } from '../../../services/workingCopy/common/workingCopyBackup.js'; import { ILifecycleService, LifecyclePhase, StartupKind } from '../../../services/lifecycle/common/lifecycle.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { joinPath } from '../../../../base/common/resources.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; @@ -37,7 +37,7 @@ const configurationKey = 'workbench.startupEditor'; const oldConfigurationKey = 'workbench.welcome.enabled'; const telemetryOptOutStorageKey = 'workbench.telemetryOptOutShown'; -export class StartupPageEditorResolverContribution implements IWorkbenchContribution { +export class StartupPageEditorResolverContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.startupPageEditorResolver'; @@ -45,6 +45,10 @@ export class StartupPageEditorResolverContribution implements IWorkbenchContribu @IInstantiationService private readonly instantiationService: IInstantiationService, @IEditorResolverService editorResolverService: IEditorResolverService ) { + super(); + const disposables = new DisposableStore(); + this._register(disposables); + editorResolverService.registerEditor( `${GettingStartedInput.RESOURCE.scheme}:/**`, { @@ -59,7 +63,7 @@ export class StartupPageEditorResolverContribution implements IWorkbenchContribu { createEditorInput: ({ resource, options }) => { return { - editor: this.instantiationService.createInstance(GettingStartedInput, options as GettingStartedEditorOptions), + editor: disposables.add(this.instantiationService.createInstance(GettingStartedInput, options as GettingStartedEditorOptions)), options: { ...options, pinned: false @@ -132,7 +136,7 @@ export class StartupPageRunnerContribution extends Disposable implements IWorkbe if (startupEditorSetting.value === 'readme') { await this.openReadme(); } else if (startupEditorSetting.value === 'welcomePage' || startupEditorSetting.value === 'welcomePageInEmptyWorkbench') { - await this.openGettingStarted(); + await this.openGettingStarted(true); } else if (startupEditorSetting.value === 'terminal') { this.commandService.executeCommand(TerminalCommandId.CreateTerminalEditor); } diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts index 8391c2cf1ef..603be0ed77a 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts @@ -174,17 +174,6 @@ export const startEntries: GettingStartedStartEntryContent = [ command: 'command:remoteHub.openRepository', } }, - { - id: 'topLevelShowWalkthroughs', - title: localize('gettingStarted.topLevelShowWalkthroughs.title', "Open a Walkthrough..."), - description: localize('gettingStarted.topLevelShowWalkthroughs.description', "View a walkthrough on the editor or an extension"), - icon: Codicon.checklist, - when: 'allWalkthroughsHidden', - content: { - type: 'startEntry', - command: 'command:welcome.showAllWalkthroughs', - } - }, { id: 'topLevelRemoteOpen', title: localize('gettingStarted.topLevelRemoteOpen.title', "Connect to..."), @@ -207,14 +196,25 @@ export const startEntries: GettingStartedStartEntryContent = [ command: 'command:workbench.action.remote.showWebStartEntryActions', } }, + { + id: 'topLevelNewWorkspaceChat', + title: localize('gettingStarted.newWorkspaceChat.title', "New Workspace with Copilot..."), + description: localize('gettingStarted.newWorkspaceChat.description', "Create a new workspace with Copilot"), + icon: Codicon.copilot, + when: '!isWeb && !chatSetupHidden', + content: { + type: 'startEntry', + command: 'command:welcome.newWorkspaceChat', + } + }, ]; const Button = (title: string, href: string) => `[${title}](${href})`; -const CopilotStepTitle = localize('gettingStarted.copilotSetup.title', "Use AI features with Copilot Free"); +const CopilotStepTitle = localize('gettingStarted.copilotSetup.title', "Use AI features with Copilot for free"); const CopilotDescription = localize({ key: 'gettingStarted.copilotSetup.description', comment: ['{Locked="["}', '{Locked="]({0})"}'] }, "You can use [Copilot]({0}) to generate code across multiple files, fix errors, ask questions about your code and much more using natural language.", product.defaultChatAgent?.documentationUrl ?? ''); -const CopilotSignedOutButton = Button(localize('setupCopilotButton.signIn', "Set up Copilot Free"), `command:workbench.action.chat.triggerSetup`); -const CopilotSignedInButton = Button(localize('setupCopilotButton.setup', "Set up Copilot Free"), `command:workbench.action.chat.triggerSetup`); +const CopilotSignedOutButton = Button(localize('setupCopilotButton.signIn', "Set up Copilot"), `command:workbench.action.chat.triggerSetup`); +const CopilotSignedInButton = Button(localize('setupCopilotButton.setup', "Set up Copilot"), `command:workbench.action.chat.triggerSetup`); const CopilotCompleteButton = Button(localize('setupCopilotButton.chatWithCopilot', "Chat with Copilot"), 'command:workbench.action.chat.open'); function createCopilotSetupStep(id: string, button: string, when: string, includeTerms: boolean): BuiltinGettingStartedStep { @@ -226,7 +226,7 @@ function createCopilotSetupStep(id: string, button: string, when: string, includ id, title: CopilotStepTitle, description, - when, + when: `${when} && !chatSetupHidden`, media: { type: 'svg', altText: 'VS Code Copilot multi file edits', path: 'multi-file-edits.svg' }, @@ -664,5 +664,78 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ }, ] } + }, + { + id: 'NewWelcomeExperience', + title: localize('gettingStarted.setup.title', "Get Started with VS Code"), + description: localize('gettingStarted.setup.description', "Customize your editor, learn the basics, and start coding"), + isFeatured: false, + icon: setupIcon, + when: '!isWeb', + walkthroughPageTitle: localize('gettingStarted.setup.walkthroughPageTitle', 'Setup VS Code'), + content: { + type: 'steps', + steps: [ + { + id: 'copilotSetup.chat', + title: localize('gettingStarted.agentMode.title', "Agent Mode"), + description: localize('gettingStarted.agentMode.description', "Tackle complex, multi-step tasks with AI"), + media: { + type: 'svg', altText: 'VS Code Copilot multi file edits', path: 'multi-file-edits.svg' + }, + }, + { + id: 'copilotSetup.inline', + title: localize('gettingStarted.nes.title', "Next Edit Suggestions"), + description: localize('gettingStarted.nes.description', "Your next move, predicted while you code"), + media: { + type: 'svg', altText: 'Next Edit Suggestions', path: 'ai-powered-suggestions.svg' + }, + }, + { + id: 'copilotSetup.customize', + title: localize('gettingStarted.customize.title', "Customize"), + description: localize('gettingStarted.customize.description', "Choose your model, tools, and personalized instructions\n{0}", Button(localize('signUp', "Set up AI in VS Code"), 'command:workbench.action.chat.triggerSetup')), + media: { + type: 'svg', altText: 'Customize', path: 'multi-file-edits.svg' + }, + }, + { + id: 'newPickColorTheme', + title: localize('gettingStarted.pickColor.title', "Choose your theme"), + description: localize('gettingStarted.pickColor.description.interpolated', "The right theme helps you focus on your code, is easy on your eyes, and is simply more fun to use.\n{0}", Button(localize('titleID', "Browse Color Themes"), 'command:workbench.action.selectTheme')), + completionEvents: [ + 'onSettingChanged:workbench.colorTheme', + 'onCommand:workbench.action.selectTheme' + ], + media: { type: 'markdown', path: 'theme_picker', } + }, + { + id: 'newFindLanguageExtensions', + title: localize('newgettingStarted.findLanguageExts.title', "Support for all languages"), + description: localize('newgettingStarted.findLanguageExts.description.interpolated', "Install the language extensions you need in your toolkit.\n{0}", Button(localize('browseLangExts', "Browse Language Extensions"), 'command:workbench.extensions.action.showLanguageExtensions')), + when: 'workspacePlatform != \'webworker\'', + media: { + type: 'svg', altText: 'Language extensions', path: 'languages.svg' + }, + }, + { + id: 'newSettingsAndSync', + title: localize('newgettingStarted.settings.title', "Customize every aspect of VS Code"), + description: localize('newgettingStarted.settingsAndSync.description.interpolated', "[Back up and sync](command:workbench.userDataSync.actions.turnOn) settings across all your devices.\n{0}", Button(localize('tweakSettings', "Open Settings"), 'command:toSide:workbench.action.openSettings')), + when: 'syncStatus != uninitialized', + completionEvents: ['onEvent:sync-enabled'], + media: { + type: 'svg', altText: 'VS Code Settings', path: 'settings.svg' + }, + }, + { + id: 'newCommandPaletteTask', + title: localize('newgettingStarted.commandPalette.title', "All VS Code commands within reach"), + description: localize('gettingStarted.commandPalette.description.interpolated', "Run commands without reaching for your mouse to accomplish any task in VS Code.\n{0}", Button(localize('commandPalette', "Open Command Palette"), 'command:workbench.action.showCommands')), + media: { type: 'svg', altText: 'Command Palette overlay for searching and executing commands.', path: 'commandPalette.svg' }, + } + ] + } } ]; diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/media/ai-powered-suggestions.svg b/src/vs/workbench/contrib/welcomeGettingStarted/common/media/ai-powered-suggestions.svg new file mode 100644 index 00000000000..c7e582b4d15 --- /dev/null +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/media/ai-powered-suggestions.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/media/multi-file-edits.svg b/src/vs/workbench/contrib/welcomeGettingStarted/common/media/multi-file-edits.svg index 9106074c4c0..fda9a0c3f7a 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/media/multi-file-edits.svg +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/media/multi-file-edits.svg @@ -1,513 +1,202 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/vs/workbench/contrib/workspaces/browser/workspaces.contribution.ts b/src/vs/workbench/contrib/workspaces/browser/workspaces.contribution.ts index 55371fbeea8..57fc1eb5de3 100644 --- a/src/vs/workbench/contrib/workspaces/browser/workspaces.contribution.ts +++ b/src/vs/workbench/contrib/workspaces/browser/workspaces.contribution.ts @@ -77,7 +77,7 @@ export class WorkspacesFinderContribution extends Disposable implements IWorkben run: () => this.hostService.openWindow([{ workspaceUri: joinPath(folder, workspaceFile) }]) }], { neverShowAgain, - priority: !this.storageService.isNew(StorageScope.WORKSPACE) ? NotificationPriority.SILENT : undefined // https://github.com/microsoft/vscode/issues/125315 + priority: !this.storageService.isNew(StorageScope.WORKSPACE) ? NotificationPriority.SILENT : NotificationPriority.OPTIONAL // https://github.com/microsoft/vscode/issues/125315 }); } @@ -99,7 +99,7 @@ export class WorkspacesFinderContribution extends Disposable implements IWorkben } }], { neverShowAgain, - priority: !this.storageService.isNew(StorageScope.WORKSPACE) ? NotificationPriority.SILENT : undefined // https://github.com/microsoft/vscode/issues/125315 + priority: !this.storageService.isNew(StorageScope.WORKSPACE) ? NotificationPriority.SILENT : NotificationPriority.OPTIONAL // https://github.com/microsoft/vscode/issues/125315 }); } } diff --git a/src/vs/workbench/electron-sandbox/actions/windowActions.ts b/src/vs/workbench/electron-sandbox/actions/windowActions.ts index ce2a584233d..424db028d1c 100644 --- a/src/vs/workbench/electron-sandbox/actions/windowActions.ts +++ b/src/vs/workbench/electron-sandbox/actions/windowActions.ts @@ -28,6 +28,9 @@ import { KeybindingWeight } from '../../../platform/keybinding/common/keybinding import { isMacintosh } from '../../../base/common/platform.js'; import { getActiveWindow } from '../../../base/browser/dom.js'; import { IOpenedAuxiliaryWindow, IOpenedMainWindow, isOpenedAuxiliaryWindow } from '../../../platform/window/common/window.js'; +import { IsAuxiliaryTitleBarContext, IsAuxiliaryWindowFocusedContext, IsWindowAlwaysOnTopContext } from '../../common/contextkeys.js'; +import { isAuxiliaryWindow } from '../../../base/browser/window.js'; +import { ContextKeyExpr } from '../../../platform/contextkey/common/contextkey.js'; export class CloseWindowAction extends Action2 { @@ -418,3 +421,87 @@ export const ToggleWindowTabsBarHandler: ICommandHandler = function (accessor: S return accessor.get(INativeHostService).toggleWindowTabsBar(); }; + +export class ToggleWindowAlwaysOnTopAction extends Action2 { + + static readonly ID = 'workbench.action.toggleWindowAlwaysOnTop'; + + constructor() { + super({ + id: ToggleWindowAlwaysOnTopAction.ID, + title: localize2('toggleWindowAlwaysOnTop', "Toggle Window Always on Top"), + f1: true, + precondition: IsAuxiliaryWindowFocusedContext + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const nativeHostService = accessor.get(INativeHostService); + + const targetWindow = getActiveWindow(); + if (!isAuxiliaryWindow(targetWindow.window)) { + return; // Currently, we only support toggling always on top for auxiliary windows + } + + return nativeHostService.toggleWindowAlwaysOnTop({ targetWindowId: getActiveWindow().vscodeWindowId }); + } +} + +export class EnableWindowAlwaysOnTopAction extends Action2 { + + static readonly ID = 'workbench.action.enableWindowAlwaysOnTop'; + + constructor() { + super({ + id: EnableWindowAlwaysOnTopAction.ID, + title: localize('enableWindowAlwaysOnTop', "Set Always on Top"), + icon: Codicon.pin, + menu: { + id: MenuId.LayoutControlMenu, + when: ContextKeyExpr.and(IsWindowAlwaysOnTopContext.toNegated(), IsAuxiliaryTitleBarContext), + order: 1 + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const nativeHostService = accessor.get(INativeHostService); + + const targetWindow = getActiveWindow(); + if (!isAuxiliaryWindow(targetWindow.window)) { + return; // Currently, we only support toggling always on top for auxiliary windows + } + + return nativeHostService.setWindowAlwaysOnTop(true, { targetWindowId: targetWindow.vscodeWindowId }); + } +} + +export class DisableWindowAlwaysOnTopAction extends Action2 { + + static readonly ID = 'workbench.action.disableWindowAlwaysOnTop'; + + constructor() { + super({ + id: DisableWindowAlwaysOnTopAction.ID, + title: localize('disableWindowAlwaysOnTop', "Unset Always on Top"), + icon: Codicon.pin, + toggled: { condition: IsWindowAlwaysOnTopContext, icon: Codicon.pinned }, + menu: { + id: MenuId.LayoutControlMenu, + when: ContextKeyExpr.and(IsWindowAlwaysOnTopContext, IsAuxiliaryTitleBarContext), + order: 1 + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const nativeHostService = accessor.get(INativeHostService); + + const targetWindow = getActiveWindow(); + if (!isAuxiliaryWindow(targetWindow.window)) { + return; // Currently, we only support toggling always on top for auxiliary windows + } + + return nativeHostService.setWindowAlwaysOnTop(false, { targetWindowId: targetWindow.vscodeWindowId }); + } +} diff --git a/src/vs/workbench/electron-sandbox/desktop.contribution.ts b/src/vs/workbench/electron-sandbox/desktop.contribution.ts index ba3850853be..88e0091fe2c 100644 --- a/src/vs/workbench/electron-sandbox/desktop.contribution.ts +++ b/src/vs/workbench/electron-sandbox/desktop.contribution.ts @@ -10,7 +10,7 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions, Configur import { KeyMod, KeyCode } from '../../base/common/keyCodes.js'; import { isLinux, isMacintosh, isWindows } from '../../base/common/platform.js'; import { ConfigureRuntimeArgumentsAction, ToggleDevToolsAction, ReloadWindowWithExtensionsDisabledAction, OpenUserDataFolderAction, ShowGPUInfoAction } from './actions/developerActions.js'; -import { ZoomResetAction, ZoomOutAction, ZoomInAction, CloseWindowAction, SwitchWindowAction, QuickSwitchWindowAction, NewWindowTabHandler, ShowPreviousWindowTabHandler, ShowNextWindowTabHandler, MoveWindowTabToNewWindowHandler, MergeWindowTabsHandlerHandler, ToggleWindowTabsBarHandler } from './actions/windowActions.js'; +import { ZoomResetAction, ZoomOutAction, ZoomInAction, CloseWindowAction, SwitchWindowAction, QuickSwitchWindowAction, NewWindowTabHandler, ShowPreviousWindowTabHandler, ShowNextWindowTabHandler, MoveWindowTabToNewWindowHandler, MergeWindowTabsHandlerHandler, ToggleWindowTabsBarHandler, ToggleWindowAlwaysOnTopAction, DisableWindowAlwaysOnTopAction, EnableWindowAlwaysOnTopAction } from './actions/windowActions.js'; import { ContextKeyExpr } from '../../platform/contextkey/common/contextkey.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; import { CommandsRegistry } from '../../platform/commands/common/commands.js'; @@ -28,6 +28,8 @@ import { NativeWindow } from './window.js'; import { ModifierKeyEmitter } from '../../base/browser/dom.js'; import { applicationConfigurationNodeBase, securityConfigurationNodeBase } from '../common/configuration.js'; import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '../../platform/window/electron-sandbox/window.js'; +import { DefaultAccountManagementContribution } from '../services/accounts/common/defaultAccount.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../common/contributions.js'; // Actions (function registerActions(): void { @@ -41,6 +43,9 @@ import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '../../platform/window/electron-s registerAction2(SwitchWindowAction); registerAction2(QuickSwitchWindowAction); registerAction2(CloseWindowAction); + registerAction2(ToggleWindowAlwaysOnTopAction); + registerAction2(EnableWindowAlwaysOnTopAction); + registerAction2(DisableWindowAlwaysOnTopAction); if (isMacintosh) { // macOS: behave like other native apps that have documents @@ -423,3 +428,7 @@ import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '../../platform/window/electron-s jsonRegistry.registerSchema(argvDefinitionFileSchemaId, schema); })(); + +(function registerWorkbenchContributions(): void { + registerWorkbenchContribution2('workbench.contributions.defaultAccountManagement', DefaultAccountManagementContribution, WorkbenchPhase.AfterRestored); +})(); diff --git a/src/vs/workbench/electron-sandbox/desktop.main.ts b/src/vs/workbench/electron-sandbox/desktop.main.ts index b173b981f3b..306d1c7c685 100644 --- a/src/vs/workbench/electron-sandbox/desktop.main.ts +++ b/src/vs/workbench/electron-sandbox/desktop.main.ts @@ -52,7 +52,7 @@ import { FileUserDataProvider } from '../../platform/userData/common/fileUserDat import { IUserDataProfilesService, reviveProfile } from '../../platform/userDataProfile/common/userDataProfile.js'; import { UserDataProfilesService } from '../../platform/userDataProfile/common/userDataProfileIpc.js'; import { PolicyChannelClient } from '../../platform/policy/common/policyIpc.js'; -import { IPolicyService, NullPolicyService } from '../../platform/policy/common/policy.js'; +import { IPolicyService } from '../../platform/policy/common/policy.js'; import { UserDataProfileService } from '../services/userDataProfile/common/userDataProfileService.js'; import { IUserDataProfileService } from '../services/userDataProfile/common/userDataProfile.js'; import { BrowserSocketFactory } from '../../platform/remote/browser/browserSocketFactory.js'; @@ -61,6 +61,9 @@ import { ElectronRemoteResourceLoader } from '../../platform/remote/electron-san import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; import { applyZoom } from '../../platform/window/electron-sandbox/window.js'; import { mainWindow } from '../../base/browser/window.js'; +import { DefaultAccountService, IDefaultAccountService } from '../services/accounts/common/defaultAccount.js'; +import { AccountPolicyService } from '../services/policies/common/accountPolicyService.js'; +import { MultiplexPolicyService } from '../services/policies/common/multiplexPolicyService.js'; export class DesktopMain extends Disposable { @@ -179,10 +182,6 @@ export class DesktopMain extends Disposable { const mainProcessService = this._register(new ElectronIPCMainProcessService(this.configuration.windowId)); serviceCollection.set(IMainProcessService, mainProcessService); - // Policies - const policyService = this.configuration.policiesData ? new PolicyChannelClient(this.configuration.policiesData, mainProcessService.getChannel('policy')) : new NullPolicyService(); - serviceCollection.set(IPolicyService, policyService); - // Product const productService: IProductService = { _serviceBrand: undefined, ...product }; serviceCollection.set(IProductService, productService); @@ -206,6 +205,21 @@ export class DesktopMain extends Disposable { logService.trace('workbench#open(): with configuration', safeStringify({ ...this.configuration, nls: undefined /* exclude large property */ })); } + // Default Account + const defaultAccountService = this._register(new DefaultAccountService()); + serviceCollection.set(IDefaultAccountService, defaultAccountService); + + // Policies + let policyService: IPolicyService; + const accountPolicy = new AccountPolicyService(logService, defaultAccountService); + if (this.configuration.policiesData) { + const policyChannel = new PolicyChannelClient(this.configuration.policiesData, mainProcessService.getChannel('policy')); + policyService = new MultiplexPolicyService([policyChannel, accountPolicy], logService); + } else { + policyService = accountPolicy; + } + serviceCollection.set(IPolicyService, policyService); + // Shared Process const sharedProcessService = new SharedProcessService(this.configuration.windowId, logService); serviceCollection.set(ISharedProcessService, sharedProcessService); diff --git a/src/vs/workbench/electron-sandbox/parts/titlebar/titlebarPart.ts b/src/vs/workbench/electron-sandbox/parts/titlebar/titlebarPart.ts index cfd13ca9d03..07e8bc67405 100644 --- a/src/vs/workbench/electron-sandbox/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/electron-sandbox/parts/titlebar/titlebarPart.ts @@ -27,6 +27,7 @@ import { IEditorGroupsContainer, IEditorGroupsService } from '../../../services/ import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { CodeWindow, mainWindow } from '../../../../base/browser/window.js'; +import { IsWindowAlwaysOnTopContext } from '../../../common/contextkeys.js'; export class NativeTitlebarPart extends BrowserTitlebarPart { @@ -80,6 +81,20 @@ export class NativeTitlebarPart extends BrowserTitlebarPart { super(id, targetWindow, editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorGroupService, editorService, menuService, keybindingService); this.bigSurOrNewer = isBigSurOrNewer(environmentService.os.release); + + this.handleWindowsAlwaysOnTop(targetWindow.vscodeWindowId); + } + + private async handleWindowsAlwaysOnTop(targetWindowId: number): Promise { + const isWindowAlwaysOnTopContext = IsWindowAlwaysOnTopContext.bindTo(this.scopedContextKeyService); + + this._register(this.nativeHostService.onDidChangeWindowAlwaysOnTop(({ windowId, alwaysOnTop }) => { + if (windowId === targetWindowId) { + isWindowAlwaysOnTopContext.set(alwaysOnTop); + } + })); + + isWindowAlwaysOnTopContext.set(await this.nativeHostService.isWindowAlwaysOnTop({ targetWindowId })); } protected override onMenubarVisibilityChanged(visible: boolean): void { diff --git a/src/vs/workbench/electron-sandbox/window.ts b/src/vs/workbench/electron-sandbox/window.ts index 1c187b2948e..a1b0dc1295e 100644 --- a/src/vs/workbench/electron-sandbox/window.ts +++ b/src/vs/workbench/electron-sandbox/window.ts @@ -6,7 +6,6 @@ import './media/window.css'; import { localize } from '../../nls.js'; import { URI } from '../../base/common/uri.js'; -import { onUnexpectedError } from '../../base/common/errors.js'; import { equals } from '../../base/common/objects.js'; import { EventType, EventHelper, addDisposableListener, ModifierKeyEmitter, getActiveElement, hasWindow, getWindowById, getWindows, $ } from '../../base/browser/dom.js'; import { Action, Separator, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../base/common/actions.js'; @@ -33,7 +32,7 @@ import { IWorkspaceFolderCreationData } from '../../platform/workspaces/common/w import { IIntegrityService } from '../services/integrity/common/integrity.js'; import { isWindows, isMacintosh } from '../../base/common/platform.js'; import { IProductService } from '../../platform/product/common/productService.js'; -import { INotificationService, NeverShowAgainScope, NotificationPriority, Severity } from '../../platform/notification/common/notification.js'; +import { INotificationService, NotificationPriority, Severity } from '../../platform/notification/common/notification.js'; import { IKeybindingService } from '../../platform/keybinding/common/keybinding.js'; import { INativeWorkbenchEnvironmentService } from '../services/environment/electron-sandbox/environmentService.js'; import { IAccessibilityService, AccessibilitySupport } from '../../platform/accessibility/common/accessibility.js'; @@ -69,7 +68,7 @@ import { Codicon } from '../../base/common/codicons.js'; import { IUriIdentityService } from '../../platform/uriIdentity/common/uriIdentity.js'; import { IPreferencesService } from '../services/preferences/common/preferences.js'; import { IUtilityProcessWorkerWorkbenchService } from '../services/utilityProcess/electron-sandbox/utilityProcessWorkerWorkbenchService.js'; -import { registerWindowDriver } from '../services/driver/electron-sandbox/driver.js'; +import { registerWindowDriver } from '../services/driver/browser/driver.js'; import { mainWindow } from '../../base/browser/window.js'; import { BaseWindow } from '../browser/window.js'; import { IHostService } from '../services/host/browser/host.js'; @@ -187,13 +186,6 @@ export class NativeWindow extends BaseWindow { } }); - // Error reporting from main - ipcRenderer.on('vscode:reportError', (event: unknown, error: string) => { - if (error) { - onUnexpectedError(JSON.parse(error)); - } - }); - // Shared Process crash reported from main ipcRenderer.on('vscode:reportSharedProcessCrash', (event: unknown, error: string) => { this.notificationService.prompt( @@ -688,7 +680,7 @@ export class NativeWindow extends BaseWindow { // Smoke Test Driver if (this.environmentService.enableSmokeTestDriver) { - this.setupDriver(); + registerWindowDriver(this.instantiationService); } } @@ -736,32 +728,6 @@ export class NativeWindow extends BaseWindow { } } - // macOS 10.15 warning - if (isMacintosh) { - const majorVersion = this.nativeEnvironmentService.os.release.split('.')[0]; - const eolReleases = new Map([ - ['19', 'macOS Catalina'], - ]); - - if (eolReleases.has(majorVersion)) { - const message = localize('macoseolmessage', "{0} on {1} will soon stop receiving updates. Consider upgrading your macOS version.", this.productService.nameLong, eolReleases.get(majorVersion)); - - this.notificationService.prompt( - Severity.Warning, - message, - [{ - label: localize('learnMore', "Learn More"), - run: () => this.openerService.open(URI.parse('https://aka.ms/vscode-faq-old-macOS')) - }], - { - neverShowAgain: { id: 'macoseol', isSecondary: true, scope: NeverShowAgainScope.APPLICATION }, - priority: NotificationPriority.URGENT, - sticky: true - } - ); - } - } - // Slow shell environment progress indicator const shellEnv = process.shellEnv(); this.progressService.withProgress({ @@ -772,25 +738,6 @@ export class NativeWindow extends BaseWindow { }, () => shellEnv, () => this.openerService.open('https://go.microsoft.com/fwlink/?linkid=2149667')); } - private setupDriver(): void { - const that = this; - let pendingQuit = false; - - registerWindowDriver(this.instantiationService, { - async exitApplication(): Promise { - if (pendingQuit) { - that.logService.info('[driver] not handling exitApplication() due to pending quit() call'); - return; - } - - that.logService.info('[driver] handling exitApplication()'); - - pendingQuit = true; - return that.nativeHostService.quit(); - } - }); - } - async resolveExternalUri(uri: URI, options?: OpenOptions): Promise { let queryTunnel: RemoteTunnel | string | undefined; if (options?.allowTunneling) { diff --git a/src/vs/workbench/services/accounts/common/defaultAccount.ts b/src/vs/workbench/services/accounts/common/defaultAccount.ts new file mode 100644 index 00000000000..67f6822c16d --- /dev/null +++ b/src/vs/workbench/services/accounts/common/defaultAccount.ts @@ -0,0 +1,306 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IAuthenticationService } from '../../authentication/common/authentication.js'; +import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IExtensionService } from '../../extensions/common/extensions.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { localize } from '../../../../nls.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { Barrier } from '../../../../base/common/async.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { getErrorMessage } from '../../../../base/common/errors.js'; + +const enum DefaultAccountStatus { + Uninitialized = 'uninitialized', + Unavailable = 'unavailable', + Available = 'available', +} + +const CONTEXT_DEFAULT_ACCOUNT_STATE = new RawContextKey('defaultAccountStatus', DefaultAccountStatus.Uninitialized); + +export interface IDefaultAccount { + readonly sessionId: string; + readonly enterprise: boolean; + readonly access_type_sku?: string; + readonly assigned_date?: string; + readonly can_signup_for_limited?: boolean; + readonly chat_enabled?: boolean; + readonly chat_preview_features_enabled?: boolean; + readonly analytics_tracking_id?: string; + readonly limited_user_quotas?: { + readonly chat: number; + readonly completions: number; + }; + readonly monthly_quotas?: { + readonly chat: number; + readonly completions: number; + }; + readonly limited_user_reset_date?: string; +} + +interface IChatEntitlementsResponse { + readonly access_type_sku: string; + readonly assigned_date: string; + readonly can_signup_for_limited: boolean; + readonly chat_enabled: boolean; + readonly analytics_tracking_id: string; + readonly limited_user_quotas?: { + readonly chat: number; + readonly completions: number; + }; + readonly monthly_quotas?: { + readonly chat: number; + readonly completions: number; + }; + readonly limited_user_reset_date: string; +} + +interface ITokenEntitlementsResponse { + token: string; +} + +export const IDefaultAccountService = createDecorator('defaultAccountService'); + +export interface IDefaultAccountService { + + readonly _serviceBrand: undefined; + + readonly onDidChangeDefaultAccount: Event; + + getDefaultAccount(): Promise; + setDefaultAccount(account: IDefaultAccount | null): void; +} + +export class DefaultAccountService extends Disposable implements IDefaultAccountService { + declare _serviceBrand: undefined; + + private _defaultAccount: IDefaultAccount | null | undefined = undefined; + get defaultAccount(): IDefaultAccount | null { return this._defaultAccount ?? null; } + + private readonly initBarrier = new Barrier(); + + private readonly _onDidChangeDefaultAccount = this._register(new Emitter()); + readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; + + async getDefaultAccount(): Promise { + await this.initBarrier.wait(); + return this.defaultAccount; + } + + setDefaultAccount(account: IDefaultAccount | null): void { + const oldAccount = this._defaultAccount; + this._defaultAccount = account; + + if (oldAccount !== this._defaultAccount) { + this._onDidChangeDefaultAccount.fire(this._defaultAccount); + } + + this.initBarrier.open(); + } + +} + +export class NullDefaultAccountService extends Disposable implements IDefaultAccountService { + + declare _serviceBrand: undefined; + + readonly onDidChangeDefaultAccount = Event.None; + + async getDefaultAccount(): Promise { + return null; + } + + setDefaultAccount(account: IDefaultAccount | null): void { + // noop + } + +} + +export class DefaultAccountManagementContribution extends Disposable implements IWorkbenchContribution { + + static ID = 'workbench.contributions.defaultAccountManagement'; + + private defaultAccount: IDefaultAccount | null = null; + private readonly accountStatusContext: IContextKey; + + constructor( + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @IExtensionService private readonly extensionService: IExtensionService, + @IProductService private readonly productService: IProductService, + @IRequestService private readonly requestService: IRequestService, + @ILogService private readonly logService: ILogService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + this.accountStatusContext = CONTEXT_DEFAULT_ACCOUNT_STATE.bindTo(contextKeyService); + this.initialize(); + } + + private async initialize(): Promise { + if (!this.productService.defaultAccount) { + return; + } + + const { authenticationProvider, tokenEntitlementUrl, chatEntitlementUrl } = this.productService.defaultAccount; + await this.extensionService.whenInstalledExtensionsRegistered(); + + const declaredProvider = this.authenticationService.declaredProviders.find(provider => provider.id === authenticationProvider.id); + if (!declaredProvider) { + this.logService.info(`Default account authentication provider ${authenticationProvider} is not declared.`); + return; + } + + this.registerSignInAction(authenticationProvider.id, declaredProvider.label, authenticationProvider.enterpriseProviderId, authenticationProvider.enterpriseProviderConfig, authenticationProvider.scopes); + this.setDefaultAccount(await this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider.id, authenticationProvider.enterpriseProviderId, authenticationProvider.enterpriseProviderConfig, authenticationProvider.scopes, tokenEntitlementUrl, chatEntitlementUrl)); + + this._register(this.authenticationService.onDidChangeSessions(async e => { + if (e.providerId !== authenticationProvider.id && e.providerId !== authenticationProvider.enterpriseProviderId) { + return; + } + + if (this.defaultAccount && e.event.removed?.some(session => session.id === this.defaultAccount?.sessionId)) { + this.setDefaultAccount(null); + return; + } + this.setDefaultAccount(await this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider.id, authenticationProvider.enterpriseProviderId, authenticationProvider.enterpriseProviderConfig, authenticationProvider.scopes, tokenEntitlementUrl, chatEntitlementUrl)); + })); + + } + + private setDefaultAccount(account: IDefaultAccount | null): void { + this.defaultAccount = account; + this.defaultAccountService.setDefaultAccount(this.defaultAccount); + if (this.defaultAccount) { + this.accountStatusContext.set(DefaultAccountStatus.Available); + } else { + this.accountStatusContext.set(DefaultAccountStatus.Unavailable); + } + } + + private extractFromToken(token: string, key: string): string | undefined { + const result = new Map(); + const firstPart = token?.split(':')[0]; + const fields = firstPart?.split(';'); + for (const field of fields) { + const [key, value] = field.split('='); + result.set(key, value); + } + return result.get(key); + } + + private async getDefaultAccountFromAuthenticatedSessions(authProviderId: string, enterpriseAuthProviderId: string, enterpriseAuthProviderConfig: string, scopes: string[], tokenEntitlementUrl: string, chatEntitlementUrl: string): Promise { + const id = this.configurationService.getValue(enterpriseAuthProviderConfig) ? enterpriseAuthProviderId : authProviderId; + const sessions = await this.authenticationService.getSessions(id, undefined, undefined, true); + const session = sessions.find(s => this.scopesMatch(s.scopes, scopes)); + + if (!session) { + return null; + } + + const [chatEntitlements, tokenEntitlements] = await Promise.all([ + this.getChatEntitlements(session.accessToken, chatEntitlementUrl), + this.getTokenEntitlements(session.accessToken, tokenEntitlementUrl) + ]); + + return { + sessionId: session.id, + enterprise: id === enterpriseAuthProviderId || session.account.label.includes('_'), + ...chatEntitlements, + ...tokenEntitlements, + }; + } + + private scopesMatch(scopes: ReadonlyArray, expectedScopes: string[]): boolean { + return scopes.length === expectedScopes.length && expectedScopes.every(scope => scopes.includes(scope)); + } + + private async getTokenEntitlements(accessToken: string, tokenEntitlementsUrl: string): Promise> { + if (!tokenEntitlementsUrl) { + return {}; + } + + try { + const chatContext = await this.requestService.request({ + type: 'GET', + url: tokenEntitlementsUrl, + disableCache: true, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }, CancellationToken.None); + + const chatData = await asJson(chatContext); + if (chatData) { + return { + // Editor preview features are disabled if the flag is present and set to 0 + chat_preview_features_enabled: this.extractFromToken(chatData.token, 'editor_preview_features') !== '0', + }; + } + this.logService.error('Failed to fetch token entitlements', 'No data returned'); + } catch (error) { + this.logService.error('Failed to fetch token entitlements', getErrorMessage(error)); + } + + return {}; + } + + private async getChatEntitlements(accessToken: string, chatEntitlementsUrl: string): Promise> { + if (!chatEntitlementsUrl) { + return {}; + } + + try { + const context = await this.requestService.request({ + type: 'GET', + url: chatEntitlementsUrl, + disableCache: true, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }, CancellationToken.None); + + const data = await asJson(context); + if (data) { + return data; + } + this.logService.error('Failed to fetch entitlements', 'No data returned'); + } catch (error) { + this.logService.error('Failed to fetch entitlements', getErrorMessage(error)); + } + return {}; + } + + private registerSignInAction(authProviderId: string, authProviderLabel: string, enterpriseAuthProviderId: string, enterpriseAuthProviderConfig: string, scopes: string[]): void { + const that = this; + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.accounts.actions.signin', + title: localize('sign in', "Sign in to {0}", authProviderLabel), + menu: { + id: MenuId.AccountsContext, + when: CONTEXT_DEFAULT_ACCOUNT_STATE.isEqualTo(DefaultAccountStatus.Unavailable), + group: '0_signin', + } + }); + } + run(): Promise { + const id = that.configurationService.getValue(enterpriseAuthProviderConfig) ? enterpriseAuthProviderId : authProviderId; + return that.authenticationService.createSession(id, scopes); + } + })); + } + +} diff --git a/src/vs/workbench/services/aiSettingsSearch/common/aiSettingsSearch.ts b/src/vs/workbench/services/aiSettingsSearch/common/aiSettingsSearch.ts new file mode 100644 index 00000000000..f5d3a3b50bc --- /dev/null +++ b/src/vs/workbench/services/aiSettingsSearch/common/aiSettingsSearch.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IDisposable } from '../../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IAiSettingsSearchService = createDecorator('IAiSettingsSearchService'); + +export enum AiSettingsSearchResultKind { + EMBEDDED = 1, + LLM_RANKED = 2, + CANCELED = 3, +} + +export interface AiSettingsSearchResult { + query: string; + kind: AiSettingsSearchResultKind; + settings: string[]; +} + +export interface AiSettingsSearchProviderOptions { + limit: number; +} + +export interface IAiSettingsSearchService { + readonly _serviceBrand: undefined; + + // Called from the Settings editor + isEnabled(): boolean; + startSearch(query: string, token: CancellationToken): void; + getEmbeddingsResults(query: string, token: CancellationToken): Promise; + getLLMRankedResults(query: string, token: CancellationToken): Promise; + + // Called from the main thread + registerSettingsSearchProvider(provider: IAiSettingsSearchProvider): IDisposable; + handleSearchResult(results: AiSettingsSearchResult): void; +} + +export interface IAiSettingsSearchProvider { + searchSettings(query: string, option: AiSettingsSearchProviderOptions, token: CancellationToken): void; +} diff --git a/src/vs/workbench/services/aiSettingsSearch/common/aiSettingsSearchService.ts b/src/vs/workbench/services/aiSettingsSearch/common/aiSettingsSearchService.ts new file mode 100644 index 00000000000..d34ec08f301 --- /dev/null +++ b/src/vs/workbench/services/aiSettingsSearch/common/aiSettingsSearchService.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise, raceCancellation } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { AiSettingsSearchResult, AiSettingsSearchResultKind, IAiSettingsSearchProvider, IAiSettingsSearchService } from './aiSettingsSearch.js'; + +export class AiSettingsSearchService implements IAiSettingsSearchService { + readonly _serviceBrand: undefined; + private static readonly MAX_PICKS = 5; + + private _providers: IAiSettingsSearchProvider[] = []; + private _llmRankedResultsPromises: Map> = new Map(); + private _embeddingsResultsPromises: Map> = new Map(); + + isEnabled(): boolean { + return this._providers.length > 0; + } + + registerSettingsSearchProvider(provider: IAiSettingsSearchProvider): IDisposable { + this._providers.push(provider); + return { + dispose: () => { + const index = this._providers.indexOf(provider); + if (index !== -1) { + this._providers.splice(index, 1); + } + } + }; + } + + startSearch(query: string, token: CancellationToken): void { + if (!this.isEnabled()) { + throw new Error('No settings search providers registered'); + } + + this._providers.forEach(provider => provider.searchSettings(query, { limit: AiSettingsSearchService.MAX_PICKS }, token)); + } + + async getEmbeddingsResults(query: string, token: CancellationToken): Promise { + if (!this.isEnabled()) { + throw new Error('No settings search providers registered'); + } + + const promise = new DeferredPromise(); + this._embeddingsResultsPromises.set(query, promise); + const result = await raceCancellation(promise.p, token); + return result ?? null; + } + + async getLLMRankedResults(query: string, token: CancellationToken): Promise { + if (!this.isEnabled()) { + throw new Error('No settings search providers registered'); + } + + const promise = new DeferredPromise(); + this._llmRankedResultsPromises.set(query, promise); + const result = await raceCancellation(promise.p, token); + return result ?? null; + } + + handleSearchResult(result: AiSettingsSearchResult): void { + if (!this.isEnabled()) { + return; + } + + if (result.kind === AiSettingsSearchResultKind.EMBEDDED) { + const promise = this._embeddingsResultsPromises.get(result.query); + if (promise) { + promise.complete(result.settings); + this._embeddingsResultsPromises.delete(result.query); + } + } else if (result.kind === AiSettingsSearchResultKind.LLM_RANKED) { + const promise = this._llmRankedResultsPromises.get(result.query); + if (promise) { + promise.complete(result.settings); + this._llmRankedResultsPromises.delete(result.query); + } + } + } +} + +registerSingleton(IAiSettingsSearchService, AiSettingsSearchService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts b/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts index 276e603077f..b11cd64b76e 100644 --- a/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts +++ b/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts @@ -21,7 +21,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { DEFAULT_AUX_WINDOW_SIZE, IRectangle, WindowMinimumSize } from '../../../../platform/window/common/window.js'; +import { DEFAULT_AUX_WINDOW_SIZE, DEFAULT_COMPACT_AUX_WINDOW_SIZE, IRectangle, WindowMinimumSize } from '../../../../platform/window/common/window.js'; import { BaseWindow } from '../../../browser/window.js'; import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; import { IHostService } from '../../host/browser/host.js'; @@ -42,9 +42,11 @@ export enum AuxiliaryWindowMode { export interface IAuxiliaryWindowOpenOptions { readonly bounds?: Partial; + readonly compact?: boolean; readonly mode?: AuxiliaryWindowMode; readonly zoomLevel?: number; + readonly alwaysOnTop?: boolean; readonly nativeTitlebar?: boolean; readonly disableFullscreen?: boolean; @@ -78,6 +80,8 @@ export interface IAuxiliaryWindow extends IDisposable { readonly window: CodeWindow; readonly container: HTMLElement; + updateOptions(options: { compact: boolean } | undefined): void; + layout(): void; createState(): IAuxiliaryWindowOpenOptions; @@ -104,6 +108,8 @@ export class AuxiliaryWindow extends BaseWindow implements IAuxiliaryWindow { readonly whenStylesHaveLoaded: Promise; + private compact = false; + constructor( readonly window: CodeWindow, readonly container: HTMLElement, @@ -119,6 +125,10 @@ export class AuxiliaryWindow extends BaseWindow implements IAuxiliaryWindow { this.registerListeners(); } + updateOptions(options: { compact: boolean }): void { + this.compact = options.compact; + } + private registerListeners(): void { this._register(addDisposableListener(this.window, EventType.BEFORE_UNLOAD, (e: BeforeUnloadEvent) => this.handleBeforeUnload(e))); this._register(addDisposableListener(this.window, EventType.UNLOAD, () => this.handleUnload())); @@ -208,7 +218,8 @@ export class AuxiliaryWindow extends BaseWindow implements IAuxiliaryWindow { width: this.window.outerWidth, height: this.window.outerHeight }, - zoomLevel: getZoomLevel(this.window) + zoomLevel: getZoomLevel(this.window), + compact: this.compact }; } @@ -227,8 +238,6 @@ export class BrowserAuxiliaryWindowService extends Disposable implements IAuxili declare readonly _serviceBrand: undefined; - private static readonly DEFAULT_SIZE = DEFAULT_AUX_WINDOW_SIZE; - private static WINDOW_IDS = getWindowId(mainWindow) + 1; // start from the main window ID + 1 private readonly _onDidOpenAuxiliaryWindow = this._register(new Emitter()); @@ -263,6 +272,7 @@ export class BrowserAuxiliaryWindowService extends Disposable implements IAuxili const { container, stylesLoaded } = this.createContainer(targetWindow, containerDisposables, options); const auxiliaryWindow = this.createAuxiliaryWindow(targetWindow, container, stylesLoaded); + auxiliaryWindow.updateOptions({ compact: options?.compact ?? false }); const registryDisposables = new DisposableStore(); this.windows.set(targetWindow.vscodeWindowId, auxiliaryWindow); @@ -309,8 +319,10 @@ export class BrowserAuxiliaryWindowService extends Disposable implements IAuxili height: activeWindow.outerHeight }; - const width = Math.max(options?.bounds?.width ?? BrowserAuxiliaryWindowService.DEFAULT_SIZE.width, WindowMinimumSize.WIDTH); - const height = Math.max(options?.bounds?.height ?? BrowserAuxiliaryWindowService.DEFAULT_SIZE.height, WindowMinimumSize.HEIGHT); + const defaultSize = options?.compact ? DEFAULT_COMPACT_AUX_WINDOW_SIZE : DEFAULT_AUX_WINDOW_SIZE; + + const width = Math.max(options?.bounds?.width ?? defaultSize.width, WindowMinimumSize.WIDTH); + const height = Math.max(options?.bounds?.height ?? defaultSize.height, WindowMinimumSize.HEIGHT); let newWindowBounds: IRectangle = { x: options?.bounds?.x ?? Math.max(activeWindowBounds.x + activeWindowBounds.width / 2 - width / 2, 0), @@ -339,6 +351,7 @@ export class BrowserAuxiliaryWindowService extends Disposable implements IAuxili // non-standard properties options?.nativeTitlebar ? 'window-native-titlebar=yes' : undefined, options?.disableFullscreen ? 'window-disable-fullscreen=yes' : undefined, + options?.alwaysOnTop ? 'window-always-on-top=yes' : undefined, options?.mode === AuxiliaryWindowMode.Maximized ? 'window-maximized=yes' : undefined, options?.mode === AuxiliaryWindowMode.Fullscreen ? 'window-fullscreen=yes' : undefined ]); diff --git a/src/vs/workbench/services/auxiliaryWindow/electron-sandbox/auxiliaryWindowService.ts b/src/vs/workbench/services/auxiliaryWindow/electron-sandbox/auxiliaryWindowService.ts index 6b98b66e621..2c6f4addc8e 100644 --- a/src/vs/workbench/services/auxiliaryWindow/electron-sandbox/auxiliaryWindowService.ts +++ b/src/vs/workbench/services/auxiliaryWindow/electron-sandbox/auxiliaryWindowService.ts @@ -34,6 +34,7 @@ export class NativeAuxiliaryWindow extends AuxiliaryWindow { private skipUnloadConfirmation = false; private maximized = false; + private alwaysOnTop = false; constructor( window: CodeWindow, @@ -55,6 +56,7 @@ export class NativeAuxiliaryWindow extends AuxiliaryWindow { } this.handleFullScreenState(); + this.handleAlwaysOnTopState(); } private handleMaximizedState(): void { @@ -75,6 +77,18 @@ export class NativeAuxiliaryWindow extends AuxiliaryWindow { })); } + private handleAlwaysOnTopState(): void { + (async () => { + this.alwaysOnTop = await this.nativeHostService.isWindowAlwaysOnTop({ targetWindowId: this.window.vscodeWindowId }); + })(); + + this._register(this.nativeHostService.onDidChangeWindowAlwaysOnTop(({ windowId, alwaysOnTop }) => { + if (windowId === this.window.vscodeWindowId) { + this.alwaysOnTop = alwaysOnTop; + } + })); + } + private async handleFullScreenState(): Promise { const fullscreen = await this.nativeHostService.isFullScreen({ targetWindowId: this.window.vscodeWindowId }); if (fullscreen) { @@ -113,7 +127,8 @@ export class NativeAuxiliaryWindow extends AuxiliaryWindow { return { ...state, bounds: state.bounds, - mode: this.maximized ? AuxiliaryWindowMode.Maximized : fullscreen ? AuxiliaryWindowMode.Fullscreen : AuxiliaryWindowMode.Normal + mode: this.maximized ? AuxiliaryWindowMode.Maximized : fullscreen ? AuxiliaryWindowMode.Fullscreen : AuxiliaryWindowMode.Normal, + alwaysOnTop: this.alwaysOnTop }; } } @@ -156,7 +171,7 @@ export class NativeAuxiliaryWindowService extends BrowserAuxiliaryWindowService return super.createContainer(auxiliaryWindow, disposables); } - protected override createAuxiliaryWindow(targetWindow: CodeWindow, container: HTMLElement, stylesHaveLoaded: Barrier,): AuxiliaryWindow { + protected override createAuxiliaryWindow(targetWindow: CodeWindow, container: HTMLElement, stylesHaveLoaded: Barrier): AuxiliaryWindow { return new NativeAuxiliaryWindow(targetWindow, container, stylesHaveLoaded, this.configurationService, this.nativeHostService, this.instantiationService, this.hostService, this.environmentService, this.dialogService); } } diff --git a/src/vs/workbench/services/clipboard/electron-sandbox/clipboardService.ts b/src/vs/workbench/services/clipboard/electron-sandbox/clipboardService.ts index b9a2f688502..0ee5260206a 100644 --- a/src/vs/workbench/services/clipboard/electron-sandbox/clipboardService.ts +++ b/src/vs/workbench/services/clipboard/electron-sandbox/clipboardService.ts @@ -20,6 +20,10 @@ export class NativeClipboardService implements IClipboardService { @INativeHostService private readonly nativeHostService: INativeHostService ) { } + async triggerPaste(targetWindowId: number): Promise { + return this.nativeHostService.triggerPaste({ targetWindowId }); + } + async readImage(): Promise { return this.nativeHostService.readImage(); } diff --git a/src/vs/workbench/services/configuration/common/jsonEditingService.ts b/src/vs/workbench/services/configuration/common/jsonEditingService.ts index 9ce5e10e4dd..13872df9537 100644 --- a/src/vs/workbench/services/configuration/common/jsonEditingService.ts +++ b/src/vs/workbench/services/configuration/common/jsonEditingService.ts @@ -61,7 +61,7 @@ export class JSONEditingService implements IJSONEditingService { let hasEdits: boolean = false; for (const value of values) { const edit = this.getEdits(model, value)[0]; - hasEdits = !!edit && this.applyEditsToBuffer(edit, model); + hasEdits = (!!edit && this.applyEditsToBuffer(edit, model)) || hasEdits; } if (hasEdits) { return this.textFileService.save(model.uri); diff --git a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts index fde5e04ed5d..c192b3a38a7 100644 --- a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts +++ b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts @@ -4,24 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import { Queue } from '../../../../base/common/async.js'; import { IStringDictionary } from '../../../../base/common/collections.js'; +import { Iterable } from '../../../../base/common/iterator.js'; import { LRUCache } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { IProcessEnvironment } from '../../../../base/common/platform.js'; import * as Types from '../../../../base/common/types.js'; import { URI as uri } from '../../../../base/common/uri.js'; import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; -import * as nls from '../../../../nls.js'; +import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationOverrides, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { IInputOptions, IPickOptions, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService, IWorkspaceFolderData, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceContextService, IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../common/editor.js'; import { IEditorService } from '../../editor/common/editorService.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { IPathService } from '../../path/common/pathService.js'; -import { ConfiguredInput } from '../common/configurationResolver.js'; +import { ConfiguredInput, VariableError, VariableKind } from '../common/configurationResolver.js'; +import { ConfigurationResolverExpression, IResolvedValue } from '../common/configurationResolverExpression.js'; import { AbstractVariableResolverService } from '../common/variableResolver.js'; const LAST_INPUT_STORAGE_KEY = 'configResolveInputLru'; @@ -42,7 +44,7 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR editorService: IEditorService, private readonly configurationService: IConfigurationService, private readonly commandService: ICommandService, - private readonly workspaceContextService: IWorkspaceContextService, + workspaceContextService: IWorkspaceContextService, private readonly quickInputService: IQuickInputService, private readonly labelService: ILabelService, private readonly pathService: IPathService, @@ -57,8 +59,8 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR getWorkspaceFolderCount: (): number => { return workspaceContextService.getWorkspace().folders.length; }, - getConfigurationValue: (folderUri: uri | undefined, suffix: string): string | undefined => { - return configurationService.getValue(suffix, folderUri ? { resource: folderUri } : {}); + getConfigurationValue: (folderUri: uri | undefined, section: string): string | undefined => { + return configurationService.getValue(section, folderUri ? { resource: folderUri } : {}); }, getAppRoot: (): string | undefined => { return context.getAppRoot(); @@ -136,182 +138,126 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR return extensionService.getExtension(id); }, }, labelService, pathService.userHome().then(home => home.path), envVariablesPromise); + + this.resolvableVariables.add('command'); + this.resolvableVariables.add('input'); } - public override async resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise { - // resolve any non-interactive variables and any contributed variables - config = await this.resolveAnyAsync(folder, config); + override async resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise { + const parsed = ConfigurationResolverExpression.parse(config); + await this.resolveWithInteraction(folder, parsed, section, variables, target); - // resolve input variables in the order in which they are encountered - return this.resolveWithInteraction(folder, config, section, variables, target).then(mapping => { - // finally substitute evaluated command variables (if there are any) - if (!mapping) { - return null; - } else if (mapping.size > 0) { - return this.resolveAnyAsync(folder, config, Object.fromEntries(mapping)); - } else { - return config; - } - }); + return parsed.toObject(); } - public override async resolveWithInteraction(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise | undefined> { - // resolve any non-interactive variables and any contributed variables - const resolved = await this.resolveAnyMap(folder, config); - config = resolved.newConfig; - const allVariableMapping: Map = resolved.resolvedVariables; + override async resolveWithInteraction(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variableToCommandMap?: IStringDictionary, target?: ConfigurationTarget): Promise | undefined> { + const expr = ConfigurationResolverExpression.parse(config); - // resolve input and command variables in the order in which they are encountered - return this.resolveWithInputAndCommands(folder, config, variables, section, target).then(inputOrCommandMapping => { - if (this.updateMapping(inputOrCommandMapping, allVariableMapping)) { - return allVariableMapping; - } - return undefined; - }); - } + // Get values for input variables from UI + for (const variable of expr.unresolved()) { + let result: IResolvedValue | undefined; - /** - * Add all items from newMapping to fullMapping. Returns false if newMapping is undefined. - */ - private updateMapping(newMapping: IStringDictionary | undefined, fullMapping: Map): boolean { - if (!newMapping) { - return false; - } - for (const [key, value] of Object.entries(newMapping)) { - fullMapping.set(key, value); - } - return true; - } - - /** - * Finds and executes all input and command variables in the given configuration and returns their values as a dictionary. - * Please note: this method does not substitute the input or command variables (so the configuration is not modified). - * The returned dictionary can be passed to "resolvePlatform" for the actual substitution. - * See #6569. - * - * @param variableToCommandMap Aliases for commands - */ - private async resolveWithInputAndCommands(folder: IWorkspaceFolderData | undefined, configuration: any, variableToCommandMap?: IStringDictionary, section?: string, target?: ConfigurationTarget): Promise | undefined> { - - if (!configuration) { - return Promise.resolve(undefined); - } - - // get all "inputs" - let inputs: ConfiguredInput[] = []; - if (this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY && section) { - const overrides: IConfigurationOverrides = folder ? { resource: folder.uri } : {}; - const result = this.configurationService.inspect(section, overrides); - if (result && (result.userValue || result.workspaceValue || result.workspaceFolderValue || result.userRemoteValue)) { - switch (target) { - case ConfigurationTarget.USER: inputs = (result.userValue)?.inputs; break; - case ConfigurationTarget.USER_REMOTE: inputs = (result.userRemoteValue)?.inputs; break; - case ConfigurationTarget.WORKSPACE: inputs = (result.workspaceValue)?.inputs; break; - default: inputs = (result.workspaceFolderValue)?.inputs; - } - } else { - const valueResult = this.configurationService.getValue(section, overrides); - if (valueResult) { - inputs = valueResult.inputs; - } - } - } - - // extract and dedupe all "input" and "command" variables and preserve their order in an array - const variables: string[] = []; - this.findVariables(configuration, variables); - - const variableValues: IStringDictionary = Object.create(null); - - for (const variable of variables) { - - const [type, name] = variable.split(':', 2); - - let result: string | undefined; - - switch (type) { - - case 'input': - result = await this.showUserInput(section, name, inputs); - break; - - case 'command': { - // use the name as a command ID #12735 - const commandId = (variableToCommandMap ? variableToCommandMap[name] : undefined) || name; - result = await this.commandService.executeCommand(commandId, configuration); - if (typeof result !== 'string' && !Types.isUndefinedOrNull(result)) { - throw new Error(nls.localize('commandVariable.noStringType', "Cannot substitute command variable '{0}' because command did not return a result of type string.", commandId)); + // Command + if (variable.name === 'command') { + const commandId = (variableToCommandMap ? variableToCommandMap[variable.arg!] : undefined) || variable.arg!; + const value = await this.commandService.executeCommand(commandId, expr.toObject()); + if (!Types.isUndefinedOrNull(value)) { + if (typeof value !== 'string') { + throw new VariableError(VariableKind.Command, localize('commandVariable.noStringType', "Cannot substitute command variable '{0}' because command did not return a result of type string.", commandId)); } - break; + result = { value }; } - default: - // Try to resolve it as a contributed variable - if (this._contributedVariables.has(variable)) { - result = await this._contributedVariables.get(variable)!(); - } + } + // Input + else if (variable.name === 'input') { + result = await this.showUserInput(section!, variable.arg!, await this.resolveInputs(folder, section!, target), variableToCommandMap); + } + // Contributed variable + else if (this._contributedVariables.has(variable.inner)) { + result = { value: await this._contributedVariables.get(variable.inner)!() }; + } + else { + // Fallback to parent evaluation + const resolvedValue = await this.evaluateSingleVariable(variable, folder?.uri); + if (resolvedValue === undefined) { + // Not something we can handle + continue; + } + result = typeof resolvedValue === 'string' ? { value: resolvedValue } : resolvedValue; } - if (typeof result === 'string') { - variableValues[variable] = result; - } else { + if (result === undefined) { + // Skip the entire flow if any input variable was canceled return undefined; } + + expr.resolve(variable, result); } - return variableValues; + return new Map(Iterable.map(expr.resolved(), ([key, value]) => [key.inner, value.value!])); } - /** - * Recursively finds all command or input variables in object and pushes them into variables. - * @param object object is searched for variables. - * @param variables All found variables are returned in variables. - */ - private findVariables(object: any, variables: string[]) { - if (typeof object === 'string') { - let matches; - while ((matches = BaseConfigurationResolverService.INPUT_OR_COMMAND_VARIABLES_PATTERN.exec(object)) !== null) { - if (matches.length === 4) { - const command = matches[1]; - if (variables.indexOf(command) < 0) { - variables.push(command); - } - } - } - for (const contributed of this._contributedVariables.keys()) { - if ((variables.indexOf(contributed) < 0) && (object.indexOf('${' + contributed + '}') >= 0)) { - variables.push(contributed); - } - } - } else if (Array.isArray(object)) { - for (const value of object) { - this.findVariables(value, variables); + private async resolveInputs(folder: IWorkspaceFolderData | undefined, section: string, target?: ConfigurationTarget): Promise { + if (!section) { + return undefined; + } - } - } else if (object) { - for (const value of Object.values(object)) { - this.findVariables(value, variables); + // Look at workspace configuration + let inputs: ConfiguredInput[] | undefined; + const overrides: IConfigurationOverrides = folder ? { resource: folder.uri } : {}; + const result = this.configurationService.inspect<{ inputs?: ConfiguredInput[] }>(section, overrides); + if (result) { + switch (target) { + case ConfigurationTarget.MEMORY: inputs = result.memoryValue?.inputs; break; + case ConfigurationTarget.DEFAULT: inputs = result.defaultValue?.inputs; break; + case ConfigurationTarget.USER: inputs = result.userValue?.inputs; break; + case ConfigurationTarget.USER_LOCAL: inputs = result.userLocalValue?.inputs; break; + case ConfigurationTarget.USER_REMOTE: inputs = result.userRemoteValue?.inputs; break; + case ConfigurationTarget.APPLICATION: inputs = result.applicationValue?.inputs; break; + case ConfigurationTarget.WORKSPACE: inputs = result.workspaceValue?.inputs; break; + + case ConfigurationTarget.WORKSPACE_FOLDER: + default: + inputs = result.workspaceFolderValue?.inputs; + break; } } + + + inputs ??= this.configurationService.getValue(section, overrides)?.inputs; + + return inputs; } - /** - * Takes the provided input info and shows the quick pick so the user can provide the value for the input - * @param variable Name of the input variable. - * @param inputInfos Information about each possible input variable. - */ - private showUserInput(section: string | undefined, variable: string, inputInfos: ConfiguredInput[]): Promise { + private readInputLru(): LRUCache { + const contents = this.storageService.get(LAST_INPUT_STORAGE_KEY, StorageScope.WORKSPACE); + const lru = new LRUCache(LAST_INPUT_CACHE_SIZE); + try { + if (contents) { + lru.fromJSON(JSON.parse(contents)); + } + } catch { + // ignored + } + return lru; + } + + private storeInputLru(lru: LRUCache): void { + this.storageService.store(LAST_INPUT_STORAGE_KEY, JSON.stringify(lru.toJSON()), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + private async showUserInput(section: string, variable: string, inputInfos: ConfiguredInput[] | undefined, variableToCommandMap?: IStringDictionary): Promise { if (!inputInfos) { - return Promise.reject(new Error(nls.localize('inputVariable.noInputSection', "Variable '{0}' must be defined in an '{1}' section of the debug or task configuration.", variable, 'inputs'))); + throw new VariableError(VariableKind.Input, localize('inputVariable.noInputSection', "Variable '{0}' must be defined in an '{1}' section of the debug or task configuration.", variable, 'inputs')); } - // find info for the given input variable + // Find info for the given input variable const info = inputInfos.filter(item => item.id === variable).pop(); if (info) { - const missingAttribute = (attrName: string) => { - throw new Error(nls.localize('inputVariable.missingAttribute', "Input variable '{0}' is of type '{1}' and must include '{2}'.", variable, info.type, attrName)); + throw new VariableError(VariableKind.Input, localize('inputVariable.missingAttribute', "Input variable '{0}' is of type '{1}' and must include '{2}'.", variable, info.type, attrName)); }; const defaultValueMap = this.readInputLru(); @@ -319,15 +265,11 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR const previousPickedValue = defaultValueMap.get(defaultValueKey); switch (info.type) { - case 'promptString': { if (!Types.isString(info.description)) { missingAttribute('description'); } - const inputOptions: IInputOptions = { prompt: info.description, ignoreFocusLost: true, value: previousPickedValue }; - if (info.default) { - inputOptions.value = info.default; - } + const inputOptions: IInputOptions = { prompt: info.description, ignoreFocusLost: true, value: variableToCommandMap?.[`input:${variable}`] ?? previousPickedValue ?? info.default }; if (info.password) { inputOptions.password = info.password; } @@ -335,7 +277,7 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR if (typeof resolvedInput === 'string') { this.storeInputLru(defaultValueMap.set(defaultValueKey, resolvedInput)); } - return resolvedInput as string; + return resolvedInput !== undefined ? { value: resolvedInput as string, input: info } : undefined; }); } @@ -352,6 +294,7 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR } else { missingAttribute('options'); } + interface PickStringItem extends IQuickPickItem { value: string; } @@ -360,27 +303,28 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR const value = Types.isString(pickOption) ? pickOption : pickOption.value; const label = Types.isString(pickOption) ? undefined : pickOption.label; - // If there is no label defined, use value as label const item: PickStringItem = { label: label ? `${label}: ${value}` : value, value: value }; + const topValue = variableToCommandMap?.[`input:${variable}`] ?? previousPickedValue ?? info.default; if (value === info.default) { - item.description = nls.localize('inputVariable.defaultInputValue', "(Default)"); + item.description = localize('inputVariable.defaultInputValue', "(Default)"); picks.unshift(item); - } else if (!info.default && value === previousPickedValue) { + } else if (value === topValue) { picks.unshift(item); } else { picks.push(item); } } + const pickOptions: IPickOptions = { placeHolder: info.description, matchOnDetail: true, ignoreFocusLost: true }; return this.userInputAccessQueue.queue(() => this.quickInputService.pick(picks, pickOptions, undefined)).then(resolvedInput => { if (resolvedInput) { const value = (resolvedInput as PickStringItem).value; this.storeInputLru(defaultValueMap.set(defaultValueKey, value)); - return value; + return { value, input: info }; } return undefined; }); @@ -392,34 +336,17 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR } return this.userInputAccessQueue.queue(() => this.commandService.executeCommand(info.command, info.args)).then(result => { if (typeof result === 'string' || Types.isUndefinedOrNull(result)) { - return result; + return { value: result, input: info }; } - throw new Error(nls.localize('inputVariable.command.noStringType', "Cannot substitute input variable '{0}' because command '{1}' did not return a result of type string.", variable, info.command)); + throw new VariableError(VariableKind.Input, localize('inputVariable.command.noStringType', "Cannot substitute input variable '{0}' because command '{1}' did not return a result of type string.", variable, info.command)); }); } default: - throw new Error(nls.localize('inputVariable.unknownType', "Input variable '{0}' can only be of type 'promptString', 'pickString', or 'command'.", variable)); + throw new VariableError(VariableKind.Input, localize('inputVariable.unknownType', "Input variable '{0}' can only be of type 'promptString', 'pickString', or 'command'.", variable)); } } - return Promise.reject(new Error(nls.localize('inputVariable.undefinedVariable', "Undefined input variable '{0}' encountered. Remove or define '{0}' to continue.", variable))); - } - private storeInputLru(lru: LRUCache): void { - this.storageService.store(LAST_INPUT_STORAGE_KEY, JSON.stringify(lru.toJSON()), StorageScope.WORKSPACE, StorageTarget.MACHINE); - } - - private readInputLru(): LRUCache { - const contents = this.storageService.get(LAST_INPUT_STORAGE_KEY, StorageScope.WORKSPACE); - const lru = new LRUCache(LAST_INPUT_CACHE_SIZE); - try { - if (contents) { - lru.fromJSON(JSON.parse(contents)); - } - } catch { - // ignored - } - - return lru; + throw new VariableError(VariableKind.Input, localize('inputVariable.undefinedVariable', "Undefined input variable '{0}' encountered. Remove or define '{0}' to continue.", variable)); } } diff --git a/src/vs/workbench/services/configurationResolver/common/configurationResolver.ts b/src/vs/workbench/services/configurationResolver/common/configurationResolver.ts index ea661b6c21d..333de5abbd7 100644 --- a/src/vs/workbench/services/configurationResolver/common/configurationResolver.ts +++ b/src/vs/workbench/services/configurationResolver/common/configurationResolver.ts @@ -9,30 +9,23 @@ import { IProcessEnvironment } from '../../../../base/common/platform.js'; import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; +import { ConfigurationResolverExpression } from './configurationResolverExpression.js'; export const IConfigurationResolverService = createDecorator('configurationResolverService'); export interface IConfigurationResolverService { readonly _serviceBrand: undefined; - resolveWithEnvironment(environment: IProcessEnvironment, folder: IWorkspaceFolderData | undefined, value: string): Promise; + /** Variables the resolver is able to resolve. */ + readonly resolvableVariables: ReadonlySet; - resolveAsync(folder: IWorkspaceFolderData | undefined, value: string): Promise; - resolveAsync(folder: IWorkspaceFolderData | undefined, value: string[]): Promise; - resolveAsync(folder: IWorkspaceFolderData | undefined, value: IStringDictionary): Promise>; + resolveWithEnvironment(environment: IProcessEnvironment, folder: IWorkspaceFolderData | undefined, value: string): Promise; /** * Recursively resolves all variables in the given config and returns a copy of it with substituted values. * Command variables are only substituted if a "commandValueMapping" dictionary is given and if it contains an entry for the command. */ - resolveAnyAsync(folder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary): Promise; - - /** - * Recursively resolves all variables in the given config. - * Returns a copy of it with substituted values and a map of variables and their resolution. - * Keys in the map will be of the format input:variableName or command:variableName. - */ - resolveAnyMap(folder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary): Promise<{ newConfig: any; resolvedVariables: Map }>; + resolveAsync(folder: IWorkspaceFolderData | undefined, config: T): Promise ? R : T>; /** * Recursively resolves all variables (including commands and user input) in the given config and returns a copy of it with substituted values. @@ -113,6 +106,8 @@ export enum VariableKind { PathSeparatorAlias = '/' } +export const allVariableKinds = Object.values(VariableKind).filter((value): value is VariableKind => typeof value === 'string'); + export class VariableError extends ErrorNoTelemetry { constructor(public readonly variable: VariableKind, message?: string) { super(message); diff --git a/src/vs/workbench/services/configurationResolver/common/configurationResolverExpression.ts b/src/vs/workbench/services/configurationResolver/common/configurationResolverExpression.ts new file mode 100644 index 00000000000..1cabb263d78 --- /dev/null +++ b/src/vs/workbench/services/configurationResolver/common/configurationResolverExpression.ts @@ -0,0 +1,301 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Iterable } from '../../../../base/common/iterator.js'; +import { isLinux, isMacintosh, isWindows } from '../../../../base/common/platform.js'; +import { ConfiguredInput } from './configurationResolver.js'; + +/** A replacement found in the object, as ${name} or ${name:arg} */ +export type Replacement = { + /** ${name:arg} */ + id: string; + /** The `name:arg` in ${name:arg} */ + inner: string; + /** The `name` in ${name:arg} */ + name: string; + /** The `arg` in ${name:arg} */ + arg?: string; +}; + +interface IConfigurationResolverExpression { + /** + * Gets the replacements which have not yet been + * resolved. + */ + unresolved(): Iterable; + + /** + * Gets the replacements which have been resolved. + */ + resolved(): Iterable<[Replacement, IResolvedValue]>; + + /** + * Resolves a replacement into the string value. + * If the value is undefined, the original variable text will be preserved. + */ + resolve(replacement: Replacement, data: string | IResolvedValue): void; + + /** + * Returns the complete object. Any unresolved replacements are left intact. + */ + toObject(): T; +} + +type PropertyLocation = { + object: any; + propertyName: string | number; + replaceKeyName?: boolean; +}; + +export interface IResolvedValue { + value: string | undefined; + + /** Present when the variable is resolved from an input field. */ + input?: ConfiguredInput; +} + +interface IReplacementLocation { + replacement: Replacement; + locations: PropertyLocation[]; + resolved?: IResolvedValue; +} + +export class ConfigurationResolverExpression implements IConfigurationResolverExpression { + public static readonly VARIABLE_LHS = '${'; + + private locations = new Map(); + private root: T; + private stringRoot: boolean; + /** + * Callbacks when a new replacement is made, so that nested resolutions from + * `expr.unresolved()` can be fulfilled in the same iteration. + */ + private newReplacementNotifiers = new Set<(r: Replacement) => void>(); + + private constructor(object: T) { + // If the input is a string, wrap it in an object so we can use the same logic + if (typeof object === 'string') { + this.stringRoot = true; + this.root = { value: object } as any; + } else { + this.stringRoot = false; + this.root = structuredClone(object); + } + } + + /** + * Creates a new {@link ConfigurationResolverExpression} from an object. + * Note that platform-specific keys (i.e. `windows`, `osx`, `linux`) are + * applied during parsing. + */ + public static parse(object: T): ConfigurationResolverExpression { + if (object instanceof ConfigurationResolverExpression) { + return object; + } + + const expr = new ConfigurationResolverExpression(object); + expr.applyPlatformSpecificKeys(); + expr.parseObject(expr.root); + return expr; + } + + private applyPlatformSpecificKeys() { + const config = this.root as any; // already cloned by ctor, safe to change + const key = isWindows ? 'windows' : isMacintosh ? 'osx' : isLinux ? 'linux' : undefined; + + if (key && config && typeof config === 'object' && config.hasOwnProperty(key)) { + Object.keys(config[key]).forEach(k => config[k] = config[key][k]); + } + + delete config.windows; + delete config.osx; + delete config.linux; + } + + private parseVariable(str: string, start: number): { replacement: Replacement; end: number } | undefined { + if (str[start] !== '$' || str[start + 1] !== '{') { + return undefined; + } + + let end = start + 2; + let braceCount = 1; + while (end < str.length) { + if (str[end] === '{') { + braceCount++; + } else if (str[end] === '}') { + braceCount--; + if (braceCount === 0) { + break; + } + } + end++; + } + + if (braceCount !== 0) { + return undefined; + } + + const id = str.slice(start, end + 1); + const inner = str.substring(start + 2, end); + const colonIdx = inner.indexOf(':'); + if (colonIdx === -1) { + return { replacement: { id, name: inner, inner }, end }; + } + + return { + replacement: { + id, + inner, + name: inner.slice(0, colonIdx), + arg: inner.slice(colonIdx + 1) + }, + end + }; + } + + private parseObject(obj: any): void { + if (typeof obj !== 'object' || obj === null) { + return; + } + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + const value = obj[i]; + if (typeof value === 'string') { + this.parseString(obj, i, value); + } else { + this.parseObject(value); + } + } + return; + } + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string') { + this.parseString(obj, key, value); + } else { + this.parseObject(value); + } + } + + // only after all values are marked for replacement, we can collect keys that have to be replaced + for (const [key] of Object.entries(obj)) { + this.parseString(obj, key, key, true); + } + } + + private parseString(object: any, propertyName: string | number, value: string, replaceKeyName?: boolean, replacementPath?: string[]): void { + let pos = 0; + while (pos < value.length) { + const match = value.indexOf('${', pos); + if (match === -1) { + break; + } + const parsed = this.parseVariable(value, match); + if (parsed) { + pos = parsed.end + 1; + if (replacementPath?.includes(parsed.replacement.id)) { + continue; + } + + const locations = this.locations.get(parsed.replacement.id) || { locations: [], replacement: parsed.replacement }; + const newLocation: PropertyLocation = { object, propertyName, replaceKeyName }; + locations.locations.push(newLocation); + this.locations.set(parsed.replacement.id, locations); + + if (locations.resolved) { + this._resolveAtLocation(parsed.replacement, newLocation, locations.resolved, replacementPath); + } else { + this.newReplacementNotifiers.forEach(n => n(parsed.replacement)); + } + } else { + pos = match + 2; + } + } + } + + public *unresolved(): Iterable { + const newReplacements = new Map(); + const notifier = (replacement: Replacement) => { + newReplacements.set(replacement.id, replacement); + }; + + for (const location of this.locations.values()) { + if (location.resolved === undefined) { + newReplacements.set(location.replacement.id, location.replacement); + } + } + + this.newReplacementNotifiers.add(notifier); + + while (true) { + const next = Iterable.first(newReplacements); + if (!next) { + break; + } + + const [key, value] = next; + yield value; + newReplacements.delete(key); + } + + this.newReplacementNotifiers.delete(notifier); + } + + public resolved(): Iterable<[Replacement, IResolvedValue]> { + return Iterable.map(Iterable.filter(this.locations.values(), l => !!l.resolved), l => [l.replacement, l.resolved!]); + } + + public resolve(replacement: Replacement, data: string | IResolvedValue): void { + if (typeof data !== 'object') { + data = { value: String(data) }; + } + + const location = this.locations.get(replacement.id); + if (!location) { + return; + } + + location.resolved = data; + + if (data.value !== undefined) { + for (const l of location.locations || Iterable.empty()) { + this._resolveAtLocation(replacement, l, data); + } + } + } + + private _resolveAtLocation(replacement: Replacement, { replaceKeyName, propertyName, object }: PropertyLocation, data: IResolvedValue, path: string[] = []) { + if (data.value === undefined) { + return; + } + + // avoid recursive resolution, e.g. ${env:FOO} -> ${env:BAR}=${env:FOO} + path.push(replacement.id); + + // note: in nested `this.parseString`, parse only the new substring for any replacements, don't reparse the whole string + if (replaceKeyName && typeof propertyName === 'string') { + const value = object[propertyName]; + const newKey = propertyName.replaceAll(replacement.id, data.value); + delete object[propertyName]; + object[newKey] = value; + this.parseString(object, newKey, data.value, true, path); + } else { + this.parseString(object, propertyName, data.value, false, path); + object[propertyName] = object[propertyName].replaceAll(replacement.id, data.value); + } + + path.pop(); + } + + public toObject(): T { + // If we wrapped a string, unwrap it + if (this.stringRoot) { + return (this.root as any).value as T; + } + + return this.root; + } +} diff --git a/src/vs/workbench/services/configurationResolver/common/variableResolver.ts b/src/vs/workbench/services/configurationResolver/common/variableResolver.ts index 2c906b6a8cb..3b2c9d392a8 100644 --- a/src/vs/workbench/services/configurationResolver/common/variableResolver.ts +++ b/src/vs/workbench/services/configurationResolver/common/variableResolver.ts @@ -5,17 +5,16 @@ import { IStringDictionary } from '../../../../base/common/collections.js'; import { normalizeDriveLetter } from '../../../../base/common/labels.js'; -import * as objects from '../../../../base/common/objects.js'; import * as paths from '../../../../base/common/path.js'; -import { IProcessEnvironment, isLinux, isMacintosh, isWindows } from '../../../../base/common/platform.js'; +import { IProcessEnvironment, isWindows } from '../../../../base/common/platform.js'; import * as process from '../../../../base/common/process.js'; -import { replaceAsync } from '../../../../base/common/strings.js'; import * as types from '../../../../base/common/types.js'; import { URI as uri } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; -import { IConfigurationResolverService, VariableError, VariableKind } from './configurationResolver.js'; +import { allVariableKinds, IConfigurationResolverService, VariableError, VariableKind } from './configurationResolver.js'; +import { ConfigurationResolverExpression, IResolvedValue, Replacement } from './configurationResolverExpression.js'; interface IVariableResolveContext { getFolderUri(folderName: string): uri | undefined; @@ -33,10 +32,7 @@ interface IVariableResolveContext { type Environment = { env: IProcessEnvironment | undefined; userHome: string | undefined }; -export class AbstractVariableResolverService implements IConfigurationResolverService { - - static readonly VARIABLE_LHS = '${'; - static readonly VARIABLE_REGEXP = /\$\{(.*?)\}/g; +export abstract class AbstractVariableResolverService implements IConfigurationResolverService { declare readonly _serviceBrand: undefined; @@ -46,6 +42,8 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe private _userHomePromise?: Promise; protected _contributedVariables: Map Promise> = new Map(); + public readonly resolvableVariables = new Set(allVariableKinds); + constructor(_context: IVariableResolveContext, _labelService?: ILabelService, _userHomePromise?: Promise, _envVariablesPromise?: Promise) { this._context = _context; this._labelService = _labelService; @@ -69,62 +67,37 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe return envVariables; } - public resolveWithEnvironment(environment: IProcessEnvironment, root: IWorkspaceFolderData | undefined, value: string): Promise { - return this.recursiveResolve({ env: this.prepareEnv(environment), userHome: undefined }, root ? root.uri : undefined, value); - } + public async resolveWithEnvironment(environment: IProcessEnvironment, folder: IWorkspaceFolderData | undefined, value: string): Promise { + const expr = ConfigurationResolverExpression.parse(value); - public async resolveAsync(root: IWorkspaceFolderData | undefined, value: string): Promise; - public async resolveAsync(root: IWorkspaceFolderData | undefined, value: string[]): Promise; - public async resolveAsync(root: IWorkspaceFolderData | undefined, value: IStringDictionary): Promise>; - public async resolveAsync(root: IWorkspaceFolderData | undefined, value: any): Promise { - const environment: Environment = { - env: await this._envVariablesPromise, - userHome: await this._userHomePromise - }; - return this.recursiveResolve(environment, root ? root.uri : undefined, value); - } - - private async resolveAnyBase(workspaceFolder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary, resolvedVariables?: Map): Promise { - - const result = objects.deepClone(config); - - // hoist platform specific attributes to top level - if (isWindows && result.windows) { - Object.keys(result.windows).forEach(key => result[key] = result.windows[key]); - } else if (isMacintosh && result.osx) { - Object.keys(result.osx).forEach(key => result[key] = result.osx[key]); - } else if (isLinux && result.linux) { - Object.keys(result.linux).forEach(key => result[key] = result.linux[key]); + for (const replacement of expr.unresolved()) { + const resolvedValue = await this.evaluateSingleVariable(replacement, folder?.uri, environment); + if (resolvedValue !== undefined) { + expr.resolve(replacement, String(resolvedValue)); + } } - // delete all platform specific sections - delete result.windows; - delete result.osx; - delete result.linux; - - // substitute all variables recursively in string values - const environmentPromises: Environment = { - env: await this._envVariablesPromise, - userHome: await this._userHomePromise - }; - return this.recursiveResolve(environmentPromises, workspaceFolder ? workspaceFolder.uri : undefined, result, commandValueMapping, resolvedVariables); + return expr.toObject(); } - public async resolveAnyAsync(workspaceFolder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary): Promise { - return this.resolveAnyBase(workspaceFolder, config, commandValueMapping); + public async resolveAsync(folder: IWorkspaceFolderData | undefined, config: T): Promise ? R : T> { + const expr = ConfigurationResolverExpression.parse(config); + + for (const replacement of expr.unresolved()) { + const resolvedValue = await this.evaluateSingleVariable(replacement, folder?.uri); + if (resolvedValue !== undefined) { + expr.resolve(replacement, String(resolvedValue)); + } + } + + return expr.toObject() as any; } - public async resolveAnyMap(workspaceFolder: IWorkspaceFolderData | undefined, config: any, commandValueMapping?: IStringDictionary): Promise<{ newConfig: any; resolvedVariables: Map }> { - const resolvedVariables = new Map(); - const newConfig = await this.resolveAnyBase(workspaceFolder, config, commandValueMapping, resolvedVariables); - return { newConfig, resolvedVariables }; - } - - public resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary): Promise { + public resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: any): Promise { throw new Error('resolveWithInteractionReplace not implemented.'); } - public resolveWithInteraction(folder: IWorkspaceFolderData | undefined, config: any, section?: string, variables?: IStringDictionary): Promise | undefined> { + public resolveWithInteraction(folder: IWorkspaceFolderData | undefined, config: any): Promise | undefined> { throw new Error('resolveWithInteraction not implemented.'); } @@ -132,77 +105,36 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe if (this._contributedVariables.has(variable)) { throw new Error('Variable ' + variable + ' is contributed twice.'); } else { + this.resolvableVariables.add(variable); this._contributedVariables.set(variable, resolution); } } - private async recursiveResolve(environment: Environment, folderUri: uri | undefined, value: any, commandValueMapping?: IStringDictionary, resolvedVariables?: Map): Promise { - if (types.isString(value)) { - return this.resolveString(environment, folderUri, value, commandValueMapping, resolvedVariables); - } else if (Array.isArray(value)) { - return Promise.all(value.map(s => this.recursiveResolve(environment, folderUri, s, commandValueMapping, resolvedVariables))); - } else if (types.isObject(value)) { - const result: IStringDictionary | string[]> = Object.create(null); - const replaced = await Promise.all(Object.keys(value).map(async key => { - const replaced = await this.resolveString(environment, folderUri, key, commandValueMapping, resolvedVariables); - return [replaced, await this.recursiveResolve(environment, folderUri, value[key], commandValueMapping, resolvedVariables)] as const; - })); - // two step process to preserve object key order - for (const [key, value] of replaced) { - result[key] = value; - } - return result; - } - return value; - } - - private resolveString(environment: Environment, folderUri: uri | undefined, value: string, commandValueMapping: IStringDictionary | undefined, resolvedVariables?: Map): Promise { - // loop through all variables occurrences in 'value' - return replaceAsync(value, AbstractVariableResolverService.VARIABLE_REGEXP, async (match: string, variable: string) => { - // disallow attempted nesting, see #77289. This doesn't exclude variables that resolve to other variables. - if (variable.includes(AbstractVariableResolverService.VARIABLE_LHS)) { - return match; - } - - let resolvedValue = await this.evaluateSingleVariable(environment, match, variable, folderUri, commandValueMapping); - - resolvedVariables?.set(variable, resolvedValue); - - if ((resolvedValue !== match) && types.isString(resolvedValue) && resolvedValue.match(AbstractVariableResolverService.VARIABLE_REGEXP)) { - resolvedValue = await this.resolveString(environment, folderUri, resolvedValue, commandValueMapping, resolvedVariables); - } - - return resolvedValue; - }); - } - private fsPath(displayUri: uri): string { return this._labelService ? this._labelService.getUriLabel(displayUri, { noPrefix: true }) : displayUri.fsPath; } - private async evaluateSingleVariable(environment: Environment, match: string, variable: string, folderUri: uri | undefined, commandValueMapping: IStringDictionary | undefined): Promise { + protected async evaluateSingleVariable(replacement: Replacement, folderUri: uri | undefined, processEnvironment?: IProcessEnvironment, commandValueMapping?: IStringDictionary): Promise { - // try to separate variable arguments from variable name - let argument: string | undefined; - const parts = variable.split(':'); - if (parts.length > 1) { - variable = parts[0]; - argument = parts[1]; - } + + const environment: Environment = { + env: (processEnvironment !== undefined) ? this.prepareEnv(processEnvironment) : await this._envVariablesPromise, + userHome: (processEnvironment !== undefined) ? undefined : await this._userHomePromise + }; + + const { name: variable, arg: argument } = replacement; // common error handling for all variables that require an open editor const getFilePath = (variableKind: VariableKind): string => { - const filePath = this._context.getFilePath(); if (filePath) { return normalizeDriveLetter(filePath); } - throw new VariableError(variableKind, (localize('canNotResolveFile', "Variable {0} can not be resolved. Please open an editor.", match))); + throw new VariableError(variableKind, (localize('canNotResolveFile', "Variable {0} can not be resolved. Please open an editor.", replacement.id))); }; // common error handling for all variables that require an open editor const getFolderPathForFile = (variableKind: VariableKind): string => { - const filePath = getFilePath(variableKind); // throws error if no editor open if (this._context.getWorkspaceFolderPathForFile) { const folderPath = this._context.getWorkspaceFolderPathForFile(); @@ -210,18 +142,17 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe return normalizeDriveLetter(folderPath); } } - throw new VariableError(variableKind, localize('canNotResolveFolderForFile', "Variable {0}: can not find workspace folder of '{1}'.", match, paths.basename(filePath))); + throw new VariableError(variableKind, localize('canNotResolveFolderForFile', "Variable {0}: can not find workspace folder of '{1}'.", replacement.id, paths.basename(filePath))); }; // common error handling for all variables that require an open folder and accept a folder name argument const getFolderUri = (variableKind: VariableKind): uri => { - if (argument) { const folder = this._context.getFolderUri(argument); if (folder) { return folder; } - throw new VariableError(variableKind, localize('canNotFindFolder', "Variable {0} can not be resolved. No such folder '{1}'.", match, argument)); + throw new VariableError(variableKind, localize('canNotFindFolder', "Variable {0} can not be resolved. No such folder '{1}'.", variableKind, argument)); } if (folderUri) { @@ -229,99 +160,105 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe } if (this._context.getWorkspaceFolderCount() > 1) { - throw new VariableError(variableKind, localize('canNotResolveWorkspaceFolderMultiRoot', "Variable {0} can not be resolved in a multi folder workspace. Scope this variable using ':' and a workspace folder name.", match)); + throw new VariableError(variableKind, localize('canNotResolveWorkspaceFolderMultiRoot', "Variable {0} can not be resolved in a multi folder workspace. Scope this variable using ':' and a workspace folder name.", variableKind)); } - throw new VariableError(variableKind, localize('canNotResolveWorkspaceFolder', "Variable {0} can not be resolved. Please open a folder.", match)); + throw new VariableError(variableKind, localize('canNotResolveWorkspaceFolder', "Variable {0} can not be resolved. Please open a folder.", variableKind)); }; - switch (variable) { - case 'env': if (argument) { if (environment.env) { - // Depending on the source of the environment, on Windows, the values may all be lowercase. const env = environment.env[isWindows ? argument.toLowerCase() : argument]; if (types.isString(env)) { return env; } } - // For `env` we should do the same as a normal shell does - evaluates undefined envs to an empty string #46436 return ''; } - throw new VariableError(VariableKind.Env, localize('missingEnvVarName', "Variable {0} can not be resolved because no environment variable name is given.", match)); + throw new VariableError(VariableKind.Env, localize('missingEnvVarName', "Variable {0} can not be resolved because no environment variable name is given.", replacement.id)); case 'config': if (argument) { const config = this._context.getConfigurationValue(folderUri, argument); if (types.isUndefinedOrNull(config)) { - throw new VariableError(VariableKind.Config, localize('configNotFound', "Variable {0} can not be resolved because setting '{1}' not found.", match, argument)); + throw new VariableError(VariableKind.Config, localize('configNotFound', "Variable {0} can not be resolved because setting '{1}' not found.", replacement.id, argument)); } if (types.isObject(config)) { - throw new VariableError(VariableKind.Config, localize('configNoString', "Variable {0} can not be resolved because '{1}' is a structured value.", match, argument)); + throw new VariableError(VariableKind.Config, localize('configNoString', "Variable {0} can not be resolved because '{1}' is a structured value.", replacement.id, argument)); } return config; } - throw new VariableError(VariableKind.Config, localize('missingConfigName', "Variable {0} can not be resolved because no settings name is given.", match)); + throw new VariableError(VariableKind.Config, localize('missingConfigName', "Variable {0} can not be resolved because no settings name is given.", replacement.id)); case 'command': - return this.resolveFromMap(VariableKind.Command, match, argument, commandValueMapping, 'command'); + return this.resolveFromMap(VariableKind.Command, replacement.id, argument, commandValueMapping, 'command'); case 'input': - return this.resolveFromMap(VariableKind.Input, match, argument, commandValueMapping, 'input'); + return this.resolveFromMap(VariableKind.Input, replacement.id, argument, commandValueMapping, 'input'); case 'extensionInstallFolder': if (argument) { const ext = await this._context.getExtension(argument); if (!ext) { - throw new VariableError(VariableKind.ExtensionInstallFolder, localize('extensionNotInstalled', "Variable {0} can not be resolved because the extension {1} is not installed.", match, argument)); + throw new VariableError(VariableKind.ExtensionInstallFolder, localize('extensionNotInstalled', "Variable {0} can not be resolved because the extension {1} is not installed.", replacement.id, argument)); } return this.fsPath(ext.extensionLocation); } - throw new VariableError(VariableKind.ExtensionInstallFolder, localize('missingExtensionName', "Variable {0} can not be resolved because no extension name is given.", match)); + throw new VariableError(VariableKind.ExtensionInstallFolder, localize('missingExtensionName', "Variable {0} can not be resolved because no extension name is given.", replacement.id)); default: { - switch (variable) { case 'workspaceRoot': - case 'workspaceFolder': - return normalizeDriveLetter(this.fsPath(getFolderUri(VariableKind.WorkspaceFolder))); + case 'workspaceFolder': { + const uri = getFolderUri(VariableKind.WorkspaceFolder); + return uri ? normalizeDriveLetter(this.fsPath(uri)) : undefined; + } - case 'cwd': - return ((folderUri || argument) ? normalizeDriveLetter(this.fsPath(getFolderUri(VariableKind.Cwd))) : process.cwd()); + case 'cwd': { + if (!folderUri && !argument) { + return process.cwd(); + } + const uri = getFolderUri(VariableKind.Cwd); + return uri ? normalizeDriveLetter(this.fsPath(uri)) : undefined; + } case 'workspaceRootFolderName': - case 'workspaceFolderBasename': - return normalizeDriveLetter(paths.basename(this.fsPath(getFolderUri(VariableKind.WorkspaceFolderBasename)))); + case 'workspaceFolderBasename': { + const uri = getFolderUri(VariableKind.WorkspaceFolderBasename); + return uri ? normalizeDriveLetter(paths.basename(this.fsPath(uri))) : undefined; + } - case 'userHome': { + case 'userHome': if (environment.userHome) { return environment.userHome; } - throw new VariableError(VariableKind.UserHome, localize('canNotResolveUserHome', "Variable {0} can not be resolved. UserHome path is not defined", match)); - } + throw new VariableError(VariableKind.UserHome, localize('canNotResolveUserHome', "Variable {0} can not be resolved. UserHome path is not defined", replacement.id)); case 'lineNumber': { const lineNumber = this._context.getLineNumber(); if (lineNumber) { return lineNumber; } - throw new VariableError(VariableKind.LineNumber, localize('canNotResolveLineNumber', "Variable {0} can not be resolved. Make sure to have a line selected in the active editor.", match)); + throw new VariableError(VariableKind.LineNumber, localize('canNotResolveLineNumber', "Variable {0} can not be resolved. Make sure to have a line selected in the active editor.", replacement.id)); } + case 'columnNumber': { const columnNumber = this._context.getColumnNumber(); if (columnNumber) { return columnNumber; } - throw new Error(localize('canNotResolveColumnNumber', "Variable {0} can not be resolved. Make sure to have a column selected in the active editor.", match)); + throw new Error(localize('canNotResolveColumnNumber', "Variable {0} can not be resolved. Make sure to have a column selected in the active editor.", replacement.id)); } + case 'selectedText': { const selectedText = this._context.getSelectedText(); if (selectedText) { return selectedText; } - throw new VariableError(VariableKind.SelectedText, localize('canNotResolveSelectedText', "Variable {0} can not be resolved. Make sure to have some text selected in the active editor.", match)); + throw new VariableError(VariableKind.SelectedText, localize('canNotResolveSelectedText', "Variable {0} can not be resolved. Make sure to have some text selected in the active editor.", replacement.id)); } + case 'file': return getFilePath(VariableKind.File); @@ -345,6 +282,7 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe } return dirname; } + case 'fileDirname': return paths.dirname(getFilePath(VariableKind.FileDirname)); @@ -358,6 +296,7 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe const basename = paths.basename(getFilePath(VariableKind.FileBasenameNoExtension)); return (basename.slice(0, basename.length - paths.extname(basename).length)); } + case 'fileDirnameBasename': return paths.basename(paths.dirname(getFilePath(VariableKind.FileDirnameBasename))); @@ -366,32 +305,34 @@ export class AbstractVariableResolverService implements IConfigurationResolverSe if (ep) { return ep; } - return match; + return replacement.id; } + case 'execInstallFolder': { const ar = this._context.getAppRoot(); if (ar) { return ar; } - return match; + return replacement.id; } + case 'pathSeparator': case '/': return paths.sep; - default: + default: { try { - const key = argument ? `${variable}:${argument}` : variable; - return this.resolveFromMap(VariableKind.Unknown, match, key, commandValueMapping, undefined); - } catch (error) { - return match; + return this.resolveFromMap(VariableKind.Unknown, replacement.id, argument, commandValueMapping, undefined); + } catch { + return replacement.id; } + } } } } } - private resolveFromMap(variableKind: VariableKind, match: string, argument: string | undefined, commandValueMapping: IStringDictionary | undefined, prefix: string | undefined): string { + private resolveFromMap(variableKind: VariableKind, match: string, argument: string | undefined, commandValueMapping: IStringDictionary | undefined, prefix: string | undefined): string { if (argument && commandValueMapping) { const v = (prefix === undefined) ? commandValueMapping[argument] : commandValueMapping[prefix + ':' + argument]; if (typeof v === 'string') { diff --git a/src/vs/workbench/services/configurationResolver/test/electron-sandbox/configurationResolverService.test.ts b/src/vs/workbench/services/configurationResolver/test/electron-sandbox/configurationResolverService.test.ts index 804966dd7ec..68d24b67cf9 100644 --- a/src/vs/workbench/services/configurationResolver/test/electron-sandbox/configurationResolverService.test.ts +++ b/src/vs/workbench/services/configurationResolver/test/electron-sandbox/configurationResolverService.test.ts @@ -10,6 +10,7 @@ import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js import { Schemas } from '../../../../../base/common/network.js'; import { IPath, normalize } from '../../../../../base/common/path.js'; import * as platform from '../../../../../base/common/platform.js'; +import { isLinux, isMacintosh, isWindows } from '../../../../../base/common/platform.js'; import { isObject } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -22,12 +23,13 @@ import { IExtensionDescription } from '../../../../../platform/extensions/common import { IFormatterChangeEvent, ILabelService, ResourceLabelFormatter, Verbosity } from '../../../../../platform/label/common/label.js'; import { IWorkspace, IWorkspaceFolder, IWorkspaceIdentifier, Workspace } from '../../../../../platform/workspace/common/workspace.js'; import { testWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; -import { BaseConfigurationResolverService } from '../../browser/baseConfigurationResolverService.js'; -import { IConfigurationResolverService } from '../../common/configurationResolver.js'; -import { IExtensionService } from '../../../extensions/common/extensions.js'; -import { IPathService } from '../../../path/common/pathService.js'; import { TestEditorService, TestQuickInputService } from '../../../../test/browser/workbenchTestServices.js'; import { TestContextService, TestExtensionService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { IExtensionService } from '../../../extensions/common/extensions.js'; +import { IPathService } from '../../../path/common/pathService.js'; +import { BaseConfigurationResolverService } from '../../browser/baseConfigurationResolverService.js'; +import { IConfigurationResolverService } from '../../common/configurationResolver.js'; +import { ConfigurationResolverExpression } from '../../common/configurationResolverExpression.js'; const mockLineNumber = 10; class TestEditorServiceWithActiveEditor extends TestEditorService { @@ -99,6 +101,49 @@ suite('Configuration Resolver Service', () => { } }); + test('does not preserve platform config even when not matched', async () => { + const obj = { + program: 'osx.sh', + windows: { + program: 'windows.exe' + }, + linux: { + program: 'linux.sh' + } + }; + const config: any = await configurationResolverService!.resolveAsync(workspace, obj); + + const expected = isWindows ? 'windows.exe' : isMacintosh ? 'osx.sh' : isLinux ? 'linux.sh' : undefined; + + assert.strictEqual(config.windows, undefined); + assert.strictEqual(config.osx, undefined); + assert.strictEqual(config.linux, undefined); + assert.strictEqual(config.program, expected); + }); + + test('apples platform specific config', async () => { + const expected = isWindows ? 'windows.exe' : isMacintosh ? 'osx.sh' : isLinux ? 'linux.sh' : undefined; + const obj = { + windows: { + program: 'windows.exe' + }, + osx: { + program: 'osx.sh' + }, + linux: { + program: 'linux.sh' + } + }; + const originalObj = JSON.stringify(obj); + const config: any = await configurationResolverService!.resolveAsync(workspace, obj); + + assert.strictEqual(config.program, expected); + assert.strictEqual(config.windows, undefined); + assert.strictEqual(config.osx, undefined); + assert.strictEqual(config.linux, undefined); + assert.strictEqual(JSON.stringify(obj), originalObj); // did not mutate original + }); + test('workspace folder with argument', async () => { if (platform.isWindows) { assert.strictEqual(await configurationResolverService!.resolveAsync(workspace, 'abc ${workspaceFolder:workspaceLocation} xyz'), 'abc \\VSCode\\workspaceLocation xyz'); @@ -107,12 +152,12 @@ suite('Configuration Resolver Service', () => { } }); - test('workspace folder with invalid argument', () => { - assert.rejects(async () => await configurationResolverService!.resolveAsync(workspace, 'abc ${workspaceFolder:invalidLocation} xyz')); + test('workspace folder with invalid argument', async () => { + await assert.rejects(async () => await configurationResolverService!.resolveAsync(workspace, 'abc ${workspaceFolder:invalidLocation} xyz')); }); - test('workspace folder with undefined workspace folder', () => { - assert.rejects(async () => await configurationResolverService!.resolveAsync(undefined, 'abc ${workspaceFolder} xyz')); + test('workspace folder with undefined workspace folder', async () => { + await assert.rejects(async () => await configurationResolverService!.resolveAsync(undefined, 'abc ${workspaceFolder} xyz')); }); test('workspace folder with argument and undefined workspace folder', async () => { @@ -188,7 +233,7 @@ suite('Configuration Resolver Service', () => { }); test('disallows nested keys (#77289)', async () => { - assert.strictEqual(await configurationResolverService!.resolveAsync(workspace, '${env:key1} ${env:key1${env:key2}}'), 'Value for key1 ${env:key1${env:key2}}'); + assert.strictEqual(await configurationResolverService!.resolveAsync(workspace, '${env:key1} ${env:key1${env:key2}}'), 'Value for key1 '); }); test('supports extensionDir', async () => { @@ -234,6 +279,17 @@ suite('Configuration Resolver Service', () => { assert.strictEqual(await service.resolveAsync(workspace, 'abc ${config:editor.fontFamily} xyz'), 'abc foo xyz'); }); + test('inlines an array (#245718)', async () => { + const configurationService: IConfigurationService = new TestConfigurationService({ + editor: { + fontFamily: ['foo', 'bar'] + }, + }); + + const service = new TestConfigurationResolverService(nullContext, Promise.resolve(envVariables), disposables.add(new TestEditorServiceWithActiveEditor()), configurationService, mockCommandService, new TestContextService(), quickInputService, labelService, pathService, extensionService, disposables.add(new TestStorageService())); + assert.strictEqual(await service.resolveAsync(workspace, 'abc ${config:editor.fontFamily} xyz'), 'abc foo,bar xyz'); + }); + test('substitute configuration variable with undefined workspace folder', async () => { const configurationService: IConfigurationService = new TestConfigurationService({ editor: { @@ -281,6 +337,17 @@ suite('Configuration Resolver Service', () => { } }); + test('recursively resolve variables', async () => { + const configurationService = new TestConfigurationService({ + key1: 'key1=${config:key2}', + key2: 'key2=${config:key3}', + key3: 'we did it!', + }); + + const service = new TestConfigurationResolverService(nullContext, Promise.resolve(envVariables), disposables.add(new TestEditorServiceWithActiveEditor()), configurationService, mockCommandService, new TestContextService(), quickInputService, labelService, pathService, extensionService, disposables.add(new TestStorageService())); + assert.strictEqual(await service.resolveAsync(workspace, '${config:key1}'), 'key1=key2=we did it!'); + }); + test('substitute many env variable and a configuration variable', async () => { const configurationService = new TestConfigurationService({ editor: { @@ -648,6 +715,43 @@ suite('Configuration Resolver Service', () => { const resolvedResult = await configurationResolverService!.resolveWithEnvironment({ ...env }, undefined, configuration); assert.deepStrictEqual(resolvedResult, 'echo VAL_1VAL_2'); }); + + test('substitution in object key', async () => { + + const configuration = { + 'name': 'Test', + 'mappings': { + 'pos1': 'value1', + '${workspaceFolder}/test1': '${workspaceFolder}/test2', + 'pos3': 'value3' + } + }; + + return configurationResolverService!.resolveWithInteractionReplace(workspace, configuration, 'tasks').then(result => { + + if (platform.isWindows) { + assert.deepStrictEqual({ ...result }, { + 'name': 'Test', + 'mappings': { + 'pos1': 'value1', + '\\VSCode\\workspaceLocation/test1': '\\VSCode\\workspaceLocation/test2', + 'pos3': 'value3' + } + }); + } else { + assert.deepStrictEqual({ ...result }, { + 'name': 'Test', + 'mappings': { + 'pos1': 'value1', + '/VSCode/workspaceLocation/test1': '/VSCode/workspaceLocation/test2', + 'pos3': 'value3' + } + }); + } + + assert.strictEqual(0, mockCommandService.callCount); + }); + }); }); @@ -748,6 +852,7 @@ class MockInputsConfigurationService extends TestConfigurationService { type: 'promptString', description: 'Enterinput3', default: 'default input3', + provide: true, password: true }, { @@ -773,3 +878,136 @@ class MockInputsConfigurationService extends TestConfigurationService { }; } } + +suite('ConfigurationResolverExpression', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('parse empty object', () => { + const expr = ConfigurationResolverExpression.parse({}); + assert.strictEqual(Array.from(expr.unresolved()).length, 0); + assert.deepStrictEqual(expr.toObject(), {}); + }); + + test('parse simple string', () => { + const expr = ConfigurationResolverExpression.parse({ value: '${env:HOME}' }); + const unresolved = Array.from(expr.unresolved()); + assert.strictEqual(unresolved.length, 1); + assert.strictEqual(unresolved[0].name, 'env'); + assert.strictEqual(unresolved[0].arg, 'HOME'); + }); + + test('parse string with argument and colon', () => { + const expr = ConfigurationResolverExpression.parse({ value: '${config:path:to:value}' }); + const unresolved = Array.from(expr.unresolved()); + assert.strictEqual(unresolved.length, 1); + assert.strictEqual(unresolved[0].name, 'config'); + assert.strictEqual(unresolved[0].arg, 'path:to:value'); + }); + + test('parse object with nested variables', () => { + const expr = ConfigurationResolverExpression.parse({ + name: '${env:USERNAME}', + path: '${env:HOME}/folder', + settings: { + value: '${config:path}' + }, + array: ['${env:TERM}', { key: '${env:KEY}' }] + }); + + const unresolved = Array.from(expr.unresolved()); + assert.strictEqual(unresolved.length, 5); + assert.deepStrictEqual(unresolved.map(r => r.name).sort(), ['config', 'env', 'env', 'env', 'env']); + }); + + test('resolve and get result', () => { + const expr = ConfigurationResolverExpression.parse({ + name: '${env:USERNAME}', + path: '${env:HOME}/folder' + }); + + expr.resolve({ inner: 'env:USERNAME', id: '${env:USERNAME}', name: 'env', arg: 'USERNAME' }, 'testuser'); + expr.resolve({ inner: 'env:HOME', id: '${env:HOME}', name: 'env', arg: 'HOME' }, '/home/testuser'); + + assert.deepStrictEqual(expr.toObject(), { + name: 'testuser', + path: '/home/testuser/folder' + }); + }); + + test('keeps unresolved variables', () => { + const expr = ConfigurationResolverExpression.parse({ + name: '${env:USERNAME}' + }); + + assert.deepStrictEqual(expr.toObject(), { + name: '${env:USERNAME}' + }); + }); + + test('deduplicates identical variables', () => { + const expr = ConfigurationResolverExpression.parse({ + first: '${env:HOME}', + second: '${env:HOME}' + }); + + const unresolved = Array.from(expr.unresolved()); + assert.strictEqual(unresolved.length, 1); + assert.strictEqual(unresolved[0].name, 'env'); + assert.strictEqual(unresolved[0].arg, 'HOME'); + + expr.resolve(unresolved[0], '/home/user'); + assert.deepStrictEqual(expr.toObject(), { + first: '/home/user', + second: '/home/user' + }); + }); + + test('handles root string value', () => { + const expr = ConfigurationResolverExpression.parse('abc ${env:HOME} xyz'); + const unresolved = Array.from(expr.unresolved()); + assert.strictEqual(unresolved.length, 1); + assert.strictEqual(unresolved[0].name, 'env'); + assert.strictEqual(unresolved[0].arg, 'HOME'); + + expr.resolve(unresolved[0], '/home/user'); + assert.strictEqual(expr.toObject(), 'abc /home/user xyz'); + }); + + test('handles root string value with multiple variables', () => { + const expr = ConfigurationResolverExpression.parse('${env:HOME}/folder${env:SHELL}'); + const unresolved = Array.from(expr.unresolved()); + assert.strictEqual(unresolved.length, 2); + + expr.resolve({ id: '${env:HOME}', inner: 'env:HOME', name: 'env', arg: 'HOME' }, '/home/user'); + expr.resolve({ id: '${env:SHELL}', inner: 'env:SHELL', name: 'env', arg: 'SHELL' }, '/bin/bash'); + assert.strictEqual(expr.toObject(), '/home/user/folder/bin/bash'); + }); + + test('handles root string with escaped variables', () => { + const expr = ConfigurationResolverExpression.parse('abc ${env:HOME${env:USER}} xyz'); + const unresolved = Array.from(expr.unresolved()); + assert.strictEqual(unresolved.length, 1); + assert.strictEqual(unresolved[0].name, 'env'); + assert.strictEqual(unresolved[0].arg, 'HOME${env:USER}'); + }); + + test('resolves nested values', () => { + const expr = ConfigurationResolverExpression.parse({ + name: '${env:REDIRECTED}', + 'key that is ${env:REDIRECTED}': 'cool!', + }); + + for (const r of expr.unresolved()) { + if (r.arg === 'REDIRECTED') { + expr.resolve(r, 'username: ${env:USERNAME}'); + } else if (r.arg === 'USERNAME') { + expr.resolve(r, 'testuser'); + } + } + + assert.deepStrictEqual(expr.toObject(), { + name: 'username: testuser', + 'key that is username: testuser': 'cool!' + }); + }); +}); diff --git a/src/vs/workbench/services/driver/browser/driver.ts b/src/vs/workbench/services/driver/browser/driver.ts index bb11f37a641..6bd4ead2265 100644 --- a/src/vs/workbench/services/driver/browser/driver.ts +++ b/src/vs/workbench/services/driver/browser/driver.ts @@ -259,11 +259,6 @@ export class BrowserWindowDriver implements IWindowDriver { return { x, y }; } - - async exitApplication(): Promise { - // No-op in web - } - } export function registerWindowDriver(instantiationService: IInstantiationService): void { diff --git a/src/vs/workbench/services/driver/common/driver.ts b/src/vs/workbench/services/driver/common/driver.ts index 4cc5952df5d..5194ee5dddd 100644 --- a/src/vs/workbench/services/driver/common/driver.ts +++ b/src/vs/workbench/services/driver/common/driver.ts @@ -45,6 +45,5 @@ export interface IWindowDriver { getLocalizedStrings(): Promise; getLogs(): Promise; whenWorkbenchRestored(): Promise; - exitApplication(): Promise; } //*END diff --git a/src/vs/workbench/services/driver/electron-sandbox/driver.ts b/src/vs/workbench/services/driver/electron-sandbox/driver.ts deleted file mode 100644 index 79740006134..00000000000 --- a/src/vs/workbench/services/driver/electron-sandbox/driver.ts +++ /dev/null @@ -1,37 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { mainWindow } from '../../../../base/browser/window.js'; -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; -import { BrowserWindowDriver } from '../browser/driver.js'; -import { ILifecycleService } from '../../lifecycle/common/lifecycle.js'; - -interface INativeWindowDriverHelper { - exitApplication(): Promise; -} - -class NativeWindowDriver extends BrowserWindowDriver { - - constructor( - private readonly helper: INativeWindowDriverHelper, - @IFileService fileService: IFileService, - @IEnvironmentService environmentService: IEnvironmentService, - @ILifecycleService lifecycleService: ILifecycleService, - @ILogService logService: ILogService - ) { - super(fileService, environmentService, lifecycleService, logService); - } - - override exitApplication(): Promise { - return this.helper.exitApplication(); - } -} - -export function registerWindowDriver(instantiationService: IInstantiationService, helper: INativeWindowDriverHelper): void { - Object.assign(mainWindow, { driver: instantiationService.createInstance(NativeWindowDriver, helper) }); -} diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index b3a64ddfbda..3b68ec6f3fe 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -5,7 +5,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IResourceEditorInput, IEditorOptions, EditorActivation, IResourceEditorInputIdentifier, ITextResourceEditorInput } from '../../../../platform/editor/common/editor.js'; -import { SideBySideEditor, IEditorPane, GroupIdentifier, IUntitledTextResourceEditorInput, IResourceDiffEditorInput, EditorInputWithOptions, isEditorInputWithOptions, IEditorIdentifier, IEditorCloseEvent, ITextDiffEditorPane, IRevertOptions, SaveReason, EditorsOrder, IWorkbenchEditorConfiguration, EditorResourceAccessor, IVisibleEditorPane, EditorInputCapabilities, isResourceDiffEditorInput, IUntypedEditorInput, isResourceEditorInput, isEditorInput, isEditorInputWithOptionsAndGroup, IFindEditorOptions, isResourceMergeEditorInput, IEditorWillOpenEvent, IEditorControl } from '../../../common/editor.js'; +import { SideBySideEditor, IEditorPane, GroupIdentifier, IUntitledTextResourceEditorInput, IResourceDiffEditorInput, EditorInputWithOptions, isEditorInputWithOptions, IEditorIdentifier, IEditorCloseEvent, ITextDiffEditorPane, IRevertOptions, SaveReason, EditorsOrder, IWorkbenchEditorConfiguration, EditorResourceAccessor, IVisibleEditorPane, EditorInputCapabilities, isResourceDiffEditorInput, IUntypedEditorInput, isResourceEditorInput, isEditorInput, isEditorInputWithOptionsAndGroup, IFindEditorOptions, isResourceMergeEditorInput, IEditorWillOpenEvent, IEditorControl, ITextResourceDiffEditorInput } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { ResourceMap, ResourceSet } from '../../../../base/common/map.js'; @@ -530,6 +530,7 @@ export class EditorService extends Disposable implements EditorServiceImpl { openEditor(editor: IUntypedEditorInput, group?: PreferredGroup): Promise; openEditor(editor: IResourceEditorInput, group?: PreferredGroup): Promise; openEditor(editor: ITextResourceEditorInput | IUntitledTextResourceEditorInput, group?: PreferredGroup): Promise; + openEditor(editor: ITextResourceDiffEditorInput, group?: PreferredGroup): Promise; openEditor(editor: IResourceDiffEditorInput, group?: PreferredGroup): Promise; openEditor(editor: EditorInput | IUntypedEditorInput, optionsOrPreferredGroup?: IEditorOptions | PreferredGroup, preferredGroup?: PreferredGroup): Promise; async openEditor(editor: EditorInput | IUntypedEditorInput, optionsOrPreferredGroup?: IEditorOptions | PreferredGroup, preferredGroup?: PreferredGroup): Promise { diff --git a/src/vs/workbench/services/editor/common/editorGroupFinder.ts b/src/vs/workbench/services/editor/common/editorGroupFinder.ts index a39a1224160..0ae0eaadb1c 100644 --- a/src/vs/workbench/services/editor/common/editorGroupFinder.ts +++ b/src/vs/workbench/services/editor/common/editorGroupFinder.ts @@ -92,7 +92,7 @@ function doFindGroup(input: EditorInputWithOptions | IUntypedEditorInput, prefer // Group: Aux Window else if (preferredGroup === AUX_WINDOW_GROUP) { - group = editorGroupService.createAuxiliaryEditorPart().then(group => group.activeGroup); + group = editorGroupService.createAuxiliaryEditorPart({ compact: options?.compact }).then(group => group.activeGroup); } // Group: Unspecified without a specific index to open diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index b340a83f1a3..1388eac0a75 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -565,7 +565,7 @@ export interface IEditorGroupsService extends IEditorGroupsContainer { * Opens a new window with a full editor part instantiated * in there at the optional position and size on screen. */ - createAuxiliaryEditorPart(options?: { bounds?: Partial }): Promise; + createAuxiliaryEditorPart(options?: { bounds?: Partial; compact?: boolean }): Promise; /** * Returns the instantiation service that is scoped to the @@ -894,8 +894,9 @@ export interface IEditorGroup { * Closes all editors from the group. This may trigger a confirmation dialog if * there are dirty editors and thus returns a promise as value. * - * @returns a promise when all editors are closed. + * @returns a promise if confirmation is needed when all editors are closed. */ + closeAllEditors(options: { excludeConfirming: true }): boolean; closeAllEditors(options?: ICloseAllEditorsOptions): Promise; /** diff --git a/src/vs/workbench/services/editor/common/editorService.ts b/src/vs/workbench/services/editor/common/editorService.ts index 3c406fcf104..2847f2d4afd 100644 --- a/src/vs/workbench/services/editor/common/editorService.ts +++ b/src/vs/workbench/services/editor/common/editorService.ts @@ -5,7 +5,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IResourceEditorInput, IEditorOptions, IResourceEditorInputIdentifier, ITextResourceEditorInput } from '../../../../platform/editor/common/editor.js'; -import { IEditorPane, GroupIdentifier, IUntitledTextResourceEditorInput, IResourceDiffEditorInput, ITextDiffEditorPane, IEditorIdentifier, ISaveOptions, IRevertOptions, EditorsOrder, IVisibleEditorPane, IEditorCloseEvent, IUntypedEditorInput, IFindEditorOptions, IEditorWillOpenEvent } from '../../../common/editor.js'; +import { IEditorPane, GroupIdentifier, IUntitledTextResourceEditorInput, IResourceDiffEditorInput, ITextDiffEditorPane, IEditorIdentifier, ISaveOptions, IRevertOptions, EditorsOrder, IVisibleEditorPane, IEditorCloseEvent, IUntypedEditorInput, IFindEditorOptions, IEditorWillOpenEvent, ITextResourceDiffEditorInput } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { Event } from '../../../../base/common/event.js'; import { IEditor, IDiffEditor } from '../../../../editor/common/editorCommon.js'; @@ -260,7 +260,7 @@ export interface IEditorService { */ openEditor(editor: IResourceEditorInput, group?: IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE): Promise; openEditor(editor: ITextResourceEditorInput | IUntitledTextResourceEditorInput, group?: IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE): Promise; - openEditor(editor: IResourceDiffEditorInput, group?: IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE): Promise; + openEditor(editor: ITextResourceDiffEditorInput | IResourceDiffEditorInput, group?: IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE): Promise; openEditor(editor: IUntypedEditorInput, group?: IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE): Promise; /** diff --git a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts index 10b674d60ea..b1baabfef30 100644 --- a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts +++ b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts @@ -348,7 +348,7 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench enablementState = this._getUserEnablementState(extension.identifier); const isEnabled = this.isEnabledEnablementState(enablementState); - if (isMalicious(extension.identifier, this.getMaliciousExtensions())) { + if (isMalicious(extension.identifier, this.getMaliciousExtensions().map(e => ({ extensionOrPublisher: e })))) { enablementState = EnablementState.DisabledByMalicious; } @@ -716,7 +716,7 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench private async checkForMaliciousExtensions(): Promise { try { const extensionsControlManifest = await this.extensionManagementService.getExtensionsControlManifest(); - const changed = this.storeMaliciousExtensions(extensionsControlManifest.malicious); + const changed = this.storeMaliciousExtensions(extensionsControlManifest.malicious.map(({ extensionOrPublisher }) => extensionOrPublisher)); if (changed) { this._onDidChangeExtensions([], [], false); } diff --git a/src/vs/workbench/services/extensionManagement/browser/webExtensionsScannerService.ts b/src/vs/workbench/services/extensionManagement/browser/webExtensionsScannerService.ts index 828322f4fc2..57fd1770ca1 100644 --- a/src/vs/workbench/services/extensionManagement/browser/webExtensionsScannerService.ts +++ b/src/vs/workbench/services/extensionManagement/browser/webExtensionsScannerService.ts @@ -146,7 +146,7 @@ export class WebExtensionsScannerService extends Disposable implements IWebExten } } else if (isUriComponents(e)) { const extensionLocation = URI.revive(e); - if (this.extensionResourceLoaderService.isExtensionGalleryResource(extensionLocation)) { + if (await this.extensionResourceLoaderService.isExtensionGalleryResource(extensionLocation)) { extensionGalleryResources.push(extensionLocation); } else { extensionLocations.push(extensionLocation); @@ -651,7 +651,7 @@ export class WebExtensionsScannerService extends Disposable implements IWebExten } private async toWebExtensionFromGallery(galleryExtension: IGalleryExtension, metadata?: Metadata): Promise { - const extensionLocation = this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ + const extensionLocation = await this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ publisher: galleryExtension.publisher, name: galleryExtension.name, version: galleryExtension.version, diff --git a/src/vs/workbench/services/extensionManagement/common/extensionManagementChannelClient.ts b/src/vs/workbench/services/extensionManagement/common/extensionManagementChannelClient.ts index 85b6b47cd9e..01bf76f61fe 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionManagementChannelClient.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionManagementChannelClient.ts @@ -131,8 +131,8 @@ export abstract class ProfileAwareExtensionManagementChannelClient extends BaseE return super.updateMetadata(local, metadata, await this.getProfileLocation(extensionsProfileResource)); } - override async toggleAppliationScope(local: ILocalExtension, fromProfileLocation: URI): Promise { - return super.toggleAppliationScope(local, await this.getProfileLocation(fromProfileLocation)); + override async toggleApplicationScope(local: ILocalExtension, fromProfileLocation: URI): Promise { + return super.toggleApplicationScope(local, await this.getProfileLocation(fromProfileLocation)); } override async copyExtensions(fromProfileLocation: URI, toProfileLocation: URI): Promise { diff --git a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts index e77726ba5cc..37dfdb643fc 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts @@ -822,7 +822,7 @@ export class ExtensionManagementService extends Disposable implements IWorkbench const untrustedExtensionManifests: IExtensionManifest[] = []; const manifestsToGetOtherUntrustedPublishers: IExtensionManifest[] = []; for (const { extension, manifest, checkForPackAndDependencies } of extensions) { - if (!this.isPublisherTrusted(extension)) { + if (!extension.private && !this.isPublisherTrusted(extension)) { untrustedExtensions.push(extension); untrustedExtensionManifests.push(manifest); if (checkForPackAndDependencies) { @@ -868,6 +868,10 @@ export class ExtensionManagementService extends Disposable implements IWorkbench } }; + const getPublisherLink = ({ publisherDisplayName, publisherLink }: { publisherDisplayName: string; publisherLink?: string }) => { + return publisherLink ? `[${publisherDisplayName}](${publisherLink})` : publisherDisplayName; + }; + const unverifiedLink = 'https://aka.ms/vscode-verify-publisher'; const title = allPublishers.length === 1 @@ -882,35 +886,35 @@ export class ExtensionManagementService extends Disposable implements IWorkbench const extension = untrustedExtensions[0]; const manifest = untrustedExtensionManifests[0]; if (otherUntrustedPublishers.length) { - customMessage.appendMarkdown(localize('extension published by message', "The extension {0} is published by {1}.", `[${extension.displayName}](${extension.detailsLink})`, extension.publisherLink)); + customMessage.appendMarkdown(localize('extension published by message', "The extension {0} is published by {1}.", `[${extension.displayName}](${extension.detailsLink})`, getPublisherLink(extension))); customMessage.appendMarkdown(' '); const commandUri = URI.parse(`command:extension.open?${encodeURIComponent(JSON.stringify([extension.identifier.id, manifest.extensionPack?.length ? 'extensionPack' : 'dependencies']))}`).toString(); if (otherUntrustedPublishers.length === 1) { - customMessage.appendMarkdown(localize('singleUntrustedPublisher', "Installing this extension will also install [extensions]({0}) published by {1}.", commandUri, otherUntrustedPublishers[0].publisherLink)); + customMessage.appendMarkdown(localize('singleUntrustedPublisher', "Installing this extension will also install [extensions]({0}) published by {1}.", commandUri, getPublisherLink(otherUntrustedPublishers[0]))); } else { - customMessage.appendMarkdown(localize('message3', "Installing this extension will also install [extensions]({0}) published by {1} and {2}.", commandUri, otherUntrustedPublishers.slice(0, otherUntrustedPublishers.length - 1).map(p => p.publisherLink).join(', '), otherUntrustedPublishers[otherUntrustedPublishers.length - 1].publisherLink)); + customMessage.appendMarkdown(localize('message3', "Installing this extension will also install [extensions]({0}) published by {1} and {2}.", commandUri, otherUntrustedPublishers.slice(0, otherUntrustedPublishers.length - 1).map(p => getPublisherLink(p)).join(', '), getPublisherLink(otherUntrustedPublishers[otherUntrustedPublishers.length - 1]))); } customMessage.appendMarkdown(' '); customMessage.appendMarkdown(localize('firstTimeInstallingMessage', "This is the first time you're installing extensions from these publishers.")); } else { - customMessage.appendMarkdown(localize('message1', "The extension {0} is published by {1}. This is the first extension you're installing from this publisher.", `[${extension.displayName}](${extension.detailsLink})`, extension.publisherLink)); + customMessage.appendMarkdown(localize('message1', "The extension {0} is published by {1}. This is the first extension you're installing from this publisher.", `[${extension.displayName}](${extension.detailsLink})`, getPublisherLink(extension))); } } else { - customMessage.appendMarkdown(localize('multiInstallMessage', "This is the first time you're installing extensions from publishers {0} and {1}.", allPublishers.slice(0, allPublishers.length - 1).map(p => p.publisherLink).join(', '), allPublishers[allPublishers.length - 1].publisherLink)); + customMessage.appendMarkdown(localize('multiInstallMessage', "This is the first time you're installing extensions from publishers {0} and {1}.", getPublisherLink(allPublishers[0]), getPublisherLink(allPublishers[allPublishers.length - 1]))); } if (verifiedPublishers.length || unverfiiedPublishers.length === 1) { for (const publisher of verifiedPublishers) { customMessage.appendText('\n'); - const publisherVerifiedMessage = localize('verifiedPublisherWithName', "{0} has verified ownership of {1}.", publisher.publisherLink, `[$(link-external) ${URI.parse(publisher.publisherDomain!.link).authority}](${publisher.publisherDomain!.link})`); + const publisherVerifiedMessage = localize('verifiedPublisherWithName', "{0} has verified ownership of {1}.", getPublisherLink(publisher), `[$(link-external) ${URI.parse(publisher.publisherDomain!.link).authority}](${publisher.publisherDomain!.link})`); customMessage.appendMarkdown(`$(${verifiedPublisherIcon.id}) ${publisherVerifiedMessage}`); } if (unverfiiedPublishers.length) { customMessage.appendText('\n'); if (unverfiiedPublishers.length === 1) { - customMessage.appendMarkdown(`$(${Codicon.unverified.id}) ${localize('unverifiedPublisherWithName', "{0} is [**not** verified]({1}).", unverfiiedPublishers[0].publisherLink, unverifiedLink)}`); + customMessage.appendMarkdown(`$(${Codicon.unverified.id}) ${localize('unverifiedPublisherWithName', "{0} is [**not** verified]({1}).", getPublisherLink(unverfiiedPublishers[0]), unverifiedLink)}`); } else { - customMessage.appendMarkdown(`$(${Codicon.unverified.id}) ${localize('unverifiedPublishers', "{0} and {1} are [**not** verified]({2}).", unverfiiedPublishers.slice(0, unverfiiedPublishers.length - 1).map(p => p.publisherLink).join(', '), unverfiiedPublishers[unverfiiedPublishers.length - 1].publisherLink, unverifiedLink)}`); + customMessage.appendMarkdown(`$(${Codicon.unverified.id}) ${localize('unverifiedPublishers', "{0} and {1} are [**not** verified]({2}).", unverfiiedPublishers.slice(0, unverfiiedPublishers.length - 1).map(p => getPublisherLink(p)).join(', '), getPublisherLink(unverfiiedPublishers[unverfiiedPublishers.length - 1]), unverifiedLink)}`); } } } else { @@ -963,7 +967,7 @@ export class ExtensionManagementService extends Disposable implements IWorkbench await this.getDependenciesAndPackedExtensionsRecursively([...extensionIds], extensions, CancellationToken.None); const publishers = new Map(); for (const [, extension] of extensions) { - if (this.isPublisherTrusted(extension)) { + if (extension.private || this.isPublisherTrusted(extension)) { continue; } publishers.set(extension.publisherDisplayName, extension); @@ -1108,10 +1112,10 @@ export class ExtensionManagementService extends Disposable implements IWorkbench await Promise.allSettled(this.servers.map(server => server.extensionManagementService.cleanUp())); } - toggleAppliationScope(extension: ILocalExtension, fromProfileLocation: URI): Promise { + toggleApplicationScope(extension: ILocalExtension, fromProfileLocation: URI): Promise { const server = this.getServer(extension); if (server) { - return server.extensionManagementService.toggleAppliationScope(extension, fromProfileLocation); + return server.extensionManagementService.toggleApplicationScope(extension, fromProfileLocation); } throw new Error('Not Supported'); } diff --git a/src/vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts b/src/vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts index 1cde4cdfc68..605a3c09fab 100644 --- a/src/vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts +++ b/src/vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts @@ -255,7 +255,7 @@ class InstallExtensionTask extends AbstractExtensionTask implem readonly identifier: IExtensionIdentifier; readonly source: URI | IGalleryExtension; - private _profileLocation = this.options.profileLocation; + private _profileLocation: URI; get profileLocation() { return this._profileLocation; } private _operation = InstallOperation.Install; @@ -269,6 +269,7 @@ class InstallExtensionTask extends AbstractExtensionTask implem private readonly userDataProfilesService: IUserDataProfilesService, ) { super(); + this._profileLocation = options.profileLocation; this.identifier = URI.isUri(extension) ? { id: getGalleryExtensionId(manifest.publisher, manifest.name) } : extension.identifier; this.source = extension; } diff --git a/src/vs/workbench/services/extensionManagement/electron-sandbox/extensionGalleryManifestService.ts b/src/vs/workbench/services/extensionManagement/electron-sandbox/extensionGalleryManifestService.ts index b93452b0138..4e494937b12 100644 --- a/src/vs/workbench/services/extensionManagement/electron-sandbox/extensionGalleryManifestService.ts +++ b/src/vs/workbench/services/extensionManagement/electron-sandbox/extensionGalleryManifestService.ts @@ -4,10 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../base/common/event.js'; import { IHeaders } from '../../../../base/parts/request/common/request.js'; +import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { IExtensionGalleryManifestService, IExtensionGalleryManifest } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { IExtensionGalleryManifestService, IExtensionGalleryManifest, ExtensionGalleryServiceUrlConfigKey } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { ExtensionGalleryManifestService as ExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifestService.js'; import { resolveMarketplaceHeaders } from '../../../../platform/externalServices/common/marketplace.js'; import { IFileService } from '../../../../platform/files/common/files.js'; @@ -18,11 +21,17 @@ import { IProductService } from '../../../../platform/product/common/productServ import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IDefaultAccount, IDefaultAccountService } from '../../accounts/common/defaultAccount.js'; +import { IHostService } from '../../host/browser/host.js'; import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryManifestService implements IExtensionGalleryManifestService { private readonly commonHeadersPromise: Promise; + private extensionGalleryManifest: [string, IExtensionGalleryManifest] | null = null; + + private _onDidChangeExtensionGalleryManifest = this._register(new Emitter()); + override readonly onDidChangeExtensionGalleryManifest = this._onDidChangeExtensionGalleryManifest.event; constructor( @IProductService productService: IProductService, @@ -34,6 +43,9 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa @ISharedProcessService sharedProcessService: ISharedProcessService, @IConfigurationService private readonly configurationService: IConfigurationService, @IRequestService private readonly requestService: IRequestService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IDialogService private readonly dialogService: IDialogService, + @IHostService private readonly hostService: IHostService, @ILogService private readonly logService: ILogService, ) { super(productService); @@ -57,20 +69,89 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa }); } - private extensionGalleryManifestPromise: Promise | undefined; - override getExtensionGalleryManifest(): Promise { + private extensionGalleryManifestPromise: Promise | undefined; + override async getExtensionGalleryManifest(): Promise { if (!this.extensionGalleryManifestPromise) { - if (this.productService.quality !== 'stable') { - const configuredServiceUrl = this.configurationService.inspect('extensions.gallery.serviceUrl').userLocalValue; - if (configuredServiceUrl) { - this.extensionGalleryManifestPromise = this.getExtensionGalleryManifestFromServiceUrl(configuredServiceUrl); - } + this.extensionGalleryManifestPromise = this.doGetExtensionGalleryManifest(); + } + await this.extensionGalleryManifestPromise; + return this.extensionGalleryManifest ? this.extensionGalleryManifest[1] : null; + } + + private async doGetExtensionGalleryManifest(): Promise { + const defaultServiceUrl = this.productService.extensionsGallery?.serviceUrl; + if (!defaultServiceUrl) { + this.extensionGalleryManifest = null; + return; + } + + const configuredServiceUrl = this.configurationService.getValue(ExtensionGalleryServiceUrlConfigKey); + if (configuredServiceUrl && this.checkAccess(await this.defaultAccountService.getDefaultAccount())) { + this.extensionGalleryManifest = [configuredServiceUrl, await this.getExtensionGalleryManifestFromServiceUrl(configuredServiceUrl)]; + } + + if (!this.extensionGalleryManifest) { + const defaultExtensionGalleryManifest = await super.getExtensionGalleryManifest(); + if (defaultExtensionGalleryManifest) { + this.extensionGalleryManifest = [defaultServiceUrl, defaultExtensionGalleryManifest]; } } - if (!this.extensionGalleryManifestPromise) { - this.extensionGalleryManifestPromise = super.getExtensionGalleryManifest(); + + this._register(this.defaultAccountService.onDidChangeDefaultAccount(account => { + if (!configuredServiceUrl) { + return; + } + const canAccess = this.checkAccess(account); + if (canAccess && this.extensionGalleryManifest?.[0] === configuredServiceUrl) { + return; + } + if (!canAccess && this.extensionGalleryManifest?.[0] === defaultServiceUrl) { + return; + } + this.extensionGalleryManifest = null; + this._onDidChangeExtensionGalleryManifest.fire(null); + this.requestRestart(); + })); + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (!e.affectsConfiguration(ExtensionGalleryServiceUrlConfigKey)) { + return; + } + const configuredServiceUrl = this.configurationService.getValue(ExtensionGalleryServiceUrlConfigKey); + if (!configuredServiceUrl && this.extensionGalleryManifest?.[0] === defaultServiceUrl) { + return; + } + if (configuredServiceUrl && this.extensionGalleryManifest?.[0] === configuredServiceUrl) { + return; + } + this.extensionGalleryManifest = null; + this._onDidChangeExtensionGalleryManifest.fire(null); + this.requestRestart(); + })); + } + + private checkAccess(account: IDefaultAccount | null): boolean { + if (!account) { + this.logService.debug('[Marketplace] Checking account access for configured gallery: No account found'); + return false; + } + this.logService.debug('[Marketplace] Checking Account SKU access for configured gallery', account.access_type_sku); + if (account.access_type_sku && this.productService.extensionsGallery?.accessSKUs?.includes(account.access_type_sku)) { + this.logService.debug('[Marketplace] Account has access to configured gallery'); + return true; + } + this.logService.debug('[Marketplace] Checking enterprise account access for configured gallery', account.enterprise); + return account.enterprise; + } + + private async requestRestart(): Promise { + const confirmation = await this.dialogService.confirm({ + message: localize('extensionGalleryManifestService.accountChange', "{0} is now configured to a different Marketplace. Please restart to apply the changes.", this.productService.nameLong), + primaryButton: localize({ key: 'restart', comment: ['&& denotes a mnemonic'] }, "&&Restart") + }); + if (confirmation.confirmed) { + return this.hostService.restart(); } - return this.extensionGalleryManifestPromise; } private async getExtensionGalleryManifestFromServiceUrl(url: string): Promise { @@ -96,10 +177,10 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa return extensionGalleryManifest; } catch (error) { - this.logService.error(error); + this.logService.error('[Marketplace] Error retrieving extension gallery manifest', error); throw error; } } } -registerSingleton(IExtensionGalleryManifestService, WorkbenchExtensionGalleryManifestService, InstantiationType.Delayed); +registerSingleton(IExtensionGalleryManifestService, WorkbenchExtensionGalleryManifestService, InstantiationType.Eager); diff --git a/src/vs/workbench/services/extensionManagement/electron-sandbox/remoteExtensionManagementService.ts b/src/vs/workbench/services/extensionManagement/electron-sandbox/remoteExtensionManagementService.ts index e7c4ef4ae5b..81a6a52a0c9 100644 --- a/src/vs/workbench/services/extensionManagement/electron-sandbox/remoteExtensionManagementService.ts +++ b/src/vs/workbench/services/extensionManagement/electron-sandbox/remoteExtensionManagementService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { ILocalExtension, IGalleryExtension, IExtensionGalleryService, InstallOperation, InstallOptions, ExtensionManagementError, ExtensionManagementErrorCode, EXTENSION_INSTALL_CLIENT_TARGET_PLATFORM_CONTEXT, IAllowedExtensionsService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { ILocalExtension, IGalleryExtension, IExtensionGalleryService, InstallOperation, InstallOptions, ExtensionManagementError, ExtensionManagementErrorCode, EXTENSION_INSTALL_CLIENT_TARGET_PLATFORM_CONTEXT, IAllowedExtensionsService, VerifyExtensionSignatureConfigKey } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { URI } from '../../../../base/common/uri.js'; import { ExtensionType, IExtensionManifest } from '../../../../platform/extensions/common/extensions.js'; import { areSameExtensions } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; @@ -55,7 +55,7 @@ export class NativeRemoteExtensionManagementService extends RemoteExtensionManag override async installFromGallery(extension: IGalleryExtension, installOptions: InstallOptions = {}): Promise { if (isUndefined(installOptions.donotVerifySignature)) { - const value = this.configurationService.getValue('extensions.verifySignature'); + const value = this.configurationService.getValue(VerifyExtensionSignatureConfigKey); installOptions.donotVerifySignature = isBoolean(value) ? !value : undefined; } const local = await this.doInstallFromGallery(extension, installOptions); diff --git a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts index b68956b47e0..1f5a36a652f 100644 --- a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts +++ b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts @@ -154,7 +154,7 @@ suite('ExtensionEnablementService Test', () => { getInstalled: () => Promise.resolve(installed), async getExtensionsControlManifest(): Promise { return { - malicious, + malicious: malicious.map(e => ({ extensionOrPublisher: e })), deprecated: {}, search: [] }; diff --git a/src/vs/workbench/services/extensions/common/extensionDescriptionRegistry.ts b/src/vs/workbench/services/extensions/common/extensionDescriptionRegistry.ts index 10486e47ffd..ed188f581ec 100644 --- a/src/vs/workbench/services/extensions/common/extensionDescriptionRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionDescriptionRegistry.ts @@ -26,7 +26,7 @@ export interface IReadOnlyExtensionDescriptionRegistry { getExtensionDescriptionByIdOrUUID(extensionId: ExtensionIdentifier | string, uuid: string | undefined): IExtensionDescription | undefined; } -export class ExtensionDescriptionRegistry implements IReadOnlyExtensionDescriptionRegistry { +export class ExtensionDescriptionRegistry extends Disposable implements IReadOnlyExtensionDescriptionRegistry { public static isHostExtension(extensionId: ExtensionIdentifier | string, myRegistry: ExtensionDescriptionRegistry, globalRegistry: ExtensionDescriptionRegistry): boolean { if (myRegistry.getExtensionDescription(extensionId)) { @@ -44,7 +44,7 @@ export class ExtensionDescriptionRegistry implements IReadOnlyExtensionDescripti return false; } - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = this._register(new Emitter()); public readonly onDidChange = this._onDidChange.event; private _versionId: number = 0; @@ -57,6 +57,7 @@ export class ExtensionDescriptionRegistry implements IReadOnlyExtensionDescripti private readonly _activationEventsReader: IActivationEventsReader, extensionDescriptions: IExtensionDescription[] ) { + super(); this._extensionDescriptions = extensionDescriptions; this._initialize(); } diff --git a/src/vs/workbench/services/extensions/common/extensions.ts b/src/vs/workbench/services/extensions/common/extensions.ts index cd50e10acfb..c4399642f0b 100644 --- a/src/vs/workbench/services/extensions/common/extensions.ts +++ b/src/vs/workbench/services/extensions/common/extensions.ts @@ -562,6 +562,7 @@ export function toExtension(extensionDescription: IExtensionDescription): IExten validations: [], isValid: true, preRelease: extensionDescription.preRelease, + publisherDisplayName: extensionDescription.publisherDisplayName, }; } diff --git a/src/vs/workbench/services/extensions/test/common/extensionDescriptionRegistry.test.ts b/src/vs/workbench/services/extensions/test/common/extensionDescriptionRegistry.test.ts index aa9cc6c629d..24348eb2260 100644 --- a/src/vs/workbench/services/extensions/test/common/extensionDescriptionRegistry.test.ts +++ b/src/vs/workbench/services/extensions/test/common/extensionDescriptionRegistry.test.ts @@ -28,6 +28,8 @@ suite('ExtensionDescriptionRegistry', () => { registry.deltaExtensions([extensionA2], [idA]); assert.deepStrictEqual(registry.getAllExtensionDescriptions(), [extensionA2]); + + registry.dispose(); }); function desc(id: ExtensionIdentifier, version: string, activationEvents: string[] = ['*']): IExtensionDescription { diff --git a/src/vs/workbench/services/host/browser/browserHostService.ts b/src/vs/workbench/services/host/browser/browserHostService.ts index 57cb6e482cf..66f4acaa3b0 100644 --- a/src/vs/workbench/services/host/browser/browserHostService.ts +++ b/src/vs/workbench/services/host/browser/browserHostService.ts @@ -41,6 +41,8 @@ import { mainWindow, isAuxiliaryWindow } from '../../../../base/browser/window.j import { isIOS, isMacintosh } from '../../../../base/common/platform.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { IElementData } from '../../../../platform/native/common/native.js'; enum HostShutdownReason { @@ -96,6 +98,7 @@ export class BrowserHostService extends Disposable implements IHostService { this.registerListeners(); } + private registerListeners(): void { // Veto shutdown depending on `window.confirmBeforeClose` setting @@ -154,7 +157,7 @@ export class BrowserHostService extends Disposable implements IHostService { Event.map(focusTracker.onDidBlur, () => this.hasFocus, disposables), Event.map(visibilityTracker.event, () => this.hasFocus, disposables), Event.map(this.onDidChangeActiveWindow, () => this.hasFocus, disposables), - )(focus => emitter.fire(focus)); + )(focus => emitter.fire(focus), undefined, disposables); }, { window: mainWindow, disposables: this._store })); return Event.latch(emitter.event, undefined, this._store); @@ -587,7 +590,7 @@ export class BrowserHostService extends Disposable implements IHostService { //#region Screenshots - async getScreenshot(): Promise { + async getScreenshot(): Promise { // Gets a screenshot from the browser. This gets the screenshot via the browser's display // media API which will typically offer a picker of all available screens and windows for // the user to select. Using the video stream provided by the display media API, this will @@ -633,8 +636,8 @@ export class BrowserHostService extends Disposable implements IHostService { throw new Error('Failed to create blob from canvas'); } - // Convert the Blob to an ArrayBuffer - return blob.arrayBuffer(); + const buf = await blob.bytes(); + return VSBuffer.wrap(buf); } catch (error) { console.error('Error taking screenshot:', error); @@ -649,6 +652,14 @@ export class BrowserHostService extends Disposable implements IHostService { } } + async getElementData(): Promise { + return undefined; + } + + async getBrowserId(): Promise { + return undefined; + } + //#endregion //#region Native Handle diff --git a/src/vs/workbench/services/host/browser/host.ts b/src/vs/workbench/services/host/browser/host.ts index 657c5ec4bad..167b5ef592d 100644 --- a/src/vs/workbench/services/host/browser/host.ts +++ b/src/vs/workbench/services/host/browser/host.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { VSBuffer } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IElementData } from '../../../../platform/native/common/native.js'; import { IWindowOpenable, IOpenWindowOptions, IOpenEmptyWindowOptions, IPoint, IRectangle } from '../../../../platform/window/common/window.js'; export const IHostService = createDecorator('hostService'); @@ -128,7 +130,9 @@ export interface IHostService { /** * Captures a screenshot. */ - getScreenshot(): Promise; + getScreenshot(rect?: IRectangle): Promise; + + getElementData(offsetX: number, offsetY: number, token: CancellationToken): Promise; //#endregion diff --git a/src/vs/workbench/services/host/electron-sandbox/nativeHostService.ts b/src/vs/workbench/services/host/electron-sandbox/nativeHostService.ts index bda11474b2c..10301ba95c3 100644 --- a/src/vs/workbench/services/host/electron-sandbox/nativeHostService.ts +++ b/src/vs/workbench/services/host/electron-sandbox/nativeHostService.ts @@ -5,7 +5,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { IHostService } from '../browser/host.js'; -import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { IElementData, INativeHostService } from '../../../../platform/native/common/native.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { ILabelService, Verbosity } from '../../../../platform/label/common/label.js'; import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; @@ -18,6 +18,8 @@ import { disposableWindowInterval, getActiveDocument, getWindowId, getWindowsCou import { memoize } from '../../../../base/common/decorators.js'; import { isAuxiliaryWindow } from '../../../../base/browser/window.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { ipcRenderer } from '../../../../base/parts/sandbox/electron-sandbox/globals.js'; class WorkbenchNativeHostService extends NativeHostService { @@ -29,6 +31,8 @@ class WorkbenchNativeHostService extends NativeHostService { } } +let cancelSelectionIdPool = 0; + class WorkbenchHostService extends Disposable implements IHostService { declare readonly _serviceBrand: undefined; @@ -193,8 +197,19 @@ class WorkbenchHostService extends Disposable implements IHostService { //#region Screenshots - getScreenshot(): Promise { - return this.nativeHostService.getScreenshot(); + getScreenshot(rect?: IRectangle): Promise { + return this.nativeHostService.getScreenshot(rect); + } + + async getElementData(offsetX: number, offsetY: number, token: CancellationToken): Promise { + const cancelSelectionId = cancelSelectionIdPool++; + const onCancelChannel = `vscode:cancelElementSelection${cancelSelectionId}`; + const disposable = token.onCancellationRequested(() => { + ipcRenderer.send(onCancelChannel, cancelSelectionId); + }); + const elementData = this.nativeHostService.getElementData(offsetX, offsetY, token, cancelSelectionId); + elementData.finally(() => disposable.dispose()); + return elementData; } //#endregion diff --git a/src/vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorker.ts b/src/vs/workbench/services/languageDetection/browser/languageDetectionWebWorker.ts similarity index 88% rename from src/vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorker.ts rename to src/vs/workbench/services/languageDetection/browser/languageDetectionWebWorker.ts index f9e86fbd6b3..0624f7cc767 100644 --- a/src/vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorker.ts +++ b/src/vs/workbench/services/languageDetection/browser/languageDetectionWebWorker.ts @@ -6,24 +6,20 @@ import type { ModelOperations, ModelResult } from '@vscode/vscode-languagedetection'; import { importAMDNodeModule } from '../../../../amdX.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; -import { IRequestHandler, IWorkerServer } from '../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerServerRequestHandler, IWebWorkerServer } from '../../../../base/common/worker/webWorker.js'; import { LanguageDetectionWorkerHost, ILanguageDetectionWorker } from './languageDetectionWorker.protocol.js'; import { WorkerTextModelSyncServer } from '../../../../editor/common/services/textModelSync/textModelSync.impl.js'; type RegexpModel = { detect: (inp: string, langBiases: Record, supportedLangs?: string[]) => string | undefined }; -/** - * Defines the worker entry point. Must be exported and named `create`. - * @skipMangle - */ -export function create(workerServer: IWorkerServer): IRequestHandler { - return new LanguageDetectionSimpleWorker(workerServer); +export function create(workerServer: IWebWorkerServer): IWebWorkerServerRequestHandler { + return new LanguageDetectionWorker(workerServer); } /** * @internal */ -export class LanguageDetectionSimpleWorker implements ILanguageDetectionWorker { +export class LanguageDetectionWorker implements ILanguageDetectionWorker { _requestHandlerBrand: any; private static readonly expectedRelativeConfidence = 0.2; @@ -42,7 +38,7 @@ export class LanguageDetectionSimpleWorker implements ILanguageDetectionWorker { private modelIdToCoreId = new Map(); - constructor(workerServer: IWorkerServer) { + constructor(workerServer: IWebWorkerServer) { this._host = LanguageDetectionWorkerHost.getChannel(workerServer); this._workerTextModelSyncServer.bindToServer(workerServer); } @@ -186,7 +182,7 @@ export class LanguageDetectionSimpleWorker implements ILanguageDetectionWorker { case 'py': case 'xml': case 'php': - modelResult.confidence += LanguageDetectionSimpleWorker.positiveConfidenceCorrectionBucket1; + modelResult.confidence += LanguageDetectionWorker.positiveConfidenceCorrectionBucket1; break; // case 'yaml': // YAML has been know to cause incorrect language detection because the language is pretty simple. We don't want to increase the confidence for this. case 'cpp': @@ -194,7 +190,7 @@ export class LanguageDetectionSimpleWorker implements ILanguageDetectionWorker { case 'java': case 'cs': case 'c': - modelResult.confidence += LanguageDetectionSimpleWorker.positiveConfidenceCorrectionBucket2; + modelResult.confidence += LanguageDetectionWorker.positiveConfidenceCorrectionBucket2; break; // For the following languages, we need to be extra confident that the language is correct because @@ -213,7 +209,7 @@ export class LanguageDetectionSimpleWorker implements ILanguageDetectionWorker { // aren't built in but suported by the model include: // * Assembly, TeX - These languages didn't have clear language modes in the community // * Markdown, Dockerfile - These languages are simple but they embed other languages - modelResult.confidence -= LanguageDetectionSimpleWorker.negativeConfidenceCorrection; + modelResult.confidence -= LanguageDetectionWorker.negativeConfidenceCorrection; break; default: @@ -247,12 +243,12 @@ export class LanguageDetectionSimpleWorker implements ILanguageDetectionWorker { if (!modelResults || modelResults.length === 0 - || modelResults[0].confidence < LanguageDetectionSimpleWorker.expectedRelativeConfidence) { + || modelResults[0].confidence < LanguageDetectionWorker.expectedRelativeConfidence) { return; } const firstModelResult = this.adjustLanguageConfidence(modelResults[0]); - if (firstModelResult.confidence < LanguageDetectionSimpleWorker.expectedRelativeConfidence) { + if (firstModelResult.confidence < LanguageDetectionWorker.expectedRelativeConfidence) { return; } @@ -266,17 +262,17 @@ export class LanguageDetectionSimpleWorker implements ILanguageDetectionWorker { current = this.adjustLanguageConfidence(current); const currentHighest = possibleLanguages[possibleLanguages.length - 1]; - if (currentHighest.confidence - current.confidence >= LanguageDetectionSimpleWorker.expectedRelativeConfidence) { + if (currentHighest.confidence - current.confidence >= LanguageDetectionWorker.expectedRelativeConfidence) { while (possibleLanguages.length) { yield possibleLanguages.shift()!; } - if (current.confidence > LanguageDetectionSimpleWorker.expectedRelativeConfidence) { + if (current.confidence > LanguageDetectionWorker.expectedRelativeConfidence) { possibleLanguages.push(current); continue; } return; } else { - if (current.confidence > LanguageDetectionSimpleWorker.expectedRelativeConfidence) { + if (current.confidence > LanguageDetectionWorker.expectedRelativeConfidence) { possibleLanguages.push(current); continue; } diff --git a/src/vs/workbench/contrib/notebook/common/services/notebookSimpleWorkerMain.ts b/src/vs/workbench/services/languageDetection/browser/languageDetectionWebWorkerMain.ts similarity index 65% rename from src/vs/workbench/contrib/notebook/common/services/notebookSimpleWorkerMain.ts rename to src/vs/workbench/services/languageDetection/browser/languageDetectionWebWorkerMain.ts index edebb727d13..1f71ea62693 100644 --- a/src/vs/workbench/contrib/notebook/common/services/notebookSimpleWorkerMain.ts +++ b/src/vs/workbench/services/languageDetection/browser/languageDetectionWebWorkerMain.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { bootstrapSimpleWorker } from '../../../../../base/common/worker/simpleWorkerBootstrap.js'; -import { create } from './notebookSimpleWorker.js'; +import { create } from './languageDetectionWebWorker.js'; +import { bootstrapWebWorker } from '../../../../base/common/worker/webWorkerBootstrap.js'; -bootstrapSimpleWorker(create); +bootstrapWebWorker(create); diff --git a/src/vs/workbench/services/languageDetection/browser/languageDetectionWorker.protocol.ts b/src/vs/workbench/services/languageDetection/browser/languageDetectionWorker.protocol.ts index ada9fc9cc6c..bf255e19c08 100644 --- a/src/vs/workbench/services/languageDetection/browser/languageDetectionWorker.protocol.ts +++ b/src/vs/workbench/services/languageDetection/browser/languageDetectionWorker.protocol.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IWorkerClient, IWorkerServer } from '../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerClient, IWebWorkerServer } from '../../../../base/common/worker/webWorker.js'; export abstract class LanguageDetectionWorkerHost { public static CHANNEL_NAME = 'languageDetectionWorkerHost'; - public static getChannel(workerServer: IWorkerServer): LanguageDetectionWorkerHost { + public static getChannel(workerServer: IWebWorkerServer): LanguageDetectionWorkerHost { return workerServer.getChannel(LanguageDetectionWorkerHost.CHANNEL_NAME); } - public static setChannel(workerClient: IWorkerClient, obj: LanguageDetectionWorkerHost): void { + public static setChannel(workerClient: IWebWorkerClient, obj: LanguageDetectionWorkerHost): void { workerClient.setChannel(LanguageDetectionWorkerHost.CHANNEL_NAME, obj); } diff --git a/src/vs/workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.ts b/src/vs/workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.ts index dad48d96187..48279c1fd1e 100644 --- a/src/vs/workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.ts +++ b/src/vs/workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.ts @@ -13,7 +13,7 @@ import { URI } from '../../../../base/common/uri.js'; import { isWeb } from '../../../../base/common/platform.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IModelService } from '../../../../editor/common/services/model.js'; -import { IWorkerClient } from '../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerClient } from '../../../../base/common/worker/webWorker.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IDiagnosticsService } from '../../../../platform/diagnostics/common/diagnostics.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; @@ -22,7 +22,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { LRUCache } from '../../../../base/common/map.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { canASAR } from '../../../../amdX.js'; -import { createWebWorker } from '../../../../base/browser/defaultWorkerFactory.js'; +import { createWebWorker } from '../../../../base/browser/webWorkerFactory.js'; import { WorkerTextModelSyncClient } from '../../../../editor/common/services/textModelSync/textModelSync.impl.js'; import { ILanguageDetectionWorker, LanguageDetectionWorkerHost } from './languageDetectionWorker.protocol.js'; @@ -179,7 +179,7 @@ export class LanguageDetectionService extends Disposable implements ILanguageDet export class LanguageDetectionWorkerClient extends Disposable { private worker: { - workerClient: IWorkerClient; + workerClient: IWebWorkerClient; workerTextModelSyncClient: WorkerTextModelSyncClient; } | undefined; @@ -196,12 +196,12 @@ export class LanguageDetectionWorkerClient extends Disposable { } private _getOrCreateLanguageDetectionWorker(): { - workerClient: IWorkerClient; + workerClient: IWebWorkerClient; workerTextModelSyncClient: WorkerTextModelSyncClient; } { if (!this.worker) { const workerClient = this._register(createWebWorker( - 'vs/workbench/services/languageDetection/browser/languageDetectionSimpleWorker', + FileAccess.asBrowserUri('vs/workbench/services/languageDetection/browser/languageDetectionWebWorkerMain.js'), 'LanguageDetectionWorker' )); LanguageDetectionWorkerHost.setChannel(workerClient, { diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 2d2b1d64426..b14a2f93ac3 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -129,6 +129,12 @@ export function panelOpensMaximizedFromString(str: string): PanelOpensMaximizedO export type MULTI_WINDOW_PARTS = Parts.EDITOR_PART | Parts.STATUSBAR_PART | Parts.TITLEBAR_PART; export type SINGLE_WINDOW_PARTS = Exclude; +export function isMultiWindowPart(part: Parts): part is MULTI_WINDOW_PARTS { + return part === Parts.EDITOR_PART || + part === Parts.STATUSBAR_PART || + part === Parts.TITLEBAR_PART; +} + export interface IWorkbenchLayoutService extends ILayoutService { readonly _serviceBrand: undefined; @@ -217,9 +223,7 @@ export interface IWorkbenchLayoutService extends ILayoutService { /** * Set part hidden or not in the target window. */ - setPartHidden(hidden: boolean, part: Exclude): void; - setPartHidden(hidden: boolean, part: Exclude, targetWindow: Window): void; - setPartHidden(hidden: boolean, part: Exclude, targetWindow: Window): void; + setPartHidden(hidden: boolean, part: Parts): void; /** * Maximizes the panel height if the panel is not already maximized. diff --git a/src/vs/workbench/services/lifecycle/browser/lifecycleService.ts b/src/vs/workbench/services/lifecycle/browser/lifecycleService.ts index 7dc09d2e7e5..5bacc9fad6b 100644 --- a/src/vs/workbench/services/lifecycle/browser/lifecycleService.ts +++ b/src/vs/workbench/services/lifecycle/browser/lifecycleService.ts @@ -161,6 +161,7 @@ export class BrowserLifecycleService extends AbstractLifecycleService { } this.didUnload = true; + this._willShutdown = true; // Register a late `pageshow` listener specifically on unload this._register(addDisposableListener(mainWindow, EventType.PAGE_SHOW, (e: PageTransitionEvent) => this.onLoadAfterUnload(e))); diff --git a/src/vs/workbench/services/lifecycle/common/lifecycle.ts b/src/vs/workbench/services/lifecycle/common/lifecycle.ts index b976238ba53..de7ce214df8 100644 --- a/src/vs/workbench/services/lifecycle/common/lifecycle.ts +++ b/src/vs/workbench/services/lifecycle/common/lifecycle.ts @@ -275,6 +275,11 @@ export interface ILifecycleService { */ readonly onWillShutdown: Event; + /** + * A flag indicating that we are about to shutdown without further veto. + */ + readonly willShutdown: boolean; + /** * Fired when the shutdown is about to happen after long running shutdown operations * have finished (from `onWillShutdown`). diff --git a/src/vs/workbench/services/lifecycle/common/lifecycleService.ts b/src/vs/workbench/services/lifecycle/common/lifecycleService.ts index c32a14ca9f7..684345132a0 100644 --- a/src/vs/workbench/services/lifecycle/common/lifecycleService.ts +++ b/src/vs/workbench/services/lifecycle/common/lifecycleService.ts @@ -38,6 +38,9 @@ export abstract class AbstractLifecycleService extends Disposable implements ILi private _phase = LifecyclePhase.Starting; get phase(): LifecyclePhase { return this._phase; } + protected _willShutdown = false; + get willShutdown(): boolean { return this._willShutdown; } + private readonly phaseWhen = new Map(); protected shutdownReason: ShutdownReason | undefined; diff --git a/src/vs/workbench/services/lifecycle/electron-sandbox/lifecycleService.ts b/src/vs/workbench/services/lifecycle/electron-sandbox/lifecycleService.ts index 8508d4c433d..b7fe1aadac3 100644 --- a/src/vs/workbench/services/lifecycle/electron-sandbox/lifecycleService.ts +++ b/src/vs/workbench/services/lifecycle/electron-sandbox/lifecycleService.ts @@ -154,6 +154,8 @@ export class NativeLifecycleService extends AbstractLifecycleService { } protected async handleWillShutdown(reason: ShutdownReason): Promise { + this._willShutdown = true; + const joiners: Promise[] = []; const lastJoiners: (() => Promise)[] = []; const pendingJoiners = new Set(); diff --git a/src/vs/workbench/services/lifecycle/test/electron-sandbox/lifecycleService.test.ts b/src/vs/workbench/services/lifecycle/test/electron-sandbox/lifecycleService.test.ts index be81648bd84..7afb9dd0ab0 100644 --- a/src/vs/workbench/services/lifecycle/test/electron-sandbox/lifecycleService.test.ts +++ b/src/vs/workbench/services/lifecycle/test/electron-sandbox/lifecycleService.test.ts @@ -186,5 +186,22 @@ suite('Lifecycleservice', function () { }); }); + test('willShutdown is set when shutting down', async function () { + let willShutdownSet = false; + + disposables.add(lifecycleService.onWillShutdown(e => { + e.join(new Promise(resolve => { + if (lifecycleService.willShutdown) { + willShutdownSet = true; + resolve(); + } + }), { id: 'test', label: 'test' }); + })); + + await lifecycleService.testHandleWillShutdown(ShutdownReason.QUIT); + + assert.strictEqual(willShutdownSet, true); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/services/notebook/common/notebookDocumentService.ts b/src/vs/workbench/services/notebook/common/notebookDocumentService.ts index 44226f33ebb..6015927f614 100644 --- a/src/vs/workbench/services/notebook/common/notebookDocumentService.ts +++ b/src/vs/workbench/services/notebook/common/notebookDocumentService.ts @@ -66,6 +66,38 @@ export function generateMetadataUri(notebook: URI): URI { return notebook.with({ scheme: Schemas.vscodeNotebookMetadata, fragment }); } +export function extractCellOutputDetails(uri: URI): { notebook: URI; openIn: string; outputId?: string; cellFragment?: string; outputIndex?: number; cellHandle?: number; cellIndex?: number } | undefined { + if (uri.scheme !== Schemas.vscodeNotebookCellOutput) { + return; + } + + const params = new URLSearchParams(uri.query); + const openIn = params.get('openIn'); + if (!openIn) { + return; + } + const outputId = params.get('outputId') ?? undefined; + const parsedCell = parse(uri.with({ scheme: Schemas.vscodeNotebookCell, query: null })); + const outputIndex = params.get('outputIndex') ? parseInt(params.get('outputIndex') || '', 10) : undefined; + const notebookUri = parsedCell ? parsedCell.notebook : uri.with({ + scheme: params.get('notebookScheme') || Schemas.file, + fragment: null, + query: null, + }); + const cellIndex = params.get('cellIndex') ? parseInt(params.get('cellIndex') || '', 10) : undefined; + + return { + notebook: notebookUri, + openIn: openIn, + outputId: outputId, + outputIndex: outputIndex, + cellHandle: parsedCell?.handle, + cellFragment: uri.fragment, + cellIndex: cellIndex, + }; +} + + export interface INotebookDocumentService { readonly _serviceBrand: undefined; @@ -89,6 +121,15 @@ export class NotebookDocumentWorkbenchService implements INotebookDocumentServic } } } + if (uri.scheme === Schemas.vscodeNotebookCellOutput) { + const parsedData = extractCellOutputDetails(uri); + if (parsedData) { + const document = this._documents.get(parsedData.notebook); + if (document) { + return document; + } + } + } return this._documents.get(uri); } diff --git a/src/vs/workbench/services/notification/common/notificationService.ts b/src/vs/workbench/services/notification/common/notificationService.ts index a61a1c3b6f0..24eeff23dc3 100644 --- a/src/vs/workbench/services/notification/common/notificationService.ts +++ b/src/vs/workbench/services/notification/common/notificationService.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; -import { INotificationService, INotification, INotificationHandle, Severity, NotificationMessage, INotificationActions, IPromptChoice, IPromptOptions, IStatusMessageOptions, NoOpNotification, NeverShowAgainScope, NotificationsFilter, INeverShowAgainOptions, INotificationSource, INotificationSourceFilter, isNotificationSource } from '../../../../platform/notification/common/notification.js'; +import { INotificationService, INotification, INotificationHandle, Severity, NotificationMessage, INotificationActions, IPromptChoice, IPromptOptions, IStatusMessageOptions, NoOpNotification, NeverShowAgainScope, NotificationsFilter, INeverShowAgainOptions, INotificationSource, INotificationSourceFilter, isNotificationSource, IStatusHandle } from '../../../../platform/notification/common/notification.js'; import { NotificationsModel, ChoiceAction, NotificationChangeType } from '../../../common/notifications.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IAction, Action } from '../../../../base/common/actions.js'; @@ -346,7 +346,7 @@ export class NotificationService extends Disposable implements INotificationServ return handle; } - status(message: NotificationMessage, options?: IStatusMessageOptions): IDisposable { + status(message: NotificationMessage, options?: IStatusMessageOptions): IStatusHandle { return this.model.showStatusMessage(message, options); } } diff --git a/src/vs/workbench/services/output/common/output.ts b/src/vs/workbench/services/output/common/output.ts index 0617017a0bc..f5f30494024 100644 --- a/src/vs/workbench/services/output/common/output.ts +++ b/src/vs/workbench/services/output/common/output.ts @@ -10,6 +10,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { LogLevel } from '../../../../platform/log/common/log.js'; import { Range } from '../../../../editor/common/core/range.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; /** * Mime type used by the output editor. @@ -271,16 +272,16 @@ export interface IOutputChannelRegistry { removeChannel(id: string): void; } -class OutputChannelRegistry implements IOutputChannelRegistry { +class OutputChannelRegistry extends Disposable implements IOutputChannelRegistry { private channels = new Map(); - private readonly _onDidRegisterChannel = new Emitter(); + private readonly _onDidRegisterChannel = this._register(new Emitter()); readonly onDidRegisterChannel = this._onDidRegisterChannel.event; - private readonly _onDidRemoveChannel = new Emitter(); + private readonly _onDidRemoveChannel = this._register(new Emitter()); readonly onDidRemoveChannel = this._onDidRemoveChannel.event; - private readonly _onDidUpdateChannelFiles = new Emitter(); + private readonly _onDidUpdateChannelFiles = this._register(new Emitter()); readonly onDidUpdateChannelSources = this._onDidUpdateChannelFiles.event; public registerChannel(descriptor: IOutputChannelDescriptor): void { diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts new file mode 100644 index 00000000000..9040c4f79f5 --- /dev/null +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../../base/common/collections.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { AbstractPolicyService, IPolicyService, PolicyDefinition } from '../../../../platform/policy/common/policy.js'; +import { IDefaultAccountService } from '../../accounts/common/defaultAccount.js'; + +export class AccountPolicyService extends AbstractPolicyService implements IPolicyService { + private chatPreviewFeaturesEnabled: boolean = true; + constructor( + @ILogService private readonly logService: ILogService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService + ) { + super(); + + this.defaultAccountService.getDefaultAccount() + .then(account => { + this._update(account?.chat_preview_features_enabled ?? true); + this._register(this.defaultAccountService.onDidChangeDefaultAccount(account => this._update(account?.chat_preview_features_enabled ?? true))); + }); + } + + private _update(chatPreviewFeaturesEnabled: boolean | undefined) { + const newValue = (chatPreviewFeaturesEnabled === undefined) || chatPreviewFeaturesEnabled; + if (this.chatPreviewFeaturesEnabled !== newValue) { + this.chatPreviewFeaturesEnabled = newValue; + this._updatePolicyDefinitions(this.policyDefinitions); + } + } + + protected async _updatePolicyDefinitions(policyDefinitions: IStringDictionary): Promise { + this.logService.trace(`AccountPolicyService#_updatePolicyDefinitions: Got ${Object.keys(policyDefinitions).length} policy definitions`); + + const update: string[] = []; + for (const key in policyDefinitions) { + const policy = policyDefinitions[key]; + if (policy.previewFeature) { + if (this.chatPreviewFeaturesEnabled) { + this.policies.delete(key); + update.push(key); + continue; + } + const defaultValue = policy.defaultValue; + const updatedValue = defaultValue === undefined ? false : defaultValue; + if (this.policies.get(key) === updatedValue) { + continue; + } + this.policies.set(key, updatedValue); + update.push(key); + } + } + + if (update.length) { + this._onDidChange.fire(update); + } + } +} diff --git a/src/vs/workbench/services/policies/common/multiplexPolicyService.ts b/src/vs/workbench/services/policies/common/multiplexPolicyService.ts new file mode 100644 index 00000000000..0bc6fbd9e47 --- /dev/null +++ b/src/vs/workbench/services/policies/common/multiplexPolicyService.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../../base/common/collections.js'; +import { Iterable } from '../../../../base/common/iterator.js'; +import { Event } from '../../../../base/common/event.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { AbstractPolicyService, IPolicyService, PolicyDefinition, PolicyValue } from '../../../../platform/policy/common/policy.js'; + +export class MultiplexPolicyService extends AbstractPolicyService implements IPolicyService { + + constructor( + private readonly policyServices: ReadonlyArray, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this.updatePolicies(); + this._register(Event.any(...this.policyServices.map(service => service.onDidChange))(names => { + this.updatePolicies(); + this._onDidChange.fire(names); + })); + } + + override async updatePolicyDefinitions(policyDefinitions: IStringDictionary): Promise> { + await this._updatePolicyDefinitions(policyDefinitions); + return Iterable.reduce(this.policies.entries(), (r, [name, value]) => ({ ...r, [name]: value }), {}); + } + + protected async _updatePolicyDefinitions(policyDefinitions: IStringDictionary): Promise { + await Promise.all(this.policyServices.map(service => service.updatePolicyDefinitions(policyDefinitions))); + this.updatePolicies(); + } + + private updatePolicies(): void { + this.policies.clear(); + const updated: string[] = []; + for (const service of this.policyServices) { + const definitions = service.policyDefinitions; + for (const name in definitions) { + const value = service.getPolicyValue(name); + this.policyDefinitions[name] = definitions[name]; + if (value !== undefined) { + updated.push(name); + this.policies.set(name, value); + } + } + } + + // Check that no results have overlapping keys + const changed = new Set(); + for (const key of updated) { + if (changed.has(key)) { + this.logService.warn(`MultiplexPolicyService#_updatePolicyDefinitions - Found overlapping keys in policy services: ${key}`); + } + changed.add(key); + } + } +} diff --git a/src/vs/workbench/services/policies/test/common/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/common/accountPolicyService.test.ts new file mode 100644 index 00000000000..330b323631a --- /dev/null +++ b/src/vs/workbench/services/policies/test/common/accountPolicyService.test.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 assert from 'assert'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { DefaultAccountService, IDefaultAccount, IDefaultAccountService } from '../../../accounts/common/defaultAccount.js'; +import { AccountPolicyService } from '../../common/accountPolicyService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; + +const BASE_DEFAULT_ACCOUNT: IDefaultAccount = { + enterprise: false, + sessionId: 'abc123', +}; + +suite('AccountPolicyService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let policyService: AccountPolicyService; + let defaultAccountService: IDefaultAccountService; + let policyConfiguration: PolicyConfiguration; + const logService = new NullLogService(); + + const policyConfigurationNode: IConfigurationNode = { + 'id': 'policyConfiguration', + 'order': 1, + 'title': 'a', + 'type': 'object', + 'properties': { + 'setting.A': { + 'type': 'string', + 'default': 'defaultValueA', + policy: { + name: 'PolicySettingA', + minimumVersion: '1.0.0', + } + }, + 'setting.B': { + 'type': 'string', + 'default': 'defaultValueB', + policy: { + name: 'PolicySettingB', + minimumVersion: '1.0.0', + previewFeature: true, + defaultValue: "policyValueB" + } + }, + 'setting.C': { + 'type': 'array', + 'default': ['defaultValueC1', 'defaultValueC2'], + policy: { + name: 'PolicySettingC', + minimumVersion: '1.0.0', + previewFeature: true, + defaultValue: JSON.stringify(['policyValueC1', 'policyValueC2']), + } + }, + 'setting.D': { + 'type': 'boolean', + 'default': true, + policy: { + name: 'PolicySettingD', + minimumVersion: '1.0.0', + previewFeature: true, + defaultValue: false, + } + }, + 'setting.E': { + 'type': 'boolean', + 'default': true, + } + } + }; + + + suiteSetup(() => Registry.as(Extensions.Configuration).registerConfiguration(policyConfigurationNode)); + suiteTeardown(() => Registry.as(Extensions.Configuration).deregisterConfigurations([policyConfigurationNode])); + + setup(async () => { + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + + defaultAccountService = disposables.add(new DefaultAccountService()); + policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService)); + policyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); + + }); + + async function assertDefaultBehavior(defaultAccount: IDefaultAccount) { + defaultAccountService.setDefaultAccount(defaultAccount); + + await policyConfiguration.initialize(); + + { + const A = policyService.getPolicyValue('PolicySettingA'); + const B = policyService.getPolicyValue('PolicySettingB'); + const C = policyService.getPolicyValue('PolicySettingC'); + const D = policyService.getPolicyValue('PolicySettingD'); + + // No policy is set + assert.strictEqual(A, undefined); + assert.strictEqual(B, undefined); + assert.strictEqual(C, undefined); + assert.strictEqual(D, undefined); + } + + { + const B = policyConfiguration.configurationModel.getValue('setting.B'); + const C = policyConfiguration.configurationModel.getValue('setting.C'); + const D = policyConfiguration.configurationModel.getValue('setting.D'); + + assert.strictEqual(B, undefined); + assert.deepStrictEqual(C, undefined); + assert.strictEqual(D, undefined); + } + } + + + test('should initialize with default account', async () => { + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT }; + await assertDefaultBehavior(defaultAccount); + }); + + test('should initialize with default account and preview features enabled', async () => { + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, chat_preview_features_enabled: true }; + await assertDefaultBehavior(defaultAccount); + }); + + test('should initialize with default account and preview features disabled', async () => { + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccount(defaultAccount); + + await policyConfiguration.initialize(); + const actualConfigurationModel = policyConfiguration.configurationModel; + + { + const A = policyService.getPolicyValue('PolicySettingA'); + const B = policyService.getPolicyValue('PolicySettingB'); + const C = policyService.getPolicyValue('PolicySettingC'); + const D = policyService.getPolicyValue('PolicySettingD'); + + assert.strictEqual(A, undefined); // Not tagged with 'previewFeature' + assert.strictEqual(B, 'policyValueB'); + assert.strictEqual(C, JSON.stringify(['policyValueC1', 'policyValueC2'])); + assert.strictEqual(D, false); + } + + { + const B = actualConfigurationModel.getValue('setting.B'); + const C = actualConfigurationModel.getValue('setting.C'); + const D = actualConfigurationModel.getValue('setting.D'); + + assert.strictEqual(B, 'policyValueB'); + assert.deepStrictEqual(C, ['policyValueC1', 'policyValueC2']); + assert.strictEqual(D, false); + } + }); +}); diff --git a/src/vs/workbench/services/policies/test/common/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/common/multiplexPolicyService.test.ts new file mode 100644 index 00000000000..5617dcb820c --- /dev/null +++ b/src/vs/workbench/services/policies/test/common/multiplexPolicyService.test.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NullLogService } from '../../../../../platform/log/common/log.js'; +import { DefaultAccountService, IDefaultAccount, IDefaultAccountService } from '../../../accounts/common/defaultAccount.js'; +import { AccountPolicyService } from '../../common/accountPolicyService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; +import { MultiplexPolicyService } from '../../common/multiplexPolicyService.js'; +import { FilePolicyService } from '../../../../../platform/policy/common/filePolicyService.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; + +const BASE_DEFAULT_ACCOUNT: IDefaultAccount = { + enterprise: false, + sessionId: 'abc123', +}; + +suite('MultiplexPolicyService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let policyService: MultiplexPolicyService; + let fileService: IFileService; + let defaultAccountService: IDefaultAccountService; + let policyConfiguration: PolicyConfiguration; + const logService = new NullLogService(); + + const policyFile = URI.file('policyFile').with({ scheme: 'vscode-tests' }); + const policyConfigurationNode: IConfigurationNode = { + 'id': 'policyConfiguration', + 'order': 1, + 'title': 'a', + 'type': 'object', + 'properties': { + 'setting.A': { + 'type': 'string', + 'default': 'defaultValueA', + policy: { + name: 'PolicySettingA', + minimumVersion: '1.0.0', + } + }, + 'setting.B': { + 'type': 'string', + 'default': 'defaultValueB', + policy: { + name: 'PolicySettingB', + minimumVersion: '1.0.0', + previewFeature: true, + defaultValue: "policyValueB" + } + }, + 'setting.C': { + 'type': 'array', + 'default': ['defaultValueC1', 'defaultValueC2'], + policy: { + name: 'PolicySettingC', + minimumVersion: '1.0.0', + previewFeature: true, + defaultValue: JSON.stringify(['policyValueC1', 'policyValueC2']), + } + }, + 'setting.D': { + 'type': 'boolean', + 'default': true, + policy: { + name: 'PolicySettingD', + minimumVersion: '1.0.0', + previewFeature: true, + defaultValue: false, + } + }, + 'setting.E': { + 'type': 'boolean', + 'default': true, + } + } + }; + + + suiteSetup(() => Registry.as(Extensions.Configuration).registerConfiguration(policyConfigurationNode)); + suiteTeardown(() => Registry.as(Extensions.Configuration).deregisterConfigurations([policyConfigurationNode])); + + setup(async () => { + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + + fileService = disposables.add(new FileService(new NullLogService())); + const diskFileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(policyFile.scheme, diskFileSystemProvider)); + + defaultAccountService = disposables.add(new DefaultAccountService()); + policyService = disposables.add(new MultiplexPolicyService([ + disposables.add(new FilePolicyService(policyFile, fileService, new NullLogService())), + disposables.add(new AccountPolicyService(logService, defaultAccountService)), + ], logService)); + policyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); + }); + + async function clear() { + // Reset + defaultAccountService.setDefaultAccount({ ...BASE_DEFAULT_ACCOUNT }); + await fileService.writeFile(policyFile, + VSBuffer.fromString( + JSON.stringify({}) + ) + ); + } + + test('no policy', async () => { + await clear(); + + await policyConfiguration.initialize(); + + { + const A = policyService.getPolicyValue('PolicySettingA'); + const B = policyService.getPolicyValue('PolicySettingB'); + const C = policyService.getPolicyValue('PolicySettingC'); + const D = policyService.getPolicyValue('PolicySettingD'); + + // No policy is set + assert.strictEqual(A, undefined); + assert.strictEqual(B, undefined); + assert.strictEqual(C, undefined); + assert.strictEqual(D, undefined); + } + + { + const A = policyConfiguration.configurationModel.getValue('setting.A'); + const B = policyConfiguration.configurationModel.getValue('setting.B'); + const C = policyConfiguration.configurationModel.getValue('setting.C'); + const D = policyConfiguration.configurationModel.getValue('setting.D'); + const E = policyConfiguration.configurationModel.getValue('setting.E'); + + assert.strictEqual(A, undefined); + assert.strictEqual(B, undefined); + assert.deepStrictEqual(C, undefined); + assert.strictEqual(D, undefined); + assert.strictEqual(E, undefined); + } + }); + + test('policy from file only', async () => { + await clear(); + + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT }; + defaultAccountService.setDefaultAccount(defaultAccount); + + await fileService.writeFile(policyFile, + VSBuffer.fromString( + JSON.stringify({ 'PolicySettingA': 'policyValueA' }) + ) + ); + + await policyConfiguration.initialize(); + + { + const A = policyService.getPolicyValue('PolicySettingA'); + const B = policyService.getPolicyValue('PolicySettingB'); + const C = policyService.getPolicyValue('PolicySettingC'); + const D = policyService.getPolicyValue('PolicySettingD'); + + assert.strictEqual(A, 'policyValueA'); + assert.strictEqual(B, undefined); + assert.strictEqual(C, undefined); + assert.strictEqual(D, undefined); + } + + { + const A = policyConfiguration.configurationModel.getValue('setting.A'); + const B = policyConfiguration.configurationModel.getValue('setting.B'); + const C = policyConfiguration.configurationModel.getValue('setting.C'); + const D = policyConfiguration.configurationModel.getValue('setting.D'); + const E = policyConfiguration.configurationModel.getValue('setting.E'); + + assert.strictEqual(A, 'policyValueA'); + assert.strictEqual(B, undefined); + assert.deepStrictEqual(C, undefined); + assert.strictEqual(D, undefined); + assert.strictEqual(E, undefined); + } + }); + + test('policy from default account only', async () => { + await clear(); + + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccount(defaultAccount); + + await fileService.writeFile(policyFile, + VSBuffer.fromString( + JSON.stringify({}) + ) + ); + + await policyConfiguration.initialize(); + const actualConfigurationModel = policyConfiguration.configurationModel; + + { + const A = policyService.getPolicyValue('PolicySettingA'); + const B = policyService.getPolicyValue('PolicySettingB'); + const C = policyService.getPolicyValue('PolicySettingC'); + const D = policyService.getPolicyValue('PolicySettingD'); + + assert.strictEqual(A, undefined); // Not tagged with 'previewFeature' + assert.strictEqual(B, 'policyValueB'); + assert.strictEqual(C, JSON.stringify(['policyValueC1', 'policyValueC2'])); + assert.strictEqual(D, false); + } + + { + const A = policyConfiguration.configurationModel.getValue('setting.A'); + const B = actualConfigurationModel.getValue('setting.B'); + const C = actualConfigurationModel.getValue('setting.C'); + const D = actualConfigurationModel.getValue('setting.D'); + + assert.strictEqual(A, undefined); + assert.strictEqual(B, 'policyValueB'); + assert.deepStrictEqual(C, ['policyValueC1', 'policyValueC2']); + assert.strictEqual(D, false); + } + }); + + test('policy from file and default account', async () => { + await clear(); + + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccount(defaultAccount); + + await fileService.writeFile(policyFile, + VSBuffer.fromString( + JSON.stringify({ 'PolicySettingA': 'policyValueA' }) + ) + ); + + await policyConfiguration.initialize(); + const actualConfigurationModel = policyConfiguration.configurationModel; + + { + const A = policyService.getPolicyValue('PolicySettingA'); + const B = policyService.getPolicyValue('PolicySettingB'); + const C = policyService.getPolicyValue('PolicySettingC'); + const D = policyService.getPolicyValue('PolicySettingD'); + + assert.strictEqual(A, 'policyValueA'); + assert.strictEqual(B, 'policyValueB'); + assert.strictEqual(C, JSON.stringify(['policyValueC1', 'policyValueC2'])); + assert.strictEqual(D, false); + } + + { + const A = actualConfigurationModel.getValue('setting.A'); + const B = actualConfigurationModel.getValue('setting.B'); + const C = actualConfigurationModel.getValue('setting.C'); + const D = actualConfigurationModel.getValue('setting.D'); + + assert.strictEqual(A, 'policyValueA'); + assert.strictEqual(B, 'policyValueB'); + assert.deepStrictEqual(C, ['policyValueC1', 'policyValueC2']); + assert.strictEqual(D, false); + } + }); +}); diff --git a/src/vs/workbench/services/preferences/browser/preferencesService.ts b/src/vs/workbench/services/preferences/browser/preferencesService.ts index c657835f97f..565c4dd6d03 100644 --- a/src/vs/workbench/services/preferences/browser/preferencesService.ts +++ b/src/vs/workbench/services/preferences/browser/preferencesService.ts @@ -123,7 +123,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic return workspace.configuration || workspace.folders[0].toResource(FOLDER_SETTINGS_PATH); } - createOrGetCachedSettingsEditor2Input(): SettingsEditor2Input { + private createOrGetCachedSettingsEditor2Input(): SettingsEditor2Input { if (!this._cachedSettingsEditor2Input || this._cachedSettingsEditor2Input.isDisposed()) { // Recreate the input if the user never opened the Settings editor, // or if they closed it and want to reopen it. diff --git a/src/vs/workbench/services/preferences/common/preferences.ts b/src/vs/workbench/services/preferences/common/preferences.ts index 79b764b569d..0a4441ef6f5 100644 --- a/src/vs/workbench/services/preferences/common/preferences.ts +++ b/src/vs/workbench/services/preferences/common/preferences.ts @@ -109,7 +109,7 @@ export interface IExtensionSetting extends ISetting { export interface ISearchResult { filterMatches: ISettingMatch[]; - exactMatch?: boolean; + exactMatch: boolean; metadata?: IFilterMetadata; } @@ -201,7 +201,6 @@ export interface ISettingsEditorModel extends IPreferencesEditorModel readonly onDidChangeGroups: Event; settingsGroups: ISettingsGroup[]; filterSettings(filter: string, groupFilter: IGroupFilter, settingMatcher: ISettingMatcher): ISettingMatch[]; - findValueMatches(filter: string, setting: ISetting): IRange[]; updateResultGroup(id: string, resultGroup: ISearchResultGroup | undefined): IFilterResult | undefined; } diff --git a/src/vs/workbench/services/preferences/common/preferencesModels.ts b/src/vs/workbench/services/preferences/common/preferencesModels.ts index 44f3e8035de..dbb67285d19 100644 --- a/src/vs/workbench/services/preferences/common/preferencesModels.ts +++ b/src/vs/workbench/services/preferences/common/preferencesModels.ts @@ -116,8 +116,6 @@ abstract class AbstractSettingsModel extends EditorModel { abstract settingsGroups: ISettingsGroup[]; - abstract findValueMatches(filter: string, setting: ISetting): IRange[]; - protected abstract update(): IFilterResult | undefined; } @@ -158,10 +156,6 @@ export class SettingsEditorModel extends AbstractSettingsModel implements ISetti return this.settingsModel.getValue(); } - findValueMatches(filter: string, setting: ISetting): IRange[] { - return this.settingsModel.findMatches(filter, setting.valueRange, false, false, null, false).map(match => match.range); - } - protected isSettingsProperty(property: string, previousParents: string[]): boolean { return previousParents.length === 0; // Settings is root } @@ -255,11 +249,6 @@ export class Settings2EditorModel extends AbstractSettingsModel implements ISett this.additionalGroups = groups; } - findValueMatches(filter: string, setting: ISetting): IRange[] { - // TODO @roblou - return []; - } - protected update(): IFilterResult { throw new Error('Not supported'); } @@ -951,10 +940,6 @@ export class DefaultSettingsEditorModel extends AbstractSettingsModel implements }; } - findValueMatches(filter: string, setting: ISetting): IRange[] { - return []; - } - override getPreference(key: string): ISetting | undefined { for (const group of this.settingsGroups) { for (const section of group.sections) { diff --git a/src/vs/workbench/services/progress/browser/progressService.ts b/src/vs/workbench/services/progress/browser/progressService.ts index 8a02c908e63..cb8831c7e77 100644 --- a/src/vs/workbench/services/progress/browser/progressService.ts +++ b/src/vs/workbench/services/progress/browser/progressService.ts @@ -545,6 +545,7 @@ export class ProgressService extends Disposable implements IProgressService { const disposables = new DisposableStore(); let dialog: Dialog; + let taskCompleted = false; const createDialog = (message: string) => { const buttons = options.buttons || []; @@ -571,8 +572,15 @@ export class ProgressService extends Disposable implements IProgressService { disposables.add(dialog); dialog.show().then(dialogResult => { - onDidCancel?.(dialogResult.button); - + // The dialog may close as a result of disposing it after the + // task has completed. In that case, we do not want to trigger + // the `onDidCancel` callback. + // However, if the task is still running, this means that the + // user has clicked the cancel button and we want to trigger + // the `onDidCancel` callback. + if (!taskCompleted) { + onDidCancel?.(dialogResult.button); + } dispose(dialog); }); @@ -611,6 +619,7 @@ export class ProgressService extends Disposable implements IProgressService { }); promise.finally(() => { + taskCompleted = true; dispose(disposables); }); diff --git a/src/vs/workbench/services/search/browser/searchService.ts b/src/vs/workbench/services/search/browser/searchService.ts index cdefb232d45..5d1dc405a68 100644 --- a/src/vs/workbench/services/search/browser/searchService.ts +++ b/src/vs/workbench/services/search/browser/searchService.ts @@ -14,14 +14,14 @@ import { IExtensionService } from '../../extensions/common/extensions.js'; import { IFileMatch, IFileQuery, ISearchComplete, ISearchProgressItem, ISearchResultProvider, ISearchService, ITextQuery, SearchProviderType, TextSearchCompleteMessageType } from '../common/search.js'; import { SearchService } from '../common/searchService.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { IWorkerClient, logOnceWebWorkerWarning } from '../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerClient, logOnceWebWorkerWarning } from '../../../../base/common/worker/webWorker.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { createWebWorker } from '../../../../base/browser/defaultWorkerFactory.js'; +import { createWebWorker } from '../../../../base/browser/webWorkerFactory.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { ILocalFileSearchSimpleWorker, LocalFileSearchSimpleWorkerHost } from '../common/localFileSearchWorkerTypes.js'; +import { ILocalFileSearchWorker, LocalFileSearchWorkerHost } from '../common/localFileSearchWorkerTypes.js'; import { memoize } from '../../../../base/common/decorators.js'; import { HTMLFileSystemProvider } from '../../../../platform/files/browser/htmlFileSystemProvider.js'; -import { Schemas } from '../../../../base/common/network.js'; +import { FileAccess, Schemas } from '../../../../base/common/network.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { localize } from '../../../../nls.js'; @@ -48,7 +48,7 @@ export class RemoteSearchService extends SearchService { export class LocalFileSearchWorkerClient extends Disposable implements ISearchResultProvider { - protected _worker: IWorkerClient | null; + protected _worker: IWebWorkerClient | null; private readonly _onDidReceiveTextSearchMatch = new Emitter<{ match: IFileMatch; queryId: number }>(); readonly onDidReceiveTextSearchMatch: Event<{ match: IFileMatch; queryId: number }> = this._onDidReceiveTextSearchMatch.event; @@ -184,14 +184,14 @@ export class LocalFileSearchWorkerClient extends Disposable implements ISearchRe if (this.cache?.key === cacheKey) { this.cache = undefined; } } - private _getOrCreateWorker(): IWorkerClient { + private _getOrCreateWorker(): IWebWorkerClient { if (!this._worker) { try { - this._worker = this._register(createWebWorker( - 'vs/workbench/services/search/worker/localFileSearch', + this._worker = this._register(createWebWorker( + FileAccess.asBrowserUri('vs/workbench/services/search/worker/localFileSearchMain.js'), 'LocalFileSearchWorker' )); - LocalFileSearchSimpleWorkerHost.setChannel(this._worker, { + LocalFileSearchWorkerHost.setChannel(this._worker, { $sendTextSearchMatch: (match, queryId) => { return this.sendTextSearchMatch(match, queryId); } diff --git a/src/vs/workbench/services/search/common/localFileSearchWorkerTypes.ts b/src/vs/workbench/services/search/common/localFileSearchWorkerTypes.ts index 64037018e6c..dfee98bbb72 100644 --- a/src/vs/workbench/services/search/common/localFileSearchWorkerTypes.ts +++ b/src/vs/workbench/services/search/common/localFileSearchWorkerTypes.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { UriComponents } from '../../../../base/common/uri.js'; -import { IWorkerClient, IWorkerServer } from '../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerClient, IWebWorkerServer } from '../../../../base/common/worker/webWorker.js'; import { IFileMatch, IFileQueryProps, IFolderQuery, ITextQueryProps } from './search.js'; export interface IWorkerTextSearchComplete { @@ -39,7 +39,7 @@ export interface IWorkerFileSystemFileHandle extends IWorkerFileSystemHandle { getFile(): Promise<{ arrayBuffer(): Promise }>; } -export interface ILocalFileSearchSimpleWorker { +export interface ILocalFileSearchWorker { _requestHandlerBrand: any; $cancelQuery(queryId: number): void; @@ -48,13 +48,13 @@ export interface ILocalFileSearchSimpleWorker { $searchDirectory(handle: IWorkerFileSystemDirectoryHandle, queryProps: ITextQueryProps, folderQuery: IFolderQuery, ignorePathCasing: boolean, queryId: number): Promise; } -export abstract class LocalFileSearchSimpleWorkerHost { +export abstract class LocalFileSearchWorkerHost { public static CHANNEL_NAME = 'localFileSearchWorkerHost'; - public static getChannel(workerServer: IWorkerServer): LocalFileSearchSimpleWorkerHost { - return workerServer.getChannel(LocalFileSearchSimpleWorkerHost.CHANNEL_NAME); + public static getChannel(workerServer: IWebWorkerServer): LocalFileSearchWorkerHost { + return workerServer.getChannel(LocalFileSearchWorkerHost.CHANNEL_NAME); } - public static setChannel(workerClient: IWorkerClient, obj: LocalFileSearchSimpleWorkerHost): void { - workerClient.setChannel(LocalFileSearchSimpleWorkerHost.CHANNEL_NAME, obj); + public static setChannel(workerClient: IWebWorkerClient, obj: LocalFileSearchWorkerHost): void { + workerClient.setChannel(LocalFileSearchWorkerHost.CHANNEL_NAME, obj); } abstract $sendTextSearchMatch(match: IFileMatch, queryId: number): void; diff --git a/src/vs/workbench/services/search/common/search.ts b/src/vs/workbench/services/search/common/search.ts index 88619390030..923021734e0 100644 --- a/src/vs/workbench/services/search/common/search.ts +++ b/src/vs/workbench/services/search/common/search.ts @@ -17,7 +17,7 @@ import { ITelemetryData } from '../../../../platform/telemetry/common/telemetry. import { Event } from '../../../../base/common/event.js'; import * as paths from '../../../../base/common/path.js'; import { isCancellationError } from '../../../../base/common/errors.js'; -import { GlobPattern, TextSearchCompleteMessageType } from './searchExtTypes.js'; +import { AISearchKeyword, GlobPattern, TextSearchCompleteMessageType } from './searchExtTypes.js'; import { isThenable } from '../../../../base/common/async.js'; import { ResourceSet } from '../../../../base/common/map.js'; @@ -263,6 +263,7 @@ export interface ISearchCompleteStats { export interface ISearchComplete extends ISearchCompleteStats { results: IFileMatch[]; exit?: SearchCompletionExitCode; + aiKeywords?: AISearchKeyword[]; } export const enum SearchCompletionExitCode { diff --git a/src/vs/workbench/services/search/common/searchExtTypes.ts b/src/vs/workbench/services/search/common/searchExtTypes.ts index decb405a400..595b0014095 100644 --- a/src/vs/workbench/services/search/common/searchExtTypes.ts +++ b/src/vs/workbench/services/search/common/searchExtTypes.ts @@ -315,11 +315,28 @@ export class TextSearchContext2 { public lineNumber: number) { } } +/** +/** + * Keyword suggestion for AI search. + */ +export class AISearchKeyword { + /** + * @param keyword The keyword associated with the search. + */ + constructor(public keyword: string) { } +} + /** * A result payload for a text search, pertaining to matches within a single file. */ export type TextSearchResult2 = TextSearchMatch2 | TextSearchContext2; +/** + * A result payload for an AI search. + * This can be a {@link TextSearchMatch2 match} or a {@link AISearchKeyword keyword}. + * The result can be a match or a keyword. +*/ +export type AISearchResult = TextSearchResult2 | AISearchKeyword; /** * A FileSearchProvider provides search results for files in the given folder that match a query string. It can be invoked by quickaccess or other extensions. diff --git a/src/vs/workbench/services/search/common/searchService.ts b/src/vs/workbench/services/search/common/searchService.ts index b0981b9891c..f52bcd99ed2 100644 --- a/src/vs/workbench/services/search/common/searchService.ts +++ b/src/vs/workbench/services/search/common/searchService.ts @@ -213,7 +213,8 @@ export class SearchService extends Disposable implements ISearchService { limitHit: completes[0] && completes[0].limitHit, stats: completes[0].stats, messages: arrays.coalesce(completes.flatMap(i => i.messages)).filter(arrays.uniqueFilter(message => message.type + message.text + message.trusted)), - results: completes.flatMap((c: ISearchComplete) => c.results) + results: completes.flatMap((c: ISearchComplete) => c.results), + aiKeywords: completes.flatMap((c: ISearchComplete) => c.aiKeywords).filter(keyword => keyword !== undefined), }; })(); diff --git a/src/vs/workbench/services/search/common/textSearchManager.ts b/src/vs/workbench/services/search/common/textSearchManager.ts index 59a10ed9024..92571aef883 100644 --- a/src/vs/workbench/services/search/common/textSearchManager.ts +++ b/src/vs/workbench/services/search/common/textSearchManager.ts @@ -12,7 +12,7 @@ import * as resources from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { FolderQuerySearchTree } from './folderQuerySearchTree.js'; import { DEFAULT_MAX_SEARCH_RESULTS, hasSiblingPromiseFn, IAITextQuery, IExtendedExtensionSearchOptions, IFileMatch, IFolderQuery, excludeToGlobPattern, IPatternInfo, ISearchCompleteStats, ITextQuery, ITextSearchContext, ITextSearchMatch, ITextSearchResult, ITextSearchStats, QueryGlobTester, QueryType, resolvePatternsForProvider, ISearchRange, DEFAULT_TEXT_SEARCH_PREVIEW_OPTIONS } from './search.js'; -import { TextSearchComplete2, TextSearchMatch2, TextSearchProviderFolderOptions, TextSearchProvider2, TextSearchProviderOptions, TextSearchQuery2, TextSearchResult2, AITextSearchProvider } from './searchExtTypes.js'; +import { TextSearchComplete2, TextSearchMatch2, TextSearchProviderFolderOptions, TextSearchProvider2, TextSearchProviderOptions, TextSearchQuery2, TextSearchResult2, AITextSearchProvider, AISearchResult, AISearchKeyword } from './searchExtTypes.js'; export interface IFileUtils { readdir: (resource: URI) => Promise; @@ -46,7 +46,7 @@ export class TextSearchManager { return this.queryProviderPair.query; } - search(onProgress: (matches: IFileMatch[]) => void, token: CancellationToken): Promise { + search(onProgress: (matches: IFileMatch[]) => void, token: CancellationToken, onKeywordResult?: (keyword: AISearchKeyword) => void): Promise { const folderQueries = this.query.folderQueries || []; const tokenSource = new CancellationTokenSource(token); @@ -55,6 +55,10 @@ export class TextSearchManager { let isCanceled = false; const onResult = (result: TextSearchResult2, folderIdx: number) => { + if (result instanceof AISearchKeyword) { + // Already processed by the callback. + return; + } if (isCanceled) { return; } @@ -80,7 +84,7 @@ export class TextSearchManager { }; // For each root folder - this.doSearch(folderQueries, onResult, tokenSource.token).then(result => { + this.doSearch(folderQueries, onResult, tokenSource.token, onKeywordResult).then(result => { tokenSource.dispose(); this.collector!.flush(); @@ -121,7 +125,7 @@ export class TextSearchManager { return new TextSearchMatch2(result.uri, result.ranges.slice(0, size), result.previewText); } - private async doSearch(folderQueries: IFolderQuery[], onResult: (result: TextSearchResult2, folderIdx: number) => void, token: CancellationToken): Promise { + private async doSearch(folderQueries: IFolderQuery[], onResult: (result: TextSearchResult2, folderIdx: number) => void, token: CancellationToken, onKeywordResult?: (keyword: AISearchKeyword) => void): Promise { const folderMappings: FolderQuerySearchTree = new FolderQuerySearchTree( folderQueries, (fq, i) => { @@ -133,31 +137,34 @@ export class TextSearchManager { const testingPs: Promise[] = []; const progress = { - report: (result: TextSearchResult2) => { + report: (result: TextSearchResult2 | AISearchResult) => { + if (result instanceof AISearchKeyword) { + onKeywordResult?.(result); + } else { + if (result.uri === undefined) { + throw Error('Text search result URI is undefined. Please check provider implementation.'); + } + const folderQuery = folderMappings.findQueryFragmentAwareSubstr(result.uri)!; + const hasSibling = folderQuery.folder.scheme === Schemas.file ? + hasSiblingPromiseFn(() => { + return this.fileUtils.readdir(resources.dirname(result.uri)); + }) : + undefined; - if (result.uri === undefined) { - throw Error('Text search result URI is undefined. Please check provider implementation.'); - } - const folderQuery = folderMappings.findQueryFragmentAwareSubstr(result.uri)!; - const hasSibling = folderQuery.folder.scheme === Schemas.file ? - hasSiblingPromiseFn(() => { - return this.fileUtils.readdir(resources.dirname(result.uri)); - }) : - undefined; - - const relativePath = resources.relativePath(folderQuery.folder, result.uri); - if (relativePath) { - // This method is only async when the exclude contains sibling clauses - const included = folderQuery.queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling); - if (isThenable(included)) { - testingPs.push( - included.then(isIncluded => { - if (isIncluded) { - onResult(result, folderQuery.folderIdx); - } - })); - } else if (included) { - onResult(result, folderQuery.folderIdx); + const relativePath = resources.relativePath(folderQuery.folder, result.uri); + if (relativePath) { + // This method is only async when the exclude contains sibling clauses + const included = folderQuery.queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling); + if (isThenable(included)) { + testingPs.push( + included.then(isIncluded => { + if (isIncluded) { + onResult(result, folderQuery.folderIdx); + } + })); + } else if (included) { + onResult(result, folderQuery.folderIdx); + } } } } diff --git a/src/vs/workbench/services/search/worker/localFileSearch.ts b/src/vs/workbench/services/search/worker/localFileSearch.ts index 4622c34e8d0..aea6f1107d1 100644 --- a/src/vs/workbench/services/search/worker/localFileSearch.ts +++ b/src/vs/workbench/services/search/worker/localFileSearch.ts @@ -5,8 +5,8 @@ import * as glob from '../../../../base/common/glob.js'; import { UriComponents, URI } from '../../../../base/common/uri.js'; -import { IRequestHandler, IWorkerServer } from '../../../../base/common/worker/simpleWorker.js'; -import { ILocalFileSearchSimpleWorker, LocalFileSearchSimpleWorkerHost, IWorkerFileSearchComplete, IWorkerFileSystemDirectoryHandle, IWorkerFileSystemHandle, IWorkerTextSearchComplete } from '../common/localFileSearchWorkerTypes.js'; +import { IWebWorkerServerRequestHandler, IWebWorkerServer } from '../../../../base/common/worker/webWorker.js'; +import { ILocalFileSearchWorker, LocalFileSearchWorkerHost, IWorkerFileSearchComplete, IWorkerFileSystemDirectoryHandle, IWorkerFileSystemHandle, IWorkerTextSearchComplete } from '../common/localFileSearchWorkerTypes.js'; import { ICommonQueryProps, IFileMatch, IFileQueryProps, IFolderQuery, IPatternInfo, ITextQueryProps, } from '../common/search.js'; import * as paths from '../../../../base/common/path.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; @@ -48,22 +48,18 @@ const time = async (name: string, task: () => Promise | T) => { return r; }; -/** - * Defines the worker entry point. Must be exported and named `create`. - * @skipMangle - */ -export function create(workerServer: IWorkerServer): IRequestHandler { - return new LocalFileSearchSimpleWorker(workerServer); +export function create(workerServer: IWebWorkerServer): IWebWorkerServerRequestHandler { + return new LocalFileSearchWorker(workerServer); } -export class LocalFileSearchSimpleWorker implements ILocalFileSearchSimpleWorker, IRequestHandler { +export class LocalFileSearchWorker implements ILocalFileSearchWorker, IWebWorkerServerRequestHandler { _requestHandlerBrand: any; - private readonly host: LocalFileSearchSimpleWorkerHost; + private readonly host: LocalFileSearchWorkerHost; cancellationTokens: Map = new Map(); - constructor(workerServer: IWorkerServer) { - this.host = LocalFileSearchSimpleWorkerHost.getChannel(workerServer); + constructor(workerServer: IWebWorkerServer) { + this.host = LocalFileSearchWorkerHost.getChannel(workerServer); } $cancelQuery(queryId: number): void { diff --git a/src/vs/workbench/services/search/worker/localFileSearchMain.ts b/src/vs/workbench/services/search/worker/localFileSearchMain.ts index d293604643e..4c4e01e5766 100644 --- a/src/vs/workbench/services/search/worker/localFileSearchMain.ts +++ b/src/vs/workbench/services/search/worker/localFileSearchMain.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { bootstrapSimpleWorker } from '../../../../base/common/worker/simpleWorkerBootstrap.js'; +import { bootstrapWebWorker } from '../../../../base/common/worker/webWorkerBootstrap.js'; import { create } from './localFileSearch.js'; -bootstrapSimpleWorker(create); +bootstrapWebWorker(create); diff --git a/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts b/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts index 0c9752a960d..51e1b5c24ab 100644 --- a/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts +++ b/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts @@ -25,6 +25,7 @@ import { canExpandCompletionItem, SimpleSuggestDetailsOverlay, SimpleSuggestDeta import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import * as strings from '../../../../base/common/strings.js'; import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { isWindows } from '../../../../base/common/platform.js'; const $ = dom.$; @@ -121,7 +122,7 @@ export class SimpleSuggestWidget, TI private readonly _getFontInfo: () => ISimpleSuggestWidgetFontInfo, private readonly _onDidFontConfigurationChange: Event, private readonly _getAdvancedExplainModeDetails: () => string | undefined, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IStorageService private readonly _storageService: IStorageService, @IContextKeyService _contextKeyService: IContextKeyService @@ -184,7 +185,7 @@ export class SimpleSuggestWidget, TI const applyIconStyle = () => this.element.domNode.classList.toggle('no-icons', !_configurationService.getValue('editor.suggest.showIcons')); applyIconStyle(); - const renderer = new SimpleSuggestWidgetItemRenderer(this._getFontInfo.bind(this), this._onDidFontConfigurationChange.bind(this)); + const renderer = this._instantiationService.createInstance(SimpleSuggestWidgetItemRenderer, this._getFontInfo.bind(this), this._onDidFontConfigurationChange.bind(this)); this._register(renderer); this._listElement = dom.append(this.element.domNode, $('.tree')); this._list = this._register(new List('SuggestWidget', this._listElement, { @@ -196,7 +197,7 @@ export class SimpleSuggestWidget, TI mouseSupport: false, multipleSelectionSupport: false, accessibilityProvider: { - getRole: () => 'option', + getRole: () => isWindows ? 'listitem' : 'option', getWidgetAriaLabel: () => localize('suggest', "Suggest"), getWidgetRole: () => 'listbox', getAriaLabel: (item: SimpleCompletionItem) => { @@ -231,13 +232,13 @@ export class SimpleSuggestWidget, TI })); this._messageElement = dom.append(this.element.domNode, dom.$('.message')); - const details: SimpleSuggestDetailsWidget = this._register(instantiationService.createInstance(SimpleSuggestDetailsWidget, this._getFontInfo.bind(this), this._onDidFontConfigurationChange.bind(this), this._getAdvancedExplainModeDetails.bind(this))); + const details: SimpleSuggestDetailsWidget = this._register(_instantiationService.createInstance(SimpleSuggestDetailsWidget, this._getFontInfo.bind(this), this._onDidFontConfigurationChange.bind(this), this._getAdvancedExplainModeDetails.bind(this))); this._register(details.onDidClose(() => this.toggleDetails())); this._details = this._register(new SimpleSuggestDetailsOverlay(details, this._listElement)); this._register(dom.addDisposableListener(this._details.widget.domNode, 'blur', (e) => this._onDidBlurDetails.fire(e))); if (_options.statusBarMenuId && _options.showStatusBarSettingId && _configurationService.getValue(_options.showStatusBarSettingId)) { - this._status = this._register(instantiationService.createInstance(SuggestWidgetStatus, this.element.domNode, _options.statusBarMenuId)); + this._status = this._register(_instantiationService.createInstance(SuggestWidgetStatus, this.element.domNode, _options.statusBarMenuId)); this.element.domNode.classList.toggle('with-status-bar', true); } @@ -257,7 +258,7 @@ export class SimpleSuggestWidget, TI if (_options.statusBarMenuId && _options.showStatusBarSettingId && e.affectsConfiguration(_options.showStatusBarSettingId)) { const showStatusBar: boolean = _configurationService.getValue(_options.showStatusBarSettingId); if (showStatusBar && !this._status) { - this._status = this._register(instantiationService.createInstance(SuggestWidgetStatus, this.element.domNode, _options.statusBarMenuId)); + this._status = this._register(_instantiationService.createInstance(SuggestWidgetStatus, this.element.domNode, _options.statusBarMenuId)); this._status.show(); } else if (showStatusBar && this._status) { this._status.show(); diff --git a/src/vs/workbench/services/suggest/browser/simpleSuggestWidgetRenderer.ts b/src/vs/workbench/services/suggest/browser/simpleSuggestWidgetRenderer.ts index 5345e38dcba..c07a6acdcfb 100644 --- a/src/vs/workbench/services/suggest/browser/simpleSuggestWidgetRenderer.ts +++ b/src/vs/workbench/services/suggest/browser/simpleSuggestWidgetRenderer.ts @@ -12,6 +12,12 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { createMatches } from '../../../../base/common/filters.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { ILanguageService } from '../../../../editor/common/languages/language.js'; +import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js'; +import { URI } from '../../../../base/common/uri.js'; +import { FileKind } from '../../../../platform/files/common/files.js'; export function getAriaId(index: number): string { return `simple-suggest-aria-id-${index}`; @@ -59,8 +65,12 @@ export class SimpleSuggestWidgetItemRenderer implements IListRenderer ISimpleSuggestWidgetFontInfo, private readonly _onDidFontConfigurationChange: Event) { - } + constructor( + private readonly _getFontInfo: () => ISimpleSuggestWidgetFontInfo, + private readonly _onDidFontConfigurationChange: Event, + @IThemeService private readonly _themeService: IThemeService, + @IModelService private readonly _modelService: IModelService, + @ILanguageService private readonly _languageService: ILanguageService) { } dispose(): void { this._onDidToggleDetails.dispose(); @@ -134,28 +144,29 @@ export class SimpleSuggestWidgetItemRenderer implements IListRenderer detailClasses.length ? labelClasses : detailClasses; + // } else + if (completion.kindLabel === 'File' && this._themeService.getFileIconTheme().hasFileIcons) { + // special logic for 'file' completion items + data.icon.className = 'icon hide'; + data.iconContainer.className = 'icon hide'; + const labelClasses = getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: element.textLabel }), FileKind.FILE); + const detailClasses = getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: completion.detail }), FileKind.FILE); + labelOptions.extraClasses = labelClasses.length > detailClasses.length ? labelClasses : detailClasses; - // } else if (completion.kind === CompletionItemKind.Folder && this._themeService.getFileIconTheme().hasFolderIcons) { - // // special logic for 'folder' completion items - // data.icon.className = 'icon hide'; - // data.iconContainer.className = 'icon hide'; - // labelOptions.extraClasses = [ - // getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: element.textLabel }), FileKind.FOLDER), - // getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: completion.detail }), FileKind.FOLDER) - // ].flat(); - // } else { - // normal icon - data.icon.className = 'icon hide'; - data.iconContainer.className = ''; - data.iconContainer.classList.add('suggest-icon', ...ThemeIcon.asClassNameArray(completion.icon || Codicon.symbolText)); - // } + } else if (completion.kindLabel === 'Folder' && this._themeService.getFileIconTheme().hasFolderIcons) { + // special logic for 'folder' completion items + data.icon.className = 'icon hide'; + data.iconContainer.className = 'icon hide'; + labelOptions.extraClasses = [ + getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: element.textLabel }), FileKind.FOLDER), + getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: completion.detail }), FileKind.FOLDER) + ].flat(); + } else { + // normal icon + data.icon.className = 'icon hide'; + data.iconContainer.className = ''; + data.iconContainer.classList.add('suggest-icon', ...ThemeIcon.asClassNameArray(completion.icon || Codicon.symbolText)); + } // if (completion.tags && completion.tags.indexOf(CompletionItemTag.Deprecated) >= 0) { // labelOptions.extraClasses = (labelOptions.extraClasses || []).concat(['deprecated']); diff --git a/src/vs/workbench/services/textMate/browser/backgroundTokenization/textMateWorkerTokenizerController.ts b/src/vs/workbench/services/textMate/browser/backgroundTokenization/textMateWorkerTokenizerController.ts index fa0f874e7e8..b42fdfdaf3d 100644 --- a/src/vs/workbench/services/textMate/browser/backgroundTokenization/textMateWorkerTokenizerController.ts +++ b/src/vs/workbench/services/textMate/browser/backgroundTokenization/textMateWorkerTokenizerController.ts @@ -6,7 +6,7 @@ import { importAMDNodeModule } from '../../../../../amdX.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { IObservable, autorun, keepObserved } from '../../../../../base/common/observable.js'; -import { Proxied } from '../../../../../base/common/worker/simpleWorker.js'; +import { Proxied } from '../../../../../base/common/worker/webWorker.js'; import { countEOL } from '../../../../../editor/common/core/eolCounter.js'; import { LineRange } from '../../../../../editor/common/core/lineRange.js'; import { Range } from '../../../../../editor/common/core/range.js'; @@ -104,7 +104,7 @@ export class TextMateWorkerTokenizerController extends Disposable { /** * This method is called from the worker through the worker host. */ - public async setTokensAndStates(controllerId: number, versionId: number, rawTokens: ArrayBuffer, stateDeltas: StateDeltas[]): Promise { + public async setTokensAndStates(controllerId: number, versionId: number, rawTokens: Uint8Array, stateDeltas: StateDeltas[]): Promise { if (this.controllerId !== controllerId) { // This event is for an outdated controller (the worker didn't receive the delete/create messages yet), ignore the event. return; diff --git a/src/vs/workbench/services/textMate/browser/backgroundTokenization/threadedBackgroundTokenizerFactory.ts b/src/vs/workbench/services/textMate/browser/backgroundTokenization/threadedBackgroundTokenizerFactory.ts index ea3939b7b91..59502ab69cc 100644 --- a/src/vs/workbench/services/textMate/browser/backgroundTokenization/threadedBackgroundTokenizerFactory.ts +++ b/src/vs/workbench/services/textMate/browser/backgroundTokenization/threadedBackgroundTokenizerFactory.ts @@ -22,14 +22,14 @@ import { TextMateWorkerHost } from './worker/textMateWorkerHost.js'; import { TextMateWorkerTokenizerController } from './textMateWorkerTokenizerController.js'; import { IValidGrammarDefinition } from '../../common/TMScopeRegistry.js'; import type { IRawTheme } from 'vscode-textmate'; -import { createWebWorker } from '../../../../../base/browser/defaultWorkerFactory.js'; -import { IWorkerClient, Proxied } from '../../../../../base/common/worker/simpleWorker.js'; +import { createWebWorker } from '../../../../../base/browser/webWorkerFactory.js'; +import { IWebWorkerClient, Proxied } from '../../../../../base/common/worker/webWorker.js'; export class ThreadedBackgroundTokenizerFactory implements IDisposable { private static _reportedMismatchingTokens = false; private _workerProxyPromise: Promise | null> | null = null; - private _worker: IWorkerClient | null = null; + private _worker: IWebWorkerClient | null = null; private _workerProxy: Proxied | null = null; private readonly _workerTokenizerControllers = new Map(); @@ -138,7 +138,7 @@ export class ThreadedBackgroundTokenizerFactory implements IDisposable { onigurumaWASMUri: FileAccess.asBrowserUri(onigurumaWASM).toString(true), }; const worker = this._worker = createWebWorker( - 'vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.worker', + FileAccess.asBrowserUri('vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain.js'), 'TextMateWorker' ); TextMateWorkerHost.setChannel(worker, { diff --git a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.worker.ts b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.worker.ts index 131ad88d91c..036b2a3e292 100644 --- a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.worker.ts +++ b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.worker.ts @@ -11,14 +11,10 @@ import { IValidEmbeddedLanguagesMap, IValidGrammarDefinition, IValidTokenTypeMap import type { IOnigLib, IRawTheme, StackDiff } from 'vscode-textmate'; import { TextMateWorkerTokenizer } from './textMateWorkerTokenizer.js'; import { importAMDNodeModule } from '../../../../../../amdX.js'; -import { IRequestHandler, IWorkerServer } from '../../../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerServerRequestHandler, IWebWorkerServer } from '../../../../../../base/common/worker/webWorker.js'; import { TextMateWorkerHost } from './textMateWorkerHost.js'; -/** - * Defines the worker entry point. Must be exported and named `create`. - * @skipMangle - */ -export function create(workerServer: IWorkerServer): TextMateTokenizationWorker { +export function create(workerServer: IWebWorkerServer): TextMateTokenizationWorker { return new TextMateTokenizationWorker(workerServer); } @@ -45,7 +41,7 @@ export interface StateDeltas { stateDeltas: (StackDiff | null)[]; } -export class TextMateTokenizationWorker implements IRequestHandler { +export class TextMateTokenizationWorker implements IWebWorkerServerRequestHandler { _requestHandlerBrand: any; private readonly _host: TextMateWorkerHost; @@ -53,7 +49,7 @@ export class TextMateTokenizationWorker implements IRequestHandler { private readonly _grammarCache: Promise[] = []; private _grammarFactory: Promise = Promise.resolve(null); - constructor(workerServer: IWorkerServer) { + constructor(workerServer: IWebWorkerServer) { this._host = TextMateWorkerHost.getChannel(workerServer); } diff --git a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain.ts b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain.ts index 9846a7b64c3..aaa4c4b5a4f 100644 --- a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain.ts +++ b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain.ts @@ -4,6 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { create } from './textMateTokenizationWorker.worker.js'; -import { bootstrapSimpleWorker } from '../../../../../../base/common/worker/simpleWorkerBootstrap.js'; +import { bootstrapWebWorker } from '../../../../../../base/common/worker/webWorkerBootstrap.js'; -bootstrapSimpleWorker(create); +bootstrapWebWorker(create); diff --git a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerHost.ts b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerHost.ts index e5c89236632..8d2338cf97a 100644 --- a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerHost.ts +++ b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerHost.ts @@ -4,15 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { UriComponents } from '../../../../../../base/common/uri.js'; -import { IWorkerServer, IWorkerClient } from '../../../../../../base/common/worker/simpleWorker.js'; +import { IWebWorkerServer, IWebWorkerClient } from '../../../../../../base/common/worker/webWorker.js'; import { StateDeltas } from './textMateTokenizationWorker.worker.js'; export abstract class TextMateWorkerHost { public static CHANNEL_NAME = 'textMateWorkerHost'; - public static getChannel(workerServer: IWorkerServer): TextMateWorkerHost { + public static getChannel(workerServer: IWebWorkerServer): TextMateWorkerHost { return workerServer.getChannel(TextMateWorkerHost.CHANNEL_NAME); } - public static setChannel(workerClient: IWorkerClient, obj: TextMateWorkerHost): void { + public static setChannel(workerClient: IWebWorkerClient, obj: TextMateWorkerHost): void { workerClient.setChannel(TextMateWorkerHost.CHANNEL_NAME, obj); } diff --git a/src/vs/workbench/services/textfile/browser/textFileService.ts b/src/vs/workbench/services/textfile/browser/textFileService.ts index 231136ccb71..a0bdb7c5b74 100644 --- a/src/vs/workbench/services/textfile/browser/textFileService.ts +++ b/src/vs/workbench/services/textfile/browser/textFileService.ts @@ -273,11 +273,6 @@ export abstract class AbstractTextFileService extends Disposable implements ITex return this.fileService.writeFile(resource, readable, options); } - getEncoding(resource: URI): string { - const model = resource.scheme === Schemas.untitled ? this.untitled.get(resource) : this.files.get(resource); - return model?.getEncoding() ?? this.encoding.getUnvalidatedEncodingForResource(resource); - } - async getEncodedReadable(resource: URI | undefined, value: ITextSnapshot): Promise; async getEncodedReadable(resource: URI | undefined, value: string): Promise; async getEncodedReadable(resource: URI | undefined, value?: ITextSnapshot): Promise; @@ -317,14 +312,37 @@ export abstract class AbstractTextFileService extends Disposable implements ITex candidateGuessEncodings: options?.candidateGuessEncodings || this.textResourceConfigurationService.getValue(resource, 'files.candidateGuessEncodings'), - overwriteEncoding: async detectedEncoding => { - const { encoding } = await this.encoding.getPreferredReadEncoding(resource, options, detectedEncoding ?? undefined); - - return encoding; - } + overwriteEncoding: async detectedEncoding => this.validateDetectedEncoding(resource, detectedEncoding ?? undefined, options) }); } + getEncoding(resource: URI): string { + const model = resource.scheme === Schemas.untitled ? this.untitled.get(resource) : this.files.get(resource); + return model?.getEncoding() ?? this.encoding.getUnvalidatedEncodingForResource(resource); + } + + async resolveDecoding(resource: URI | undefined, options?: IReadTextFileEncodingOptions): Promise<{ preferredEncoding: string; guessEncoding: boolean; candidateGuessEncodings: string[] }> { + return { + preferredEncoding: (await this.encoding.getPreferredReadEncoding(resource, options, undefined)).encoding, + guessEncoding: + options?.autoGuessEncoding || + this.textResourceConfigurationService.getValue(resource, 'files.autoGuessEncoding'), + candidateGuessEncodings: + options?.candidateGuessEncodings || + this.textResourceConfigurationService.getValue(resource, 'files.candidateGuessEncodings'), + }; + } + + async validateDetectedEncoding(resource: URI | undefined, detectedEncoding: string | undefined, options?: IReadTextFileEncodingOptions): Promise { + const { encoding } = await this.encoding.getPreferredReadEncoding(resource, options, detectedEncoding); + + return encoding; + } + + resolveEncoding(resource: URI | undefined, options?: IWriteTextFileOptions): Promise<{ encoding: string; addBOM: boolean }> { + return this.encoding.getWriteEncoding(resource, options); + } + //#endregion diff --git a/src/vs/workbench/services/textfile/common/textfiles.ts b/src/vs/workbench/services/textfile/common/textfiles.ts index efbd5451f99..84c90a5581b 100644 --- a/src/vs/workbench/services/textfile/common/textfiles.ts +++ b/src/vs/workbench/services/textfile/common/textfiles.ts @@ -98,12 +98,6 @@ export interface ITextFileService extends IDisposable { */ create(operations: { resource: URI; value?: string | ITextSnapshot; options?: { overwrite?: boolean } }[], undoInfo?: IFileOperationUndoRedoInfo): Promise; - /** - * Get the encoding for the provided `resource`. Will try to determine the encoding - * from any existing model for that `resource` and fallback to the configured defaults. - */ - getEncoding(resource: URI): string; - /** * Returns the readable that uses the appropriate encoding. This method should * be used whenever a `string` or `ITextSnapshot` is being persisted to the @@ -122,6 +116,27 @@ export interface ITextFileService extends IDisposable { * Will throw an error if `acceptTextOnly: true` for resources that seem to be binary. */ getDecodedStream(resource: URI | undefined, value: VSBufferReadableStream, options?: IReadTextFileEncodingOptions): Promise>; + + /** + * Get the encoding for the provided `resource`. Will try to determine the encoding + * from any existing model for that `resource` and fallback to the configured defaults. + */ + getEncoding(resource: URI): string; + + /** + * Get the properties for decoding the provided `resource` based on configuration. + */ + resolveDecoding(resource: URI | undefined, options?: IReadTextFileEncodingOptions): Promise<{ preferredEncoding: string; guessEncoding: boolean; candidateGuessEncodings: string[] }>; + + /** + * Get the properties for encoding the provided `resource` based on configuration. + */ + resolveEncoding(resource: URI | undefined, options?: IWriteTextFileOptions): Promise<{ encoding: string; addBOM: boolean }>; + + /** + * Given a detected encoding, validate it against the configured encoding options. + */ + validateDetectedEncoding(resource: URI | undefined, detectedEncoding: string, options?: IReadTextFileEncodingOptions): Promise; } export interface IReadTextFileEncodingOptions { diff --git a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts index f2a4f0d8dcb..56a3b2d8f18 100644 --- a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts @@ -6,7 +6,7 @@ import * as nls from '../../../../nls.js'; import * as types from '../../../../base/common/types.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; -import { IWorkbenchThemeService, IWorkbenchColorTheme, IWorkbenchFileIconTheme, ExtensionData, ThemeSettings, IWorkbenchProductIconTheme, ThemeSettingTarget, ThemeSettingDefaults, COLOR_THEME_DARK_INITIAL_COLORS, COLOR_THEME_LIGHT_INITIAL_COLORS, IWorkbenchThemeChangeEvent } from '../common/workbenchThemeService.js'; +import { IWorkbenchThemeService, IWorkbenchColorTheme, IWorkbenchFileIconTheme, ExtensionData, ThemeSettings, IWorkbenchProductIconTheme, ThemeSettingTarget, ThemeSettingDefaults, COLOR_THEME_DARK_INITIAL_COLORS, COLOR_THEME_LIGHT_INITIAL_COLORS } from '../common/workbenchThemeService.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -79,7 +79,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme private readonly colorThemeRegistry: ThemeRegistry; private currentColorTheme: ColorThemeData; - private readonly onColorThemeChange: Emitter; + private readonly onColorThemeChange: Emitter; private readonly colorThemeWatcher: ThemeFileWatcher; private colorThemingParticipantChangeListener: IDisposable | undefined; private readonly colorThemeSequencer: Sequencer; @@ -117,7 +117,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme this.colorThemeRegistry = this._register(new ThemeRegistry(colorThemesExtPoint, ColorThemeData.fromExtensionTheme)); this.colorThemeWatcher = this._register(new ThemeFileWatcher(fileService, environmentService, this.reloadCurrentColorTheme.bind(this))); - this.onColorThemeChange = new Emitter({ leakWarningThreshold: 400 }); + this.onColorThemeChange = new Emitter({ leakWarningThreshold: 400 }); this.currentColorTheme = ColorThemeData.createUnloadedTheme(''); this.colorThemeSequencer = new Sequencer(); @@ -134,7 +134,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme this.currentProductIconTheme = ProductIconThemeData.createUnloadedTheme(''); this.productIconThemeSequencer = new Sequencer(); - this._register(this.onDidColorThemeChange(e => getColorRegistry().notifyThemeUpdate(e.theme))); + this._register(this.onDidColorThemeChange(theme => getColorRegistry().notifyThemeUpdate(theme))); // In order to avoid paint flashing for tokens, because // themes are loaded asynchronously, we need to initialize @@ -277,7 +277,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme } if (hasColorChanges) { this.updateDynamicCSSRules(this.currentColorTheme); - this.onColorThemeChange.fire({ theme: this.currentColorTheme, target: 'auto' }); + this.onColorThemeChange.fire(this.currentColorTheme); } } })); @@ -375,7 +375,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme } public async getMarketplaceColorThemes(publisher: string, name: string, version: string): Promise { - const extensionLocation = this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ publisher, name, version }, 'extension'); + const extensionLocation = await this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ publisher, name, version }, 'extension'); if (extensionLocation) { try { const manifestContent = await this.extensionResourceLoaderService.readExtensionResource(resources.joinPath(extensionLocation, 'package.json')); @@ -387,7 +387,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme return []; } - public get onDidColorThemeChange(): Event { + public get onDidColorThemeChange(): Event { return this.onColorThemeChange.event; } @@ -506,7 +506,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme return Promise.resolve(null); } - this.onColorThemeChange.fire({ theme: this.currentColorTheme, target: settingsTarget }); + this.onColorThemeChange.fire(this.currentColorTheme); // remember theme data for a quick restore if (newTheme.isLoaded && settingsTarget !== 'preview') { @@ -599,7 +599,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme } public async getMarketplaceFileIconThemes(publisher: string, name: string, version: string): Promise { - const extensionLocation = this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ publisher, name, version }, 'extension'); + const extensionLocation = await this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ publisher, name, version }, 'extension'); if (extensionLocation) { try { const manifestContent = await this.extensionResourceLoaderService.readExtensionResource(resources.joinPath(extensionLocation, 'package.json')); @@ -705,7 +705,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme } public async getMarketplaceProductIconThemes(publisher: string, name: string, version: string): Promise { - const extensionLocation = this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ publisher, name, version }, 'extension'); + const extensionLocation = await this.extensionResourceLoaderService.getExtensionGalleryResourceURL({ publisher, name, version }, 'extension'); if (extensionLocation) { try { const manifestContent = await this.extensionResourceLoaderService.readExtensionResource(resources.joinPath(extensionLocation, 'package.json')); diff --git a/src/vs/workbench/services/themes/common/workbenchThemeService.ts b/src/vs/workbench/services/themes/common/workbenchThemeService.ts index 4154648ee78..3806f1a2d6c 100644 --- a/src/vs/workbench/services/themes/common/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/common/workbenchThemeService.ts @@ -6,7 +6,7 @@ import { refineServiceDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { Event } from '../../../../base/common/event.js'; import { Color } from '../../../../base/common/color.js'; -import { IColorTheme, IThemeService, IFileIconTheme, IProductIconTheme, IThemeChangeEvent } from '../../../../platform/theme/common/themeService.js'; +import { IColorTheme, IThemeService, IFileIconTheme, IProductIconTheme } from '../../../../platform/theme/common/themeService.js'; import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; import { isBoolean, isString } from '../../../../base/common/types.js'; import { IconContribution, IconDefinition } from '../../../../platform/theme/common/iconRegistry.js'; @@ -132,9 +132,7 @@ export interface IWorkbenchProductIconTheme extends IWorkbenchTheme, IProductIco } export type ThemeSettingTarget = ConfigurationTarget | undefined | 'auto' | 'preview'; -export interface IWorkbenchThemeChangeEvent extends IThemeChangeEvent { - target: ThemeSettingTarget; -} + export interface IWorkbenchThemeService extends IThemeService { readonly _serviceBrand: undefined; @@ -142,7 +140,7 @@ export interface IWorkbenchThemeService extends IThemeService { getColorTheme(): IWorkbenchColorTheme; getColorThemes(): Promise; getMarketplaceColorThemes(publisher: string, name: string, version: string): Promise; - onDidColorThemeChange: Event; + onDidColorThemeChange: Event; getPreferredColorScheme(): ColorScheme | undefined; diff --git a/src/vs/workbench/services/themes/test/node/tokenStyleResolving.test.ts b/src/vs/workbench/services/themes/test/node/tokenStyleResolving.test.ts index 13c3b9caf20..2ded86bb38c 100644 --- a/src/vs/workbench/services/themes/test/node/tokenStyleResolving.test.ts +++ b/src/vs/workbench/services/themes/test/node/tokenStyleResolving.test.ts @@ -21,6 +21,7 @@ import { IStorageService } from '../../../../../platform/storage/common/storage. import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ExtensionGalleryManifestService } from '../../../../../platform/extensionManagement/common/extensionGalleryManifestService.js'; const undefinedStyle = { bold: undefined, underline: undefined, italic: undefined }; const unsetStyle = { bold: false, underline: false, italic: false }; @@ -89,7 +90,7 @@ suite('Themes - TokenStyleResolving', () => { const environmentService = new (mock())(); const configurationService = new (mock())(); - const extensionResourceLoaderService = new ExtensionResourceLoaderService(fileService, storageService, TestProductService, environmentService, configurationService, requestService); + const extensionResourceLoaderService = new ExtensionResourceLoaderService(fileService, storageService, TestProductService, environmentService, configurationService, new ExtensionGalleryManifestService(TestProductService), requestService, new NullLogService()); const diskFileSystemProvider = new DiskFileSystemProvider(new NullLogService()); fileService.registerProvider(Schemas.file, diskFileSystemProvider); diff --git a/src/vs/workbench/services/timer/browser/timerService.ts b/src/vs/workbench/services/timer/browser/timerService.ts index 755f00263d4..9b2a06f7a90 100644 --- a/src/vs/workbench/services/timer/browser/timerService.ts +++ b/src/vs/workbench/services/timer/browser/timerService.ts @@ -18,7 +18,7 @@ import { IPaneCompositePartService } from '../../panecomposite/browser/panecompo import { ViewContainerLocation } from '../../../common/views.js'; import { TelemetryTrustedValue } from '../../../../platform/telemetry/common/telemetryUtils.js'; import { isWeb } from '../../../../base/common/platform.js'; -import { createBlobWorker } from '../../../../base/browser/defaultWorkerFactory.js'; +import { createBlobWorker } from '../../../../base/browser/webWorkerFactory.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { ITerminalBackendRegistry, TerminalExtensions } from '../../../../platform/terminal/common/terminal.js'; diff --git a/src/vs/workbench/services/treeSitter/browser/treeSitterCodeEditors.ts b/src/vs/workbench/services/treeSitter/browser/treeSitterCodeEditors.ts index e8df3a5f6a8..b820b73f0fd 100644 --- a/src/vs/workbench/services/treeSitter/browser/treeSitterCodeEditors.ts +++ b/src/vs/workbench/services/treeSitter/browser/treeSitterCodeEditors.ts @@ -17,24 +17,28 @@ export interface IViewPortChangeEvent { } export class TreeSitterCodeEditors extends Disposable { - private readonly _languageEditors = this._register(new DisposableMap()); + private readonly _textModels = new Set(); + private readonly _languageEditors = this._register(new DisposableMap); private readonly _allEditors = this._register(new DisposableMap()); private readonly _onDidChangeViewport = this._register(new Emitter()); public readonly onDidChangeViewport = this._onDidChangeViewport.event; - private readonly _onDidRemoveEditor = this._register(new Emitter()); - public readonly onDidRemoveEditor = this._onDidRemoveEditor.event; constructor(private readonly _languageId: string, @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, @ITreeSitterParserService private readonly _treeSitterParserService: ITreeSitterParserService) { + super(); this._register(this._codeEditorService.onCodeEditorAdd(this._onCodeEditorAdd, this)); this._register(this._codeEditorService.onCodeEditorRemove(this._onCodeEditorRemove, this)); this._codeEditorService.listCodeEditors().forEach(this._onCodeEditorAdd, this); } - get editors(): ICodeEditor[] { - return Array.from(this._languageEditors.keys()); + get textModels(): ITextModel[] { + return Array.from(this._textModels.keys()); + } + + getEditorForModel(model: ITextModel): ICodeEditor | undefined { + return this._codeEditorService.listCodeEditors().find(editor => editor.getModel() === model); } public async getInitialViewPorts(): Promise { @@ -43,7 +47,7 @@ export class TreeSitterCodeEditors extends Disposable { const viewports: IViewPortChangeEvent[] = []; for (const editor of editors) { const model = await this.getEditorModel(editor); - if (model) { + if (model && model.getLanguageId() === this._languageId) { viewports.push({ model, ranges: this._nonIntersectingViewPortRanges(editor) @@ -54,7 +58,7 @@ export class TreeSitterCodeEditors extends Disposable { } private _onCodeEditorRemove(editor: ICodeEditor): void { - this._languageEditors.deleteAndDispose(editor); + this._allEditors.deleteAndDispose(editor); } private async getEditorModel(editor: ICodeEditor): Promise { @@ -68,40 +72,44 @@ export class TreeSitterCodeEditors extends Disposable { } private async _onCodeEditorAdd(editor: ICodeEditor): Promise { - const model = await this.getEditorModel(editor); - if (model) { - const otherEditorDisposables = new DisposableStore(); - otherEditorDisposables.add(model.onDidChangeLanguage(() => this._onLanguageChange(editor, model), this)); - this._allEditors.set(editor, otherEditorDisposables); + const otherEditorDisposables = new DisposableStore(); + otherEditorDisposables.add(editor.onDidChangeModel(() => this._onDidChangeModel(editor, editor.getModel()), this)); + this._allEditors.set(editor, otherEditorDisposables); + const model = editor.getModel(); + if (model) { this._tryAddEditor(editor, model); } } private _tryAddEditor(editor: ICodeEditor, model: ITextModel): void { const language = model.getLanguageId(); - if (language === this._languageId) { - const langaugeEditorDisposables = new DisposableStore(); - langaugeEditorDisposables.add(editor.onDidScrollChange(() => this._onViewportChange(editor), this)); - this._languageEditors.set(editor, langaugeEditorDisposables); - this._onViewportChange(editor); + if ((language === this._languageId)) { + if (!this._textModels.has(model)) { + this._textModels.add(model); + } + if (!this._languageEditors.has(editor)) { + const langaugeEditorDisposables = new DisposableStore(); + langaugeEditorDisposables.add(editor.onDidScrollChange(() => this._onViewportChange(editor), this)); + this._languageEditors.set(editor, langaugeEditorDisposables); + this._onViewportChange(editor); + } } } - private async _onLanguageChange(editor: ICodeEditor, model: ITextModel): Promise { - const language = model.getLanguageId(); - if ((language !== this._languageId) && this._languageEditors.has(editor)) { - this._languageEditors.deleteAndDispose(editor); - this._onDidRemoveEditor.fire(model); - } else if (!this._languageEditors.has(editor)) { + private async _onDidChangeModel(editor: ICodeEditor, model: ITextModel | null): Promise { + if (model) { this._tryAddEditor(editor, model); + } else { + this._languageEditors.deleteAndDispose(editor); } } private async _onViewportChange(editor: ICodeEditor): Promise { const ranges = this._nonIntersectingViewPortRanges(editor); - const model = await this.getEditorModel(editor); + const model = editor.getModel(); if (!model) { + this._languageEditors.deleteAndDispose(editor); return; } this._onDidChangeViewport.fire({ model: model, ranges }); diff --git a/src/vs/workbench/services/treeSitter/browser/treeSitterTokenizationFeature.ts b/src/vs/workbench/services/treeSitter/browser/treeSitterTokenizationFeature.ts index 7264b70a34d..06dd1568acf 100644 --- a/src/vs/workbench/services/treeSitter/browser/treeSitterTokenizationFeature.ts +++ b/src/vs/workbench/services/treeSitter/browser/treeSitterTokenizationFeature.ts @@ -9,7 +9,7 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../. import { AppResourcePath, FileAccess } from '../../../../base/common/network.js'; import { ILanguageIdCodec, ITreeSitterTokenizationSupport, LazyTokenizationSupport, QueryCapture, TreeSitterTokenizationRegistry } from '../../../../editor/common/languages.js'; import { ITextModel } from '../../../../editor/common/model.js'; -import { EDITOR_EXPERIMENTAL_PREFER_TREESITTER, ITreeSitterParserService, ITreeSitterParseResult, TreeUpdateEvent, RangeChange, ITreeSitterImporter, TREESITTER_ALLOWED_SUPPORT, RangeWithOffsets } from '../../../../editor/common/services/treeSitterParserService.js'; +import { EDITOR_EXPERIMENTAL_PREFER_TREESITTER, ITreeSitterParserService, RangeChange, ITreeSitterImporter, TREESITTER_ALLOWED_SUPPORT, RangeWithOffsets, ITextModelTreeSitter } from '../../../../editor/common/services/treeSitterParserService.js'; import { IModelTokensChangedEvent } from '../../../../editor/common/textModelEvents.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../platform/files/common/files.js'; @@ -22,12 +22,11 @@ import { ITreeSitterTokenizationStoreService } from '../../../../editor/common/m import { LanguageId } from '../../../../editor/common/encodedTokenAttributes.js'; import { TokenQuality, TokenUpdate } from '../../../../editor/common/model/tokenStore.js'; import { Range } from '../../../../editor/common/core/range.js'; -import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { setTimeout0 } from '../../../../base/common/platform.js'; import { findLikelyRelevantLines } from '../../../../editor/common/model/textModelTokens.js'; import { TreeSitterCodeEditors } from './treeSitterCodeEditors.js'; +import { IWorkbenchColorTheme, IWorkbenchThemeService } from '../../themes/common/workbenchThemeService.js'; import { Position } from '../../../../editor/common/core/position.js'; -import { IWorkbenchThemeChangeEvent, IWorkbenchThemeService } from '../../themes/common/workbenchThemeService.js'; type TreeSitterQueries = string; @@ -46,8 +45,20 @@ interface EndOffsetAndScopes { endOffset: number; scopes: string[]; bracket?: number[]; + encodedLanguageId: LanguageId; } +interface EndOffsetWithMeta extends EndOffsetAndScopes { + metadata?: number; +} + +export const TREESITTER_BASE_SCOPES: Record = { + 'css': 'source.css', + 'typescript': 'source.ts', + 'ini': 'source.ini', + 'regex': 'source.regex', +}; + const BRACKETS = /[\{\}\[\]\<\>\(\)]/g; export class TreeSitterTokenizationFeature extends Disposable implements ITreeSitterTokenizationFeature { @@ -116,6 +127,7 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi private _colorThemeData!: ColorThemeData; private _languageAddedListener: IDisposable | undefined; private _codeEditors: TreeSitterCodeEditors; + private _encodedLanguage: LanguageId | undefined; constructor( private readonly _queries: TreeSitterQueries, @@ -125,7 +137,6 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi @ITreeSitterParserService private readonly _treeSitterService: ITreeSitterParserService, @IWorkbenchThemeService private readonly _themeService: IWorkbenchThemeService, @ITreeSitterTokenizationStoreService private readonly _tokenizationStoreService: ITreeSitterTokenizationStoreService, - @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -133,9 +144,6 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi this._register(this._codeEditors.onDidChangeViewport(e => { this._parseAndTokenizeViewPort(e.model, e.ranges); })); - this._register(this._codeEditors.onDidRemoveEditor(e => { - this._tokenizationStoreService.delete(e); - })); this._codeEditors.getInitialViewPorts().then(async (viewports) => { for (const viewport of viewports) { this._parseAndTokenizeViewPort(viewport.model, viewport.ranges); @@ -143,7 +151,7 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi }); this._register(Event.runAndSubscribe(this._themeService.onDidColorThemeChange, (e) => this._updateTheme(e))); this._register(this._treeSitterService.onDidUpdateTree((e) => { - if (e.textModel.getLanguageId() !== this._languageId) { + if (e.languageId !== this._languageId) { return; } if (this._tokenizationStoreService.hasTokens(e.textModel)) { @@ -159,20 +167,28 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi // First time we see a tree we need to build a token store. if (!this._tokenizationStoreService.hasTokens(e.textModel)) { // This will likely not happen as we first handle all models, which are ready before trees. - this._firstTreeUpdate(e.textModel, e.versionId); + this._firstTreeUpdate(e.textModel, e.versionId, e.tree); } else { - this._handleTreeUpdate(e); + this._handleTreeUpdate(e.ranges, e.textModel, e.versionId, e.tree); } })); } + private get _encodedLanguageId(): LanguageId { + if (!this._encodedLanguage) { + this._encodedLanguage = this._languageIdCodec.encodeLanguageId(this._languageId); + } + return this._encodedLanguage; + } + private _setInitialTokens(textModel: ITextModel) { const tokens: TokenUpdate[] = this._createEmptyTokens(textModel); this._tokenizationStoreService.setTokens(textModel, tokens, TokenQuality.None); } - private _parseAndTokenizeViewPortRange(model: ITextModel, range: Range, languageId: LanguageId, startOffsetOfRangeInDocument: number, endOffsetOfRangeInDocument: number) { - const content = model.getValueInRange(range); + private _forceParseAndTokenizeContent(model: ITextModel, range: Range, startOffsetOfRangeInDocument: number, endOffsetOfRangeInDocument: number, content: string, asUpdate: true): TokenUpdate[] | undefined; + private _forceParseAndTokenizeContent(model: ITextModel, range: Range, startOffsetOfRangeInDocument: number, endOffsetOfRangeInDocument: number, content: string, asUpdate: false): EndOffsetToken[] | undefined; + private _forceParseAndTokenizeContent(model: ITextModel, range: Range, startOffsetOfRangeInDocument: number, endOffsetOfRangeInDocument: number, content: string, asUpdate: boolean): EndOffsetToken[] | TokenUpdate[] | undefined { const likelyRelevantLines = findLikelyRelevantLines(model, range.startLineNumber).likelyRelevantLines; const likelyRelevantPrefix = likelyRelevantLines.join(model.getEOL()); const tree = this._treeSitterService.getTreeSync(`${likelyRelevantPrefix}${content}`, this._languageId); @@ -182,12 +198,16 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi const treeRange = new Range(1, 1, range.endLineNumber - range.startLineNumber + 1 + likelyRelevantLines.length, range.endColumn); const captures = this._captureAtRange(treeRange, tree); - const tokens = this._tokenizeCapturesWithMetadata(tree, captures, languageId, likelyRelevantPrefix.length, endOffsetOfRangeInDocument - startOffsetOfRangeInDocument + likelyRelevantPrefix.length); + const tokens = this._tokenizeCapturesWithMetadata(tree, captures, likelyRelevantPrefix.length, endOffsetOfRangeInDocument - startOffsetOfRangeInDocument + likelyRelevantPrefix.length); if (!tokens) { return; } - return this._rangeTokensAsUpdates(startOffsetOfRangeInDocument, tokens.endOffsetsAndMetadata, likelyRelevantPrefix.length); + if (asUpdate) { + return this._rangeTokensAsUpdates(startOffsetOfRangeInDocument, tokens.endOffsetsAndMetadata, likelyRelevantPrefix.length); + } else { + return tokens.endOffsetsAndMetadata; + } } private async _parseAndTokenizeViewPort(model: ITextModel, viewportRanges: Range[]) { @@ -195,8 +215,6 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi this._setInitialTokens(model); } - const languageId = this._languageIdCodec.encodeLanguageId(this._languageId); - for (const range of viewportRanges) { const startOffsetOfRangeInDocument = model.getOffsetAt(range.getStartPosition()); const endOffsetOfRangeInDocument = model.getOffsetAt(range.getEndPosition()); @@ -204,7 +222,8 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi if (this._tokenizationStoreService.rangeHasTokens(model, range, TokenQuality.ViewportGuess)) { continue; } - const tokenUpdates = await this._parseAndTokenizeViewPortRange(model, range, languageId, startOffsetOfRangeInDocument, endOffsetOfRangeInDocument); + const content = model.getValueInRange(range); + const tokenUpdates = await this._forceParseAndTokenizeContent(model, range, startOffsetOfRangeInDocument, endOffsetOfRangeInDocument, content, true); if (!tokenUpdates || this._tokenizationStoreService.rangeHasTokens(model, range, TokenQuality.ViewportGuess)) { continue; } @@ -218,33 +237,69 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi } } + guessTokensForLinesContent(lineNumber: number, textModel: ITextModel, lines: string[]): Uint32Array[] | undefined { + if (lines.length === 0) { + return undefined; + } + const lineContent = lines.join(textModel.getEOL()); + const range = new Range(1, 1, lineNumber + lines.length, lines[lines.length - 1].length + 1); + const startOffset = textModel.getOffsetAt({ lineNumber, column: 1 }); + const tokens = this._forceParseAndTokenizeContent(textModel, range, startOffset, startOffset + lineContent.length, lineContent, false); + if (!tokens) { + return undefined; + } + const tokensByLine: Uint32Array[] = new Array(lines.length); + let tokensIndex: number = 0; + let tokenStartOffset = 0; + let lineStartOffset = 0; + for (let i = 0; i < lines.length; i++) { + const tokensForLine: EndOffsetToken[] = []; + let moveToNextLine = false; + for (let j = tokensIndex; (!moveToNextLine && (j < tokens.length)); j++) { + const token = tokens[j]; + const lineAdjustedEndOffset = token.endOffset - lineStartOffset; + const lineAdjustedStartOffset = tokenStartOffset - lineStartOffset; + if (lineAdjustedEndOffset <= lines[i].length) { + tokensForLine.push({ endOffset: lineAdjustedEndOffset, metadata: token.metadata }); + tokensIndex++; + } else if (lineAdjustedStartOffset < lines[i].length) { + const partialToken: EndOffsetToken = { endOffset: lines[i].length, metadata: token.metadata }; + tokensForLine.push(partialToken); + moveToNextLine = true; + } else { + moveToNextLine = true; + } + tokenStartOffset = token.endOffset; + } + + tokensByLine[i] = this._endOffsetTokensToUint32Array(tokensForLine); + lineStartOffset += lines[i].length + textModel.getEOL().length; + } + + return tokensByLine; + } + private _emptyTokensForOffsetAndLength(offset: number, length: number, emptyToken: number): TokenUpdate { return { token: emptyToken, length: offset + length, startOffsetInclusive: 0 }; - } private _createEmptyTokens(textModel: ITextModel) { - const languageId = this._languageIdCodec.encodeLanguageId(this._languageId); - const emptyToken = this._emptyToken(languageId); + const emptyToken = this._emptyToken(); const modelEndOffset = textModel.getValueLength(); const emptyTokens: TokenUpdate[] = [this._emptyTokensForOffsetAndLength(0, modelEndOffset, emptyToken)]; return emptyTokens; } - private _firstTreeUpdate(textModel: ITextModel, versionId: number) { + private _firstTreeUpdate(textModel: ITextModel, versionId: number, tree: ITextModelTreeSitter) { this._setInitialTokens(textModel); - return this._setViewPortTokens(textModel, versionId); + return this._setViewPortTokens(textModel, versionId, tree); } - private _codeEditorForModel(textModel: ITextModel) { - return this._codeEditorService.listCodeEditors().find(editor => editor.getModel() === textModel); - } - - private _setViewPortTokens(textModel: ITextModel, versionId: number) { + private _setViewPortTokens(textModel: ITextModel, versionId: number, tree: ITextModelTreeSitter) { const maxLine = textModel.getLineCount(); let rangeChanges: RangeChange[]; - const editor = this._codeEditorForModel(textModel); + const editor = this._codeEditors.getEditorForModel(textModel); if (editor) { const viewPort = editor.getVisibleRangesPlusViewportAboveBelow(); const ranges: { readonly fromLineNumber: number; readonly toLineNumber: number }[] = new Array(viewPort.length); @@ -265,35 +320,43 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi const valueLength = textModel.getValueLength(); rangeChanges = [{ newRange: new Range(1, 1, maxLine, textModel.getLineMaxColumn(maxLine)), newRangeStartOffset: 0, newRangeEndOffset: valueLength }]; } - return this._handleTreeUpdate({ ranges: rangeChanges, textModel, versionId }); + return this._handleTreeUpdate(rangeChanges, textModel, versionId, tree); } /** * Do not await in this method, it will cause a race */ - private _handleTreeUpdate(e: TreeUpdateEvent) { + private _handleTreeUpdate(ranges: RangeChange[], textModel: ITextModel, versionId: number, textModelTreeSitter: ITextModelTreeSitter) { + const tree = textModelTreeSitter.parseResult?.tree; + if (!tree) { + return; + } + const rangeChanges: RangeWithOffsets[] = []; const chunkSize = 1000; - for (let i = 0; i < e.ranges.length; i++) { - const rangeLinesLength = e.ranges[i].newRange.endLineNumber - e.ranges[i].newRange.startLineNumber; + for (let i = 0; i < ranges.length; i++) { + const rangeLinesLength = ranges[i].newRange.endLineNumber - ranges[i].newRange.startLineNumber; if (rangeLinesLength > chunkSize) { // Split the range into chunks to avoid long operations - const fullRangeEndLineNumber = e.ranges[i].newRange.endLineNumber; - let chunkLineStart = e.ranges[i].newRange.startLineNumber; + const fullRangeEndLineNumber = ranges[i].newRange.endLineNumber; + let chunkLineStart = ranges[i].newRange.startLineNumber; + let chunkColumnStart = ranges[i].newRange.startColumn; let chunkLineEnd = chunkLineStart + chunkSize; do { - const chunkStartingPosition = new Position(chunkLineStart, 1); - const chunkEndPosition = new Position(chunkLineEnd, e.textModel.getLineMaxColumn(chunkLineEnd)); + const chunkStartingPosition = new Position(chunkLineStart, chunkColumnStart); + const chunkEndColumn = ((chunkLineEnd === ranges[i].newRange.endLineNumber) ? ranges[i].newRange.endColumn : textModel.getLineMaxColumn(chunkLineEnd)); + const chunkEndPosition = new Position(chunkLineEnd, chunkEndColumn); const chunkRange = Range.fromPositions(chunkStartingPosition, chunkEndPosition); rangeChanges.push({ range: chunkRange, - startOffset: e.textModel.getOffsetAt(chunkRange.getStartPosition()), - endOffset: e.textModel.getOffsetAt(chunkRange.getEndPosition()) + startOffset: textModel.getOffsetAt(chunkRange.getStartPosition()), + endOffset: textModel.getOffsetAt(chunkRange.getEndPosition()) }); chunkLineStart = chunkLineEnd + 1; + chunkColumnStart = 1; if (chunkLineEnd < fullRangeEndLineNumber && chunkLineEnd + chunkSize > fullRangeEndLineNumber) { chunkLineEnd = fullRangeEndLineNumber; } else { @@ -302,20 +365,20 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi } while (chunkLineEnd <= fullRangeEndLineNumber); } else { // Check that the previous range doesn't overlap - if ((i === 0) || (rangeChanges[i - 1].range.endLineNumber < e.ranges[i].newRange.startLineNumber)) { - const range = new Range(e.ranges[i].newRange.startLineNumber, 1, e.ranges[i].newRange.endLineNumber, e.textModel.getLineMaxColumn(e.ranges[i].newRange.endLineNumber)); + if ((i === 0) || (rangeChanges[i - 1].endOffset < ranges[i].newRangeStartOffset)) { rangeChanges.push({ - range, - startOffset: e.textModel.getOffsetAt(range.getStartPosition()), - endOffset: e.textModel.getOffsetAt(range.getEndPosition()) + range: ranges[i].newRange, + startOffset: ranges[i].newRangeStartOffset, + endOffset: ranges[i].newRangeEndOffset }); - } else if (rangeChanges[i - 1].range.endLineNumber < e.ranges[i].newRange.endLineNumber) { + } else if (rangeChanges[i - 1].endOffset < ranges[i].newRangeEndOffset) { // clip the range to the previous range - const range = new Range(rangeChanges[i - 1].range.endLineNumber + 1, 1, e.ranges[i].newRange.endLineNumber, e.textModel.getLineMaxColumn(e.ranges[i].newRange.endLineNumber)); + const startPosition = textModel.getPositionAt(rangeChanges[i - 1].endOffset + 1); + const range = new Range(startPosition.lineNumber, startPosition.column, ranges[i].newRange.endLineNumber, ranges[i].newRange.endColumn); rangeChanges.push({ range, - startOffset: e.textModel.getOffsetAt(range.getStartPosition()), - endOffset: e.textModel.getOffsetAt(range.getEndPosition()) + startOffset: rangeChanges[i - 1].endOffset + 1, + endOffset: ranges[i].newRangeEndOffset }); } } @@ -323,18 +386,18 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi } // Get the captures immediately while the text model is correct - const captures = rangeChanges.map(range => this._getTreeAndCaptures(range.range, e.textModel)); + const captures = rangeChanges.map(range => this._getCaptures(range.range, textModelTreeSitter, tree)); // Don't block - return this._updateTreeForRanges(e.textModel, rangeChanges, e.versionId, captures).then(() => { - const tree = this._getTree(e.textModel); - if (!e.textModel.isDisposed() && (tree?.versionId === e.textModel.getVersionId())) { - this._refreshNeedsRefresh(e.textModel, e.versionId); + return this._updateTreeForRanges(textModel, rangeChanges, versionId, tree, captures).then(() => { + const tree = this._getTree(textModel); + if (!textModel.isDisposed() && (tree?.parseResult?.versionId === textModel.getVersionId())) { + this._refreshNeedsRefresh(textModel, versionId); } }); } - private async _updateTreeForRanges(textModel: ITextModel, rangeChanges: RangeWithOffsets[], versionId: number, captures: { tree: ITreeSitterParseResult | undefined; captures: QueryCapture[] }[]) { + private async _updateTreeForRanges(textModel: ITextModel, rangeChanges: RangeWithOffsets[], versionId: number, tree: Parser.Tree, captures: QueryCapture[][]) { let tokenUpdate: { newTokens: TokenUpdate[] } | undefined; for (let i = 0; i < rangeChanges.length; i++) { @@ -345,7 +408,7 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi const capture = captures[i]; const range = rangeChanges[i]; - const updates = this.getTokensInRange(textModel, range.range, range.startOffset, range.endOffset, capture); + const updates = this.getTokensInRange(textModel, range.range, range.startOffset, range.endOffset, tree, capture); if (updates) { tokenUpdate = { newTokens: updates }; } else { @@ -379,7 +442,10 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi newRangeEndOffset: range.endOffset }; } - this._handleTreeUpdate({ ranges: rangeChanges, textModel, versionId }); + const tree = this._getTree(textModel); + if (tree?.parseResult?.tree && tree.parseResult.versionId === versionId) { + this._handleTreeUpdate(rangeChanges, textModel, versionId, tree); + } } private _rangeTokensAsUpdates(rangeOffset: number, endOffsetToken: EndOffsetToken[], startingOffsetInArray?: number) { @@ -401,17 +467,15 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi return updates; } - public getTokensInRange(textModel: ITextModel, range: Range, rangeStartOffset: number, rangeEndOffset: number, captures?: { tree: ITreeSitterParseResult | undefined; captures: QueryCapture[] }): TokenUpdate[] | undefined { - const languageId = this._languageIdCodec.encodeLanguageId(this._languageId); - - const tokens = captures ? this._tokenizeCapturesWithMetadata(captures.tree?.tree, captures.captures, languageId, rangeStartOffset, rangeEndOffset) : this._tokenize(languageId, range, rangeStartOffset, rangeEndOffset, textModel); + public getTokensInRange(textModel: ITextModel, range: Range, rangeStartOffset: number, rangeEndOffset: number, tree?: Parser.Tree, captures?: QueryCapture[]): TokenUpdate[] | undefined { + const tokens = captures ? this._tokenizeCapturesWithMetadata(tree, captures, rangeStartOffset, rangeEndOffset) : this._tokenize(range, rangeStartOffset, rangeEndOffset, textModel); if (tokens?.endOffsetsAndMetadata) { return this._rangeTokensAsUpdates(rangeStartOffset, tokens.endOffsetsAndMetadata); } return undefined; } - private _getTree(textModel: ITextModel): ITreeSitterParseResult | undefined { + private _getTree(textModel: ITextModel): ITextModelTreeSitter | undefined { return this._treeSitterService.getParseResult(textModel); } @@ -431,42 +495,32 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi return this._query; } - private _updateTheme(e: IWorkbenchThemeChangeEvent | undefined) { + private _updateTheme(e: IWorkbenchColorTheme | undefined) { this._colorThemeData = this._themeService.getColorTheme() as ColorThemeData; - for (const editor of this._codeEditors.editors) { - const model = editor.getModel(); - if (model) { - const modelRange = model.getFullModelRange(); - this._tokenizationStoreService.markForRefresh(model, modelRange); + for (const model of this._codeEditors.textModels) { + const modelRange = model.getFullModelRange(); + this._tokenizationStoreService.markForRefresh(model, modelRange); + const editor = this._codeEditors.getEditorForModel(model); + if (editor) { this._parseAndTokenizeViewPort(model, editor.getVisibleRangesPlusViewportAboveBelow()); - - if (e?.target !== 'preview') { - this._handleTreeUpdate({ - ranges: [{ - newRange: modelRange, - newRangeStartOffset: 0, - newRangeEndOffset: model.getValueLength() - }], - textModel: model, - versionId: model.getVersionId() - }); - } } } } captureAtPosition(lineNumber: number, column: number, textModel: ITextModel): QueryCapture[] { - const tree = this._getTree(textModel); - const captures = this._captureAtRange(new Range(lineNumber, column, lineNumber, column + 1), tree?.tree); + const textModelTreeSitter = this._getTree(textModel); + if (!textModelTreeSitter?.parseResult?.tree) { + return []; + } + const captures = this._captureAtRangeWithInjections(new Range(lineNumber, column, lineNumber, column + 1), textModelTreeSitter, textModelTreeSitter.parseResult.tree); return captures; } - captureAtPositionTree(lineNumber: number, column: number, tree: Parser.Tree): QueryCapture[] { - const captures = this._captureAtRange(new Range(lineNumber, column, lineNumber, column + 1), tree); + captureAtRangeTree(range: Range, tree: Parser.Tree, textModelTreeSitter: ITextModelTreeSitter | undefined): QueryCapture[] { + const captures = textModelTreeSitter ? this._captureAtRangeWithInjections(range, textModelTreeSitter, tree) : this._captureAtRange(range, tree); return captures; } - private _captureAtRange(range: Range, tree: Parser.Tree | undefined): QueryCapture[] { const query = this._ensureQuery(); if (!tree || !query) { @@ -479,12 +533,50 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi text: capture.node.text, node: { startIndex: capture.node.startIndex, - endIndex: capture.node.endIndex - } + endIndex: capture.node.endIndex, + startPosition: { + lineNumber: capture.node.startPosition.row + 1, + column: capture.node.startPosition.column + 1 + }, + endPosition: { + lineNumber: capture.node.endPosition.row + 1, + column: capture.node.endPosition.column + 1 + } + }, + encodedLanguageId: this._encodedLanguageId } )); } + private _captureAtRangeWithInjections(range: Range, textModelTreeSitter: ITextModelTreeSitter, tree: Parser.Tree): QueryCapture[] { + const query = this._ensureQuery(); + if (!textModelTreeSitter?.parseResult || !query) { + return []; + } + const captures: QueryCapture[] = this._captureAtRange(range, tree); + for (let i = 0; i < captures.length; i++) { + const capture = captures[i]; + + const capStartLine = capture.node.startPosition.lineNumber; + const capEndLine = capture.node.endPosition.lineNumber; + const capStartColumn = capture.node.startPosition.column; + const capEndColumn = capture.node.endPosition.column; + + const startLine = ((capStartLine > range.startLineNumber) && (capStartLine < range.endLineNumber)) ? capStartLine : range.startLineNumber; + const endLine = ((capEndLine > range.startLineNumber) && (capEndLine < range.endLineNumber)) ? capEndLine : range.endLineNumber; + const startColumn = (capStartLine === range.startLineNumber) ? (capStartColumn < range.startColumn ? range.startColumn : capStartColumn) : (capStartLine < range.startLineNumber ? range.startColumn : capStartColumn); + const endColumn = (capEndLine === range.endLineNumber) ? (capEndColumn > range.endColumn ? range.endColumn : capEndColumn) : (capEndLine > range.endLineNumber ? range.endColumn : capEndColumn); + const injectionRange = new Range(startLine, startColumn, endLine, endColumn); + + const injection = this._getInjectionCaptures(textModelTreeSitter, capture, injectionRange); + if (injection && injection.length > 0) { + captures.splice(i + 1, 0, ...injection); + i += injection.length; + } + } + return captures; + } + /** * Gets the tokens for a given line. * Each token takes 2 elements in the array. The first element is the offset of the end of the token *in the line, not in the document*, and the second element is the metadata. @@ -511,40 +603,45 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi return { result: this._endOffsetTokensToUint32Array(tokens.result), captureTime: tokens.captureTime, metadataTime: tokens.metadataTime }; } - private _getTreeAndCaptures(range: Range, textModel: ITextModel): { tree: ITreeSitterParseResult | undefined; captures: QueryCapture[] } { - const tree = this._getTree(textModel); - const captures = this._captureAtRange(range, tree?.tree); - return { tree, captures }; + private _getCaptures(range: Range, textModelTreeSitter: ITextModelTreeSitter, tree: Parser.Tree): QueryCapture[] { + const captures = this._captureAtRangeWithInjections(range, textModelTreeSitter, tree); + return captures; } - private _tokenize(encodedLanguageId: LanguageId, range: Range, rangeStartOffset: number, rangeEndOffset: number, textModel: ITextModel): { endOffsetsAndMetadata: { endOffset: number; metadata: number }[]; versionId: number; captureTime: number; metadataTime: number } | undefined { - const { tree, captures } = this._getTreeAndCaptures(range, textModel); - const result = this._tokenizeCapturesWithMetadata(tree?.tree, captures, encodedLanguageId, rangeStartOffset, rangeEndOffset); - if (!tree || !result) { + private _tokenize(range: Range, rangeStartOffset: number, rangeEndOffset: number, textModel: ITextModel): { endOffsetsAndMetadata: { endOffset: number; metadata: number }[]; versionId: number; captureTime: number; metadataTime: number } | undefined { + const tree = this._getTree(textModel); + if (!tree?.parseResult?.tree) { return undefined; } - return { ...result, versionId: tree.versionId }; + const captures = this._getCaptures(range, tree, tree.parseResult.tree); + const result = this._tokenizeCapturesWithMetadata(tree.parseResult.tree, captures, rangeStartOffset, rangeEndOffset); + if (!result) { + return undefined; + } + return { ...result, versionId: tree.parseResult.versionId }; } private _createTokensFromCaptures(tree: Parser.Tree | undefined, captures: QueryCapture[], rangeStartOffset: number, rangeEndOffset: number): { endOffsets: EndOffsetAndScopes[]; captureTime: number } | undefined { const stopwatch = StopWatch.create(); const rangeLength = rangeEndOffset - rangeStartOffset; + const encodedLanguageId = this._languageIdCodec.encodeLanguageId(this._languageId); + const baseScope: string = TREESITTER_BASE_SCOPES[this._languageId] || 'source'; if (captures.length === 0) { if (tree) { stopwatch.stop(); - const endOffsetsAndMetadata = [{ endOffset: rangeLength, scopes: [] }]; + const endOffsetsAndMetadata = [{ endOffset: rangeLength, scopes: [], encodedLanguageId }]; return { endOffsets: endOffsetsAndMetadata, captureTime: stopwatch.elapsed() }; } return undefined; } const endOffsetsAndScopes: EndOffsetAndScopes[] = Array(captures.length); - endOffsetsAndScopes.fill({ endOffset: 0, scopes: [] }); + endOffsetsAndScopes.fill({ endOffset: 0, scopes: [baseScope], encodedLanguageId }); let tokenIndex = 0; const increaseSizeOfTokensByOneToken = () => { - endOffsetsAndScopes.push({ endOffset: 0, scopes: [] }); + endOffsetsAndScopes.push({ endOffset: 0, scopes: [baseScope], encodedLanguageId }); }; const brackets = (capture: QueryCapture, startOffset: number): number[] | undefined => { @@ -580,16 +677,16 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi } } // We need to add some of the position token to cover the space - endOffsetsAndScopes.splice(position, 0, { endOffset: startOffset, scopes: [...oldScopes], bracket: preInsertBracket }); + endOffsetsAndScopes.splice(position, 0, { endOffset: startOffset, scopes: [...oldScopes], bracket: preInsertBracket, encodedLanguageId: capture.encodedLanguageId }); position++; increaseSizeOfTokensByOneToken(); tokenIndex++; } - endOffsetsAndScopes.splice(position, 0, { endOffset: endOffset, scopes: [...oldScopes, capture.name], bracket: brackets(capture, startOffset) }); + endOffsetsAndScopes.splice(position, 0, { endOffset: endOffset, scopes: [...oldScopes, capture.name], bracket: brackets(capture, startOffset), encodedLanguageId: capture.encodedLanguageId }); endOffsetsAndScopes[tokenIndex].bracket = oldBracket; } else { - endOffsetsAndScopes[tokenIndex] = { endOffset: endOffset, scopes: [capture.name], bracket: brackets(capture, startOffset) }; + endOffsetsAndScopes[tokenIndex] = { endOffset: endOffset, scopes: [baseScope, capture.name], bracket: brackets(capture, startOffset), encodedLanguageId: capture.encodedLanguageId }; } tokenIndex++; }; @@ -613,7 +710,7 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi const startOffset = endOffset - currentTokenLength; if ((previousEndOffset >= 0) && (previousEndOffset < startOffset)) { // Add en empty token to cover the space where there were no captures - endOffsetsAndScopes[tokenIndex] = { endOffset: startOffset, scopes: [] }; + endOffsetsAndScopes[tokenIndex] = { endOffset: startOffset, scopes: [baseScope], encodedLanguageId: this._encodedLanguageId }; tokenIndex++; increaseSizeOfTokensByOneToken(); @@ -658,50 +755,61 @@ export class TreeSitterTokenizationSupport extends Disposable implements ITreeSi if ((endOffsetsAndScopes[tokenIndex - 1].endOffset < rangeLength)) { if (rangeLength - endOffsetsAndScopes[tokenIndex - 1].endOffset > 0) { increaseSizeOfTokensByOneToken(); - endOffsetsAndScopes[tokenIndex] = { endOffset: rangeLength, scopes: endOffsetsAndScopes[tokenIndex].scopes }; + endOffsetsAndScopes[tokenIndex] = { endOffset: rangeLength, scopes: endOffsetsAndScopes[tokenIndex].scopes, encodedLanguageId: this._encodedLanguageId }; tokenIndex++; } } for (let i = 0; i < endOffsetsAndScopes.length; i++) { const token = endOffsetsAndScopes[i]; - if (token.endOffset === 0 && token.scopes.length === 0 && i !== 0) { + if (token.endOffset === 0 && i !== 0) { endOffsetsAndScopes.splice(i, endOffsetsAndScopes.length - i); break; } } const captureTime = stopwatch.elapsed(); - return { endOffsets: endOffsetsAndScopes as { endOffset: number; scopes: string[] }[], captureTime }; - + return { endOffsets: endOffsetsAndScopes as { endOffset: number; scopes: string[]; encodedLanguageId: LanguageId }[], captureTime }; } - private _tokenizeCapturesWithMetadata(tree: Parser.Tree | undefined, captures: QueryCapture[], encodedLanguageId: LanguageId, rangeStartOffset: number, rangeEndOffset: number): { endOffsetsAndMetadata: { endOffset: number; metadata: number }[]; captureTime: number; metadataTime: number } | undefined { + private _getInjectionCaptures(textModelTreeSitter: ITextModelTreeSitter, parentCapture: QueryCapture, range: Range) { + const injection = textModelTreeSitter.getInjection(parentCapture.node.startIndex, this._languageId); + if (!injection?.tree || injection.versionId !== textModelTreeSitter.parseResult?.versionId) { + return undefined; + } + + const feature = TreeSitterTokenizationRegistry.get(injection.languageId); + if (!feature) { + return undefined; + } + return feature.captureAtRangeTree(range, injection.tree, textModelTreeSitter); + } + + private _tokenizeCapturesWithMetadata(tree: Parser.Tree | undefined, captures: QueryCapture[], rangeStartOffset: number, rangeEndOffset: number): { endOffsetsAndMetadata: EndOffsetToken[]; captureTime: number; metadataTime: number } | undefined { const stopwatch = StopWatch.create(); const emptyTokens = this._createTokensFromCaptures(tree, captures, rangeStartOffset, rangeEndOffset); if (!emptyTokens) { return undefined; } - const endOffsetsAndScopes: { endOffset: number; scopes: string[]; metadata?: number; bracket?: number[] }[] = emptyTokens.endOffsets; + const endOffsetsAndScopes: EndOffsetWithMeta[] = emptyTokens.endOffsets; for (let i = 0; i < endOffsetsAndScopes.length; i++) { const token = endOffsetsAndScopes[i]; - token.metadata = findMetadata(this._colorThemeData, token.scopes, encodedLanguageId, !!token.bracket && (token.bracket.length > 0)); + token.metadata = findMetadata(this._colorThemeData, token.scopes, token.encodedLanguageId, !!token.bracket && (token.bracket.length > 0)); } const metadataTime = stopwatch.elapsed(); return { endOffsetsAndMetadata: endOffsetsAndScopes as { endOffset: number; scopes: string[]; metadata: number }[], captureTime: emptyTokens.captureTime, metadataTime }; } - private _emptyToken(encodedLanguageId: number) { - return findMetadata(this._colorThemeData, [], encodedLanguageId, false); + private _emptyToken() { + return findMetadata(this._colorThemeData, [], this._encodedLanguageId, false); } private _tokenizeEncoded(lineNumber: number, textModel: ITextModel): { result: EndOffsetToken[]; captureTime: number; metadataTime: number; versionId: number } | undefined { - const encodedLanguageId = this._languageIdCodec.encodeLanguageId(this._languageId); const lineOffset = textModel.getOffsetAt({ lineNumber: lineNumber, column: 1 }); const maxLine = textModel.getLineCount(); const lineEndOffset = (lineNumber + 1 <= maxLine) ? textModel.getOffsetAt({ lineNumber: lineNumber + 1, column: 1 }) : textModel.getValueLength(); const lineLength = lineEndOffset - lineOffset; - const result = this._tokenize(encodedLanguageId, new Range(lineNumber, 1, lineNumber, lineLength + 1), lineOffset, lineEndOffset, textModel); + const result = this._tokenize(new Range(lineNumber, 1, lineNumber, lineLength + 1), lineOffset, lineEndOffset, textModel); if (!result) { return undefined; } diff --git a/src/vs/workbench/services/userDataProfile/browser/snippetsResource.ts b/src/vs/workbench/services/userDataProfile/browser/snippetsResource.ts index 7b29090f272..547a1690868 100644 --- a/src/vs/workbench/services/userDataProfile/browser/snippetsResource.ts +++ b/src/vs/workbench/services/userDataProfile/browser/snippetsResource.ts @@ -99,7 +99,7 @@ export class SnippetsResource implements IProfileResource { export class SnippetsResourceTreeItem implements IProfileResourceTreeItem { readonly type = ProfileResourceType.Snippets; - readonly handle = this.profile.snippetsHome.toString(); + readonly handle: string; readonly label = { label: localize('snippets', "Snippets") }; readonly collapsibleState = TreeItemCollapsibleState.Collapsed; checkbox: ITreeItemCheckboxState | undefined; @@ -110,7 +110,9 @@ export class SnippetsResourceTreeItem implements IProfileResourceTreeItem { private readonly profile: IUserDataProfile, @IInstantiationService private readonly instantiationService: IInstantiationService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, - ) { } + ) { + this.handle = this.profile.snippetsHome.toString(); + } async getChildren(): Promise { const snippetsResources = await this.instantiationService.createInstance(SnippetsResource).getSnippetsResources(this.profile); diff --git a/src/vs/workbench/services/views/browser/viewsService.ts b/src/vs/workbench/services/views/browser/viewsService.ts index a852be8ed7b..8fbe567e1c3 100644 --- a/src/vs/workbench/services/views/browser/viewsService.ts +++ b/src/vs/workbench/services/views/browser/viewsService.ts @@ -471,69 +471,67 @@ export class ViewsService extends Disposable implements IViewsService { private registerOpenViewAction(viewDescriptor: IViewDescriptor): IDisposable { const disposables = new DisposableStore(); - if (viewDescriptor.openCommandActionDescriptor) { - const title = viewDescriptor.openCommandActionDescriptor.title ?? viewDescriptor.name; - const commandId = viewDescriptor.openCommandActionDescriptor.id; - const that = this; - disposables.add(registerAction2(class OpenViewAction extends Action2 { - constructor() { - super({ - id: commandId, - get title(): ICommandActionTitle { - const viewContainerLocation = that.viewDescriptorService.getViewLocationById(viewDescriptor.id); - const localizedTitle = typeof title === 'string' ? title : title.value; - const originalTitle = typeof title === 'string' ? title : title.original; - if (viewContainerLocation === ViewContainerLocation.Sidebar) { - return { value: localize('show view', "Show {0}", localizedTitle), original: `Show ${originalTitle}` }; - } else { - return { value: localize('toggle view', "Toggle {0}", localizedTitle), original: `Toggle ${originalTitle}` }; - } - }, - category: Categories.View, - precondition: ContextKeyExpr.has(`${viewDescriptor.id}.active`), - keybinding: viewDescriptor.openCommandActionDescriptor!.keybindings ? { ...viewDescriptor.openCommandActionDescriptor!.keybindings, weight: KeybindingWeight.WorkbenchContrib } : undefined, - f1: true - }); - } - public async run(serviceAccessor: ServicesAccessor): Promise { - const editorGroupService = serviceAccessor.get(IEditorGroupsService); - const viewDescriptorService = serviceAccessor.get(IViewDescriptorService); - const layoutService = serviceAccessor.get(IWorkbenchLayoutService); - const viewsService = serviceAccessor.get(IViewsService); - const contextKeyService = serviceAccessor.get(IContextKeyService); - - const focusedViewId = FocusedViewContext.getValue(contextKeyService); - if (focusedViewId === viewDescriptor.id) { - - const viewLocation = viewDescriptorService.getViewLocationById(viewDescriptor.id); - if (viewDescriptorService.getViewLocationById(viewDescriptor.id) === ViewContainerLocation.Sidebar) { - // focus the editor if the view is focused and in the side bar - editorGroupService.activeGroup.focus(); - } else if (viewLocation !== null) { - // otherwise hide the part where the view lives if focused - layoutService.setPartHidden(true, getPartByLocation(viewLocation)); + const title = viewDescriptor.openCommandActionDescriptor?.title ?? viewDescriptor.name; + const commandId = viewDescriptor.openCommandActionDescriptor?.id ?? `${viewDescriptor.id}.open`; + const that = this; + disposables.add(registerAction2(class OpenViewAction extends Action2 { + constructor() { + super({ + id: commandId, + get title(): ICommandActionTitle { + const viewContainerLocation = that.viewDescriptorService.getViewLocationById(viewDescriptor.id); + const localizedTitle = typeof title === 'string' ? title : title.value; + const originalTitle = typeof title === 'string' ? title : title.original; + if (viewContainerLocation === ViewContainerLocation.Sidebar) { + return { value: localize('show view', "Show {0}", localizedTitle), original: `Show ${originalTitle}` }; + } else { + return { value: localize('toggle view', "Toggle {0}", localizedTitle), original: `Toggle ${originalTitle}` }; } - } else { - viewsService.openView(viewDescriptor.id, true); - } - } - })); + }, + category: Categories.View, + precondition: ContextKeyExpr.has(`${viewDescriptor.id}.active`), + keybinding: viewDescriptor.openCommandActionDescriptor?.keybindings ? { ...viewDescriptor.openCommandActionDescriptor.keybindings, weight: KeybindingWeight.WorkbenchContrib } : undefined, + f1: viewDescriptor.openCommandActionDescriptor ? true : undefined + }); + } + public async run(serviceAccessor: ServicesAccessor, options?: { preserveFocus?: boolean }): Promise { + const editorGroupService = serviceAccessor.get(IEditorGroupsService); + const viewDescriptorService = serviceAccessor.get(IViewDescriptorService); + const layoutService = serviceAccessor.get(IWorkbenchLayoutService); + const viewsService = serviceAccessor.get(IViewsService); + const contextKeyService = serviceAccessor.get(IContextKeyService); - if (viewDescriptor.openCommandActionDescriptor.mnemonicTitle) { - const defaultViewContainer = this.viewDescriptorService.getDefaultContainerById(viewDescriptor.id); - if (defaultViewContainer) { - const defaultLocation = this.viewDescriptorService.getDefaultViewContainerLocation(defaultViewContainer); - disposables.add(MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, { - command: { - id: commandId, - title: viewDescriptor.openCommandActionDescriptor.mnemonicTitle, - }, - group: defaultLocation === ViewContainerLocation.Sidebar ? '3_sidebar' : defaultLocation === ViewContainerLocation.AuxiliaryBar ? '4_auxbar' : '5_panel', - when: ContextKeyExpr.has(`${viewDescriptor.id}.active`), - order: viewDescriptor.openCommandActionDescriptor.order ?? Number.MAX_VALUE - })); + const focusedViewId = FocusedViewContext.getValue(contextKeyService); + if (focusedViewId === viewDescriptor.id && !options?.preserveFocus) { + + const viewLocation = viewDescriptorService.getViewLocationById(viewDescriptor.id); + if (viewDescriptorService.getViewLocationById(viewDescriptor.id) === ViewContainerLocation.Sidebar) { + // focus the editor if the view is focused and in the side bar + editorGroupService.activeGroup.focus(); + } else if (viewLocation !== null) { + // otherwise hide the part where the view lives if focused + layoutService.setPartHidden(true, getPartByLocation(viewLocation)); + } + } else { + viewsService.openView(viewDescriptor.id, !options?.preserveFocus); } } + })); + + if (viewDescriptor.openCommandActionDescriptor?.mnemonicTitle) { + const defaultViewContainer = this.viewDescriptorService.getDefaultContainerById(viewDescriptor.id); + if (defaultViewContainer) { + const defaultLocation = this.viewDescriptorService.getDefaultViewContainerLocation(defaultViewContainer); + disposables.add(MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, { + command: { + id: commandId, + title: viewDescriptor.openCommandActionDescriptor.mnemonicTitle, + }, + group: defaultLocation === ViewContainerLocation.Sidebar ? '3_sidebar' : defaultLocation === ViewContainerLocation.AuxiliaryBar ? '4_auxbar' : '5_panel', + when: ContextKeyExpr.has(`${viewDescriptor.id}.active`), + order: viewDescriptor.openCommandActionDescriptor.order ?? Number.MAX_VALUE + })); + } } return disposables; } diff --git a/src/vs/workbench/services/workspaces/common/workspaceUtils.ts b/src/vs/workbench/services/workspaces/common/workspaceUtils.ts index 4ba2f5af24a..a67b3457ad0 100644 --- a/src/vs/workbench/services/workspaces/common/workspaceUtils.ts +++ b/src/vs/workbench/services/workspaces/common/workspaceUtils.ts @@ -2,22 +2,8 @@ * 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 { IWorkspace } from '../../../../platform/workspace/common/workspace.js'; import { IFileService } from '../../../../platform/files/common/files.js'; -import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; - -export function isChatTransferredWorkspace(workspace: IWorkspace, storageService: IStorageService): boolean { - const workspaceUri = workspace.folders[0]?.uri; - if (!workspaceUri) { - return false; - } - const chatWorkspaceTransfer = storageService.getObject('chat.workspaceTransfer', StorageScope.PROFILE, []); - const toWorkspace: { toWorkspace: URI }[] = chatWorkspaceTransfer.map((item: any) => { - return { toWorkspace: URI.from(item.toWorkspace) }; - }); - return toWorkspace.some(item => item.toWorkspace.toString() === workspaceUri.toString()); -} export async function areWorkspaceFoldersEmpty(workspace: IWorkspace, fileService: IFileService): Promise { for (const folder of workspace.folders) { diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index cda2844478e..ee93a668ed5 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -32,7 +32,7 @@ import { ILanguageService } from '../../../editor/common/languages/language.js'; import { IHistoryService } from '../../services/history/common/history.js'; import { IInstantiationService, ServiceIdentifier } from '../../../platform/instantiation/common/instantiation.js'; import { TestConfigurationService } from '../../../platform/configuration/test/common/testConfigurationService.js'; -import { MenuBarVisibility, IWindowOpenable, IOpenWindowOptions, IOpenEmptyWindowOptions } from '../../../platform/window/common/window.js'; +import { MenuBarVisibility, IWindowOpenable, IOpenWindowOptions, IOpenEmptyWindowOptions, IRectangle } from '../../../platform/window/common/window.js'; import { TestWorkspace } from '../../../platform/workspace/test/common/testWorkspace.js'; import { IEnvironmentService } from '../../../platform/environment/common/environment.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; @@ -183,6 +183,7 @@ import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { NullHoverService } from '../../../platform/hover/test/browser/nullHoverService.js'; import { IActionViewItemService, NullActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; +import { IElementData } from '../../../platform/native/common/native.js'; export function createFileEditorInput(instantiationService: IInstantiationService, resource: URI): FileEditorInput { return instantiationService.createInstance(FileEditorInput, resource, undefined, undefined, undefined, undefined, undefined, undefined); @@ -966,7 +967,7 @@ export class TestEditorGroupView implements IEditorGroupView { copyEditors(_editors: EditorInputWithOptions[], _target: IEditorGroup): void { } async closeEditor(_editor?: EditorInput, options?: ICloseEditorOptions): Promise { return true; } async closeEditors(_editors: EditorInput[] | ICloseEditorsFilter, options?: ICloseEditorOptions): Promise { return true; } - async closeAllEditors(options?: ICloseAllEditorsOptions): Promise { return true; } + closeAllEditors(options?: ICloseAllEditorsOptions): any { return true; } async replaceEditors(_editors: IEditorReplacement[]): Promise { } pinEditor(_editor?: EditorInput): void { } stickEditor(editor?: EditorInput | undefined): void { } @@ -1369,6 +1370,7 @@ export class TestLifecycleService extends Disposable implements ILifecycleServic } startupKind!: StartupKind; + willShutdown = false; private readonly _onBeforeShutdown = this._register(new Emitter()); get onBeforeShutdown(): Event { return this._onBeforeShutdown.event; } @@ -1576,7 +1578,8 @@ export class TestHostService implements IHostService { async toggleFullScreen(): Promise { } - async getScreenshot(): Promise { return undefined; } + async getScreenshot(rect?: IRectangle): Promise { return undefined; } + async getElementData(offsetX: number, offsetY: number, token: CancellationToken): Promise { return undefined; } async getNativeWindowHandle(_windowId: number): Promise { return undefined; } @@ -2246,7 +2249,7 @@ export class TestWorkbenchExtensionManagementService implements IWorkbenchExtens throw new Error('Method not implemented.'); } copyExtensions(): Promise { throw new Error('Not Supported'); } - toggleAppliationScope(): Promise { throw new Error('Not Supported'); } + toggleApplicationScope(): Promise { throw new Error('Not Supported'); } installExtensionsFromProfile(): Promise { throw new Error('Not Supported'); } whenProfileChanged(from: IUserDataProfile, to: IUserDataProfile): Promise { throw new Error('Not Supported'); } getInstalledWorkspaceExtensionLocations(): URI[] { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/test/common/notifications.test.ts b/src/vs/workbench/test/common/notifications.test.ts index 510a48dc03c..80cb08bf05f 100644 --- a/src/vs/workbench/test/common/notifications.test.ts +++ b/src/vs/workbench/test/common/notifications.test.ts @@ -275,7 +275,7 @@ suite('Notifications', () => { assert.strictEqual(model.statusMessage!.message, 'Hello World'); assert.strictEqual(lastStatusMessageEvent.item.message, model.statusMessage!.message); assert.strictEqual(lastStatusMessageEvent.kind, StatusMessageChangeType.ADD); - disposable.dispose(); + disposable.close(); assert.ok(!model.statusMessage); assert.strictEqual(lastStatusMessageEvent.kind, StatusMessageChangeType.REMOVE); @@ -284,10 +284,10 @@ suite('Notifications', () => { assert.strictEqual(model.statusMessage!.message, 'Hello World 3'); - disposable2.dispose(); + disposable2.close(); assert.strictEqual(model.statusMessage!.message, 'Hello World 3'); - disposable3.dispose(); + disposable3.close(); assert.ok(!model.statusMessage); item2DuplicateHandle.close(); diff --git a/src/vs/workbench/test/electron-main/treeSitterTokenizationFeature.test.ts b/src/vs/workbench/test/electron-main/treeSitterTokenizationFeature.test.ts index 126a837bba1..9c394af0826 100644 --- a/src/vs/workbench/test/electron-main/treeSitterTokenizationFeature.test.ts +++ b/src/vs/workbench/test/electron-main/treeSitterTokenizationFeature.test.ts @@ -120,13 +120,13 @@ suite('Tree Sitter TokenizationFeature', function () { let fileService: IFileService; let textResourcePropertiesService: ITextResourcePropertiesService; let languageConfigurationService: ILanguageConfigurationService; - const telemetryService: ITelemetryService = new MockTelemetryService(); - const logService: ILogService = new NullLogService(); - const configurationService: IConfigurationService = new TestConfigurationService({ 'editor.experimental.preferTreeSitter.typescript': true }); - const themeService: IThemeService = new TestThemeService(new TestTreeSitterColorTheme()); + let telemetryService: ITelemetryService; + let logService: ILogService; + let configurationService: IConfigurationService; + let themeService: IThemeService; let languageService: ILanguageService; - const environmentService: IEnvironmentService = {} as IEnvironmentService; - const tokenStoreService: ITreeSitterTokenizationStoreService = new MockTokenStoreService(); + let environmentService: IEnvironmentService; + let tokenStoreService: ITreeSitterTokenizationStoreService; let treeSitterParserService: TreeSitterTextModelService; let treeSitterTokenizationSupport: ITreeSitterTokenizationSupport; @@ -135,6 +135,14 @@ suite('Tree Sitter TokenizationFeature', function () { setup(async () => { disposables = new DisposableStore(); instantiationService = disposables.add(new TestInstantiationService()); + + telemetryService = new MockTelemetryService(); + logService = new NullLogService(); + configurationService = new TestConfigurationService({ 'editor.experimental.preferTreeSitter.typescript': true }); + themeService = new TestThemeService(new TestTreeSitterColorTheme()); + environmentService = {} as IEnvironmentService; + tokenStoreService = new MockTokenStoreService(); + instantiationService.set(IEnvironmentService, environmentService); instantiationService.set(IConfigurationService, configurationService); instantiationService.set(ILogService, logService); @@ -255,9 +263,9 @@ class y { assert.ok(change); assert.strictEqual(change.versionId, 4); - assert.strictEqual(change.ranges[0].newRangeStartOffset, 7); + assert.strictEqual(change.ranges[0].newRangeStartOffset, 0); assert.strictEqual(change.ranges[0].newRangeEndOffset, 32); - assert.strictEqual(change.ranges[0].newRange.startLineNumber, 2); + assert.strictEqual(change.ranges[0].newRange.startLineNumber, 1); assert.strictEqual(change.ranges[0].newRange.endLineNumber, 7); updateListener?.dispose(); diff --git a/src/vs/workbench/test/electron-sandbox/workbenchTestServices.ts b/src/vs/workbench/test/electron-sandbox/workbenchTestServices.ts index 9bc958141ff..9b40dac4954 100644 --- a/src/vs/workbench/test/electron-sandbox/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-sandbox/workbenchTestServices.ts @@ -6,7 +6,7 @@ import { Event } from '../../../base/common/event.js'; import { workbenchInstantiationService as browserWorkbenchInstantiationService, ITestInstantiationService, TestEncodingOracle, TestEnvironmentService, TestFileDialogService, TestFilesConfigurationService, TestFileService, TestLifecycleService, TestTextFileService } from '../browser/workbenchTestServices.js'; import { ISharedProcessService } from '../../../platform/ipc/electron-sandbox/services.js'; -import { INativeHostService, INativeHostOptions, IOSProperties, IOSStatistics } from '../../../platform/native/common/native.js'; +import { INativeHostService, INativeHostOptions, IOSProperties, IOSStatistics, IElementData } from '../../../platform/native/common/native.js'; import { VSBuffer, VSBufferReadable, VSBufferReadableStream } from '../../../base/common/buffer.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; @@ -77,6 +77,7 @@ export class TestNativeHostService implements INativeHostService { onDidChangePassword = Event.None; onDidTriggerWindowSystemContextMenu: Event<{ windowId: number; x: number; y: number }> = Event.None; onDidChangeWindowFullScreen = Event.None; + onDidChangeWindowAlwaysOnTop = Event.None; onDidChangeDisplay = Event.None; windowCount = Promise.resolve(1); @@ -100,6 +101,9 @@ export class TestNativeHostService implements INativeHostService { async unmaximizeWindow(): Promise { } async minimizeWindow(): Promise { } async moveWindowTop(options?: INativeHostOptions): Promise { } + async isWindowAlwaysOnTop(options?: INativeHostOptions): Promise { return false; } + async toggleWindowAlwaysOnTop(options?: INativeHostOptions): Promise { } + async setWindowAlwaysOnTop(alwaysOnTop: boolean, options?: INativeHostOptions): Promise { } getCursorScreenPoint(): Promise<{ readonly point: IPoint; readonly display: IRectangle }> { throw new Error('Method not implemented.'); } async positionWindow(position: IRectangle, options?: INativeHostOptions): Promise { } async updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string }): Promise { } @@ -156,12 +160,14 @@ export class TestNativeHostService implements INativeHostService { async readClipboardFindText(): Promise { return ''; } async writeClipboardFindText(text: string): Promise { } async writeClipboardBuffer(format: string, buffer: VSBuffer, type?: 'selection' | 'clipboard' | undefined): Promise { } + async triggerPaste(options?: INativeHostOptions): Promise { } async readImage(): Promise { return Uint8Array.from([]); } async readClipboardBuffer(format: string): Promise { return VSBuffer.wrap(Uint8Array.from([])); } async hasClipboard(format: string, type?: 'selection' | 'clipboard' | undefined): Promise { return false; } async windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise { return undefined; } async profileRenderer(): Promise { throw new Error(); } - async getScreenshot(): Promise { return undefined; } + async getScreenshot(rect?: IRectangle): Promise { return undefined; } + async getElementData(offsetX: number, offsetY: number, token: CancellationToken, cancellationId?: number): Promise { return undefined; } } export class TestExtensionTipsService extends AbstractNativeExtensionTipsService { diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index a894014f776..93e4c4fc944 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -69,6 +69,7 @@ import './services/editor/browser/editorService.js'; import './services/editor/browser/editorResolverService.js'; import './services/aiEmbeddingVector/common/aiEmbeddingVectorService.js'; import './services/aiRelatedInformation/common/aiRelatedInformationService.js'; +import './services/aiSettingsSearch/common/aiSettingsSearchService.js'; import './services/history/browser/historyService.js'; import './services/activity/browser/activityService.js'; import './services/keybinding/browser/keybindingService.js'; @@ -108,6 +109,7 @@ import './services/authentication/browser/authenticationService.js'; import './services/authentication/browser/authenticationExtensionsService.js'; import './services/authentication/browser/authenticationUsageService.js'; import './services/authentication/browser/authenticationAccessService.js'; +import './services/accounts/common/defaultAccount.js'; import '../editor/browser/services/hoverService/hoverService.js'; import './services/assignment/common/assignmentService.js'; import './services/outline/browser/outlineService.js'; @@ -146,6 +148,7 @@ import { IgnoredExtensionsManagementService, IIgnoredExtensionsManagementService import { ExtensionStorageService, IExtensionStorageService } from '../platform/extensionManagement/common/extensionStorage.js'; import { IUserDataSyncLogService } from '../platform/userDataSync/common/userDataSync.js'; import { UserDataSyncLogService } from '../platform/userDataSync/common/userDataSyncLog.js'; +import { AllowedExtensionsService } from '../platform/extensionManagement/common/allowedExtensionsService.js'; registerSingleton(IUserDataSyncLogService, UserDataSyncLogService, InstantiationType.Delayed); registerSingleton(IAllowedExtensionsService, AllowedExtensionsService, InstantiationType.Delayed); @@ -377,9 +380,6 @@ import './contrib/list/browser/list.contribution.js'; // Accessibility Signals import './contrib/accessibilitySignals/browser/accessibilitySignal.contribution.js'; -// Deprecated Extension Migrator -import './contrib/deprecatedExtensionMigrator/browser/deprecatedExtensionMigrator.contribution.js'; - // Bracket Pair Colorizer 2 Telemetry import './contrib/bracketPairColorizer2Telemetry/browser/bracketPairColorizer2Telemetry.contribution.js'; @@ -397,7 +397,6 @@ import './contrib/inlineCompletions/browser/inlineCompletions.contribution.js'; // Drop or paste into import './contrib/dropOrPasteInto/browser/dropOrPasteInto.contribution.js'; -import { AllowedExtensionsService } from '../platform/extensionManagement/common/allowedExtensionsService.js'; //#endregion diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index a75ca518457..801eccfff62 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -126,9 +126,6 @@ import './contrib/issue/electron-sandbox/process.contribution.js'; // Remote import './contrib/remote/electron-sandbox/remote.contribution.js'; -// Configuration Exporter -import './contrib/configExporter/electron-sandbox/configurationExportHelper.contribution.js'; - // Terminal import './contrib/terminal/electron-sandbox/terminal.contribution.js'; diff --git a/src/vs/workbench/workbench.web.main.internal.ts b/src/vs/workbench/workbench.web.main.internal.ts index 98629bedadf..ca91b124d63 100644 --- a/src/vs/workbench/workbench.web.main.internal.ts +++ b/src/vs/workbench/workbench.web.main.internal.ts @@ -93,7 +93,8 @@ import { ITimerService, TimerService } from './services/timer/browser/timerServi import { IDiagnosticsService, NullDiagnosticsService } from '../platform/diagnostics/common/diagnostics.js'; import { ILanguagePackService } from '../platform/languagePacks/common/languagePacks.js'; import { WebLanguagePacksService } from '../platform/languagePacks/browser/languagePacks.js'; -import { IWebContentExtractorService, NullWebContentExtractorService } from '../platform/webContentExtractor/common/webContentExtractor.js'; +import { IWebContentExtractorService, NullWebContentExtractorService, ISharedWebContentExtractorService, NullSharedWebContentExtractorService } from '../platform/webContentExtractor/common/webContentExtractor.js'; +import { IDefaultAccountService, NullDefaultAccountService } from './services/accounts/common/defaultAccount.js'; registerSingleton(IWorkbenchExtensionManagementService, ExtensionManagementService, InstantiationType.Delayed); registerSingleton(IAccessibilityService, AccessibilityService, InstantiationType.Delayed); @@ -112,6 +113,8 @@ registerSingleton(ICustomEndpointTelemetryService, NullEndpointTelemetryService, registerSingleton(IDiagnosticsService, NullDiagnosticsService, InstantiationType.Delayed); registerSingleton(ILanguagePackService, WebLanguagePacksService, InstantiationType.Delayed); registerSingleton(IWebContentExtractorService, NullWebContentExtractorService, InstantiationType.Delayed); +registerSingleton(ISharedWebContentExtractorService, NullSharedWebContentExtractorService, InstantiationType.Delayed); +registerSingleton(IDefaultAccountService, NullDefaultAccountService, InstantiationType.Delayed); //#endregion @@ -136,9 +139,6 @@ import './contrib/debug/browser/extensionHostDebugService.js'; // Welcome Banner import './contrib/welcomeBanner/browser/welcomeBanner.contribution.js'; -// Welcome Dialog -import './contrib/welcomeDialog/browser/welcomeDialog.contribution.js'; - // Webview import './contrib/webview/browser/webview.web.contribution.js'; diff --git a/src/vscode-dts/vscode.d.ts b/src/vscode-dts/vscode.d.ts index b206caf2d71..cefcb1a81b3 100644 --- a/src/vscode-dts/vscode.d.ts +++ b/src/vscode-dts/vscode.d.ts @@ -116,6 +116,24 @@ declare module 'vscode' { */ readonly languageId: string; + /** + * The file encoding of this document that will be used when the document is saved. + * + * Use the {@link workspace.onDidChangeTextDocument onDidChangeTextDocument}-event to + * get notified when the document encoding changes. + * + * Note that the possible encoding values are currently defined as any of the following: + * 'utf8', 'utf8bom', 'utf16le', 'utf16be', 'windows1252', 'iso88591', 'iso88593', + * 'iso885915', 'macroman', 'cp437', 'windows1256', 'iso88596', 'windows1257', + * 'iso88594', 'iso885914', 'windows1250', 'iso88592', 'cp852', 'windows1251', + * 'cp866', 'cp1125', 'iso88595', 'koi8r', 'koi8u', 'iso885913', 'windows1253', + * 'iso88597', 'windows1255', 'iso88598', 'iso885910', 'iso885916', 'windows1254', + * 'iso88599', 'windows1258', 'gbk', 'gb18030', 'cp950', 'big5hkscs', 'shiftjis', + * 'eucjp', 'euckr', 'windows874', 'iso885911', 'koi8ru', 'koi8t', 'gb2312', + * 'cp865', 'cp850'. + */ + readonly encoding: string; + /** * The version number of this document (it will strictly increase after each * change, including undo/redo). @@ -11997,6 +12015,8 @@ declare module 'vscode' { * When the user starts dragging items from this `DragAndDropController`, `handleDrag` will be called. * Extensions can use `handleDrag` to add their {@link DataTransferItem `DataTransferItem`} items to the drag and drop. * + * Mime types added in `handleDrag` won't be available outside the application. + * * When the items are dropped on **another tree item** in **the same tree**, your `DataTransferItem` objects * will be preserved. Use the recommended mime type for the tree (`application/vnd.code.tree.`) to add * tree objects in a data transfer. See the documentation for `DataTransferItem` for how best to take advantage of this. @@ -13990,7 +14010,29 @@ declare module 'vscode' { * @param uri Identifies the resource to open. * @returns A promise that resolves to a {@link TextDocument document}. */ - export function openTextDocument(uri: Uri): Thenable; + export function openTextDocument(uri: Uri, options?: { + /** + * The {@link TextDocument.encoding encoding} of the document to use + * for decoding the underlying buffer to text. If omitted, the encoding + * will be guessed based on the file content and/or the editor settings + * unless the document is already opened. + * + * Opening a text document that was already opened with a different encoding + * has the potential of changing the text contents of the text document. + * Specifically, when the encoding results in a different set of characters + * than the previous encoding. As such, an error is thrown for dirty documents + * when the specified encoding is different from the encoding of the document. + * + * See {@link TextDocument.encoding} for more information about valid + * values for encoding. Using an unsupported encoding will fallback to the + * default encoding for the document. + * + * *Note* that if you open a document with an encoding that does not + * support decoding the underlying bytes, content may be replaced with + * substitution characters as appropriate. + */ + readonly encoding?: string; + }): Thenable; /** * A short-hand for `openTextDocument(Uri.file(path))`. @@ -13999,7 +14041,29 @@ declare module 'vscode' { * @param path A path of a file on disk. * @returns A promise that resolves to a {@link TextDocument document}. */ - export function openTextDocument(path: string): Thenable; + export function openTextDocument(path: string, options?: { + /** + * The {@link TextDocument.encoding encoding} of the document to use + * for decoding the underlying buffer to text. If omitted, the encoding + * will be guessed based on the file content and/or the editor settings + * unless the document is already opened. + * + * Opening a text document that was already opened with a different encoding + * has the potential of changing the text contents of the text document. + * Specifically, when the encoding results in a different set of characters + * than the previous encoding. As such, an error is thrown for dirty documents + * when the specified encoding is different from the encoding of the document. + * + * See {@link TextDocument.encoding} for more information about valid + * values for encoding. Using an unsupported encoding will fallback to the + * default encoding for the document. + * + * *Note* that if you open a document with an encoding that does not + * support decoding the underlying bytes, content may be replaced with + * substitution characters as appropriate. + */ + readonly encoding?: string; + }): Thenable; /** * Opens an untitled text document. The editor will prompt the user for a file @@ -14018,6 +14082,14 @@ declare module 'vscode' { * The initial contents of the document. */ content?: string; + /** + * The {@link TextDocument.encoding encoding} of the document. + * + * See {@link TextDocument.encoding} for more information about valid + * values for encoding. Using an unsupported encoding will fallback to the + * default encoding for the document. + */ + readonly encoding?: string; }): Thenable; /** @@ -14301,6 +14373,129 @@ declare module 'vscode' { * Event that fires when the current workspace has been trusted. */ export const onDidGrantWorkspaceTrust: Event; + + /** + * Decodes the content from a `Uint8Array` to a `string`. You MUST + * provide the entire content at once to ensure that the encoding + * can properly apply. Do not use this method to decode content + * in chunks, as that may lead to incorrect results. + * + * Will pick an encoding based on settings and the content of the + * buffer (for example byte order marks). + * + * *Note* that if you decode content that is unsupported by the + * encoding, the result may contain substitution characters as + * appropriate. + * + * @throws This method will throw an error when the content is binary. + * + * @param content The text content to decode as a `Uint8Array`. + * @returns A thenable that resolves to the decoded `string`. + */ + export function decode(content: Uint8Array): Thenable; + + /** + * Decodes the content from a `Uint8Array` to a `string` using the + * provided encoding. You MUST provide the entire content at once + * to ensure that the encoding can properly apply. Do not use this + * method to decode content in chunks, as that may lead to incorrect + * results. + * + * *Note* that if you decode content that is unsupported by the + * encoding, the result may contain substitution characters as + * appropriate. + * + * @throws This method will throw an error when the content is binary. + * + * @param content The text content to decode as a `Uint8Array`. + * @param options Additional context for picking the encoding. + * @returns A thenable that resolves to the decoded `string`. + */ + export function decode(content: Uint8Array, options: { + /** + * Allows to explicitly pick the encoding to use. + * See {@link TextDocument.encoding} for more information + * about valid values for encoding. + * Using an unsupported encoding will fallback to the + * default configured encoding. + */ + readonly encoding: string; + }): Thenable; + + /** + * Decodes the content from a `Uint8Array` to a `string`. You MUST + * provide the entire content at once to ensure that the encoding + * can properly apply. Do not use this method to decode content + * in chunks, as that may lead to incorrect results. + * + * The encoding is picked based on settings and the content + * of the buffer (for example byte order marks). + * + * *Note* that if you decode content that is unsupported by the + * encoding, the result may contain substitution characters as + * appropriate. + * + * @throws This method will throw an error when the content is binary. + * + * @param content The content to decode as a `Uint8Array`. + * @param options Additional context for picking the encoding. + * @returns A thenable that resolves to the decoded `string`. + */ + export function decode(content: Uint8Array, options: { + /** + * The URI that represents the file if known. This information + * is used to figure out the encoding related configuration + * for the file if any. + */ + readonly uri: Uri; + }): Thenable; + + /** + * Encodes the content of a `string` to a `Uint8Array`. + * + * Will pick an encoding based on settings. + * + * @param content The content to decode as a `string`. + * @returns A thenable that resolves to the encoded `Uint8Array`. + */ + export function encode(content: string): Thenable; + + /** + * Encodes the content of a `string` to a `Uint8Array` using the + * provided encoding. + * + * @param content The content to decode as a `string`. + * @param options Additional context for picking the encoding. + * @returns A thenable that resolves to the encoded `Uint8Array`. + */ + export function encode(content: string, options: { + /** + * Allows to explicitly pick the encoding to use. + * See {@link TextDocument.encoding} for more information + * about valid values for encoding. + * Using an unsupported encoding will fallback to the + * default configured encoding. + */ + readonly encoding: string; + }): Thenable; + + /** + * Encodes the content of a `string` to a `Uint8Array`. + * + * The encoding is picked based on settings. + * + * @param content The content to decode as a `string`. + * @param options Additional context for picking the encoding. + * @returns A thenable that resolves to the encoded `Uint8Array`. + */ + export function encode(content: string, options: { + /** + * The URI that represents the file if known. This information + * is used to figure out the encoding related configuration + * for the file if any. + */ + readonly uri: Uri; + }): Thenable; } /** @@ -16295,7 +16490,7 @@ declare module 'vscode' { } /** - * Namespace for source control mangement. + * Namespace for source control management. */ export namespace scm { @@ -16609,7 +16804,7 @@ declare module 'vscode' { export type DebugAdapterDescriptor = DebugAdapterExecutable | DebugAdapterServer | DebugAdapterNamedPipeServer | DebugAdapterInlineImplementation; /** - * A debug adaper factory that creates {@link DebugAdapterDescriptor debug adapter descriptors}. + * A debug adapter factory that creates {@link DebugAdapterDescriptor debug adapter descriptors}. */ export interface DebugAdapterDescriptorFactory { /** @@ -16663,7 +16858,7 @@ declare module 'vscode' { } /** - * A debug adaper factory that creates {@link DebugAdapterTracker debug adapter trackers}. + * A debug adapter factory that creates {@link DebugAdapterTracker debug adapter trackers}. */ export interface DebugAdapterTrackerFactory { /** @@ -17200,7 +17395,7 @@ declare module 'vscode' { * Whether the thread supports reply. * Defaults to true. */ - canReply: boolean; + canReply: boolean | CommentAuthorInformation; /** * Context value of the comment thread. This can be used to contribute thread specific actions. @@ -19062,7 +19257,7 @@ declare module 'vscode' { readonly value: T; /** - * Creates a new telementry trusted value. + * Creates a new telemetry trusted value. * * @param value A value to trust */ @@ -19070,7 +19265,7 @@ declare module 'vscode' { } /** - * A telemetry logger which can be used by extensions to log usage and error telementry. + * A telemetry logger which can be used by extensions to log usage and error telemetry. * * A logger wraps around an {@link TelemetrySender sender} but it guarantees that * - user settings to disable or tweak telemetry are respected, and that @@ -20249,7 +20444,7 @@ declare module 'vscode' { */ export class LanguageModelToolResult { /** - * A list of tool result content parts. Includes `unknown` becauses this list may be extended with new content types in + * A list of tool result content parts. Includes `unknown` because this list may be extended with new content types in * the future. * @see {@link lm.invokeTool}. */ diff --git a/src/vscode-dts/vscode.proposed.aiSettingsSearch.d.ts b/src/vscode-dts/vscode.proposed.aiSettingsSearch.d.ts new file mode 100644 index 00000000000..373c5a42ae0 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.aiSettingsSearch.d.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + export enum SettingsSearchResultKind { + EMBEDDED = 1, + LLM_RANKED = 2, + CANCELED = 3 + } + + export interface SettingsSearchResult { + query: string; + kind: SettingsSearchResultKind; + settings: string[]; + } + + export interface SettingsSearchProviderOptions { + limit: number; + } + + export interface SettingsSearchProvider { + provideSettingsSearchResults(query: string, option: SettingsSearchProviderOptions, progress: Progress, token: CancellationToken): Thenable; + } + + export namespace ai { + export function registerSettingsSearchProvider(provider: SettingsSearchProvider): Disposable; + } +} diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 24bdd2d404a..68be8e580b6 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -30,8 +30,9 @@ declare module 'vscode' { } export class ChatResponseCodeblockUriPart { + isEdit?: boolean; value: Uri; - constructor(value: Uri); + constructor(value: Uri, isEdit?: boolean); } /** @@ -78,7 +79,7 @@ declare module 'vscode' { constructor(value: Uri, license: string, snippet: string); } - export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart; + export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart; export class ChatResponseWarningPart { value: MarkdownString; @@ -158,6 +159,13 @@ declare module 'vscode' { resolve?(token: CancellationToken): Thenable; } + export class ChatResponseExtensionsPart { + + readonly extensions: string[]; + + constructor(extensions: string[]); + } + export interface ChatResponseStream { /** @@ -179,7 +187,7 @@ declare module 'vscode' { notebookEdit(target: Uri, isDone: true): void; markdownWithVulnerabilities(value: string | MarkdownString, vulnerabilities: ChatVulnerability[]): void; - codeblockUri(uri: Uri): void; + codeblockUri(uri: Uri, isEdit?: boolean): void; push(part: ChatResponsePart | ChatResponseTextEditPart | ChatResponseWarningPart | ChatResponseProgressPart2): void; /** @@ -219,19 +227,6 @@ declare module 'vscode' { } - export interface ChatRequest { - - /** - * A list of tools that the user selected for this request, when `undefined` any tool - * from {@link lm.tools} should be used. - * - * Tools can be called with {@link lm.invokeTool} with input that match their - * declared `inputSchema`. - */ - readonly tools: readonly LanguageModelToolInformation[] | undefined; - } - - /** * Does this piggy-back on the existing ChatRequest, or is it a different type of request entirely? * Does it show up in history? @@ -248,6 +243,14 @@ declare module 'vscode' { rejectedConfirmationData?: any[]; } + export interface ChatRequest { + + /** + * A map of all tools that should (`true`) and should not (`false`) be used in this request. + */ + readonly tools: Map; + } + // TODO@API fit this into the stream export interface ChatUsedContext { documents: ChatDocumentContext[]; @@ -261,7 +264,7 @@ declare module 'vscode' { /** * Event that fires when a request is paused or unpaused. - * Chat requests are initialy unpaused in the {@link requestHandler}. + * Chat requests are initially unpaused in the {@link requestHandler}. */ onDidChangePauseState: Event; } @@ -404,7 +407,7 @@ declare module 'vscode' { } export namespace lm { - export function fileIsIgnored(uri: Uri, token: CancellationToken): Thenable; + export function fileIsIgnored(uri: Uri, token?: CancellationToken): Thenable; } export interface ChatVariableValue { @@ -432,4 +435,8 @@ declare module 'vscode' { Medium = 2, Full = 3 } + + export interface LanguageModelToolInvocationOptions { + model?: LanguageModelChat; + } } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 0ed00651e95..8b0d17fa0ca 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 6 +// version: 9 declare module 'vscode' { @@ -27,10 +27,6 @@ declare module 'vscode' { * Code editor inline chat */ Editor = 4, - /** - * Chat is happening in an editing session - */ - EditingSession = 5, } export class ChatRequestEditorData { @@ -81,6 +77,67 @@ declare module 'vscode' { * or terminal. Will be `undefined` for the chat panel. */ readonly location2: ChatRequestEditorData | ChatRequestNotebookData | undefined; + + /** + * Events for edited files in this session collected since the last request. + */ + readonly editedFileEvents?: ChatRequestEditedFileEvent[]; + } + + export enum ChatRequestEditedFileEventKind { + Keep = 1, + Undo = 2, + UserModification = 3, + } + + export interface ChatRequestEditedFileEvent { + readonly uri: Uri; + readonly eventKind: ChatRequestEditedFileEventKind; + } + + /** + * ChatRequestTurn + private additions. Note- at runtime this is the SAME as ChatRequestTurn and instanceof is safe. + */ + export class ChatRequestTurn2 { + /** + * The prompt as entered by the user. + * + * Information about references used in this request is stored in {@link ChatRequestTurn.references}. + * + * *Note* that the {@link ChatParticipant.name name} of the participant and the {@link ChatCommand.name command} + * are not part of the prompt. + */ + readonly prompt: string; + + /** + * The id of the chat participant to which this request was directed. + */ + readonly participant: string; + + /** + * The name of the {@link ChatCommand command} that was selected for this request. + */ + readonly command?: string; + + /** + * The references that were used in this message. + */ + readonly references: ChatPromptReference[]; + + /** + * The list of tools were attached to this request. + */ + readonly toolReferences: readonly ChatLanguageModelToolReference[]; + + /** + * Events for edited files in this session collected between the previous request and this one. + */ + readonly editedFileEvents?: ChatRequestEditedFileEvent[]; + + /** + * @hidden + */ + private constructor(prompt: string, command: string | undefined, references: ChatPromptReference[], participant: string, toolReferences: ChatLanguageModelToolReference[], editedFileEvents: ChatRequestEditedFileEvent[] | undefined); } export interface ChatParticipant { @@ -128,6 +185,7 @@ declare module 'vscode' { export interface LanguageModelToolInvocationOptions { chatRequestId?: string; + chatSessionId?: string; chatInteractionId?: string; terminalCommand?: string; } @@ -177,7 +235,31 @@ declare module 'vscode' { export namespace chat { export function registerChatParticipantDetectionProvider(participantDetectionProvider: ChatParticipantDetectionProvider): Disposable; + + export const onDidDisposeChatSession: Event; } // #endregion + + export interface ChatRequestToolSelection { + /** + * A list of tools that the user selected for this request. + * Tools can be called with {@link lm.invokeTool} with input that match their + * declared `inputSchema`. + */ + readonly tools: readonly LanguageModelToolInformation[]; + + /** + * When true, only this set of tools (and toolReferences) should be used. When false, the base set of agent tools can also be included. + */ + readonly isExclusive?: boolean; + } + + export interface ChatRequest { + /** + * A list of tools that the user selected for this request, when `undefined` any tool + * from {@link lm.tools} should be used. + */ + readonly toolSelection: ChatRequestToolSelection | undefined; + } } diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index 7903aed3e8c..a47bc76133f 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -5,11 +5,6 @@ declare module 'vscode' { - export interface ChatResponseFragment { - index: number; - part: string; - } - export interface ChatResponseFragment2 { index: number; part: LanguageModelTextPart | LanguageModelToolCallPart; @@ -19,17 +14,14 @@ declare module 'vscode' { /** * Represents a large language model that accepts ChatML messages and produces a streaming response - */ + */ export interface LanguageModelChatProvider { onDidReceiveLanguageModelResponse2?: Event<{ readonly extensionId: string; readonly participant?: string; readonly tokenCount?: number }>; - provideLanguageModelResponse(messages: LanguageModelChatMessage[], options: LanguageModelChatRequestOptions, extensionId: string, progress: Progress, token: CancellationToken): Thenable; + provideLanguageModelResponse(messages: Array, options: LanguageModelChatRequestOptions, extensionId: string, progress: Progress, token: CancellationToken): Thenable; - /** @deprecated */ - provideLanguageModelResponse2?(messages: LanguageModelChatMessage[], options: LanguageModelChatRequestOptions, extensionId: string, progress: Progress, token: CancellationToken): Thenable; - - provideTokenCount(text: string | LanguageModelChatMessage, token: CancellationToken): Thenable; + provideTokenCount(text: string | LanguageModelChatMessage | LanguageModelChatMessage2, token: CancellationToken): Thenable; } export type ChatResponseProvider = LanguageModelChatProvider; @@ -48,6 +40,16 @@ declare module 'vscode' { */ readonly family: string; + /** + * An optional, human-readable description of the language model. + */ + readonly description?: string; + + /** + * An optional, human-readable string representing the cost of using the language model. + */ + readonly cost?: string; + /** * Opaque version string of the model. This is defined by the extension contributing the language model * and subject to change while the identifier is stable. @@ -73,6 +75,14 @@ declare module 'vscode' { readonly toolCalling?: boolean; readonly agentMode?: boolean; }; + + /** + * Optional category to group models by in the model picker. + * The lower the order, the higher the category appears in the list. + * Has no effect if `isUserSelectable` is `false`. + * If not specified, the model will appear in the "Other Models" category. + */ + readonly category?: { label: string; order: number }; } export interface ChatResponseProviderMetadata { @@ -80,14 +90,6 @@ declare module 'vscode' { extensions?: string[]; } - export namespace chat { - - /** - * @deprecated use `lm.registerChatResponseProvider` instead - */ - export function registerChatResponseProvider(id: string, provider: ChatResponseProvider, metadata: ChatResponseProviderMetadata): Disposable; - } - export namespace lm { export function registerChatModelProvider(id: string, provider: LanguageModelChatProvider, metadata: ChatResponseProviderMetadata): Disposable; diff --git a/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts b/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts new file mode 100644 index 00000000000..b03afe16ca1 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export interface ChatStatusItem { + /** + * The identifier of this item. + */ + readonly id: string; + + /** + * The main name of the entry, like 'Indexing Status' + */ + title: string | { label: string; link: string }; + + /** + * Optional additional description of the entry. + * + * This is rendered after the title. Supports Markdown style links (`[text](http://example.com)`) and rendering of + * {@link ThemeIcon theme icons} via the `$()`-syntax. + */ + description: string; + + /** + * Optional additional details of the entry. + * + * This is rendered less prominently after the title. Supports Markdown style links (`[text](http://example.com)`) and rendering of + * {@link ThemeIcon theme icons} via the `$()`-syntax. + */ + detail: string | undefined; + + /** + * Shows the entry in the chat status. + */ + show(): void; + + /** + * Hide the entry in the chat status. + */ + hide(): void; + + /** + * Dispose and free associated resources + */ + dispose(): void; + } + + namespace window { + /** + * Create a new chat status item. + * + * @param id The unique identifier of the status bar item. + * + * @returns A new chat status item. + */ + export function createChatStatusItem(id: string): ChatStatusItem; + } +} diff --git a/src/vscode-dts/vscode.proposed.commentThreadApplicability.d.ts b/src/vscode-dts/vscode.proposed.commentThreadApplicability.d.ts index fb99abb48bd..772771eef77 100644 --- a/src/vscode-dts/vscode.proposed.commentThreadApplicability.d.ts +++ b/src/vscode-dts/vscode.proposed.commentThreadApplicability.d.ts @@ -32,7 +32,7 @@ declare module 'vscode' { range: Range | undefined; comments: readonly Comment[]; collapsibleState: CommentThreadCollapsibleState; - canReply: boolean; + canReply: boolean | CommentAuthorInformation; contextValue?: string; label?: string; dispose(): void; diff --git a/src/vscode-dts/vscode.proposed.contribDebugCreateConfiguration.d.ts b/src/vscode-dts/vscode.proposed.contribDebugCreateConfiguration.d.ts index 9f866269987..c28b6b4cc42 100644 --- a/src/vscode-dts/vscode.proposed.contribDebugCreateConfiguration.d.ts +++ b/src/vscode-dts/vscode.proposed.contribDebugCreateConfiguration.d.ts @@ -3,4 +3,4 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// empty placeholder declaration for the `debugCreateConfiguation` menu +// empty placeholder declaration for the `debugCreateConfiguration` menu diff --git a/src/vscode-dts/vscode.proposed.defaultChatParticipant.d.ts b/src/vscode-dts/vscode.proposed.defaultChatParticipant.d.ts index 3d35ae14dcf..830ae96509a 100644 --- a/src/vscode-dts/vscode.proposed.defaultChatParticipant.d.ts +++ b/src/vscode-dts/vscode.proposed.defaultChatParticipant.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 3 +// version: 4 declare module 'vscode' { @@ -34,12 +34,6 @@ declare module 'vscode' { } export interface ChatParticipant { - /** - * When true, this participant is invoked when the user submits their query using ctrl/cmd+enter - * TODO@API name - */ - isSecondary?: boolean; - /** * A string that will be added before the listing of chat participants in `/help`. */ @@ -56,7 +50,7 @@ declare module 'vscode' { helpTextPostfix?: string | MarkdownString; welcomeMessageProvider?: ChatWelcomeMessageProvider; - welcomeMessageContent?: ChatWelcomeMessageContent; + additionalWelcomeMessage?: string | MarkdownString; titleProvider?: ChatTitleProvider; requester?: ChatRequesterInformation; } diff --git a/src/vscode-dts/vscode.proposed.envExtractUri.d.ts b/src/vscode-dts/vscode.proposed.envExtractUri.d.ts deleted file mode 100644 index 73d116bcc96..00000000000 --- a/src/vscode-dts/vscode.proposed.envExtractUri.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/243615 - - export namespace env { - export function isTrustedExternalUris(uri: Uri[]): boolean[]; - export function extractExternalUris(uris: Uri[]): Thenable; - } -} diff --git a/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts b/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts index d7c85101091..34eef9c6dab 100644 --- a/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts @@ -44,6 +44,13 @@ declare module 'vscode' { showInlineEditMenu?: boolean; action?: Command; + + displayLocation?: InlineCompletionDisplayLocation; + } + + export interface InlineCompletionDisplayLocation { + range: Range; + label: string; } export interface InlineCompletionWarning { @@ -72,10 +79,27 @@ declare module 'vscode' { handleDidShowCompletionItem?(completionItem: InlineCompletionItem, updatedInsertText: string): void; /** - * @param completionItem The completion item that was rejected. + * Is called when an inline completion item was accepted partially. + * @param info Additional info for the partial accepted trigger. + */ + // eslint-disable-next-line local/vscode-dts-provider-naming + handleDidPartiallyAcceptCompletionItem?(completionItem: InlineCompletionItem, info: PartialAcceptInfo): void; + + /** + * Is called when an inline completion item is no longer being used. + * Provides a reason of why it is not used anymore. */ // eslint-disable-next-line local/vscode-dts-provider-naming - handleDidRejectCompletionItem?(completionItem: InlineCompletionItem): void; + handleEndOfLifetime?(completionItem: InlineCompletionItem, reason: InlineCompletionEndOfLifeReason): void; + + readonly debounceDelayMs?: number; + + onDidChange?: Event; + + // #region Deprecated methods + + /** @deprecated */ + provideInlineEditsForRange?(document: TextDocument, range: Range, context: InlineCompletionContext, token: CancellationToken): ProviderResult; /** * Is called when an inline completion item was accepted partially. @@ -86,17 +110,31 @@ declare module 'vscode' { handleDidPartiallyAcceptCompletionItem?(completionItem: InlineCompletionItem, acceptedLength: number): void; /** - * Is called when an inline completion item was accepted partially. - * @param info Additional info for the partial accepted trigger. - */ + * @param completionItem The completion item that was rejected. + * @deprecated Use {@link handleEndOfLifetime} instead. + */ // eslint-disable-next-line local/vscode-dts-provider-naming - handleDidPartiallyAcceptCompletionItem?(completionItem: InlineCompletionItem, info: PartialAcceptInfo): void; + handleDidRejectCompletionItem?(completionItem: InlineCompletionItem): void; - provideInlineEditsForRange?(document: TextDocument, range: Range, context: InlineCompletionContext, token: CancellationToken): ProviderResult; - - readonly debounceDelayMs?: number; + // #endregion } + export enum InlineCompletionEndOfLifeReasonKind { + Accepted = 0, + Rejected = 1, + Ignored = 2, + } + + export type InlineCompletionEndOfLifeReason = { + kind: InlineCompletionEndOfLifeReasonKind.Accepted; // User did an explicit action to accept + } | { + kind: InlineCompletionEndOfLifeReasonKind.Rejected; // User did an explicit action to reject + } | { + kind: InlineCompletionEndOfLifeReasonKind.Ignored; + supersededBy?: InlineCompletionItem; + userTypingDisagreed: boolean; + }; + export interface InlineCompletionContext { readonly userPrompt?: string; diff --git a/src/vscode-dts/vscode.proposed.languageModelDataPart.d.ts b/src/vscode-dts/vscode.proposed.languageModelDataPart.d.ts new file mode 100644 index 00000000000..f365ede02e6 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.languageModelDataPart.d.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 2 + +declare module 'vscode' { + + export interface LanguageModelChat { + sendRequest(messages: Array, options?: LanguageModelChatRequestOptions, token?: CancellationToken): Thenable; + countTokens(text: string | LanguageModelChatMessage | LanguageModelChatMessage2, token?: CancellationToken): Thenable; + } + + /** + * Represents a message in a chat. Can assume different roles, like user or assistant. + */ + export class LanguageModelChatMessage2 { + + /** + * Utility to create a new user message. + * + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + static User(content: string | Array, name?: string): LanguageModelChatMessage2; + + /** + * Utility to create a new assistant message. + * + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + static Assistant(content: string | Array, name?: string): LanguageModelChatMessage2; + + /** + * The role of this message. + */ + role: LanguageModelChatMessageRole; + + /** + * A string or heterogeneous array of things that a message can contain as content. Some parts may be message-type + * specific for some models. + */ + content: Array; + + /** + * The optional name of a user for this message. + */ + name: string | undefined; + + /** + * Create a new user message. + * + * @param role The role of the message. + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + constructor(role: LanguageModelChatMessageRole, content: string | Array, name?: string); + } + + /** + * A language model response part containing arbitrary data, returned from a {@link LanguageModelChatResponse}. + */ + export class LanguageModelDataPart { + /** + * Factory function to create a `LanguageModelDataPart` for an image. + * @param data Binary image data + * @param mimeType The MIME type of the image + */ + static image(data: Uint8Array, mimeType: ChatImageMimeType): LanguageModelDataPart; + + static json(value: object): LanguageModelDataPart; + + static text(value: string): LanguageModelDataPart; + + /** + * The mime type which determines how the data property is interpreted. + */ + mimeType: string; + + /** + * The data of the part. + */ + data: Uint8Array; + + /** + * Construct a generic data part with the given content. + * @param value The data of the part. + */ + constructor(data: Uint8Array, mimeType: string); + } + + /** + * Enum for supported image MIME types. + */ + export enum ChatImageMimeType { + PNG = 'image/png', + JPEG = 'image/jpeg', + GIF = 'image/gif', + WEBP = 'image/webp', + BMP = 'image/bmp', + } + + /** + * Tagging onto this proposal, because otherwise managing two different extensions of LanguageModelChatMessage could be confusing. + * A language model response part containing arbitrary model-specific data, returned from a {@link LanguageModelChatResponse}. + * TODO@API naming, looking at LanguageModelChatRequestOptions.modelOptions, but LanguageModelModelData is not very good. + * LanguageModelOpaqueData from prompt-tsx? + */ + export class LanguageModelExtraDataPart { + /** + * The type of data. The allowed values and data types here are model-specific. + */ + kind: string; + + /** + * Extra model-specific data. + */ + data: any; + + /** + * Construct an extra data part with the given content. + * @param value The image content of the part. + */ + constructor(kind: string, data: any); + } + + + /** + * The result of a tool call. This is the counterpart of a {@link LanguageModelToolCallPart tool call} and + * it can only be included in the content of a User message + */ + export class LanguageModelToolResultPart2 { + /** + * The ID of the tool call. + * + * *Note* that this should match the {@link LanguageModelToolCallPart.callId callId} of a tool call part. + */ + callId: string; + + /** + * The value of the tool result. + */ + content: Array; + + /** + * @param callId The ID of the tool call. + * @param content The content of the tool result. + */ + constructor(callId: string, content: Array); + } + + + /** + * A tool that can be invoked by a call to a {@link LanguageModelChat}. + */ + export interface LanguageModelTool { + /** + * Invoke the tool with the given input and return a result. + * + * The provided {@link LanguageModelToolInvocationOptions.input} has been validated against the declared schema. + */ + invoke(options: LanguageModelToolInvocationOptions, token: CancellationToken): ProviderResult; + } + + /** + * A result returned from a tool invocation. If using `@vscode/prompt-tsx`, this result may be rendered using a `ToolResult`. + */ + export class LanguageModelToolResult2 { + /** + * A list of tool result content parts. Includes `unknown` becauses this list may be extended with new content types in + * the future. + * @see {@link lm.invokeTool}. + */ + content: Array; + + /** + * Create a LanguageModelToolResult + * @param content A list of tool result content parts + */ + constructor(content: Array); + } + + export namespace lm { + export function invokeTool(name: string, options: LanguageModelToolInvocationOptions, token?: CancellationToken): Thenable; + } +} diff --git a/src/vscode-dts/vscode.proposed.mappedEditsProvider.d.ts b/src/vscode-dts/vscode.proposed.mappedEditsProvider.d.ts index 50249caa8a9..ae22bc707b2 100644 --- a/src/vscode-dts/vscode.proposed.mappedEditsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.mappedEditsProvider.d.ts @@ -75,6 +75,8 @@ declare module 'vscode' { readonly codeBlocks: { code: string; resource: Uri; markdownBeforeBlock?: string }[]; readonly location?: string; readonly chatRequestId?: string; + readonly chatRequestModel?: string; + readonly chatSessionId?: string; } export interface MappedEditsResponseStream { diff --git a/src/vscode-dts/vscode.proposed.mcpConfigurationProvider.d.ts b/src/vscode-dts/vscode.proposed.mcpConfigurationProvider.d.ts index 28a9c7b2f36..9f36dd5aca1 100644 --- a/src/vscode-dts/vscode.proposed.mcpConfigurationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.mcpConfigurationProvider.d.ts @@ -6,40 +6,134 @@ declare module 'vscode' { // https://github.com/microsoft/vscode/issues/243522 + /** + * McpStdioServerDefinition represents an MCP server available by running + * a local process and listening to its stdin and stdout streams. The process + * will be spawned as a child process of the extension host and by default + * will not run in a shell environment. + */ export class McpStdioServerDefinition { + /** + * The human-readable name of the server. + */ + readonly label: string; - label: string; - + /** + * The working directory used to start the server. + */ cwd?: Uri; + + /** + * The command used to start the server. Node.js-based servers may use + * `process.execPath` to use the editor's version of Node.js to run the script. + */ command: string; - args: readonly string[]; + /** + * Additional command-line arguments passed to the server. + */ + args: string[]; + + /** + * Optional additional environment information for the server. Variables + * in this environment will overwrite or remove (if null) the default + * environment variables. + */ env: Record; - constructor(label: string, command: string, args: string[], env: { [key: string]: string }); + /** + * Optional version identification for the server. If this changes, the + * editor will indicate that tools have changed and prompt to refresh them. + */ + version?: string; + + /** + * @param label The human-readable name of the server. + * @param command The command used to start the server. + * @param args Additional command-line arguments passed to the server. + * @param env Optional additional environment information for the server. + * @param version Optional version identification for the server. + */ + constructor(label: string, command: string, args?: string[], env?: Record, version?: string); } - export class McpSSEServerDefinition { - - label: string; + /** + * McpHttpServerDefinition represents an MCP server available using the + * Streamable HTTP transport. + */ + export class McpHttpServerDefinition { + /** + * The human-readable name of the server. + */ + readonly label: string; + /** + * The URI of the server. The editor will make a POST request to this URI + * to begin each session. + */ uri: Uri; - headers: [string, string][]; + /** + * Optional additional heads included with each request to the server. + */ + headers: Record; - constructor(label: string, uri: Uri); + /** + * Optional version identification for the server. If this changes, the + * editor will indicate that tools have changed and prompt to refresh them. + */ + version?: string; + + /** + * @param label The human-readable name of the server. + * @param uri The URI of the server. + * @param headers Optional additional heads included with each request to the server. + */ + constructor(label: string, uri: Uri, headers?: Record, version?: string); } - export type McpServerDefinition = McpStdioServerDefinition | McpSSEServerDefinition; + export type McpServerDefinition = McpStdioServerDefinition | McpHttpServerDefinition; - export interface McpConfigurationProvider { + /** + * A type that can provide server configurations. This may only be used in + * conjunction with `contributes.modelContextServerCollections` in the + * extension's package.json. + * + * To allow the editor to cache available servers, extensions should register + * this before `activate()` resolves. + */ + export interface McpServerDefinitionProvider { + /** + * Optional event fired to signal that the set of available servers has changed. + */ + onDidChangeServerDefinitions?: Event; - onDidChange?: Event; - - provideMcpServerDefinitions(token: CancellationToken): ProviderResult; + /** + * Provides available MCP servers. The editor will call this method eagerly + * to ensure the availability of servers for the language model, and so + * extensions should not take actions which would require user + * interaction, such as authentication. + * + * @param token A cancellation token. + * @returns An array of MCP available MCP servers + */ + provideMcpServerDefinitions(token: CancellationToken): ProviderResult; + /** + * This function will be called when the editor needs to start MCP server. + * At this point, the extension may take any actions which may require user + * interaction, such as authentication. + * + * The extension may return undefined on error to indicate that the server + * should not be started. + * + * @param server The MCP server to resolve + * @param token A cancellation token. + * @returns The given, resolved server or thenable that resolves to such. + */ + resolveMcpServerDefinition?(server: T, token: CancellationToken): ProviderResult; } namespace lm { - export function registerMcpConfigurationProvider(id: string, provider: McpConfigurationProvider): Disposable; + export function registerMcpServerDefinitionProvider(id: string, provider: McpServerDefinitionProvider): Disposable; } } diff --git a/src/vscode-dts/vscode.proposed.quickDiffProvider.d.ts b/src/vscode-dts/vscode.proposed.quickDiffProvider.d.ts index 674c1ae279d..43f4c935993 100644 --- a/src/vscode-dts/vscode.proposed.quickDiffProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.quickDiffProvider.d.ts @@ -8,11 +8,15 @@ declare module 'vscode' { // https://github.com/microsoft/vscode/issues/169012 export namespace window { - export function registerQuickDiffProvider(selector: DocumentSelector, quickDiffProvider: QuickDiffProvider, label: string, rootUri?: Uri): Disposable; + export function registerQuickDiffProvider(selector: DocumentSelector, quickDiffProvider: QuickDiffProvider, id: string, label: string, rootUri?: Uri): Disposable; + } + + export interface SourceControl { + secondaryQuickDiffProvider?: QuickDiffProvider; } export interface QuickDiffProvider { - label?: string; - readonly visible?: boolean; + readonly id?: string; + readonly label?: string; } } diff --git a/src/vscode-dts/vscode.proposed.textDocumentEncoding.d.ts b/src/vscode-dts/vscode.proposed.textDocumentEncoding.d.ts deleted file mode 100644 index 6886cb1cbfe..00000000000 --- a/src/vscode-dts/vscode.proposed.textDocumentEncoding.d.ts +++ /dev/null @@ -1,170 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/241449 - - export interface TextDocument { - - /** - * The file encoding of this document that will be used when the document is saved. - * - * Use the {@link workspace.onDidChangeTextDocument onDidChangeTextDocument}-event to - * get notified when the document encoding changes. - * - * Note that the possible encoding values are currently defined as any of the following: - * 'utf8', 'utf8bom', 'utf16le', 'utf16be', 'windows1252', 'iso88591', 'iso88593', - * 'iso885915', 'macroman', 'cp437', 'windows1256', 'iso88596', 'windows1257', - * 'iso88594', 'iso885914', 'windows1250', 'iso88592', 'cp852', 'windows1251', - * 'cp866', 'cp1125', 'iso88595', 'koi8r', 'koi8u', 'iso885913', 'windows1253', - * 'iso88597', 'windows1255', 'iso88598', 'iso885910', 'iso885916', 'windows1254', - * 'iso88599', 'windows1258', 'gbk', 'gb18030', 'cp950', 'big5hkscs', 'shiftjis', - * 'eucjp', 'euckr', 'windows874', 'iso885911', 'koi8ru', 'koi8t', 'gb2312', - * 'cp865', 'cp850'. - */ - readonly encoding: string; - } - - export namespace workspace { - - /** - * Opens a document. Will return early if this document is already open. Otherwise - * the document is loaded and the {@link workspace.onDidOpenTextDocument didOpen}-event fires. - * - * The document is denoted by an {@link Uri}. Depending on the {@link Uri.scheme scheme} the - * following rules apply: - * * `file`-scheme: Open a file on disk (`openTextDocument(Uri.file(path))`). Will be rejected if the file - * does not exist or cannot be loaded. - * * `untitled`-scheme: Open a blank untitled file with associated path (`openTextDocument(Uri.file(path).with({ scheme: 'untitled' }))`). - * The language will be derived from the file name. - * * For all other schemes contributed {@link TextDocumentContentProvider text document content providers} and - * {@link FileSystemProvider file system providers} are consulted. - * - * *Note* that the lifecycle of the returned document is owned by the editor and not by the extension. That means an - * {@linkcode workspace.onDidCloseTextDocument onDidClose}-event can occur at any time after opening it. - * - * @throws This method will throw an error when an existing text document with the provided uri is dirty. - * - * @param uri Identifies the resource to open. - * @param options Options to control how the document will be opened. - * @returns A promise that resolves to a {@link TextDocument document}. - */ - export function openTextDocument(uri: Uri, options?: { - /** - * The {@link TextDocument.encoding encoding} of the document to use - * for decoding the underlying buffer to text. If omitted, the encoding - * will be guessed based on the file content and/or the editor settings - * unless the document is already opened. - * - * See {@link TextDocument.encoding} for more information about valid - * values for encoding. - * - * *Note* that opening a text document that was already opened with a - * different encoding has the potential of changing the text contents of - * the text document. Specifically, when the encoding results in a - * different set of characters than the previous encoding. - * - * *Note* that if you open a document with an encoding that does not - * support decoding the underlying bytes, content may be replaced with - * substitution characters as appropriate. - */ - encoding?: string; - }): Thenable; - - /** - * A short-hand for `openTextDocument(Uri.file(path))`. - * - * @see {@link workspace.openTextDocument} - * @param path A path of a file on disk. - * @param options Options to control how the document will be opened. - * @returns A promise that resolves to a {@link TextDocument document}. - */ - export function openTextDocument(path: string, options?: { - /** - * The {@link TextDocument.encoding encoding} of the document to use - * for decoding the underlying buffer to text. If omitted, the encoding - * will be guessed based on the file content and/or the editor settings - * unless the document is already opened. - * - * See {@link TextDocument.encoding} for more information about valid - * values for encoding. - * - * *Note* that opening a text document that was already opened with a - * different encoding has the potential of changing the text contents of - * the text document. Specifically, when the encoding results in a - * different set of characters than the previous encoding. - * - * *Note* that if you open a document with an encoding that does not - * support decoding the underlying bytes, content may be replaced with - * substitution characters as appropriate. - */ - encoding?: string; - }): Thenable; - - /** - * Opens an untitled text document. The editor will prompt the user for a file - * path when the document is to be saved. The `options` parameter allows to - * specify the *language*, *encoding* and/or the *content* of the document. - * - * @param options Options to control how the document will be created. - * @returns A promise that resolves to a {@link TextDocument document}. - */ - export function openTextDocument(options?: { - /** - * The {@link TextDocument.languageId language} of the document. - */ - language?: string; - /** - * The initial contents of the document. - */ - content?: string; - /** - * The {@link TextDocument.encoding encoding} of the document. - */ - encoding?: string; - }): Thenable; - - /** - * Decodes the content from a `Uint8Array` to a `string`. You MUST - * provide the entire content at once to ensure that the encoding - * can properly apply. Do not use this method to decode content - * in chunks, as that may lead to incorrect results. - * - * If no encoding is provided, will try to pick an encoding based - * on user settings and the content of the buffer (for example - * byte order marks). - * - * *Note* that if you decode content that is unsupported by the - * encoding, the result may contain substitution characters as - * appropriate. - * - * @throws This method will throw an error when the content is binary. - * - * @param content The content to decode as a `Uint8Array`. - * @param uri The URI that represents the file. This information - * is used to figure out the encoding related configuration for the file. - * @param options Allows to explicitly pick the encoding to use. See {@link TextDocument.encoding} - * for more information about valid values for encoding. - * @returns A thenable that resolves to the decoded `string`. - */ - export function decode(content: Uint8Array, uri: Uri | undefined, options?: { encoding: string }): Thenable; - - /** - * Encodes the content of a `string` to a `Uint8Array`. - * - * If no encoding is provided, will try to pick an encoding based - * on user settings. - * - * @param content The content to decode as a `string`. - * @param uri The URI that represents the file. This information - * is used to figure out the encoding related configuration for the file. - * @param options Allows to explicitly pick the encoding to use. See {@link TextDocument.encoding} - * for more information about valid values for encoding. - * @returns A thenable that resolves to the encoded `Uint8Array`. - */ - export function encode(content: string, uri: Uri | undefined, options?: { encoding: string }): Thenable; - } -} diff --git a/src/vscode-dts/vscode.proposed.textSearchProvider2.d.ts b/src/vscode-dts/vscode.proposed.textSearchProvider2.d.ts index 146b76b5fa5..2bcb81299b8 100644 --- a/src/vscode-dts/vscode.proposed.textSearchProvider2.d.ts +++ b/src/vscode-dts/vscode.proposed.textSearchProvider2.d.ts @@ -237,12 +237,34 @@ declare module 'vscode' { lineNumber: number; } + /** + * Keyword suggestion for AI search. + */ + export class AISearchKeyword { + /** + * @param keyword The keyword associated with the search. + */ + constructor(keyword: string); + + /** + * The keyword associated with the search. + */ + keyword: string; + } + /** * A result payload for a text search, pertaining to {@link TextSearchMatch2 matches} * and its associated {@link TextSearchContext2 context} within a single file. */ export type TextSearchResult2 = TextSearchMatch2 | TextSearchContext2; + /** + * A result payload for an AI search. + * This can be a {@link TextSearchMatch2 match} or a {@link AISearchKeyword keyword}. + * The result can be a match or a keyword. + */ + export type AISearchResult = TextSearchResult2 | AISearchKeyword; + /** * A TextSearchProvider provides search results for text results inside files in the workspace. */ @@ -255,7 +277,7 @@ declare module 'vscode' { * These results can be direct matches, or context that surrounds matches. * @param token A cancellation token. */ - provideTextSearchResults(query: TextSearchQuery2, options: TextSearchProviderOptions, progress: Progress, token: CancellationToken): ProviderResult; + provideTextSearchResults(query: TextSearchQuery2, options: TextSearchProviderOptions, progress: Progress, token: CancellationToken): ProviderResult; } export namespace workspace { diff --git a/src/vscode-dts/vscode.proposed.toolProgress.d.ts b/src/vscode-dts/vscode.proposed.toolProgress.d.ts new file mode 100644 index 00000000000..0d20f626cc1 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.toolProgress.d.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. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + /** + * A progress update during an {@link LanguageModelTool.invoke} call. + */ + export interface ToolProgressStep { + /** + * A progress message that represents a chunk of work + */ + message?: string | MarkdownString; + /** + * An increment for discrete progress. Increments will be summed up until 100 (100%) is reached + */ + increment?: number; + } + + export interface LanguageModelTool { + invoke(options: LanguageModelToolInvocationOptions, token: CancellationToken, progress: Progress): ProviderResult; + } +} diff --git a/src/vscode-dts/vscode.proposed.tunnelFactory.d.ts b/src/vscode-dts/vscode.proposed.tunnelFactory.d.ts index eab3ffa5d2e..aa5c4b7739c 100644 --- a/src/vscode-dts/vscode.proposed.tunnelFactory.d.ts +++ b/src/vscode-dts/vscode.proposed.tunnelFactory.d.ts @@ -31,7 +31,7 @@ declare module 'vscode' { export interface TunnelProvider { /** - * Provides port forwarding capabilities. If there is a resolver that already provids tunnels, then the resolver's provider will + * Provides port forwarding capabilities. If there is a resolver that already provides tunnels, then the resolver's provider will * be used. If multiple providers are registered, then only the first will be used. */ provideTunnel(tunnelOptions: TunnelOptions, tunnelCreationOptions: TunnelCreationOptions, token: CancellationToken): ProviderResult; diff --git a/test/automation/src/code.ts b/test/automation/src/code.ts index 75b8a4980ec..ae612626c9e 100644 --- a/test/automation/src/code.ts +++ b/test/automation/src/code.ts @@ -5,7 +5,6 @@ import * as cp from 'child_process'; import * as os from 'os'; -import * as treekill from 'tree-kill'; import { IElement, ILocaleInfo, ILocalizedStrings, ILogFile } from './driver'; import { Logger, measureAndLog } from './logger'; import { launch as launchPlaywrightBrowser } from './playwrightBrowser'; @@ -22,14 +21,14 @@ export interface LaunchOptions { readonly logger: Logger; logsPath: string; crashesPath: string; - readonly verbose?: boolean; + verbose?: boolean; readonly extraArgs?: string[]; readonly remote?: boolean; readonly web?: boolean; readonly tracing?: boolean; snapshots?: boolean; readonly headless?: boolean; - readonly browser?: 'chromium' | 'webkit' | 'firefox'; + readonly browser?: 'chromium' | 'webkit' | 'firefox' | 'chromium-msedge' | 'chromium-chrome'; readonly quality: Quality; } @@ -39,18 +38,28 @@ interface ICodeInstance { const instances = new Set(); -function registerInstance(process: cp.ChildProcess, logger: Logger, type: string) { +function registerInstance(process: cp.ChildProcess, logger: Logger, type: 'electron' | 'server'): { safeToKill: Promise } { const instance = { kill: () => teardown(process, logger) }; instances.add(instance); - process.stdout?.on('data', data => logger.log(`[${type}] stdout: ${data}`)); - process.stderr?.on('data', error => logger.log(`[${type}] stderr: ${error}`)); + const safeToKill = new Promise(resolve => { + process.stdout?.on('data', data => { + const output = data.toString(); + if (output.indexOf('calling app.quit()') >= 0 && type === 'electron') { + setTimeout(() => resolve(), 500 /* give Electron some time to actually terminate fully */); + } + logger.log(`[${type}] stdout: ${output}`); + }); + process.stderr?.on('data', error => logger.log(`[${type}] stderr: ${error}`)); + }); process.once('exit', (code, signal) => { logger.log(`[${type}] Process terminated (pid: ${process.pid}, code: ${code}, signal: ${signal})`); instances.delete(instance); }); + + return { safeToKill }; } async function teardownAll(signal?: number) { @@ -80,15 +89,15 @@ export async function launch(options: LaunchOptions): Promise { const { serverProcess, driver } = await measureAndLog(() => launchPlaywrightBrowser(options), 'launch playwright (browser)', options.logger); registerInstance(serverProcess, options.logger, 'server'); - return new Code(driver, options.logger, serverProcess, options.quality); + return new Code(driver, options.logger, serverProcess, undefined, options.quality); } // Electron smoke tests (playwright) else { const { electronProcess, driver } = await measureAndLog(() => launchPlaywrightElectron(options), 'launch playwright (electron)', options.logger); - registerInstance(electronProcess, options.logger, 'electron'); + const { safeToKill } = registerInstance(electronProcess, options.logger, 'electron'); - return new Code(driver, options.logger, electronProcess, options.quality); + return new Code(driver, options.logger, electronProcess, safeToKill, options.quality); } } @@ -100,6 +109,7 @@ export class Code { driver: PlaywrightDriver, readonly logger: Logger, private readonly mainProcess: cp.ChildProcess, + private readonly safeToKill: Promise | undefined, readonly quality: Quality ) { this.driver = new Proxy(driver, { @@ -144,7 +154,13 @@ export class Code { let done = false; // Start the exit flow via driver - this.driver.exitApplication(); + this.driver.close(); + + let safeToKill = false; + this.safeToKill?.then(() => { + this.logger.log('Smoke test exit(): safeToKill() called'); + safeToKill = true; + }); // Await the exit of the application (async () => { @@ -152,38 +168,27 @@ export class Code { while (!done) { retries++; + if (safeToKill) { + this.logger.log('Smoke test exit(): call did not terminate the process yet, but safeToKill is true, so we can kill it'); + this.kill(pid); + } + switch (retries) { - // after 5 / 10 seconds: try to exit gracefully again - case 10: + // after 10 seconds: forcefully kill case 20: { - this.logger.log('Smoke test exit call did not terminate process after 5-10s, gracefully trying to exit the application again...'); - this.driver.exitApplication(); + this.logger.log('Smoke test exit(): call did not terminate process after 10s, forcefully exiting the application...'); + this.kill(pid); break; } - // after 20 seconds: forcefully kill + // after 20 seconds: give up case 40: { - this.logger.log('Smoke test exit call did not terminate process after 20s, forcefully exiting the application...'); - - // no need to await since we're polling for the process to die anyways - treekill(pid, err => { - try { - process.kill(pid, 0); // throws an exception if the process doesn't exist anymore - this.logger.log('Failed to kill Electron process tree:', err?.message); - } catch (error) { - // Expected when process is gone - } - }); - - break; - } - - // after 30 seconds: give up - case 60: { + this.logger.log('Smoke test exit(): call did not terminate process after 20s, giving up'); + this.kill(pid); done = true; - this.logger.log('Smoke test exit call did not terminate process after 30s, giving up'); resolve(); + break; } } @@ -191,6 +196,8 @@ export class Code { process.kill(pid, 0); // throws an exception if the process doesn't exist anymore. await this.wait(500); } catch (error) { + this.logger.log('Smoke test exit(): call terminated process successfully'); + done = true; resolve(); } @@ -199,6 +206,22 @@ export class Code { }), 'Code#exit()', this.logger); } + private kill(pid: number): void { + try { + process.kill(pid, 0); // throws an exception if the process doesn't exist anymore. + } catch (e) { + this.logger.log('Smoke test kill(): returning early because process does not exist anymore'); + return; + } + + try { + this.logger.log(`Smoke test kill(): Trying to SIGTERM process: ${pid}`); + process.kill(pid); + } catch (e) { + this.logger.log('Smoke test kill(): SIGTERM failed', e); + } + } + async getElement(selector: string): Promise { return (await this.driver.getElements(selector))?.[0]; } diff --git a/test/automation/src/electron.ts b/test/automation/src/electron.ts index 8a9a73974f6..7d162daf1d2 100644 --- a/test/automation/src/electron.ts +++ b/test/automation/src/electron.ts @@ -41,25 +41,6 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom args.push('--verbose'); } - if (process.platform === 'linux') { - // --disable-dev-shm-usage: when run on docker containers where size of /dev/shm - // partition < 64MB which causes OOM failure for chromium compositor that uses - // this partition for shared memory. - // Refs https://github.com/microsoft/vscode/issues/152143 - args.push('--disable-dev-shm-usage'); - // Refs https://github.com/microsoft/vscode/issues/192206 - args.push('--disable-gpu'); - } - - if (process.platform === 'darwin') { - // On macOS force software based rendering since we are seeing GPU process - // hangs when initializing GL context. This is very likely possible - // that there are new displays available in the CI hardware and - // the relevant drivers couldn't be loaded via the GPU sandbox. - // TODO(deepak1556): remove this switch with Electron update. - args.push('--use-gl=swiftshader'); - } - if (remote) { // Replace workspace path with URI args[0] = `--${workspacePath.endsWith('.code-workspace') ? 'file' : 'folder'}-uri=vscode-remote://test+test/${URI.file(workspacePath).path}`; diff --git a/test/automation/src/extensions.ts b/test/automation/src/extensions.ts index 3713faa6700..06bd324465f 100644 --- a/test/automation/src/extensions.ts +++ b/test/automation/src/extensions.ts @@ -59,7 +59,7 @@ export class Extensions extends Viewlet { await this.code.waitAndClick(`div.extensions-viewlet[id="workbench.view.extensions"] .monaco-list-row[data-extension-id="${id}"] .extension-list-item .monaco-action-bar .action-item:not(.disabled) .extension-action.install`); try { - await this.code.waitForElement(`.extension-editor .monaco-action-bar .action-item:not(.disabled) .extension-action.uninstall`); + await this.waitForExtensionToBeInstalled(); break; } catch (err) { if (attempt++ === 3) { @@ -72,6 +72,25 @@ export class Extensions extends Viewlet { await this.code.waitForElement(`.extension-editor .monaco-action-bar .action-item:not(.disabled) a[aria-label="Disable this extension"]`); } } + + private async waitForExtensionToBeInstalled(): Promise { + let attempt = 1; + while (true) { + try { + await this.code.waitForElement(`.extension-editor .monaco-action-bar .action-item:not(.disabled) .extension-action.uninstall`, undefined); + break; + } catch (err) { + if (await this.code.getElement(`.extension-editor .monaco-action-bar .action-item .extension-action.install.installing`)) { + if (attempt++ === 3) { + throw err; + } + this.code.logger.log('Extension is still being installed. Waiting...'); + } else { + throw err; + } + } + } + } } export async function copyExtension(repoPath: string, extensionsPath: string, extId: string): Promise { diff --git a/test/automation/src/playwrightBrowser.ts b/test/automation/src/playwrightBrowser.ts index 3c52d261051..f4f63875b2a 100644 --- a/test/automation/src/playwrightBrowser.ts +++ b/test/automation/src/playwrightBrowser.ts @@ -90,9 +90,11 @@ async function launchServer(options: LaunchOptions) { async function launchBrowser(options: LaunchOptions, endpoint: string) { const { logger, workspacePath, tracing, snapshots, headless } = options; - const browser = await measureAndLog(() => playwright[options.browser ?? 'chromium'].launch({ + const [browserType, browserChannel] = (options.browser ?? 'chromium').split('-'); + const browser = await measureAndLog(() => playwright[browserType as unknown as 'chromium' | 'webkit' | 'firefox'].launch({ headless: headless ?? false, - timeout: 0 + timeout: 0, + channel: browserChannel, }), 'playwright#launch', logger); browser.on('disconnected', () => logger.log(`Playwright: browser disconnected`)); diff --git a/test/automation/src/playwrightDriver.ts b/test/automation/src/playwrightDriver.ts index 018fe7c351a..a6fd72754f9 100644 --- a/test/automation/src/playwrightDriver.ts +++ b/test/automation/src/playwrightDriver.ts @@ -174,7 +174,7 @@ export class PlaywrightDriver { await this.page.reload(); } - async exitApplication() { + async close() { // Stop tracing try { @@ -194,22 +194,11 @@ export class PlaywrightDriver { } } - // Web: exit via `close` method - if (this.options.web) { - try { - await measureAndLog(() => this.application.close(), 'playwright.close()', this.options.logger); - } catch (error) { - this.options.logger.log(`Error closing appliction (${error})`); - } - } - - // Desktop: exit via `driver.exitApplication` - else { - try { - await measureAndLog(() => this.evaluateWithDriver(([driver]) => driver.exitApplication()), 'driver.exitApplication()', this.options.logger); - } catch (error) { - this.options.logger.log(`Error exiting appliction (${error})`); - } + // exit via `close` method + try { + await measureAndLog(() => this.application.close(), 'playwright.close()', this.options.logger); + } catch (error) { + this.options.logger.log(`Error closing application (${error})`); } // Server: via `teardown` @@ -329,6 +318,15 @@ export class PlaywrightDriver { private async getDriverHandle(): Promise> { return this.page.evaluateHandle('window.driver'); } + + async isAlive(): Promise { + try { + await this.getDriverHandle(); + return true; + } catch (error) { + return false; + } + } } export function wait(ms: number): Promise { diff --git a/test/integration/browser/src/index.ts b/test/integration/browser/src/index.ts index 2613f10da62..0c3cd8efd32 100644 --- a/test/integration/browser/src/index.ts +++ b/test/integration/browser/src/index.ts @@ -49,7 +49,7 @@ if (args.help) { --workspacePath Path to the workspace (folder or *.code-workspace file) to open in the test --extensionDevelopmentPath Path to the extension to test --extensionTestsPath Path to the extension tests - --browser Browser in which integration tests should run + --browser Browser in which integration tests should run. separate the channel with a dash, e.g. 'chromium-msedge' or 'chromium-chrome' --debug Do not run browsers headless --help Print this help message `); @@ -61,9 +61,10 @@ const width = 1200; const height = 800; type BrowserType = 'chromium' | 'firefox' | 'webkit'; +type BrowserChannel = 'msedge' | 'chrome'; -async function runTestsInBrowser(browserType: BrowserType, endpoint: url.UrlWithStringQuery, server: cp.ChildProcess): Promise { - const browser = await playwright[browserType].launch({ headless: !Boolean(args.debug) }); +async function runTestsInBrowser(browserType: BrowserType, browserChannel: BrowserChannel, endpoint: url.UrlWithStringQuery, server: cp.ChildProcess): Promise { + const browser = await playwright[browserType].launch({ headless: !Boolean(args.debug), channel: browserChannel }); const context = await browser.newContext(); const page = await context.newPage(); @@ -154,7 +155,7 @@ function consoleLogFn(msg: playwright.ConsoleMessage) { return console.log; } -async function launchServer(browserType: BrowserType): Promise<{ endpoint: url.UrlWithStringQuery; server: cp.ChildProcess }> { +async function launchServer(browserType: BrowserType, browserChannel: BrowserChannel): Promise<{ endpoint: url.UrlWithStringQuery; server: cp.ChildProcess }> { // Ensure a tmp user-data-dir is used for the tests const tmpDir = tmp.dirSync({ prefix: 't' }); @@ -164,7 +165,7 @@ async function launchServer(browserType: BrowserType): Promise<{ endpoint: url.U const userDataDir = path.join(testDataPath, 'd'); const env = { - VSCODE_BROWSER: browserType, + VSCODE_BROWSER: browserChannel ? `${browserType}-${browserChannel}` : browserType, ...process.env }; @@ -224,8 +225,9 @@ async function launchServer(browserType: BrowserType): Promise<{ endpoint: url.U }); } -launchServer(args.browser).then(async ({ endpoint, server }) => { - return runTestsInBrowser(args.browser, endpoint, server); +const [browserType, browserChannel] = args.browser.split('-'); +launchServer(browserType, browserChannel).then(async ({ endpoint, server }) => { + return runTestsInBrowser(browserType, browserChannel, endpoint, server); }, error => { console.error(error); process.exit(1); diff --git a/test/monaco/core.js b/test/monaco/core.js index 37cc80c487e..8880c68146d 100644 --- a/test/monaco/core.js +++ b/test/monaco/core.js @@ -7,7 +7,7 @@ import * as monaco from 'monaco-editor-core'; self.MonacoEnvironment = { getWorkerUrl: function (moduleId, label) { - return './editor.worker.bundle.js'; + return './editorWebWorkerMain.bundle.js'; } }; diff --git a/test/monaco/webpack.config.js b/test/monaco/webpack.config.js index 9bac9fd117f..a301f64d479 100644 --- a/test/monaco/webpack.config.js +++ b/test/monaco/webpack.config.js @@ -10,7 +10,7 @@ module.exports = { mode: 'production', entry: { 'core': './core.js', - 'editor.worker': '../../out-monaco-editor-core/esm/vs/editor/editor.worker.js', + 'editorWebWorkerMain': '../../out-monaco-editor-core/esm/vs/editor/common/services/editorWebWorkerMain.js', }, output: { globalObject: 'self', @@ -39,7 +39,6 @@ module.exports = { }, resolve: { alias: { - 'monaco-editor-core/esm/vs/editor/editor.worker': path.resolve(__dirname, '../../out-monaco-editor-core/esm/vs/editor/editor.worker.js'), 'monaco-editor-core': path.resolve(__dirname, '../../out-monaco-editor-core/esm/vs/editor/editor.main.js'), } }, diff --git a/test/smoke/src/areas/extensions/extensions.test.ts b/test/smoke/src/areas/extensions/extensions.test.ts index c20700cbc91..875c61d6a4b 100644 --- a/test/smoke/src/areas/extensions/extensions.test.ts +++ b/test/smoke/src/areas/extensions/extensions.test.ts @@ -11,6 +11,7 @@ export function setup(logger: Logger) { // Shared before/after handling installAllHandlers(logger, opts => { + opts.verbose = true; // enable verbose logging for tracing opts.snapshots = true; // enable network tab in devtools for tracing since we install an extension return opts; }); diff --git a/test/smoke/src/areas/workbench/localization.test.ts b/test/smoke/src/areas/workbench/localization.test.ts index c3ce9b04fc2..74d3ea3acf9 100644 --- a/test/smoke/src/areas/workbench/localization.test.ts +++ b/test/smoke/src/areas/workbench/localization.test.ts @@ -12,6 +12,7 @@ export function setup(logger: Logger) { // Shared before/after handling installAllHandlers(logger, opts => { + opts.verbose = true; // enable verbose logging for tracing opts.snapshots = true; // enable network tab in devtools for tracing since we install an extension return opts; }); diff --git a/test/unit/browser/index.js b/test/unit/browser/index.js index f19c21e46c4..e5cf68ccab5 100644 --- a/test/unit/browser/index.js +++ b/test/unit/browser/index.js @@ -76,7 +76,7 @@ Options: --grep, -g, -f only run tests matching --debug, --debug-browser do not run browsers headless --sequential only run suites for a single browser at a time ---browser browsers in which tests should run +--browser browsers in which tests should run. separate the channel with a dash, e.g. 'chromium-msedge' or 'chromium-chrome' --reporter the mocha reporter --reporter-options the mocha reporter options --tfs tfs @@ -151,7 +151,7 @@ const testModules = (async function () { modules.push(file.replace(/\.js$/, '')); } else if (!isDefaultModules) { - console.warn(`DROPPONG ${file} because it cannot be run inside a browser`); + console.warn(`DROPPING ${file} because it cannot be run inside a browser`); } } return modules; @@ -239,9 +239,9 @@ async function createServer() { }); } -async function runTestsInBrowser(testModules, browserType) { +async function runTestsInBrowser(testModules, browserType, browserChannel) { const server = await createServer(); - const browser = await playwright[browserType].launch({ headless: !Boolean(args.debug), devtools: Boolean(args.debug) }); + const browser = await playwright[browserType].launch({ headless: !Boolean(args.debug), devtools: Boolean(args.debug), channel: browserChannel }); const context = await browser.newContext(); const page = await context.newPage(); const target = new URL(server.url + '/test/unit/browser/renderer.html'); @@ -281,7 +281,7 @@ async function runTestsInBrowser(testModules, browserType) { consoleLogFn(msg)(msg.text(), await Promise.all(msg.args().map(async arg => await arg.jsonValue()))); }); - withReporter(browserType, new EchoRunner(emitter, browserType.toUpperCase())); + withReporter(browserType, new EchoRunner(emitter, browserChannel ? `${browserType.toUpperCase()}-${browserChannel.toUpperCase()}` : browserType.toUpperCase())); // collection failures for console printing const failingModuleIds = []; @@ -382,7 +382,7 @@ class EchoRunner extends events.EventEmitter { testModules.then(async modules => { // run tests in selected browsers - const browserTypes = Array.isArray(args.browser) + const browsers = Array.isArray(args.browser) ? args.browser : [args.browser]; let messages = []; @@ -390,12 +390,14 @@ testModules.then(async modules => { try { if (args.sequential) { - for (const browserType of browserTypes) { - messages.push(await runTestsInBrowser(modules, browserType)); + for (const browser of browsers) { + const [browserType, browserChannel] = browser.split('-'); + messages.push(await runTestsInBrowser(modules, browserType, browserChannel)); } } else { - messages = await Promise.all(browserTypes.map(async browserType => { - return await runTestsInBrowser(modules, browserType); + messages = await Promise.all(browsers.map(async browser => { + const [browserType, browserChannel] = browser.split('-'); + return await runTestsInBrowser(modules, browserType, browserChannel); })); } } catch (err) { diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index aa4f05984a2..661be873561 100644 --- a/test/unit/electron/renderer.js +++ b/test/unit/electron/renderer.js @@ -372,7 +372,7 @@ function safeStringify(obj) { function isObject(obj) { // The method can't do a type cast since there are type (like strings) which - // are subclasses of any put not positvely matched by the function. Hence type + // are subclasses of any put not positively matched by the function. Hence type // narrowing results in wrong results. return typeof obj === 'object' && obj !== null