diff --git a/.config/guardian/.gdnbaselines b/.config/guardian/.gdnbaselines index 063d926b6ba..e8f9f8db2f7 100644 --- a/.config/guardian/.gdnbaselines +++ b/.config/guardian/.gdnbaselines @@ -296,21 +296,6 @@ "expirationDate": "2025-11-19 21:48:17Z", "justification": "This error is baselined with an expiration date of 180 days from 2025-06-02 21:48:17Z" }, - "216e2ac9cb596796224b47799f656570a01fa0d9b5f935608b47d15ab613c8e8": { - "signature": "216e2ac9cb596796224b47799f656570a01fa0d9b5f935608b47d15ab613c8e8", - "alternativeSignatures": [ - "07746898f43afab7cc50931b33154c2d9e1a35f82a649dbe8aecf785b3d5a813" - ], - "target": "file:///D:/a/_work/1/vscode-server-win32-x64/node_modules/@vscode/vsce-sign/bin/vsce-sign.exe", - "memberOf": [ - "default" - ], - "tool": "binskim", - "ruleId": "BA2008", - "createdDate": "2025-06-02 21:46:49Z", - "expirationDate": "2025-11-19 21:48:17Z", - "justification": "This error is baselined with an expiration date of 180 days from 2025-06-02 21:48:17Z" - }, "1d4a48ebc63e3b652146bc16309b2d960a7168d299c7ac94cf794347c06265ef": { "signature": "1d4a48ebc63e3b652146bc16309b2d960a7168d299c7ac94cf794347c06265ef", "alternativeSignatures": [ @@ -326,21 +311,6 @@ "expirationDate": "2025-11-19 21:48:17Z", "justification": "This error is baselined with an expiration date of 180 days from 2025-06-02 21:48:17Z" }, - "77797a3e44634bb2994bd13ccc95ff4575bba474585dbd2cf3068a1c16bc0624": { - "signature": "77797a3e44634bb2994bd13ccc95ff4575bba474585dbd2cf3068a1c16bc0624", - "alternativeSignatures": [ - "4a6cb67bd4b401e9669c13a2162660aaefc0a94a4122e5b50c198414db545672" - ], - "target": "file:///D:/a/_work/1/vscode-server-win32-x64-web/node_modules/@vscode/vsce-sign/bin/vsce-sign.exe", - "memberOf": [ - "default" - ], - "tool": "binskim", - "ruleId": "BA2008", - "createdDate": "2025-06-02 21:46:49Z", - "expirationDate": "2025-11-19 21:48:17Z", - "justification": "This error is baselined with an expiration date of 180 days from 2025-06-02 21:48:17Z" - }, "21b8091cf937b1be55c7a300483182fec206bc0cd8e2666727b29c8c200aa101": { "signature": "21b8091cf937b1be55c7a300483182fec206bc0cd8e2666727b29c8c200aa101", "alternativeSignatures": [ @@ -416,21 +386,6 @@ "expirationDate": "2025-11-19 21:48:17Z", "justification": "This error is baselined with an expiration date of 180 days from 2025-06-02 21:48:17Z" }, - "30418bcc5269eaeb2832a2404465784431d4e72a2af332320c2b1db4768902ad": { - "signature": "30418bcc5269eaeb2832a2404465784431d4e72a2af332320c2b1db4768902ad", - "alternativeSignatures": [ - "b7b9eb974d7d3a4ae14df8695ca5a62592c8c9d20b7eda70a6535d50cbda3e7f" - ], - "target": "file:///D:/a/_work/1/VSCode-win32-x64/resources/app/node_modules/@vscode/vsce-sign/bin/vsce-sign.exe", - "memberOf": [ - "default" - ], - "tool": "binskim", - "ruleId": "BA2008", - "createdDate": "2025-06-02 21:46:49Z", - "expirationDate": "2025-11-19 21:48:17Z", - "justification": "This error is baselined with an expiration date of 180 days from 2025-06-02 21:48:17Z" - }, "d23a7cc83e649f9a9c5831255cb7569d363799adb5490ff7e299685ea7cf5000": { "signature": "d23a7cc83e649f9a9c5831255cb7569d363799adb5490ff7e299685ea7cf5000", "alternativeSignatures": [ @@ -462,4 +417,4 @@ "justification": "This error is baselined with an expiration date of 180 days from 2025-06-02 21:48:17Z" } } -} \ No newline at end of file +} diff --git a/.eslint-plugin-local/index.js b/.eslint-plugin-local/index.js index 198cb8362dc..3646c8c4157 100644 --- a/.eslint-plugin-local/index.js +++ b/.eslint-plugin-local/index.js @@ -2,12 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +// @ts-check const glob = require('glob'); const path = require('path'); require('ts-node').register({ experimentalResolver: true, transpileOnly: true }); // Re-export all .ts files as rules +/** @type {Record} */ const rules = {}; glob.sync(`${__dirname}/*.ts`).forEach((file) => { rules[path.basename(file, '.ts')] = require(file); diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8da51487c84..b74accb8908 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,11 @@ +# Ensure that Lad and Joao review changes to the Code OSS actions +.github/workflows/pr-darwin-test.yml @lszomoru @joaomoreno +.github/workflows/pr-linux-cli-test.yml @lszomoru @joaomoreno +.github/workflows/pr-linux-test.yml @lszomoru @joaomoreno +.github/workflows/pr-node-modules.yml @lszomoru @joaomoreno +.github/workflows/pr-win32-test.yml @lszomoru @joaomoreno +.github/workflows/pr.yml @lszomoru @joaomoreno + # ensure the API police is aware of changes to the vscode-dts file # this is only about the final API, not about proposed API changes src/vscode-dts/vscode.d.ts @jrieken @mjbvz diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ce9e17c1c96..ad1f2b23812 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,7 +1,12 @@ --- name: Bug report about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + --- + @@ -19,4 +24,4 @@ Does this issue occur when all extensions are disabled?: Yes/No Steps to Reproduce: 1. -2. +2. diff --git a/.github/ISSUE_TEMPLATE/copilot_bug_report.md b/.github/ISSUE_TEMPLATE/copilot_bug_report.md new file mode 100644 index 00000000000..9a77481a8c6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/copilot_bug_report.md @@ -0,0 +1,23 @@ +--- +name: Copilot Bug report +about: Create a report to help us improve Copilot's chat interface in VS Code +title: '' +labels: chat-ext-issue +assignees: '' + +--- + + + + +- Copilot Chat Extension Version: +- VS Code Version: +- OS Version: +- Feature (e.g. agent/edit/ask mode): +- Selected model (e.g. GPT 4.1, Claude 3.7 Sonnet): +- Logs: + +Steps to Reproduce: + +1. +2. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index b9c6c83caa3..4e2639b9c6f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,9 @@ --- name: Feature request about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' --- diff --git a/.github/commands.json b/.github/commands.json index d948a1bf493..2d10e053f86 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -596,7 +596,7 @@ "reason": "not_planned", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253126. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, - { + { "type": "label", "name": "~chat-billing", "removeLabel": "~chat-billing", @@ -604,5 +604,14 @@ "action": "close", "reason": "not_planned", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/252230. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." + }, + { + "type": "label", + "name": "~chat-infinite-response-loop", + "removeLabel": "~chat-infinite-response-loop", + "addLabel":"chat-infinite-response-loop", + "action": "close", + "reason": "not_planned", + "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253134. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." } ] diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml new file mode 100644 index 00000000000..e97f3bbb03a --- /dev/null +++ b/.github/workflows/pr-darwin-test.yml @@ -0,0 +1,242 @@ +on: + workflow_call: + inputs: + job_name: + type: string + required: true + electron_tests: + type: boolean + default: false + browser_tests: + type: boolean + default: false + remote_tests: + type: boolean + default: false + +jobs: + macOS-test: + name: ${{ inputs.job_name }} + runs-on: macos-14-xlarge + env: + ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} + NPM_ARCH: arm64 + VSCODE_ARCH: arm64 + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + env: + NODEJS_ORG_MIRROR: https://github.com/joaomoreno/node-mirror/releases/download + + - name: Prepare node_modules cache key + run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache/restore@v4 + with: + path: .build/node_modules_cache + key: "node_modules-macos-${{ hashFiles('.build/packagelockhash') }}" + + - name: Extract node_modules cache + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: tar -xzf .build/node_modules_cache/cache.tgz + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + set -e + c++ --version + xcode-select -print-path + python3 -m pip install --break-system-packages 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: ${{ env.NPM_ARCH }} + VSCODE_ARCH: ${{ env.VSCODE_ARCH }} + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + # 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" + + - name: Create node_modules archive + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + 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 + + - name: Create .build folder + run: mkdir -p .build + + - name: Prepare built-in extensions cache key + run: node build/azure-pipelines/common/computeBuiltInDepsCacheKey.js > .build/builtindepshash + + - name: Restore built-in extensions cache + id: cache-builtin-extensions + uses: actions/cache/restore@v4 + with: + path: .build/builtInExtensions + key: "builtin-extensions-${{ hashFiles('.build/builtindepshash') }}" + + - name: Download built-in extensions + if: steps.cache-builtin-extensions.outputs.cache-hit != 'true' + run: node build/lib/builtInExtensions.js + env: + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: Transpile client and extensions + run: npm run gulp transpile-client-esbuild transpile-extensions + + - name: Download Electron and Playwright + run: | + set -e + + for i in {1..3}; do # try 3 times (matching retryCountOnTaskFailure: 3) + if npm exec -- npm-run-all -lp "electron ${{ env.VSCODE_ARCH }}" "playwright-install"; then + echo "Download successful on attempt $i" + break + fi + + if [ $i -eq 3 ]; then + echo "Download failed after 3 attempts" >&2 + exit 1 + fi + + echo "Download failed on attempt $i, retrying..." + sleep 5 # optional: add a small delay between retries + done + env: + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: 🧪 Run unit tests (Electron) + if: ${{ inputs.electron_tests }} + timeout-minutes: 15 + run: ./scripts/test.sh --tfs "Unit Tests" + + - name: 🧪 Run unit tests (node.js) + if: ${{ inputs.electron_tests }} + timeout-minutes: 15 + run: npm run test-node + + - name: 🧪 Run unit tests (Browser, Webkit) + if: ${{ inputs.browser_tests }} + timeout-minutes: 30 + run: npm run test-browser-no-install -- --browser webkit --tfs "Browser Unit Tests" + env: + DEBUG: "*browser*" + + - name: Build integration tests + run: | + 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 + + - name: 🧪 Run integration tests (Electron) + if: ${{ inputs.electron_tests }} + timeout-minutes: 20 + run: ./scripts/test-integration.sh --tfs "Integration Tests" + + - name: 🧪 Run integration tests (Browser, Webkit) + if: ${{ inputs.browser_tests }} + timeout-minutes: 20 + run: ./scripts/test-web-integration.sh --browser webkit + + - name: 🧪 Run integration tests (Remote) + if: ${{ inputs.remote_tests }} + timeout-minutes: 20 + run: ./scripts/test-remote-integration.sh + + - name: Compile smoke tests + working-directory: test/smoke + run: npm run compile + + - name: Compile extensions for smoke tests + run: npm run gulp compile-extension-media + + - name: Diagnostics before smoke test run + run: ps -ef + continue-on-error: true + if: always() + + - name: 🧪 Run smoke tests (Electron) + if: ${{ inputs.electron_tests }} + timeout-minutes: 20 + run: npm run smoketest-no-compile -- --tracing + + - name: 🧪 Run smoke tests (Browser, Chromium) + if: ${{ inputs.browser_tests }} + timeout-minutes: 20 + run: npm run smoketest-no-compile -- --web --tracing --headless + + - name: 🧪 Run smoke tests (Remote) + if: ${{ inputs.remote_tests }} + timeout-minutes: 20 + run: npm run smoketest-no-compile -- --remote --tracing + + - name: Diagnostics after smoke test run + run: ps -ef + continue-on-error: true + if: always() + + - name: Publish Crash Reports + uses: actions/upload-artifact@v4 + if: failure() + continue-on-error: true + with: + name: ${{ format('crash-dump-macos-{0}-{1}-{2}', env.VSCODE_ARCH, env.ARTIFACT_NAME, github.run_attempt) }} + path: .build/crashes + if-no-files-found: ignore + + # In order to properly symbolify above crash reports + # (if any), we need the compiled native modules too + - name: Publish Node Modules + uses: actions/upload-artifact@v4 + if: failure() + continue-on-error: true + with: + name: ${{ format('node-modules-macos-{0}-{1}-{2}', env.VSCODE_ARCH, env.ARTIFACT_NAME, github.run_attempt) }} + path: node_modules + if-no-files-found: ignore + + - name: Publish Log Files + uses: actions/upload-artifact@v4 + if: always() + continue-on-error: true + with: + name: ${{ format('logs-macos-{0}-{1}-{2}', env.VSCODE_ARCH, env.ARTIFACT_NAME, github.run_attempt) }} + path: .build/logs + if-no-files-found: ignore diff --git a/.github/workflows/pr-linux-cli-test.yml b/.github/workflows/pr-linux-cli-test.yml new file mode 100644 index 00000000000..7466c639cae --- /dev/null +++ b/.github/workflows/pr-linux-cli-test.yml @@ -0,0 +1,46 @@ +on: + workflow_call: + inputs: + job_name: + type: string + required: true + rustup_toolchain: + type: string + required: true + +jobs: + linux-cli-test: + name: ${{ inputs.job_name }} + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + env: + RUSTUP_TOOLCHAIN: ${{ inputs.rustup_toolchain }} + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v4 + + - name: Install Rust + run: | + set -e + curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain $RUSTUP_TOOLCHAIN + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Set Rust version + run: | + set -e + rustup default $RUSTUP_TOOLCHAIN + rustup update $RUSTUP_TOOLCHAIN + rustup component add clippy + + - name: Check Rust versions + run: | + set -e + rustc --version + cargo --version + + - name: Clippy lint + run: cargo clippy -- -D warnings + working-directory: cli + + - name: 🧪 Run unit tests + run: cargo test + working-directory: cli diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml new file mode 100644 index 00000000000..b9c24a317d4 --- /dev/null +++ b/.github/workflows/pr-linux-test.yml @@ -0,0 +1,288 @@ +on: + workflow_call: + inputs: + job_name: + type: string + required: true + electron_tests: + type: boolean + default: false + browser_tests: + type: boolean + default: false + remote_tests: + type: boolean + default: false + +jobs: + linux-test: + name: ${{ inputs.job_name }} + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + env: + ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} + NPM_ARCH: x64 + VSCODE_ARCH: x64 + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + env: + NODEJS_ORG_MIRROR: https://github.com/joaomoreno/node-mirror/releases/download + + - name: Setup system services + run: | + set -e + # 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 \ + xvfb \ + libgtk-3-0 \ + libxkbfile-dev \ + libkrb5-dev \ + libgbm1 \ + rpm + 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 + sudo service xvfb start + + - name: Prepare node_modules cache key + run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache/restore@v4 + with: + path: .build/node_modules_cache + key: "node_modules-linux-${{ hashFiles('.build/packagelockhash') }}" + + - name: Extract node_modules cache + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: tar -xzf .build/node_modules_cache/cache.tgz + + - name: Install build dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + working-directory: build + run: | + set -e + + 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: + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + set -e + + source ./build/azure-pipelines/linux/setup-env.sh + + 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: ${{ env.NPM_ARCH }} + VSCODE_ARCH: ${{ env.VSCODE_ARCH }} + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: Create node_modules archive + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + 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 + + - name: Create .build folder + run: mkdir -p .build + + - name: Prepare built-in extensions cache key + run: node build/azure-pipelines/common/computeBuiltInDepsCacheKey.js > .build/builtindepshash + + - name: Restore built-in extensions cache + id: cache-builtin-extensions + uses: actions/cache/restore@v4 + with: + path: .build/builtInExtensions + key: "builtin-extensions-${{ hashFiles('.build/builtindepshash') }}" + + - name: Download built-in extensions + if: steps.cache-builtin-extensions.outputs.cache-hit != 'true' + run: node build/lib/builtInExtensions.js + env: + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: Transpile client and extensions + run: npm run gulp transpile-client-esbuild transpile-extensions + + - name: Download Electron and Playwright + run: | + set -e + + for i in {1..3}; do # try 3 times (matching retryCountOnTaskFailure: 3) + if npm exec -- npm-run-all -lp "electron ${{ env.VSCODE_ARCH }}" "playwright-install"; then + echo "Download successful on attempt $i" + break + fi + + if [ $i -eq 3 ]; then + echo "Download failed after 3 attempts" >&2 + exit 1 + fi + + echo "Download failed on attempt $i, retrying..." + sleep 5 # optional: add a small delay between retries + done + env: + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: 🧪 Run unit tests (Electron) + if: ${{ inputs.electron_tests }} + timeout-minutes: 15 + run: ./scripts/test.sh --tfs "Unit Tests" + env: + DISPLAY: ":10" + + - name: 🧪 Run unit tests (node.js) + if: ${{ inputs.electron_tests }} + timeout-minutes: 15 + run: npm run test-node + + - name: 🧪 Run unit tests (Browser, Chromium) + if: ${{ inputs.browser_tests }} + timeout-minutes: 30 + run: npm run test-browser-no-install -- --browser chromium --tfs "Browser Unit Tests" + env: + DEBUG: "*browser*" + + - name: Build integration tests + run: | + 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 + + - name: 🧪 Run integration tests (Electron) + if: ${{ inputs.electron_tests }} + timeout-minutes: 20 + run: ./scripts/test-integration.sh --tfs "Integration Tests" + env: + DISPLAY: ":10" + + - name: 🧪 Run integration tests (Browser, Chromium) + if: ${{ inputs.browser_tests }} + timeout-minutes: 20 + run: ./scripts/test-web-integration.sh --browser chromium + + - name: 🧪 Run integration tests (Remote) + if: ${{ inputs.remote_tests }} + timeout-minutes: 20 + run: ./scripts/test-remote-integration.sh + env: + DISPLAY: ":10" + + - name: Compile smoke tests + working-directory: test/smoke + run: npm run compile + + - name: Compile extensions for smoke tests + run: npm run gulp compile-extension-media + + - name: Diagnostics before smoke test run (processes, max_user_watches, number of opened file handles) + run: | + set -e + ps -ef + cat /proc/sys/fs/inotify/max_user_watches + lsof | wc -l + continue-on-error: true + if: always() + + - name: 🧪 Run smoke tests (Electron) + if: ${{ inputs.electron_tests }} + timeout-minutes: 20 + run: npm run smoketest-no-compile -- --tracing + env: + DISPLAY: ":10" + + - name: 🧪 Run smoke tests (Browser, Chromium) + if: ${{ inputs.browser_tests }} + timeout-minutes: 20 + run: npm run smoketest-no-compile -- --web --tracing --headless + + - name: 🧪 Run smoke tests (Remote) + if: ${{ inputs.remote_tests }} + timeout-minutes: 20 + run: npm run smoketest-no-compile -- --remote --tracing + env: + DISPLAY: ":10" + + - name: Diagnostics after smoke test run (processes, max_user_watches, number of opened file handles) + run: | + set -e + ps -ef + cat /proc/sys/fs/inotify/max_user_watches + lsof | wc -l + continue-on-error: true + if: always() + + - name: Publish Crash Reports + uses: actions/upload-artifact@v4 + if: failure() + continue-on-error: true + with: + name: ${{ format('crash-dump-linux-{0}-{1}-{2}', env.VSCODE_ARCH, env.ARTIFACT_NAME, github.run_attempt) }} + path: .build/crashes + if-no-files-found: ignore + + # In order to properly symbolify above crash reports + # (if any), we need the compiled native modules too + - name: Publish Node Modules + uses: actions/upload-artifact@v4 + if: failure() + continue-on-error: true + with: + name: ${{ format('node-modules-linux-{0}-{1}-{2}', env.VSCODE_ARCH, env.ARTIFACT_NAME, github.run_attempt) }} + path: node_modules + if-no-files-found: ignore + + - name: Publish Log Files + uses: actions/upload-artifact@v4 + if: always() + continue-on-error: true + with: + name: ${{ format('logs-linux-{0}-{1}-{2}', env.VSCODE_ARCH, env.ARTIFACT_NAME, github.run_attempt) }} + path: .build/logs + if-no-files-found: ignore diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml new file mode 100644 index 00000000000..fdc8a0901ec --- /dev/null +++ b/.github/workflows/pr-node-modules.yml @@ -0,0 +1,288 @@ +name: Code OSS (node_modules) + +on: + push: + branches: + - main + +permissions: {} + +jobs: + compile: + name: Compile + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + env: + NODEJS_ORG_MIRROR: https://github.com/joaomoreno/node-mirror/releases/download + + - name: Prepare node_modules cache key + run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js compile $(node -p process.arch) > .build/packagelockhash + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: .build/node_modules_cache + key: "node_modules-compile-${{ hashFiles('.build/packagelockhash') }}" + + - name: Install build tools + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: sudo apt update -y && sudo apt install -y build-essential pkg-config libx11-dev libx11-xcb-dev libxkbfile-dev libnotify-bin libkrb5-dev + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + set -e + + 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: + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + + - name: Create node_modules archive + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + 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 + + - name: Prepare built-in extensions cache key + run: | + set -e + mkdir -p .build + node build/azure-pipelines/common/computeBuiltInDepsCacheKey.js > .build/builtindepshash + + - name: Restore built-in extensions cache + id: cache-builtin-extensions + uses: actions/cache@v4 + with: + path: .build/builtInExtensions + key: "builtin-extensions-${{ hashFiles('.build/builtindepshash') }}" + + - name: Download built-in extensions + if: steps.cache-builtin-extensions.outputs.cache-hit != 'true' + run: node build/lib/builtInExtensions.js + env: + GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + + linux: + name: Linux + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + env: + NPM_ARCH: x64 + VSCODE_ARCH: x64 + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + env: + NODEJS_ORG_MIRROR: https://github.com/joaomoreno/node-mirror/releases/download + + - name: Prepare node_modules cache key + run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: .build/node_modules_cache + key: "node_modules-linux-${{ hashFiles('.build/packagelockhash') }}" + + - name: Install build dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + working-directory: build + run: | + set -e + + 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: + GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + set -e + + source ./build/azure-pipelines/linux/setup-env.sh + + 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: ${{ env.NPM_ARCH }} + VSCODE_ARCH: ${{ env.VSCODE_ARCH }} + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + + - name: Create node_modules archive + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + 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 + + macOS: + name: macOS + runs-on: macos-14-xlarge + env: + NPM_ARCH: arm64 + VSCODE_ARCH: arm64 + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + env: + NODEJS_ORG_MIRROR: https://github.com/joaomoreno/node-mirror/releases/download + + - name: Prepare node_modules cache key + run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: .build/node_modules_cache + key: "node_modules-macos-${{ hashFiles('.build/packagelockhash') }}" + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + set -e + c++ --version + xcode-select -print-path + python3 -m pip install --break-system-packages 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: ${{ env.NPM_ARCH }} + VSCODE_ARCH: ${{ env.VSCODE_ARCH }} + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + # 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" + + - name: Create node_modules archive + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + 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 + + windows: + name: Windows + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64 ] + env: + NPM_ARCH: x64 + VSCODE_ARCH: x64 + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + env: + NODEJS_ORG_MIRROR: https://github.com/joaomoreno/node-mirror/releases/download + + - name: Prepare node_modules cache key + shell: pwsh + run: | + mkdir .build -ea 0 + node build/azure-pipelines/common/computeNodeModulesCacheKey.js win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch) > .build/packagelockhash + + - name: Restore node_modules cache + uses: actions/cache@v4 + id: node-modules-cache + with: + path: .build/node_modules_cache + key: "node_modules-windows-${{ hashFiles('.build/packagelockhash') }}" + + - name: Install dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + shell: pwsh + run: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + + for ($i = 1; $i -le 5; $i++) { + try { + exec { npm ci } + break + } + catch { + if ($i -eq 5) { + Write-Error "npm ci failed after 5 attempts" + throw + } + Write-Host "npm ci failed attempt $i, retrying..." + Start-Sleep -Seconds 2 + } + } + env: + npm_config_arch: ${{ env.NPM_ARCH }} + npm_config_foreground_scripts: "true" + VSCODE_ARCH: ${{ env.VSCODE_ARCH }} + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + + - name: Create node_modules archive + if: steps.node-modules-cache.outputs.cache-hit != 'true' + shell: pwsh + run: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + exec { node build/azure-pipelines/common/listNodeModules.js .build/node_modules_list.txt } + exec { mkdir -Force .build/node_modules_cache } + exec { 7z.exe a .build/node_modules_cache/cache.7z -mx3 `@.build/node_modules_list.txt } diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml new file mode 100644 index 00000000000..b0fed3bd32c --- /dev/null +++ b/.github/workflows/pr-win32-test.yml @@ -0,0 +1,279 @@ +on: + workflow_call: + inputs: + job_name: + type: string + required: true + electron_tests: + type: boolean + default: false + browser_tests: + type: boolean + default: false + remote_tests: + type: boolean + default: false + +jobs: + windows-test: + name: ${{ inputs.job_name }} + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64 ] + env: + ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} + NPM_ARCH: x64 + VSCODE_ARCH: x64 + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + env: + NODEJS_ORG_MIRROR: https://github.com/joaomoreno/node-mirror/releases/download + + - name: Prepare node_modules cache key + shell: pwsh + run: | + mkdir .build -ea 0 + node build/azure-pipelines/common/computeNodeModulesCacheKey.js win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch) > .build/packagelockhash + + - name: Restore node_modules cache + uses: actions/cache/restore@v4 + id: node-modules-cache + with: + path: .build/node_modules_cache + key: "node_modules-windows-${{ hashFiles('.build/packagelockhash') }}" + + - name: Extract node_modules cache + if: steps.node-modules-cache.outputs.cache-hit == 'true' + shell: pwsh + run: 7z.exe x .build/node_modules_cache/cache.7z -aoa + + - name: Install dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + shell: pwsh + run: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + + for ($i = 1; $i -le 5; $i++) { + try { + exec { npm ci } + break + } + catch { + if ($i -eq 5) { + Write-Error "npm ci failed after 5 attempts" + throw + } + Write-Host "npm ci failed attempt $i, retrying..." + Start-Sleep -Seconds 2 + } + } + env: + npm_config_arch: ${{ env.NPM_ARCH }} + npm_config_foreground_scripts: "true" + VSCODE_ARCH: ${{ env.VSCODE_ARCH }} + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: Create node_modules archive + if: steps.node-modules-cache.outputs.cache-hit != 'true' + shell: pwsh + run: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + exec { node build/azure-pipelines/common/listNodeModules.js .build/node_modules_list.txt } + exec { mkdir -Force .build/node_modules_cache } + exec { 7z.exe a .build/node_modules_cache/cache.7z -mx3 `@.build/node_modules_list.txt } + + - name: Create .build folder + shell: pwsh + run: mkdir .build -ea 0 + + - name: Prepare built-in extensions cache key + shell: pwsh + run: node build/azure-pipelines/common/computeBuiltInDepsCacheKey.js > .build/builtindepshash + + - name: Restore built-in extensions cache + id: cache-builtin-extensions + uses: actions/cache/restore@v4 + with: + path: .build/builtInExtensions + key: "builtin-extensions-${{ hashFiles('.build/builtindepshash') }}" + + - name: Download built-in extensions + if: steps.cache-builtin-extensions.outputs.cache-hit != 'true' + run: node build/lib/builtInExtensions.js + env: + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: Transpile client and extensions + shell: pwsh + run: npm run gulp "transpile-client-esbuild" "transpile-extensions" + + - name: Download Electron and Playwright + shell: pwsh + run: | + for ($i = 1; $i -le 3; $i++) { + try { + npm exec -- -- npm-run-all -lp "electron ${{ env.VSCODE_ARCH }}" "playwright-install" + break + } + catch { + if ($i -eq 3) { + Write-Error "Download failed after 3 attempts" + throw + } + Write-Host "Download failed attempt $i, retrying..." + Start-Sleep -Seconds 2 + } + } + env: + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: 🧪 Run unit tests (Electron) + if: ${{ inputs.electron_tests }} + shell: pwsh + run: .\scripts\test.bat --tfs "Unit Tests" + timeout-minutes: 15 + + - name: 🧪 Run unit tests (node.js) + if: ${{ inputs.electron_tests }} + shell: pwsh + run: npm run test-node + timeout-minutes: 15 + + - name: 🧪 Run unit tests (Browser, Chromium) + if: ${{ inputs.browser_tests }} + shell: pwsh + run: node test/unit/browser/index.js --browser chromium --tfs "Browser Unit Tests" + env: + DEBUG: "*browser*" + timeout-minutes: 20 + + - name: Build integration tests + shell: pwsh + run: | + . 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 ` + } + + - name: Diagnostics before integration test runs + shell: pwsh + run: .\build\azure-pipelines\win32\listprocesses.bat + continue-on-error: true + if: always() + + - name: 🧪 Run integration tests (Electron) + if: ${{ inputs.electron_tests }} + shell: pwsh + run: .\scripts\test-integration.bat --tfs "Integration Tests" + timeout-minutes: 20 + + - name: 🧪 Run integration tests (Browser, Chromium) + if: ${{ inputs.browser_tests }} + shell: pwsh + run: .\scripts\test-web-integration.bat --browser chromium + timeout-minutes: 20 + + - name: 🧪 Run integration tests (Remote) + if: ${{ inputs.remote_tests }} + shell: pwsh + run: .\scripts\test-remote-integration.bat + timeout-minutes: 20 + + - name: Diagnostics after integration test runs + shell: pwsh + run: .\build\azure-pipelines\win32\listprocesses.bat + continue-on-error: true + if: always() + + - name: Diagnostics before smoke test run + shell: pwsh + run: .\build\azure-pipelines\win32\listprocesses.bat + continue-on-error: true + if: always() + + - name: Compile smoke tests + working-directory: test/smoke + shell: pwsh + run: npm run compile + + - name: Compile extensions for smoke tests + shell: pwsh + run: npm run gulp compile-extension-media + + - name: 🧪 Run smoke tests (Electron) + if: ${{ inputs.electron_tests }} + timeout-minutes: 20 + shell: pwsh + run: npm run smoketest-no-compile -- -- --tracing + + - name: 🧪 Run smoke tests (Browser, Chromium) + if: ${{ inputs.browser_tests }} + timeout-minutes: 20 + shell: pwsh + run: npm run smoketest-no-compile -- -- --web --tracing --headless + + - name: 🧪 Run smoke tests (Remote) + if: ${{ inputs.remote_tests }} + timeout-minutes: 20 + shell: pwsh + run: npm run smoketest-no-compile -- -- --remote --tracing + + - name: Diagnostics after smoke test run + shell: pwsh + run: .\build\azure-pipelines\win32\listprocesses.bat + continue-on-error: true + if: always() + + - name: Publish Crash Reports + uses: actions/upload-artifact@v4 + if: failure() + continue-on-error: true + with: + name: ${{ format('crash-dump-windows-{0}-{1}-{2}', env.VSCODE_ARCH, env.ARTIFACT_NAME, github.run_attempt) }} + path: .build/crashes + if-no-files-found: ignore + + # In order to properly symbolify above crash reports + # (if any), we need the compiled native modules too + - name: Publish Node Modules + uses: actions/upload-artifact@v4 + if: failure() + continue-on-error: true + with: + name: ${{ format('node-modules-windows-{0}-{1}-{2}', env.VSCODE_ARCH, env.ARTIFACT_NAME, github.run_attempt) }} + path: node_modules + if-no-files-found: ignore + + - name: Publish Log Files + uses: actions/upload-artifact@v4 + if: always() + continue-on-error: true + with: + name: ${{ format('logs-windows-{0}-{1}-{2}', env.VSCODE_ARCH, env.ARTIFACT_NAME, github.run_attempt) }} + path: .build/logs + if-no-files-found: ignore diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 00000000000..5e9449c5a5f --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,157 @@ +name: Code OSS + +on: + pull_request: + branches: + - main + - 'release/*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +env: + VSCODE_QUALITY: 'oss' + +jobs: + compile: + name: Compile & Hygiene + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + env: + NODEJS_ORG_MIRROR: https://github.com/joaomoreno/node-mirror/releases/download + + - name: Prepare node_modules cache key + run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.js compile $(node -p process.arch) > .build/packagelockhash + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache/restore@v4 + with: + path: .build/node_modules_cache + key: "node_modules-compile-${{ hashFiles('.build/packagelockhash') }}" + + - name: Extract node_modules cache + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: tar -xzf .build/node_modules_cache/cache.tgz + + - name: Install build tools + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: sudo apt update -y && sudo apt install -y build-essential pkg-config libx11-dev libx11-xcb-dev libxkbfile-dev libnotify-bin libkrb5-dev + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + set -e + + 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: + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + - name: Create node_modules archive + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + 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 + + - name: Compile /build/ folder + run: npm run compile + working-directory: build + + - name: Check /build/ folder + run: .github/workflows/check-clean-git-state.sh + + - name: Compile & Hygiene + run: npm exec -- npm-run-all -lp core-ci-pr extensions-ci-pr hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check + env: + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} + + linux-cli-tests: + name: Linux + uses: ./.github/workflows/pr-linux-cli-test.yml + with: + job_name: CLI + rustup_toolchain: 1.85 + + linux-electron-tests: + name: Linux + uses: ./.github/workflows/pr-linux-test.yml + with: + job_name: Electron + electron_tests: true + + linux-browser-tests: + name: Linux + uses: ./.github/workflows/pr-linux-test.yml + with: + job_name: Browser + browser_tests: true + + linux-remote-tests: + name: Linux + uses: ./.github/workflows/pr-linux-test.yml + with: + job_name: Remote + remote_tests: true + + macos-electron-tests: + name: macOS + uses: ./.github/workflows/pr-darwin-test.yml + with: + job_name: Electron + electron_tests: true + + macos-browser-tests: + name: macOS + uses: ./.github/workflows/pr-darwin-test.yml + with: + job_name: Browser + browser_tests: true + + macos-remote-tests: + name: macOS + uses: ./.github/workflows/pr-darwin-test.yml + with: + job_name: Remote + remote_tests: true + + windows-electron-tests: + name: Windows + uses: ./.github/workflows/pr-win32-test.yml + with: + job_name: Electron + electron_tests: true + + windows-browser-tests: + name: Windows + uses: ./.github/workflows/pr-win32-test.yml + with: + job_name: Browser + browser_tests: true + + windows-remote-tests: + name: Windows + uses: ./.github/workflows/pr-win32-test.yml + with: + job_name: Remote + remote_tests: true diff --git a/.gitignore b/.gitignore index b73ce578e7f..62394c60784 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ vscode.db /cli/openssl product.overrides.json *.snap.actual +*.tsbuildinfo .vscode-test diff --git a/.vscode-test.js b/.vscode-test.js index 823ef615f4f..2e49c90126b 100644 --- a/.vscode-test.js +++ b/.vscode-test.js @@ -84,7 +84,7 @@ const extensions = [ const defaultLaunchArgs = process.env.API_TESTS_EXTRA_ARGS?.split(' ') || [ - '--disable-telemetry', '--skip-welcome', '--skip-release-notes', `--crash-reporter-directory=${__dirname}/.build/crashes`, `--logsPath=${__dirname}/.build/logs/integration-tests`, '--no-cached-data', '--disable-updates', '--use-inmemory-secretstorage', '--disable-extensions', '--disable-workspace-trust' + '--disable-telemetry', '--disable-experiments', '--skip-welcome', '--skip-release-notes', `--crash-reporter-directory=${__dirname}/.build/crashes`, `--logsPath=${__dirname}/.build/logs/integration-tests`, '--no-cached-data', '--disable-updates', '--use-inmemory-secretstorage', '--disable-extensions', '--disable-workspace-trust' ]; const config = defineConfig(extensions.map(extension => { @@ -97,7 +97,7 @@ const config = defineConfig(extensions.map(extension => { }; config.mocha ??= {}; - if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { + if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { let suite = ''; if (process.env.VSCODE_BROWSER) { suite = `${process.env.VSCODE_BROWSER} Browser Integration ${config.label} tests`; @@ -112,7 +112,10 @@ const config = defineConfig(extensions.map(extension => { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml` + ) } }; } diff --git a/.vscode/notebooks/my-work.github-issues b/.vscode/notebooks/my-work.github-issues index 68c38b3ca49..710a4398b0e 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:\"May 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:\"July 2025\"\n" }, { "kind": 1, @@ -82,7 +82,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPOS is:open assignee:@me label:triage-needed\n" + "value": "$REPOS is:open assignee:@me label:triage-needed,copilot-triage-needed\n" }, { "kind": 1, diff --git a/build/azure-pipelines/product-build-macos.yml b/build/azure-pipelines/product-build-macos.yml index 7be9e7cfa00..cc8985c07ca 100644 --- a/build/azure-pipelines/product-build-macos.yml +++ b/build/azure-pipelines/product-build-macos.yml @@ -1,9 +1,6 @@ pr: none -trigger: - batch: true - branches: - include: ["main"] +trigger: none parameters: - name: VSCODE_QUALITY diff --git a/build/buildfile.js b/build/buildfile.js index b5e8f6e7e6c..3acb1218b99 100644 --- a/build/buildfile.js +++ b/build/buildfile.js @@ -44,7 +44,6 @@ exports.code = [ createModuleDescription('vs/code/node/cliProcessMain'), createModuleDescription('vs/code/electron-utility/sharedProcess/sharedProcessMain'), createModuleDescription('vs/code/electron-browser/workbench/workbench'), - createModuleDescription('vs/workbench/contrib/webview/browser/pre/service-worker') ]; exports.codeWeb = createModuleDescription('vs/code/browser/workbench/workbench'); diff --git a/build/gulpfile.vscode.js b/build/gulpfile.vscode.js index f819cd6a1c2..22090d74318 100644 --- a/build/gulpfile.vscode.js +++ b/build/gulpfile.vscode.js @@ -213,7 +213,7 @@ function packageTask(platform, arch, sourceFolderName, destinationFolderName, op const destination = path.join(path.dirname(root), destinationFolderName); platform = platform || process.platform; - return () => { + const task = () => { const electron = require('@vscode/gulp-electron'); const json = require('gulp-json-editor'); @@ -422,6 +422,8 @@ function packageTask(platform, arch, sourceFolderName, destinationFolderName, op return result.pipe(vfs.dest(destination)); }; + task.taskName = `package-${platform}-${arch}`; + return task; } function patchWin32DependenciesTask(destinationFolderName) { diff --git a/build/lib/fetch.js b/build/lib/fetch.js index 078706cdd00..9f2b974b7ac 100644 --- a/build/lib/fetch.js +++ b/build/lib/fetch.js @@ -36,7 +36,7 @@ function fetchUrls(urls, options) { })); } async function fetchUrl(url, options, retries = 10, retryDelay = 1000) { - const verbose = !!options.verbose || !!process.env['CI'] || !!process.env['BUILD_ARTIFACTSTAGINGDIRECTORY']; + const verbose = !!options.verbose || !!process.env['CI'] || !!process.env['BUILD_ARTIFACTSTAGINGDIRECTORY'] || !!process.env['GITHUB_WORKSPACE']; try { let startTime = 0; if (verbose) { diff --git a/build/lib/fetch.ts b/build/lib/fetch.ts index 47a65b88fb5..f09b53e121c 100644 --- a/build/lib/fetch.ts +++ b/build/lib/fetch.ts @@ -42,7 +42,7 @@ export function fetchUrls(urls: string[] | string, options: IFetchOptions): es.T } export async function fetchUrl(url: string, options: IFetchOptions, retries = 10, retryDelay = 1000): Promise { - const verbose = !!options.verbose || !!process.env['CI'] || !!process.env['BUILD_ARTIFACTSTAGINGDIRECTORY']; + const verbose = !!options.verbose || !!process.env['CI'] || !!process.env['BUILD_ARTIFACTSTAGINGDIRECTORY'] || !!process.env['GITHUB_WORKSPACE']; try { let startTime = 0; if (verbose) { diff --git a/eslint.config.js b/eslint.config.js index dde24771a66..b1e83f734e8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,7 @@ import tseslint from 'typescript-eslint'; import { fileURLToPath } from 'url'; import stylisticTs from '@stylistic/eslint-plugin-ts'; -import pluginLocal from 'eslint-plugin-local'; +import * as pluginLocal from './.eslint-plugin-local/index.js'; import pluginJsdoc from 'eslint-plugin-jsdoc'; import pluginHeader from 'eslint-plugin-header'; diff --git a/extensions/configuration-editing/src/test/index.ts b/extensions/configuration-editing/src/test/index.ts index ee8aeb0c6eb..c361b97928d 100644 --- a/extensions/configuration-editing/src/test/index.ts +++ b/extensions/configuration-editing/src/test/index.ts @@ -24,13 +24,15 @@ if (process.env.VSCODE_BROWSER) { suite = 'Integration Configuration-Editing Tests'; } -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/css-language-features/server/test/index.js b/extensions/css-language-features/server/test/index.js index 1699883a574..0c9be2d9710 100644 --- a/extensions/css-language-features/server/test/index.js +++ b/extensions/css-language-features/server/test/index.js @@ -15,13 +15,15 @@ const options = { timeout: 60000 }; -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/emmet/src/test/index.ts b/extensions/emmet/src/test/index.ts index e36bb6f7c77..51b4929af06 100644 --- a/extensions/emmet/src/test/index.ts +++ b/extensions/emmet/src/test/index.ts @@ -24,13 +24,15 @@ if (process.env.VSCODE_BROWSER) { suite = 'Integration Emmet Tests'; } -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 152e78b161f..fc4193392b8 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -2247,8 +2247,8 @@ export class CommandCenter { const messageWarning = !discardUntrackedChangesToTrash ? resources.length === 1 - ? '\n\nThis is IRREVERSIBLE!\nThis file will be FOREVER LOST if you proceed.' - : '\n\nThis is IRREVERSIBLE!\nThese files will be FOREVER LOST if you proceed.' + ? '\n\n' + l10n.t('This is IRREVERSIBLE!\nThis file will be FOREVER LOST if you proceed.') + : '\n\n' + l10n.t('This is IRREVERSIBLE!\nThese files will be FOREVER LOST if you proceed.') : ''; const message = resources.length === 1 @@ -2258,11 +2258,11 @@ export class CommandCenter { const messageDetail = discardUntrackedChangesToTrash ? isWindows ? resources.length === 1 - ? 'You can restore this file from the Recycle Bin.' - : 'You can restore these files from the Recycle Bin.' + ? l10n.t('You can restore this file from the Recycle Bin.') + : l10n.t('You can restore these files from the Recycle Bin.') : resources.length === 1 - ? 'You can restore this file from the Trash.' - : 'You can restore these files from the Trash.' + ? l10n.t('You can restore this file from the Trash.') + : l10n.t('You can restore these files from the Trash.') : ''; const primaryAction = discardUntrackedChangesToTrash diff --git a/extensions/git/src/test/index.ts b/extensions/git/src/test/index.ts index c18561aaa66..da526f4d4c7 100644 --- a/extensions/git/src/test/index.ts +++ b/extensions/git/src/test/index.ts @@ -24,13 +24,15 @@ if (process.env.VSCODE_BROWSER) { suite = 'Integration Git Tests'; } -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/github-authentication/media/index.html b/extensions/github-authentication/media/index.html index 16712a2e5f7..385aa8991f1 100644 --- a/extensions/github-authentication/media/index.html +++ b/extensions/github-authentication/media/index.html @@ -47,15 +47,17 @@ document.querySelector('.error-message > .detail').textContent = error; document.querySelector('body').classList.add('error'); } else if (redirectUri) { + // Wrap the redirect URI so that the browser remembers who triggered the redirect + const wrappedRedirectUri = `https://vscode.dev/redirect?url=${encodeURIComponent(redirectUri)}`; // Set up the fallback link const fallbackLink = document.getElementById('fallback-link'); if (fallbackLink) { - fallbackLink.href = redirectUri; + fallbackLink.href = wrappedRedirectUri; } // Redirect after a delay setTimeout(() => { - window.location = redirectUri; + window.location = wrappedRedirectUri; }, 1000); } diff --git a/extensions/github-authentication/src/flows.ts b/extensions/github-authentication/src/flows.ts index 0c470b1d65d..e9954dc2d02 100644 --- a/extensions/github-authentication/src/flows.ts +++ b/extensions/github-authentication/src/flows.ts @@ -571,14 +571,14 @@ export function getFlows(query: IFlowQuery) { */ export const enum GitHubSocialSignInProvider { Google = 'google', - // Apple = 'apple', + Apple = 'apple', } const GitHubSocialSignInProviderLabels = { [GitHubSocialSignInProvider.Google]: l10n.t('Google'), - // [GitHubSocialSignInProvider.Apple]: l10n.t('Apple'), + [GitHubSocialSignInProvider.Apple]: l10n.t('Apple'), }; export function isSocialSignInProvider(provider: unknown): provider is GitHubSocialSignInProvider { - return provider === GitHubSocialSignInProvider.Google; // || provider === GitHubSocialSignInProvider.Apple; + return provider === GitHubSocialSignInProvider.Google || provider === GitHubSocialSignInProvider.Apple; } diff --git a/extensions/github/src/test/index.ts b/extensions/github/src/test/index.ts index 6573ab1daa4..382e728bfb2 100644 --- a/extensions/github/src/test/index.ts +++ b/extensions/github/src/test/index.ts @@ -14,13 +14,15 @@ const options: import('mocha').MochaOptions = { timeout: 60000 }; -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/go/cgmanifest.json b/extensions/go/cgmanifest.json index 2fbbe980d75..d41f8a2672d 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": "0ce19cdf1cb5dab6aa99ccc933be9bd21e855ed1" + "commitHash": "8c70c078f56d237f72574ce49cc95839c4f8a741" } }, "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.8.1" + "version": "0.8.4" } ], "version": 1 diff --git a/extensions/go/syntaxes/go.tmLanguage.json b/extensions/go/syntaxes/go.tmLanguage.json index 00472b67ddc..e83763a8eb5 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/0ce19cdf1cb5dab6aa99ccc933be9bd21e855ed1", + "version": "https://github.com/worlpaker/go-syntax/commit/8c70c078f56d237f72574ce49cc95839c4f8a741", "name": "Go", "scopeName": "source.go", "patterns": [ @@ -34,7 +34,7 @@ "include": "#group-variables" }, { - "include": "#field_hover" + "include": "#hover" } ] }, @@ -115,7 +115,7 @@ "include": "#property_variables" }, { - "include": "#switch_select_case_variables" + "include": "#switch_variables" }, { "include": "#other_variables" @@ -1704,7 +1704,7 @@ }, "support_functions": { "comment": "Support Functions", - "match": "(?:(?:((?<=\\.)\\b\\w+)|(\\b\\w+))(\\[(?:(?:[\\w\\.\\*\\[\\]\\{\\}\"\\']+)(?:(?:\\,\\s*(?:[\\w\\.\\*\\[\\]\\{\\}]+))*))?\\])?(?=\\())", + "match": "(?:(?:((?<=\\.)\\b\\w+)|(\\b\\w+))(?\\[(?:[^\\[\\]]|\\g)*\\])?(?=\\())", "captures": { "1": { "name": "entity.name.function.support.go" @@ -1761,7 +1761,8 @@ "include": "#after_control_variables" }, { - "match": "(\\b[\\w\\.]+)(\\[(?:(?:[\\w\\.\\*\\[\\]\\{\\}]+)(?:(?:\\,\\s*(?:[\\w\\.\\*\\[\\]\\{\\}]+))*))?\\])?(?=\\{)(?\\[(?:[^\\[\\]]|\\g)*\\])?(?=\\{)", "captures": { "1": { "patterns": [ @@ -1807,7 +1808,7 @@ }, "type_assertion_inline": { "comment": "struct/interface types in-line (type assertion) | switch type keyword", - "match": "(?:(?<=\\.\\()(?:(\\btype\\b)|((?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?[\\w\\.\\[\\]\\*]+))(?=\\)))", + "match": "(?:(?<=\\.\\()(?:(\\btype\\b)|((?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:[\\[\\]\\*]+)?(?:[\\w\\.]+)(?:\\[(?:(?:[\\w\\.\\*\\[\\]\\{\\}]+)(?:(?:\\,\\s*(?:[\\w\\.\\*\\[\\]\\{\\}]+))*))?\\])?))(?=\\)))", "captures": { "1": { "name": "keyword.type.go" @@ -1815,7 +1816,31 @@ "2": { "patterns": [ { - "include": "#type-declarations" + "include": "#type-declarations-without-brackets" + }, + { + "match": "\\(", + "name": "punctuation.definition.begin.bracket.round.go" + }, + { + "match": "\\)", + "name": "punctuation.definition.end.bracket.round.go" + }, + { + "match": "\\[", + "name": "punctuation.definition.begin.bracket.square.go" + }, + { + "match": "\\]", + "name": "punctuation.definition.end.bracket.square.go" + }, + { + "match": "\\{", + "name": "punctuation.definition.begin.bracket.curly.go" + }, + { + "match": "\\}", + "name": "punctuation.definition.end.bracket.curly.go" }, { "match": "\\w+", @@ -1904,12 +1929,12 @@ }, { "comment": "one line with semicolon(;) without formatting gofmt - single type | property variables and types", - "match": "(?:(?<=\\{)((?:\\s*(?:(?:(?:\\w+\\,\\s*)+)?(?:\\w+\\s+))?(?:(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:[\\S]+)(?:\\;)?))+)\\s*(?=\\}))", + "match": "(?:(?<=\\{)((?:\\s*(?:(?:(?:\\w+\\,\\s*)+)?(?:\\w+\\s+))?(?:(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:[^\\s/]+)(?:\\;)?))+)\\s*(?=\\}))", "captures": { "1": { "patterns": [ { - "match": "(?:((?:(?:\\w+\\,\\s*)+)?(?:\\w+\\s+))?((?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:[\\S]+)(?:\\;)?))", + "match": "(?:((?:(?:\\w+\\,\\s*)+)?(?:\\w+\\s+))?((?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:[^\\s/]+)(?:\\;)?))", "captures": { "1": { "patterns": [ @@ -1958,7 +1983,7 @@ }, { "comment": "property variables and types", - "match": "(?:((?:(?:\\w+\\,\\s*)+)?(?:\\w+\\s+))([^\\`\"\\/]+))", + "match": "(\\b\\w+(?:\\s*\\,\\s*\\b\\w+)*)\\s*([^\\`\"\\/]+)", "captures": { "1": { "patterns": [ @@ -1994,7 +2019,7 @@ "patterns": [ { "comment": "struct in struct types", - "begin": "(?:((?:\\w+(?:\\,\\s*\\w+)*)(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:\\s+)(?:[\\[\\]\\*]+)?)(\\bstruct\\b)(?:\\s*)(\\{))", + "begin": "(?:((?:\\b\\w+(?:\\,\\s*\\b\\w+)*)(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:\\s*)(?:[\\[\\]\\*]+)?)(\\bstruct\\b)(?:\\s*)(\\{))", "beginCaptures": { "1": { "patterns": [ @@ -2031,7 +2056,7 @@ }, { "comment": "interface in struct types", - "begin": "(?:((?:\\w+(?:\\,\\s*\\w+)*)(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:\\s+)(?:[\\[\\]\\*]+)?)(\\binterface\\b)(?:\\s*)(\\{))", + "begin": "(?:((?:\\b\\w+(?:\\,\\s*\\b\\w+)*)(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:\\s*)(?:[\\[\\]\\*]+)?)(\\binterface\\b)(?:\\s*)(\\{))", "beginCaptures": { "1": { "patterns": [ @@ -2068,7 +2093,7 @@ }, { "comment": "function in struct types", - "begin": "(?:((?:\\w+(?:\\,\\s*\\w+)*)(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:\\s+)(?:[\\[\\]\\*]+)?)(\\bfunc\\b)(?:\\s*)(\\())", + "begin": "(?:((?:\\b\\w+(?:\\,\\s*\\b\\w+)*)(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:\\s*)(?:[\\[\\]\\*]+)?)(\\bfunc\\b)(?:\\s*)(\\())", "beginCaptures": { "1": { "patterns": [ @@ -2390,7 +2415,7 @@ }, "after_control_variables": { "comment": "After control variables, to not highlight as a struct/interface (before formatting with gofmt)", - "match": "(?:(?<=\\brange\\b|\\bswitch\\b|\\;|\\bif\\b|\\bfor\\b|\\<|\\>|\\<\\=|\\>\\=|\\=\\=|\\!\\=|\\w(?:\\+|/|\\-|\\*|\\%)|\\w(?:\\+|/|\\-|\\*|\\%)\\=|\\|\\||\\&\\&)(?:\\s*)((?![\\[\\]]+)[[:alnum:]\\-\\_\\!\\.\\[\\]\\<\\>\\=\\*/\\+\\%\\:]+)(?:\\s*)(?=\\{))", + "match": "(?:(?<=\\brange\\b|\\;|\\bif\\b|\\bfor\\b|\\<|\\>|\\<\\=|\\>\\=|\\=\\=|\\!\\=|\\w(?:\\+|/|\\-|\\*|\\%)|\\w(?:\\+|/|\\-|\\*|\\%)\\=|\\|\\||\\&\\&)(?:\\s*)((?![\\[\\]]+)[[:alnum:]\\-\\_\\!\\.\\[\\]\\<\\>\\=\\*/\\+\\%\\:]+)(?:\\s*)(?=\\{))", "captures": { "1": { "patterns": [ @@ -2649,6 +2674,103 @@ } ] }, + "switch_variables": { + "comment": "variables after case control keyword in switch/select expression, to not scope them as property variables", + "patterns": [ + { + "comment": "single line", + "match": "(?:(?:^\\s*(\\bcase\\b))(?:\\s+)([\\s\\S]+(?:\\:)\\s*(?:/(?:/|\\*).*)?)$)", + "captures": { + "1": { + "name": "keyword.control.go" + }, + "2": { + "patterns": [ + { + "include": "#type-declarations" + }, + { + "include": "#support_functions" + }, + { + "include": "#variable_assignment" + }, + { + "match": "\\w+", + "name": "variable.other.go" + } + ] + } + } + }, + { + "comment": "multi lines", + "begin": "(?<=\\bswitch\\b)(?:\\s*)((?:[\\w\\.]+(?:\\s*(?:[\\:\\=\\!\\,\\+/\\-\\%\\<\\>\\|\\&]+)\\s*[\\w\\.]+)*\\s*(?:[\\:\\=\\!\\,\\+/\\-\\%\\<\\>\\|\\&]+))?(?:\\s*(?:[\\w\\.\\*\\(\\)\\[\\]\\+/\\-\\%\\<\\>\\|\\&]+)?\\s*(?:\\;\\s*(?:[\\w\\.\\*\\(\\)\\[\\]\\+/\\-\\%\\<\\>\\|\\&]+)\\s*)?))(\\{)", + "beginCaptures": { + "1": { + "patterns": [ + { + "include": "#support_functions" + }, + { + "include": "#type-declarations" + }, + { + "include": "#variable_assignment" + }, + { + "match": "\\w+", + "name": "variable.other.go" + } + ] + }, + "2": { + "name": "punctuation.definition.begin.bracket.curly.go" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.end.bracket.curly.go" + } + }, + "patterns": [ + { + "begin": "\\bcase\\b", + "beginCaptures": { + "0": { + "name": "keyword.control.go" + } + }, + "end": "\\:", + "endCaptures": { + "0": { + "name": "punctuation.other.colon.go" + } + }, + "patterns": [ + { + "include": "#support_functions" + }, + { + "include": "#type-declarations" + }, + { + "include": "#variable_assignment" + }, + { + "match": "\\w+", + "name": "variable.other.go" + } + ] + }, + { + "include": "$self" + } + ] + } + ] + }, "var_assignment": { "comment": "variable assignment with var keyword", "patterns": [ @@ -2959,32 +3081,6 @@ } } }, - "switch_select_case_variables": { - "comment": "variables after case control keyword in switch/select expression, to not scope them as property variables", - "match": "(?:(?:^\\s*(\\bcase\\b))(?:\\s+)([\\s\\S]+(?:\\:)\\s*(?:/(?:/|\\*).*)?)$)", - "captures": { - "1": { - "name": "keyword.control.go" - }, - "2": { - "patterns": [ - { - "include": "#type-declarations" - }, - { - "include": "#support_functions" - }, - { - "include": "#variable_assignment" - }, - { - "match": "\\w+", - "name": "variable.other.go" - } - ] - } - } - }, "slice_index_variables": { "comment": "slice index and capacity variables, to not scope them as property variables", "match": "(?<=\\w\\[)((?:(?:\\b[\\w\\.\\*\\+/\\-\\%\\<\\>\\|\\&]+\\:)|(?:\\:\\b[\\w\\.\\*\\+/\\-\\%\\<\\>\\|\\&]+))(?:\\b[\\w\\.\\*\\+/\\-\\%\\<\\>\\|\\&]+)?(?:\\:\\b[\\w\\.\\*\\+/\\-\\%\\<\\>\\|\\&]+)?)(?=\\])", @@ -3077,40 +3173,65 @@ } } }, - "field_hover": { - "comment": "struct field property and types when hovering with the mouse", - "match": "(?:(?<=^\\bfield\\b)\\s+([\\w\\*\\.]+)\\s+([\\s\\S]+))", - "captures": { - "1": { - "patterns": [ - { - "include": "#type-declarations" + "hover": { + "comment": "hovering with the mouse", + "patterns": [ + { + "comment": "struct field property and types when hovering with the mouse", + "match": "(?:(?<=^\\bfield\\b)\\s+([\\w\\*\\.]+)\\s+([\\s\\S]+))", + "captures": { + "1": { + "patterns": [ + { + "include": "#type-declarations" + }, + { + "match": "\\w+", + "name": "variable.other.property.go" + } + ] }, - { - "match": "\\w+", - "name": "variable.other.property.go" + "2": { + "patterns": [ + { + "match": "\\binvalid\\b\\s+\\btype\\b", + "name": "invalid.field.go" + }, + { + "include": "#type-declarations-without-brackets" + }, + { + "include": "#parameter-variable-types" + }, + { + "match": "\\w+", + "name": "entity.name.type.go" + } + ] } - ] + } }, - "2": { - "patterns": [ - { - "match": "\\binvalid\\b\\s+\\btype\\b", - "name": "invalid.field.go" - }, - { - "include": "#type-declarations-without-brackets" - }, - { - "include": "#parameter-variable-types" - }, - { - "match": "\\w+", - "name": "entity.name.type.go" + { + "comment": "return types when hovering with the mouse", + "match": "(?:(?<=^\\breturns\\b)\\s+([\\s\\S]+))", + "captures": { + "1": { + "patterns": [ + { + "include": "#type-declarations-without-brackets" + }, + { + "include": "#parameter-variable-types" + }, + { + "match": "\\w+", + "name": "entity.name.type.go" + } + ] } - ] + } } - } + ] }, "other_variables": { "comment": "all other variables", diff --git a/extensions/html-language-features/server/test/index.js b/extensions/html-language-features/server/test/index.js index 50e250b78b8..93ffe7d6c09 100644 --- a/extensions/html-language-features/server/test/index.js +++ b/extensions/html-language-features/server/test/index.js @@ -15,13 +15,15 @@ const options = { timeout: 60000 }; -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/ipynb/src/test/index.ts b/extensions/ipynb/src/test/index.ts index 290194bfb2f..d70cf2e0628 100644 --- a/extensions/ipynb/src/test/index.ts +++ b/extensions/ipynb/src/test/index.ts @@ -24,13 +24,15 @@ if (process.env.VSCODE_BROWSER) { suite = 'Integration .ipynb Tests'; } -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/julia/cgmanifest.json b/extensions/julia/cgmanifest.json index b15d7716c69..2d59c264d57 100644 --- a/extensions/julia/cgmanifest.json +++ b/extensions/julia/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "JuliaEditorSupport/atom-language-julia", "repositoryUrl": "https://github.com/JuliaEditorSupport/atom-language-julia", - "commitHash": "8eaad3e9560c223b00616c8a4610304b9b925d1c" + "commitHash": "111548fbd25d083ec131d2732a4f46953ea92a65" } }, "license": "MIT", diff --git a/extensions/julia/syntaxes/julia.tmLanguage.json b/extensions/julia/syntaxes/julia.tmLanguage.json index 0e19c8792f9..8f7298ea4ce 100644 --- a/extensions/julia/syntaxes/julia.tmLanguage.json +++ b/extensions/julia/syntaxes/julia.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/JuliaEditorSupport/atom-language-julia/commit/8eaad3e9560c223b00616c8a4610304b9b925d1c", + "version": "https://github.com/JuliaEditorSupport/atom-language-julia/commit/111548fbd25d083ec131d2732a4f46953ea92a65", "name": "Julia", "scopeName": "source.julia", "comment": "This grammar is used by Atom (Oniguruma), GitHub (PCRE), and VSCode (Oniguruma),\nso all regexps must be compatible with both engines.\n\nSpecs:\n- https://github.com/kkos/oniguruma/blob/master/doc/RE\n- https://www.pcre.org/current/doc/html/", @@ -337,7 +337,7 @@ "name": "keyword.control.as.julia" }, { - "match": "(@((?:\\.|[\\p{S}\\p{P}&&[^\\s@]]+)|(?:[[:alpha:]_\\p{Lu}\\p{Ll}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nl}\\p{Sc}⅀-⅄∿⊾⊿⊤⊥∂∅-∇∎∏∐∑∞∟∫-∳⋀-⋃◸-◿♯⟘⟙⟀⟁⦰-⦴⨀-⨆⨉-⨖⨛⨜𝛁𝛛𝛻𝜕𝜵𝝏𝝯𝞉𝞩𝟃ⁱ-⁾₁-₎∠-∢⦛-⦯℘℮゛-゜𝟎-𝟡]|[^\\P{So}←-⇿])(?:[[:word:]_!\\p{Lu}\\p{Ll}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nl}\\p{Sc}⅀-⅄∿⊾⊿⊤⊥∂∅-∇∎∏∐∑∞∟∫-∳⋀-⋃◸-◿♯⟘⟙⟀⟁⦰-⦴⨀-⨆⨉-⨖⨛⨜𝛁𝛛𝛻𝜕𝜵𝝏𝝯𝞉𝞩𝟃ⁱ-⁾₁-₎∠-∢⦛-⦯℘℮゛-゜𝟎-𝟡]|[^\\P{Mn}\u0001-¡]|[^\\P{Mc}\u0001-¡]|[^\\P{Nd}\u0001-¡]|[^\\P{Pc}\u0001-¡]|[^\\P{Sk}\u0001-¡]|[^\\P{Me}\u0001-¡]|[^\\P{No}\u0001-¡]|[′-‷⁗]|[^\\P{So}←-⇿])*))", + "match": "@(\\.|(?:[[:alpha:]_\\p{Lu}\\p{Ll}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nl}\\p{Sc}⅀-⅄∿⊾⊿⊤⊥∂∅-∇∎∏∐∑∞∟∫-∳⋀-⋃◸-◿♯⟘⟙⟀⟁⦰-⦴⨀-⨆⨉-⨖⨛⨜𝛁𝛛𝛻𝜕𝜵𝝏𝝯𝞉𝞩𝟃ⁱ-⁾₁-₎∠-∢⦛-⦯℘℮゛-゜𝟎-𝟡]|[^\\P{So}←-⇿])(?:[[:word:]_!\\p{Lu}\\p{Ll}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nl}\\p{Sc}⅀-⅄∿⊾⊿⊤⊥∂∅-∇∎∏∐∑∞∟∫-∳⋀-⋃◸-◿♯⟘⟙⟀⟁⦰-⦴⨀-⨆⨉-⨖⨛⨜𝛁𝛛𝛻𝜕𝜵𝝏𝝯𝞉𝞩𝟃ⁱ-⁾₁-₎∠-∢⦛-⦯℘℮゛-゜𝟎-𝟡]|[^\\P{Mn}\u0001-¡]|[^\\P{Mc}\u0001-¡]|[^\\P{Nd}\u0001-¡]|[^\\P{Pc}\u0001-¡]|[^\\P{Sk}\u0001-¡]|[^\\P{Me}\u0001-¡]|[^\\P{No}\u0001-¡]|[′-‷⁗]|[^\\P{So}←-⇿])*|[\\p{S}\\p{P}&&[^\\s@]]+)", "name": "support.function.macro.julia" } ] diff --git a/extensions/latex/cgmanifest.json b/extensions/latex/cgmanifest.json index fd381574f80..1e0ee670a79 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": "eb0d146b16839076a61c3fdec85d6f80d9a94c8c" + "commitHash": "6bd99800f7b2cbd0e36cecb56fe1936da5affadb" } }, "license": "MIT", - "version": "1.13.0", + "version": "1.14.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/TeX.tmLanguage.json b/extensions/latex/syntaxes/TeX.tmLanguage.json index db2a62a2267..b31ccccb631 100644 --- a/extensions/latex/syntaxes/TeX.tmLanguage.json +++ b/extensions/latex/syntaxes/TeX.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/b46aaf9bf4d265e63e262ded4bf9beffe19d35b2", + "version": "https://github.com/jlelong/vscode-latex-basics/commit/6bd99800f7b2cbd0e36cecb56fe1936da5affadb", "name": "TeX", "scopeName": "text.tex", "patterns": [ @@ -55,7 +55,7 @@ "name": "meta.catcode.tex" }, "iffalse-block": { - "begin": "(?<=^\\s*)((\\\\)iffalse)(?!\\s*[{}]\\s*\\\\fi)", + "begin": "(?<=^\\s*)((\\\\)iffalse)(?!\\s*[{}]\\s*\\\\fi\\b)", "beginCaptures": { "1": { "name": "keyword.control.tex" @@ -65,7 +65,7 @@ } }, "contentName": "comment.line.percentage.tex", - "end": "((\\\\)(?:else|fi))", + "end": "((\\\\)(?:else|fi)\\b)", "endCaptures": { "1": { "name": "keyword.control.tex" diff --git a/extensions/markdown-basics/cgmanifest.json b/extensions/markdown-basics/cgmanifest.json index 380b0c74ac6..82e47b637e1 100644 --- a/extensions/markdown-basics/cgmanifest.json +++ b/extensions/markdown-basics/cgmanifest.json @@ -33,7 +33,7 @@ "git": { "name": "microsoft/vscode-markdown-tm-grammar", "repositoryUrl": "https://github.com/microsoft/vscode-markdown-tm-grammar", - "commitHash": "7418dd20d76c72e82fadee2909e03239e9973b35" + "commitHash": "548ccb91ef58ba40ac745b400d889933ccd5eb4d" } }, "license": "MIT", diff --git a/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json b/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json index 9761ca716ab..c6d5110bd02 100644 --- a/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json +++ b/extensions/markdown-basics/syntaxes/markdown.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/microsoft/vscode-markdown-tm-grammar/commit/7418dd20d76c72e82fadee2909e03239e9973b35", + "version": "https://github.com/microsoft/vscode-markdown-tm-grammar/commit/548ccb91ef58ba40ac745b400d889933ccd5eb4d", "name": "Markdown", "scopeName": "text.html.markdown", "patterns": [ @@ -3084,7 +3084,7 @@ "name": "punctuation.definition.strikethrough.markdown" } }, - "match": "(? specLabel === (command.definitionCommand ?? (typeof command.label === 'string' ? command.label : command.label.label)))); if ( !(osIsWindows() - ? commandAndAliases.some(e => currentCommand.startsWith(removeAnyFileExtension((typeof e.label === 'string' ? e.label : e.label.label)))) - : commandAndAliases.some(e => currentCommand.startsWith(typeof e.label === 'string' ? e.label : e.label.label))) + ? commandAndAliases.some(e => currentCommand === (removeAnyFileExtension((typeof e.label === 'string' ? e.label : e.label.label)))) + : commandAndAliases.some(e => currentCommand === (typeof e.label === 'string' ? e.label : e.label.label))) ) { continue; } diff --git a/extensions/terminal-suggest/src/test/completions/code.test.ts b/extensions/terminal-suggest/src/test/completions/code.test.ts index 323024e06f4..9022c0c5a3e 100644 --- a/extensions/terminal-suggest/src/test/completions/code.test.ts +++ b/extensions/terminal-suggest/src/test/completions/code.test.ts @@ -72,7 +72,7 @@ export function createCodeTestSpecs(executable: string): ITestSpec[] { const categoryOptions = ['azure', 'data science', 'debuggers', 'extension packs', 'education', 'formatters', 'keymaps', 'language packs', 'linters', 'machine learning', 'notebooks', 'programming languages', 'scm providers', 'snippets', 'testing', 'themes', 'visualization', 'other']; const logOptions = ['critical', 'error', 'warn', 'info', 'debug', 'trace', 'off']; const syncOptions = ['on', 'off']; - const chatOptions = ['--add-file ', '--help', '--mode ', '-a ', '-h', '-m ']; + const chatOptions = ['--add-file ', '--help', '--maximize', '--mode ', '--new-window', '--reuse-window', '-a ', '-h', '-m ', '-n', '-r']; const typingTests: ITestSpec[] = []; for (let i = 1; i < executable.length; i++) { @@ -281,7 +281,7 @@ export function createCodeTunnelTestSpecs(executable: string): ITestSpec[] { { input: `${executable} tunnel unregister |`, expectedCompletions: [...commonFlags] }, { input: `${executable} tunnel service |`, expectedCompletions: [...commonFlags, 'help', 'install', 'log', 'uninstall'] }, { input: `${executable} tunnel help |`, expectedCompletions: helpSubcommands }, - { input: `${executable} chat |`, expectedCompletions: ['--mode ', '--add-file ', '--help', '-m ', '-a ', '-h'] }, + { input: `${executable} chat |`, expectedCompletions: ['--mode ', '--add-file ', '--help', '--maximize', '--new-window', '--reuse-window', '-m ', '-a ', '-h', '-n', '-r'] }, { input: `${executable} chat --mode |`, expectedCompletions: ['agent', 'ask', 'edit'] }, { input: `${executable} chat --add-file |`, expectedResourceRequests: { type: 'files', cwd: testPaths.cwd } }, { input: `${executable} serve-web |`, expectedCompletions: serveWebSubcommandsAndFlags }, diff --git a/extensions/terminal-suggest/src/tokens.ts b/extensions/terminal-suggest/src/tokens.ts index ee39314b6f9..cadab15ba64 100644 --- a/extensions/terminal-suggest/src/tokens.ts +++ b/extensions/terminal-suggest/src/tokens.ts @@ -14,7 +14,7 @@ export const enum TokenType { export const shellTypeResetChars = new Map([ [TerminalShellType.Bash, ['>', '>>', '<', '2>', '2>>', '&>', '&>>', '|', '|&', '&&', '||', '&', ';', '(', '{', '<<']], [TerminalShellType.Zsh, ['>', '>>', '<', '2>', '2>>', '&>', '&>>', '<>', '|', '|&', '&&', '||', '&', ';', '(', '{', '<<', '<<<', '<(']], - [TerminalShellType.PowerShell, ['>', '>>', '<', '2>', '2>>', '*>', '*>>', '|', ';', '-and', '-or', '-not', '!', '&', '-eq', '-ne', '-gt', '-lt', '-ge', '-le', '-like', '-notlike', '-match', '-notmatch', '-contains', '-notcontains', '-in', '-notin']] + [TerminalShellType.PowerShell, ['>', '>>', '<', '2>', '2>>', '*>', '*>>', '|', ';', ' -and ', ' -or ', ' -not ', '!', '&', ' -eq ', ' -ne ', ' -gt ', ' -lt ', ' -ge ', ' -le ', ' -like ', ' -notlike ', ' -match ', ' -notmatch ', ' -contains ', ' -notcontains ', ' -in ', ' -notin ']] ]); export const defaultShellTypeResetChars = shellTypeResetChars.get(TerminalShellType.Bash)!; @@ -31,7 +31,7 @@ export function getTokenType(ctx: { commandLine: string; cursorPosition: number // Look for " " before the word for (const resetChar of commandResetChars) { - const pattern = ` ${resetChar} `; + const pattern = shellType === TerminalShellType.PowerShell ? `${resetChar}` : ` ${resetChar} `; if (beforeWord.endsWith(pattern)) { return TokenType.Command; } diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index a087e162080..7f66178f09c 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -1512,7 +1512,8 @@ "off", "terse", "normal", - "verbose" + "verbose", + "requestTime" ], "default": "off", "description": "%typescript.tsserver.log%", diff --git a/extensions/typescript-language-features/src/configuration/configuration.ts b/extensions/typescript-language-features/src/configuration/configuration.ts index 6a0a2e78beb..881e0cd1ad4 100644 --- a/extensions/typescript-language-features/src/configuration/configuration.ts +++ b/extensions/typescript-language-features/src/configuration/configuration.ts @@ -12,6 +12,7 @@ export enum TsServerLogLevel { Normal, Terse, Verbose, + RequestTime } export namespace TsServerLogLevel { @@ -23,6 +24,8 @@ export namespace TsServerLogLevel { return TsServerLogLevel.Terse; case 'verbose': return TsServerLogLevel.Verbose; + case 'requestTime': + return TsServerLogLevel.RequestTime; case 'off': default: return TsServerLogLevel.Off; @@ -37,6 +40,8 @@ export namespace TsServerLogLevel { return 'terse'; case TsServerLogLevel.Verbose: return 'verbose'; + case TsServerLogLevel.RequestTime: + return 'requestTime'; case TsServerLogLevel.Off: default: return 'off'; diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/debug.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/debug.test.ts index cc2f2675297..60c2931418a 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/debug.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/debug.test.ts @@ -6,11 +6,14 @@ import * as assert from 'assert'; import { basename } from 'path'; import { commands, debug, Disposable, FunctionBreakpoint, window, workspace } from 'vscode'; -import { assertNoRpc, createRandomFile, disposeAll } from '../utils'; +import { assertNoRpc, closeAllEditors, createRandomFile, disposeAll } from '../utils'; suite('vscode API - debug', function () { - teardown(assertNoRpc); + teardown(async function () { + assertNoRpc(); + await closeAllEditors(); + }); test('breakpoints are available before accessing debug extension API', async () => { const file = await createRandomFile(undefined, undefined, '.js'); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/index.ts b/extensions/vscode-api-tests/src/singlefolder-tests/index.ts index 6798fc5a1e0..d7cce06ea62 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/index.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/index.ts @@ -24,13 +24,15 @@ if (process.env.VSCODE_BROWSER) { suite = 'Integration Single Folder Tests'; } -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/ipynb.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/ipynb.test.ts index b73025ab2e7..c3e28b4c303 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/ipynb.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/ipynb.test.ts @@ -6,13 +6,40 @@ import * as assert from 'assert'; import 'mocha'; import * as vscode from 'vscode'; +import { assertNoRpc, closeAllEditors, createRandomFile } from '../utils'; + +const ipynbContent = JSON.stringify({ + "cells": [ + { + "cell_type": "markdown", + "source": ["## Header"], + "metadata": {} + }, + { + "cell_type": "code", + "execution_count": 2, + "source": ["print('hello 1')\n", "print('hello 2')"], + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": ["hello 1\n", "hello 2\n"] + } + ], + "metadata": {} + } + ] +}); + +suite('ipynb NotebookSerializer', function () { + teardown(async function () { + assertNoRpc(); + await closeAllEditors(); + }); -(vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('ipynb NotebookSerializer', function () { test('Can open an ipynb notebook', async () => { - assert.ok(vscode.workspace.workspaceFolders); - const workspace = vscode.workspace.workspaceFolders[0]; - const uri = vscode.Uri.joinPath(workspace.uri, 'test.ipynb'); - const notebook = await vscode.workspace.openNotebookDocument(uri); + const file = await createRandomFile(ipynbContent, undefined, '.ipynb'); + const notebook = await vscode.workspace.openNotebookDocument(file); await vscode.window.showNotebookDocument(notebook); const notebookEditor = vscode.window.activeNotebookEditor; diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.api.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.api.test.ts index e4489090017..0903b12a14a 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.api.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.api.test.ts @@ -244,7 +244,7 @@ const apiTestSerializer: vscode.NotebookSerializer = { // no kernel -> no default language assert.strictEqual(getFocusedCell(editor)?.document.languageId, 'typescript'); - await vscode.commands.executeCommand('vscode.openWith', notebook.uri, 'default'); + await vscode.window.showNotebookDocument(await vscode.workspace.openNotebookDocument(notebook.uri)); assert.strictEqual(vscode.window.activeTextEditor?.document.uri.path, notebook.uri.path); }); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.kernel.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.kernel.test.ts index d1fafae7591..b232de35ffb 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.kernel.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.kernel.test.ts @@ -213,7 +213,7 @@ const apiTestSerializer: vscode.NotebookSerializer = { } })); - vscode.commands.executeCommand('notebook.cell.execute', { document: notebook.uri, ranges: [{ start: 0, end: 1 }, { start: 1, end: 2 }] }); + await vscode.commands.executeCommand('notebook.cell.execute', { document: notebook.uri, ranges: [{ start: 0, end: 1 }, { start: 1, end: 2 }] }); await def.p; await saveAllFilesAndCloseAll(); diff --git a/extensions/vscode-api-tests/src/workspace-tests/index.ts b/extensions/vscode-api-tests/src/workspace-tests/index.ts index 314a2e0d8f4..2010b075565 100644 --- a/extensions/vscode-api-tests/src/workspace-tests/index.ts +++ b/extensions/vscode-api-tests/src/workspace-tests/index.ts @@ -24,13 +24,15 @@ if (process.env.VSCODE_BROWSER) { suite = 'Integration Workspace Tests'; } -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/vscode-colorize-perf-tests/src/index.ts b/extensions/vscode-colorize-perf-tests/src/index.ts index 4376f31accd..65554f64ceb 100644 --- a/extensions/vscode-colorize-perf-tests/src/index.ts +++ b/extensions/vscode-colorize-perf-tests/src/index.ts @@ -14,13 +14,15 @@ const options: import('mocha').MochaOptions = { timeout: 60000 }; -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/vscode-colorize-tests/src/index.ts b/extensions/vscode-colorize-tests/src/index.ts index 51634213040..ea6a3357a63 100644 --- a/extensions/vscode-colorize-tests/src/index.ts +++ b/extensions/vscode-colorize-tests/src/index.ts @@ -20,7 +20,9 @@ if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: path.join( + process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts index 2fab3ec306a..74d25c65ae0 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts @@ -142,7 +142,7 @@ export function activate(context: vscode.ExtensionContext) { } const { updateUrl, commit, quality, serverDataFolderName, serverApplicationName, dataFolderName } = getProductConfiguration(); - const commandArgs = ['--host=127.0.0.1', '--port=0', '--disable-telemetry', '--use-host-proxy', '--accept-server-license-terms']; + const commandArgs = ['--host=127.0.0.1', '--port=0', '--disable-telemetry', '--disable-experiments', '--use-host-proxy', '--accept-server-license-terms']; const env = getNewEnv(); const remoteDataDir = process.env['TESTRESOLVER_DATA_FOLDER'] || path.join(os.homedir(), `${serverDataFolderName || dataFolderName}-testresolver`); const logsDir = process.env['TESTRESOLVER_LOGS_FOLDER']; diff --git a/package-lock.json b/package-lock.json index f86132d242a..3236c13dd5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-oss-dev", - "version": "1.102.0", + "version": "1.103.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.102.0", + "version": "1.103.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -40,6 +40,7 @@ "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", + "katex": "^0.16.22", "kerberos": "2.1.1", "minimist": "^1.2.8", "native-is-elevated": "0.7.0", @@ -56,7 +57,7 @@ "yazl": "^2.4.3" }, "devDependencies": { - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.53.2", "@stylistic/eslint-plugin-ts": "^2.8.0", "@types/cookie": "^0.3.3", "@types/debug": "^4.1.5", @@ -77,7 +78,7 @@ "@types/winreg": "^1.2.30", "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", - "@typescript-eslint/utils": "^8.8.0", + "@typescript-eslint/utils": "^8.36.0", "@vscode/gulp-electron": "^1.37.1", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", @@ -100,7 +101,6 @@ "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", "eslint-plugin-jsdoc": "^50.3.1", - "eslint-plugin-local": "^6.0.0", "event-stream": "3.3.4", "fancy-log": "^1.3.3", "file-loader": "^6.2.0", @@ -150,7 +150,7 @@ "ts-node": "^10.9.1", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^5.9.0-dev.20250613", + "typescript": "^5.9.0-dev.20250707", "typescript-eslint": "^8.8.0", "util": "^0.12.4", "webpack": "^5.94.0", @@ -905,16 +905,20 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } @@ -1283,34 +1287,33 @@ } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz", + "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -1937,13 +1940,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", - "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "version": "1.53.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.2.tgz", + "integrity": "sha512-tEB2U5z74ebBeyfGNZ3Jfg29AnW+5HlWhvHtb/Mqco9pFdZU1ZLNdVb2UtB5CvmiilNr2ZfVH/qMmAROG/XTzw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.52.0" + "playwright": "1.53.2" }, "bin": { "playwright": "cli.js" @@ -2047,18 +2050,6 @@ "node": ">=10" } }, - "node_modules/@thisismanta/pessimist": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@thisismanta/pessimist/-/pessimist-1.2.0.tgz", - "integrity": "sha512-rm8/zjNMuO9hPYhEMavVIIxmvawJJB8mthvbVXd74XUW7V/SbgmtDBQjICbCWKjluvA+gh+cqi7dv85/jexknA==", - "dev": true, - "dependencies": { - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@tootallnate/once": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-3.0.0.tgz", @@ -2195,11 +2186,23 @@ "@types/json-schema": "*" } }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/expect": { "version": "1.20.4", @@ -2447,6 +2450,42 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.36.0.tgz", + "integrity": "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.36.0", + "@typescript-eslint/types": "^8.36.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz", + "integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", @@ -2464,6 +2503,23 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz", + "integrity": "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, "node_modules/@typescript-eslint/types": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", @@ -2531,15 +2587,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", - "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.36.0.tgz", + "integrity": "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.8.0", - "@typescript-eslint/types": "8.8.0", - "@typescript-eslint/typescript-estree": "8.8.0" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/typescript-estree": "8.36.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2549,7 +2606,139 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz", + "integrity": "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz", + "integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz", + "integrity": "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.36.0", + "@typescript-eslint/tsconfig-utils": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz", + "integrity": "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.36.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/@typescript-eslint/visitor-keys": { @@ -3360,148 +3549,163 @@ "hasInstallScript": true }, "node_modules/@webassemblyjs/ast": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", - "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", - "dev": true + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", - "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, + "license": "MIT", "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "node_modules/@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", - "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-opt": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1", - "@webassemblyjs/wast-printer": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", - "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", - "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", - "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", - "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, @@ -3653,13 +3857,15 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/abbrev": { "version": "1.1.1", @@ -3681,10 +3887,11 @@ } }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -3692,13 +3899,17 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "node_modules/acorn-import-phases": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.3.tgz", + "integrity": "sha512-jtKLnfoOzm28PazuQ4dVBcE9Jeo6ha1GAJvq3N0LlNOszmTfx+wSycBehn+FN0RnyeR77IBxN/qVYMw0Rlj0Xw==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, "peerDependencies": { - "acorn": "^8" + "acorn": "^8.14.0" } }, "node_modules/acorn-jsx": { @@ -6294,10 +6505,11 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", + "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -6629,23 +6841,6 @@ "spdx-license-ids": "^3.0.0" } }, - "node_modules/eslint-plugin-local": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-local/-/eslint-plugin-local-6.0.0.tgz", - "integrity": "sha512-pvy/pTTyanEKAqpYqy/SLfd4TdiAQ/yFO+GRXDGvGQa2vEUGtmlEjmWQXBDGSk790j4nrAB/7ipqPQY3nLduDg==", - "deprecated": "Since the coming of ESLint flat config file, you can specify local rules without the need of this package. For running ESLint rule unit tests, use eslint-rule-tester instead", - "dev": true, - "dependencies": { - "@thisismanta/pessimist": "^1.2.0", - "chalk": "^4.0.0" - }, - "bin": { - "eslint-plugin-local": "executable.js" - }, - "peerDependencies": { - "eslint": ">=9.0.0" - } - }, "node_modules/eslint-scope": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", @@ -10977,6 +11172,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -10991,6 +11187,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -11000,6 +11197,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -11225,6 +11423,31 @@ "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, + "node_modules/katex": { + "version": "0.16.22", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/kerberos": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-2.1.1.tgz", @@ -12103,7 +12326,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -13939,13 +14163,13 @@ } }, "node_modules/playwright": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", - "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "version": "1.53.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.2.tgz", + "integrity": "sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.52.0" + "playwright-core": "1.53.2" }, "bin": { "playwright": "cli.js" @@ -13971,9 +14195,9 @@ } }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", - "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "version": "1.53.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.2.tgz", + "integrity": "sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -15083,18 +15307,19 @@ "dev": true }, "node_modules/schema-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", - "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", - "ajv": "^8.8.0", + "ajv": "^8.9.0", "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.0.0" + "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 10.13.0" }, "funding": { "type": "opencollective", @@ -16522,13 +16747,14 @@ } }, "node_modules/terser": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.0.tgz", - "integrity": "sha512-Y/SblUl5kEyEFzhMAQdsxVHh+utAxd4IuRNJzKywY/4uzSogh3G219jqbDDxYu4MXO9CzY3tSEqmZvW6AoEDJw==", + "version": "5.43.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", + "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.14.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -16540,16 +16766,17 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", - "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.20", + "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" }, "engines": { "node": ">= 10.13.0" @@ -16573,35 +16800,19 @@ } } }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/terser/node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -17133,9 +17344,9 @@ "dev": true }, "node_modules/typescript": { - "version": "5.9.0-dev.20250613", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.0-dev.20250613.tgz", - "integrity": "sha512-5bzU//x0svUVjUCMlHRG7IassWFQ7/dofYXqSSbQpQ8a1Kh/GqIsvfc2CQIS+EpYrdoHaNLroxmlsLNsqLXaww==", + "version": "5.9.0-dev.20250707", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.0-dev.20250707.tgz", + "integrity": "sha512-aLNq2y90Rk+gkNN2ptDQOoA4IeCnpEDoMMkz0o4jZcVC7fEeYAwgJ3Z+c2G9//TCukY8IvXGu0Jbfp2FtvdcFQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -17254,6 +17465,29 @@ } } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", + "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, "node_modules/typical": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", @@ -17820,20 +18054,23 @@ "dev": true }, "node_modules/webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "version": "5.100.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.0.tgz", + "integrity": "sha512-H8yBSBTk+BqxrINJnnRzaxU94SVP2bjd7WmA+PfCphoIdDpeQMJ77pq9/4I7xjLq38cB1bNKfzYPZu8pB3zKtg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", + "enhanced-resolve": "^5.17.2", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -17843,11 +18080,11 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", + "schema-utils": "^4.3.2", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", + "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -17954,10 +18191,11 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.13.0" } @@ -18056,24 +18294,6 @@ "node": ">=4.0" } }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index 5501dd89e1e..c069eeabadc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", - "version": "1.102.0", - "distro": "969a2e84edcb47f53fbc4f8aa419dc7c062c71cf", + "version": "1.103.0", + "distro": "e050762924418e1fb937b0aee594b586defeac82", "author": { "name": "Microsoft Corporation" }, @@ -99,6 +99,7 @@ "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", + "katex": "^0.16.22", "kerberos": "2.1.1", "minimist": "^1.2.8", "native-is-elevated": "0.7.0", @@ -115,7 +116,7 @@ "yazl": "^2.4.3" }, "devDependencies": { - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.53.2", "@stylistic/eslint-plugin-ts": "^2.8.0", "@types/cookie": "^0.3.3", "@types/debug": "^4.1.5", @@ -136,7 +137,7 @@ "@types/winreg": "^1.2.30", "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", - "@typescript-eslint/utils": "^8.8.0", + "@typescript-eslint/utils": "^8.36.0", "@vscode/gulp-electron": "^1.37.1", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", @@ -159,7 +160,6 @@ "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", "eslint-plugin-jsdoc": "^50.3.1", - "eslint-plugin-local": "^6.0.0", "event-stream": "3.3.4", "fancy-log": "^1.3.3", "file-loader": "^6.2.0", @@ -209,7 +209,7 @@ "ts-node": "^10.9.1", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^5.9.0-dev.20250613", + "typescript": "^5.9.0-dev.20250707", "typescript-eslint": "^8.8.0", "util": "^0.12.4", "webpack": "^5.94.0", diff --git a/product.json b/product.json index 91140d9a483..a0719353135 100644 --- a/product.json +++ b/product.json @@ -52,8 +52,8 @@ }, { "name": "ms-vscode.js-debug", - "version": "1.100.1", - "sha256": "8c2218df3422d45b95e96d9d28cdc4aa4426a2799aaaedd862d3f60ecab03844", + "version": "1.102.0", + "sha256": "0e8ed27ba2d707bcfb008e89e490c2d287d9537d84893b0792a4ee418274fa0b", "repo": "https://github.com/microsoft/vscode-js-debug", "metadata": { "id": "25629058-ddac-4e17-abba-74678e126c5d", diff --git a/scripts/test-integration.bat b/scripts/test-integration.bat index b59cceada07..50c14ff8d3f 100644 --- a/scripts/test-integration.bat +++ b/scripts/test-integration.bat @@ -35,7 +35,7 @@ if %errorlevel% neq 0 exit /b %errorlevel% :: Tests in the extension host -set API_TESTS_EXTRA_ARGS=--disable-telemetry --skip-welcome --skip-release-notes --crash-reporter-directory=%VSCODECRASHDIR% --logsPath=%VSCODELOGSDIR% --no-cached-data --disable-updates --use-inmemory-secretstorage --disable-extensions --disable-workspace-trust --user-data-dir=%VSCODEUSERDATADIR% +set API_TESTS_EXTRA_ARGS=--disable-telemetry --disable-experiments --skip-welcome --skip-release-notes --crash-reporter-directory=%VSCODECRASHDIR% --logsPath=%VSCODELOGSDIR% --no-cached-data --disable-updates --use-inmemory-secretstorage --disable-extensions --disable-workspace-trust --user-data-dir=%VSCODEUSERDATADIR% echo. echo ### API tests (folder) diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index 33d00615359..3e26bb17a17 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -41,7 +41,7 @@ echo # Tests in the extension host -API_TESTS_EXTRA_ARGS="--disable-telemetry --skip-welcome --skip-release-notes --crash-reporter-directory=$VSCODECRASHDIR --logsPath=$VSCODELOGSDIR --no-cached-data --disable-updates --use-inmemory-secretstorage --disable-extensions --disable-workspace-trust --user-data-dir=$VSCODEUSERDATADIR" +API_TESTS_EXTRA_ARGS="--disable-telemetry --disable-experiments --skip-welcome --skip-release-notes --crash-reporter-directory=$VSCODECRASHDIR --logsPath=$VSCODELOGSDIR --no-cached-data --disable-updates --use-inmemory-secretstorage --disable-extensions --disable-workspace-trust --user-data-dir=$VSCODEUSERDATADIR" if [ -z "$INTEGRATION_TEST_APP_NAME" ]; then kill_app() { true; } diff --git a/scripts/test-remote-integration.bat b/scripts/test-remote-integration.bat index e79466424db..96288d35886 100644 --- a/scripts/test-remote-integration.bat +++ b/scripts/test-remote-integration.bat @@ -55,7 +55,7 @@ echo Storing log files into '%VSCODELOGSDIR%' :: Tests in the extension host -set API_TESTS_EXTRA_ARGS=--disable-telemetry --skip-welcome --skip-release-notes --crash-reporter-directory=%VSCODECRASHDIR% --logsPath=%VSCODELOGSDIR% --no-cached-data --disable-updates --use-inmemory-secretstorage --disable-inspect --disable-workspace-trust --user-data-dir=%VSCODEUSERDATADIR% +set API_TESTS_EXTRA_ARGS=--disable-telemetry --disable-experiments --skip-welcome --skip-release-notes --crash-reporter-directory=%VSCODECRASHDIR% --logsPath=%VSCODELOGSDIR% --no-cached-data --disable-updates --use-inmemory-secretstorage --disable-inspect --disable-workspace-trust --user-data-dir=%VSCODEUSERDATADIR% echo. echo ### API tests (folder) diff --git a/scripts/test-remote-integration.sh b/scripts/test-remote-integration.sh index 7325757418e..7224216b2c0 100755 --- a/scripts/test-remote-integration.sh +++ b/scripts/test-remote-integration.sh @@ -65,7 +65,7 @@ else kill_app() { killall $INTEGRATION_TEST_APP_NAME || true; } fi -API_TESTS_EXTRA_ARGS="--disable-telemetry --skip-welcome --skip-release-notes --crash-reporter-directory=$VSCODECRASHDIR --logsPath=$VSCODELOGSDIR --no-cached-data --disable-updates --use-inmemory-secretstorage --disable-workspace-trust --user-data-dir=$VSCODEUSERDATADIR" +API_TESTS_EXTRA_ARGS="--disable-telemetry --disable-experiments --skip-welcome --skip-release-notes --crash-reporter-directory=$VSCODECRASHDIR --logsPath=$VSCODELOGSDIR --no-cached-data --disable-updates --use-inmemory-secretstorage --disable-workspace-trust --user-data-dir=$VSCODEUSERDATADIR" echo "Storing crash reports into '$VSCODECRASHDIR'." echo "Storing log files into '$VSCODELOGSDIR'." diff --git a/src/tsconfig.json b/src/tsconfig.json index 88d3daa0f29..bfda0ebafc1 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -31,5 +31,8 @@ "./vs/**/*.ts", "./vscode-dts/vscode.proposed.*.d.ts", "./vscode-dts/vscode.d.ts" + ], + "exclude": [ + "vs/workbench/contrib/webview/browser/pre/service-worker.js" ] } diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 1c7c66f76aa..8c0484bc21f 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -1770,6 +1770,82 @@ export const basicMarkupHtmlTags = Object.freeze([ 'wbr', ]); +export const trustedMathMlTags = Object.freeze([ + 'semantics', + 'annotation', + 'math', + 'menclose', + 'merror', + 'mfenced', + 'mfrac', + 'mglyph', + 'mi', + 'mlabeledtr', + 'mmultiscripts', + 'mn', + 'mo', + 'mover', + 'mpadded', + 'mphantom', + 'mroot', + 'mrow', + 'ms', + 'mspace', + 'msqrt', + 'mstyle', + 'msub', + 'msup', + 'msubsup', + 'mtable', + 'mtd', + 'mtext', + 'mtr', + 'munder', + 'munderover', + 'mprescripts', + + // svg tags + 'svg', + 'altglyph', + 'altglyphdef', + 'altglyphitem', + 'circle', + 'clippath', + 'defs', + 'desc', + 'ellipse', + 'filter', + 'font', + 'g', + 'glyph', + 'glyphref', + 'hkern', + 'line', + 'lineargradient', + 'marker', + 'mask', + 'metadata', + 'mpath', + 'path', + 'pattern', + 'polygon', + 'polyline', + 'radialgradient', + 'rect', + 'stop', + 'style', + 'switch', + 'symbol', + 'text', + 'textpath', + 'title', + 'tref', + 'tspan', + 'view', + 'vkern', +]); + + const defaultDomPurifyConfig = Object.freeze({ ALLOWED_TAGS: ['a', 'button', 'blockquote', 'code', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'input', 'label', 'li', 'p', 'pre', 'select', 'small', 'span', 'strong', 'textarea', 'ul', 'ol'], ALLOWED_ATTR: ['href', 'data-href', 'data-command', 'target', 'title', 'name', 'src', 'alt', 'class', 'id', 'role', 'tabindex', 'style', 'data-code', 'width', 'height', 'align', 'x-dispatch', 'required', 'checked', 'placeholder', 'type', 'start'], diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index 06aec51b9bb..d4dc25f94f6 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -40,9 +40,11 @@ export interface MarkdownRenderOptions extends FormattedTextRenderOptions { } export interface ISanitizerOptions { - replaceWithPlaintext?: boolean; - allowedTags?: string[]; - allowedProductProtocols?: string[]; + readonly replaceWithPlaintext?: boolean; + readonly allowedTags?: readonly string[]; + readonly customAttrSanitizer?: (attrName: string, attrValue: string) => boolean | string; + readonly allowedSchemes?: readonly string[]; + readonly allowedProductProtocols?: readonly string[]; } const defaultMarkedRenderers = Object.freeze({ @@ -107,14 +109,14 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende const element = createElement(options); const markedInstance = new marked.Marked(...(markedOptions.markedExtensions ?? [])); - const { renderer, codeBlocks, syncCodeBlocks } = createMarkdownRenderer(markedInstance, options, markdown); + const { renderer, codeBlocks, syncCodeBlocks } = createMarkdownRenderer(markedInstance, options, markedOptions, markdown); const value = preprocessMarkdownString(markdown); let renderedMarkdown: string; if (options.fillInIncompleteTokens) { // The defaults are applied by parse but not lexer()/parser(), and they need to be present const opts: MarkedOptions = { - ...marked.defaults, + ...markedInstance.defaults, ...markedOptions, renderer }; @@ -245,8 +247,8 @@ function rewriteRenderedLinks(markdown: IMarkdownString, options: MarkdownRender } } -function createMarkdownRenderer(marked: marked.Marked, options: MarkdownRenderOptions, markdown: IMarkdownString): { renderer: marked.Renderer; codeBlocks: Promise<[string, HTMLElement]>[]; syncCodeBlocks: [string, HTMLElement][] } { - const renderer = new marked.Renderer(); +function createMarkdownRenderer(marked: marked.Marked, options: MarkdownRenderOptions, markedOptions: MarkedOptions, markdown: IMarkdownString): { renderer: marked.Renderer; codeBlocks: Promise<[string, HTMLElement]>[]; syncCodeBlocks: [string, HTMLElement][] } { + const renderer = new marked.Renderer(markedOptions); renderer.image = defaultMarkedRenderers.image; renderer.link = defaultMarkedRenderers.link; renderer.paragraph = defaultMarkedRenderers.paragraph; @@ -397,7 +399,7 @@ function resolveWithBaseUri(baseUri: URI, href: string): string { } interface IInternalSanitizerOptions extends ISanitizerOptions { - isTrusted?: boolean | MarkdownStringTrustedOptions; + readonly isTrusted?: boolean | MarkdownStringTrustedOptions; } const selfClosingTags = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']; @@ -409,6 +411,21 @@ function sanitizeRenderedMarkdown( const { config, allowedSchemes } = getSanitizerOptions(options); const store = new DisposableStore(); store.add(addDompurifyHook('uponSanitizeAttribute', (element, e) => { + if (options.customAttrSanitizer) { + const result = options.customAttrSanitizer(e.attrName, e.attrValue); + if (typeof result === 'string') { + if (result) { + e.attrValue = result; + e.keepAttr = true; + } else { + e.keepAttr = false; + } + } else { + e.keepAttr = result; + } + return; + } + if (e.attrName === 'style' || e.attrName === 'class') { if (element.tagName === 'SPAN') { if (e.attrName === 'style') { @@ -419,6 +436,7 @@ function sanitizeRenderedMarkdown( return; } } + e.keepAttr = false; return; } else if (element.tagName === 'INPUT' && element.attributes.getNamedItem('type')?.value === 'checkbox') { @@ -546,7 +564,7 @@ function getSanitizerOptions(options: IInternalSanitizerOptions): { config: domp // Since we have our own sanitize function for marked, it's possible we missed some tag so let dompurify make sure. // HTML tags that can result from markdown are from reading https://spec.commonmark.org/0.29/ // HTML table tags that can result from markdown are from https://github.github.com/gfm/#tables-extension- - ALLOWED_TAGS: options.allowedTags ?? [...DOM.basicMarkupHtmlTags], + ALLOWED_TAGS: options.allowedTags ? [...options.allowedTags] : [...DOM.basicMarkupHtmlTags], ALLOWED_ATTR: allowedMarkdownAttr, ALLOW_UNKNOWN_PROTOCOLS: true, }, @@ -727,7 +745,7 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par } // Contains the start of link text, and no following tokens contain the link target - else if (lastLine.match(/(^|\s)\[\w*/)) { + else if (lastLine.match(/(^|\s)\[\w*[^\]]*$/)) { return completeLinkText(token); } } diff --git a/src/vs/base/browser/ui/actionbar/actionViewItems.ts b/src/vs/base/browser/ui/actionbar/actionViewItems.ts index 84987a24288..86b3fdcb28b 100644 --- a/src/vs/base/browser/ui/actionbar/actionViewItems.ts +++ b/src/vs/base/browser/ui/actionbar/actionViewItems.ts @@ -228,16 +228,11 @@ export class BaseActionViewItem extends Disposable implements IActionViewItem { const title = this.getTooltip() ?? ''; this.updateAriaLabel(); - if (this.options.hoverDelegate?.showNativeHover) { - /* While custom hover is not inside custom hover */ - this.element.title = title; - } else { - if (!this.customHover && title !== '') { - const hoverDelegate = this.options.hoverDelegate ?? getDefaultHoverDelegate('element'); - this.customHover = this._store.add(getBaseLayerHoverDelegate().setupManagedHover(hoverDelegate, this.element, title)); - } else if (this.customHover) { - this.customHover.update(title); - } + if (!this.customHover && title !== '') { + const hoverDelegate = this.options.hoverDelegate ?? getDefaultHoverDelegate('element'); + this.customHover = this._store.add(getBaseLayerHoverDelegate().setupManagedHover(hoverDelegate, this.element, title)); + } else if (this.customHover) { + this.customHover.update(title); } } diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index f8add4a6aab..b6f4840005e 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -333,9 +333,7 @@ export class Button extends Disposable implements IButton { } setTitle(title: string) { - if (this.options.hoverDelegate?.showNativeHover) { - this._element.title = title; - } else if (!this._hover && title !== '') { + if (!this._hover && title !== '') { this._hover = this._register(getBaseLayerHoverDelegate().setupManagedHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('element'), this._element, title)); } else if (this._hover) { this._hover.update(title); diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf index 8d8e3a824b0..29a5a9895e0 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/dialog/dialog.css b/src/vs/base/browser/ui/dialog/dialog.css index 9a15ab6a2d9..2260cee2b66 100644 --- a/src/vs/base/browser/ui/dialog/dialog.css +++ b/src/vs/base/browser/ui/dialog/dialog.css @@ -143,6 +143,7 @@ cursor: pointer; user-select: none; -webkit-user-select: none; + flex: 1; } /** Dialog: Input */ diff --git a/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts b/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts index 203caa79c61..0c6cb1f1fa7 100644 --- a/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts +++ b/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts @@ -135,16 +135,11 @@ export class HighlightedLabel extends Disposable { dom.reset(this.domNode, ...children); - if (this.options?.hoverDelegate?.showNativeHover) { - /* While custom hover is not inside custom hover */ - this.domNode.title = this.title; - } else { - if (!this.customHover && this.title !== '') { - const hoverDelegate = this.options?.hoverDelegate ?? getDefaultHoverDelegate('mouse'); - this.customHover = this._register(getBaseLayerHoverDelegate().setupManagedHover(hoverDelegate, this.domNode, this.title)); - } else if (this.customHover) { - this.customHover.update(this.title); - } + if (!this.customHover && this.title !== '') { + const hoverDelegate = this.options?.hoverDelegate ?? getDefaultHoverDelegate('mouse'); + this.customHover = this._register(getBaseLayerHoverDelegate().setupManagedHover(hoverDelegate, this.domNode, this.title)); + } else if (this.customHover) { + this.customHover.update(this.title); } this.didEverRender = true; diff --git a/src/vs/base/browser/ui/hover/hoverWidget.css b/src/vs/base/browser/ui/hover/hoverWidget.css index c8138b37c16..ccaac1ed9b2 100644 --- a/src/vs/base/browser/ui/hover/hoverWidget.css +++ b/src/vs/base/browser/ui/hover/hoverWidget.css @@ -207,3 +207,14 @@ opacity: 0.4; cursor: default; } + +/* Prevent text selection in all button-like elements within hovers */ +.monaco-hover .action-container, +.monaco-hover .action, +.monaco-hover button, +.monaco-hover .monaco-button, +.monaco-hover .monaco-text-button, +.monaco-hover [role="button"] { + -webkit-user-select: none; + user-select: none; +} diff --git a/src/vs/base/browser/ui/iconLabel/iconLabel.ts b/src/vs/base/browser/ui/iconLabel/iconLabel.ts index 986b3792068..4d6d6431283 100644 --- a/src/vs/base/browser/ui/iconLabel/iconLabel.ts +++ b/src/vs/base/browser/ui/iconLabel/iconLabel.ts @@ -15,8 +15,6 @@ import { Range } from '../../../common/range.js'; import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js'; import type { IManagedHoverTooltipMarkdownString } from '../hover/hover.js'; import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js'; -import { isString } from '../../../common/types.js'; -import { stripIcons } from '../../../common/iconLabels.js'; import { URI } from '../../../common/uri.js'; export interface IIconLabelCreationOptions { @@ -222,23 +220,9 @@ export class IconLabel extends Disposable { hoverTarget = this.creationOptions.hoverTargetOverride; } - if (this.hoverDelegate.showNativeHover) { - function setupNativeHover(htmlElement: HTMLElement, tooltip: string | IManagedHoverTooltipMarkdownString | undefined): void { - if (isString(tooltip)) { - // Icons don't render in the native hover so we strip them out - htmlElement.title = stripIcons(tooltip); - } else if (tooltip?.markdownNotSupportedFallback) { - htmlElement.title = tooltip.markdownNotSupportedFallback; - } else { - htmlElement.removeAttribute('title'); - } - } - setupNativeHover(hoverTarget, tooltip); - } else { - const hoverDisposable = getBaseLayerHoverDelegate().setupManagedHover(this.hoverDelegate, hoverTarget, tooltip); - if (hoverDisposable) { - this.customHovers.set(htmlElement, hoverDisposable); - } + const hoverDisposable = getBaseLayerHoverDelegate().setupManagedHover(this.hoverDelegate, hoverTarget, tooltip); + if (hoverDisposable) { + this.customHovers.set(htmlElement, hoverDisposable); } } diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index 44735ae4b66..015088e035e 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -32,7 +32,7 @@ interface IAsyncDataTreeNode { readonly parent: IAsyncDataTreeNode | null; readonly children: IAsyncDataTreeNode[]; readonly id?: string | null; - refreshPromise: Promise | undefined; + refreshPromise: CancelablePromise | undefined; hasChildren: boolean; stale: boolean; slow: boolean; @@ -528,7 +528,7 @@ export class AsyncDataTree implements IDisposable private readonly findController?: AsyncFindController; private readonly getDefaultCollapseState: { (e: T): undefined | ObjectTreeElementCollapseState.PreserveOrCollapsed | ObjectTreeElementCollapseState.PreserveOrExpanded }; - private readonly subTreeRefreshPromises = new Map, Promise>(); + private readonly subTreeRefreshPromises = new Map, CancelablePromise>(); private readonly refreshPromises = new Map, CancelablePromise>>(); protected readonly identityProvider?: IIdentityProvider; @@ -769,8 +769,7 @@ export class AsyncDataTree implements IDisposable } async setInput(input: TInput, viewState?: IAsyncDataTreeViewState): Promise { - this.refreshPromises.forEach(promise => promise.cancel()); - this.refreshPromises.clear(); + this.cancelAllRefreshPromises(); this.root.element = input!; @@ -792,6 +791,14 @@ export class AsyncDataTree implements IDisposable await this._updateChildren(element, recursive, rerender, undefined, options); } + cancelAllRefreshPromises(): void { + this.refreshPromises.forEach(promise => promise.cancel()); + this.refreshPromises.clear(); + + this.subTreeRefreshPromises.forEach(promise => promise.cancel()); + this.subTreeRefreshPromises.clear(); + } + private async _updateChildren(element: TInput | T = this.root.element, recursive = true, rerender = false, viewStateContext?: IAsyncDataTreeViewStateContext, options?: IAsyncDataTreeUpdateChildrenOptions): Promise { if (typeof this.root.element === 'undefined') { throw new TreeError(this.user, 'Tree input not set'); @@ -875,7 +882,7 @@ export class AsyncDataTree implements IDisposable } if (node.refreshPromise) { - await this.root.refreshPromise; + await node.refreshPromise; await Event.toPromise(this._onDidRender.event); } @@ -886,7 +893,7 @@ export class AsyncDataTree implements IDisposable const result = this.tree.expand(node === this.root ? null : node, recursive); if (node.refreshPromise) { - await this.root.refreshPromise; + await node.refreshPromise; await Event.toPromise(this._onDidRender.event); } @@ -1088,28 +1095,26 @@ export class AsyncDataTree implements IDisposable return; } } - return this.doRefreshSubTree(node, recursive, viewStateContext); } private async doRefreshSubTree(node: IAsyncDataTreeNode, recursive: boolean, viewStateContext?: IAsyncDataTreeViewStateContext): Promise { - let done: () => void; - node.refreshPromise = new Promise(c => done = c); - this.subTreeRefreshPromises.set(node, node.refreshPromise); - - node.refreshPromise.finally(() => { - node.refreshPromise = undefined; - this.subTreeRefreshPromises.delete(node); - }); - - try { + const cancelablePromise = createCancelablePromise(async () => { const childrenToRefresh = await this.doRefreshNode(node, recursive, viewStateContext); node.stale = false; await Promises.settled(childrenToRefresh.map(child => this.doRefreshSubTree(child, recursive, viewStateContext))); - } finally { - done!(); - } + }); + + node.refreshPromise = cancelablePromise; + this.subTreeRefreshPromises.set(node, cancelablePromise); + + cancelablePromise.finally(() => { + node.refreshPromise = undefined; + this.subTreeRefreshPromises.delete(node); + }); + + return cancelablePromise; } private async doRefreshNode(node: IAsyncDataTreeNode, recursive: boolean, viewStateContext?: IAsyncDataTreeViewStateContext): Promise[]> { diff --git a/src/vs/base/browser/webWorkerFactory.ts b/src/vs/base/browser/webWorkerFactory.ts index 58a43681228..d55ef7601cc 100644 --- a/src/vs/base/browser/webWorkerFactory.ts +++ b/src/vs/base/browser/webWorkerFactory.ts @@ -129,13 +129,14 @@ class WebWorker extends Disposable implements IWebWorker { private readonly _onError = this._register(new Emitter()); public readonly onError = this._onError.event; - constructor(descriptorOrWorker: IWebWorkerDescriptor | Worker) { + constructor(descriptorOrWorker: IWebWorkerDescriptor | Worker | Promise) { super(); this.id = ++WebWorker.LAST_WORKER_ID; const workerOrPromise = ( descriptorOrWorker instanceof Worker - ? descriptorOrWorker - : getWorker(descriptorOrWorker, this.id) + ? descriptorOrWorker : + 'then' in descriptorOrWorker ? descriptorOrWorker + : getWorker(descriptorOrWorker, this.id) ); if (isPromiseLike(workerOrPromise)) { this.worker = workerOrPromise; @@ -197,8 +198,8 @@ export class WebWorkerDescriptor implements IWebWorkerDescriptor { } 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 { +export function createWebWorker(workerDescriptor: IWebWorkerDescriptor | Worker | Promise): IWebWorkerClient; +export function createWebWorker(arg0: URI | IWebWorkerDescriptor | Worker | Promise, 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/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 2c819c26f12..0421cac571d 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -608,4 +608,5 @@ export const codiconsLibrary = { chatSparkle: register('chat-sparkle', 0xec4f), searchSparkle: register('search-sparkle', 0xec50), editSparkle: register('edit-sparkle', 0xec51), + copilotSnooze: register('copilot-snooze', 0xec52), } as const; diff --git a/src/vs/base/common/observableInternal/changeTracker.ts b/src/vs/base/common/observableInternal/changeTracker.ts index 3cbddc534a8..b29fb62ce40 100644 --- a/src/vs/base/common/observableInternal/changeTracker.ts +++ b/src/vs/base/common/observableInternal/changeTracker.ts @@ -53,3 +53,42 @@ export function recordChanges>>(getObs: () => TObs): + IChangeTracker<{ [TKey in keyof TObs]: ReturnType } + & { changes: readonly ({ [TKey in keyof TObs]: { key: TKey; change: TObs[TKey]['TChange'] } }[keyof TObs])[] }> { + let obs: TObs | undefined = undefined; + return { + createChangeSummary: (_previousChangeSummary) => { + return { + changes: [], + } as any; + }, + handleChange(ctx, changeSummary) { + if (!obs) { + obs = getObs(); + } + for (const key in obs) { + if (ctx.didChange(obs[key])) { + (changeSummary.changes as any).push({ key, change: ctx.change }); + } + } + return true; + }, + beforeUpdate(reader, changeSummary) { + if (!obs) { + obs = getObs(); + } + 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/index.ts b/src/vs/base/common/observableInternal/index.ts index df8df29a80c..d54520780d1 100644 --- a/src/vs/base/common/observableInternal/index.ts +++ b/src/vs/base/common/observableInternal/index.ts @@ -20,14 +20,14 @@ export { signalFromObservable, wasEventTriggeredRecently, } from './utils/utils.js'; export { type DebugOwner } from './debugName.js'; -export { type IChangeContext, type IChangeTracker, recordChanges } from './changeTracker.js'; +export { type IChangeContext, type IChangeTracker, recordChanges, recordChangesLazy } from './changeTracker.js'; export { constObservable } from './observables/constObservable.js'; export { type IObservableSignal, observableSignal } from './observables/observableSignal.js'; export { observableFromEventOpts } from './observables/observableFromEvent.js'; export { observableSignalFromEvent } from './observables/observableSignalFromEvent.js'; export { asyncTransaction, globalTransaction, subtransaction, transaction, TransactionImpl } from './transaction.js'; export { observableFromValueWithChangeEvent, ValueWithChangeEventFromObservable } from './utils/valueWithChangeEvent.js'; -export { runOnChange, runOnChangeWithCancellationToken, runOnChangeWithStore } from './utils/runOnChange.js'; +export { runOnChange, runOnChangeWithCancellationToken, runOnChangeWithStore, type RemoveUndefined } from './utils/runOnChange.js'; export { derivedConstOnceDefined, latestChangedValue } from './experimental/utils.js'; export { observableFromEvent } from './observables/observableFromEvent.js'; export { observableValue } from './observables/observableValue.js'; diff --git a/src/vs/base/common/observableInternal/logging/debugger/debuggerApi.d.ts b/src/vs/base/common/observableInternal/logging/debugger/debuggerApi.d.ts index 138732f44b2..10557a75864 100644 --- a/src/vs/base/common/observableInternal/logging/debugger/debuggerApi.d.ts +++ b/src/vs/base/common/observableInternal/logging/debugger/debuggerApi.d.ts @@ -24,9 +24,15 @@ export type ObsDebuggerApi = { getDerivedInfo(instanceId: ObsInstanceId): IDerivedObservableDetailedInfo; getAutorunInfo(instanceId: ObsInstanceId): IAutorunDetailedInfo; getObservableValueInfo(instanceId: ObsInstanceId): IObservableValueInfo; + setValue(instanceId: ObsInstanceId, jsonValue: unknown): void; getValue(instanceId: ObsInstanceId): unknown; + // For autorun and deriveds + rerun(instanceId: ObsInstanceId): void; + + logValue(instanceId: ObsInstanceId): void; + getTransactionState(): ITransactionState | undefined; } }; diff --git a/src/vs/base/common/observableInternal/logging/debugger/devToolsLogger.ts b/src/vs/base/common/observableInternal/logging/debugger/devToolsLogger.ts index 2405a11138c..513921408a5 100644 --- a/src/vs/base/common/observableInternal/logging/debugger/devToolsLogger.ts +++ b/src/vs/base/common/observableInternal/logging/debugger/devToolsLogger.ts @@ -135,7 +135,25 @@ export class DevToolsLogger implements IObservableLogger { } return undefined; - } + }, + logValue: (instanceId) => { + const obs = this._aliveInstances.get(instanceId); + if (obs && 'get' in obs) { + console.log('Logged Value:', obs.get()); + } else { + throw new BugIndicatingError('Observable is not supported'); + } + }, + rerun: (instanceId) => { + const obs = this._aliveInstances.get(instanceId); + if (obs instanceof Derived) { + obs.debugRecompute(); + } else if (obs instanceof AutorunObserver) { + obs.debugRerun(); + } else { + throw new BugIndicatingError('Observable is not supported'); + } + }, } }; }); diff --git a/src/vs/base/common/observableInternal/observables/derivedImpl.ts b/src/vs/base/common/observableInternal/observables/derivedImpl.ts index bbedd1518a6..011e9c76f18 100644 --- a/src/vs/base/common/observableInternal/observables/derivedImpl.ts +++ b/src/vs/base/common/observableInternal/observables/derivedImpl.ts @@ -400,6 +400,14 @@ export class Derived extends BaseObserv this._value = newValue as any; } + public debugRecompute(): void { + if (!this._isComputing) { + this._recompute(); + } else { + this._state = DerivedState.stale; + } + } + public setValue(newValue: T, tx: ITransaction, change: TChange): void { this._value = newValue; const observers = this._observers; diff --git a/src/vs/base/common/platform.ts b/src/vs/base/common/platform.ts index 4bc8f61532b..cf76f7ce0e1 100644 --- a/src/vs/base/common/platform.ts +++ b/src/vs/base/common/platform.ts @@ -77,7 +77,7 @@ if (typeof nodeProcess === 'object') { _isLinux = (nodeProcess.platform === 'linux'); _isLinuxSnap = _isLinux && !!nodeProcess.env['SNAP'] && !!nodeProcess.env['SNAP_REVISION']; _isElectron = isElectronProcess; - _isCI = !!nodeProcess.env['CI'] || !!nodeProcess.env['BUILD_ARTIFACTSTAGINGDIRECTORY']; + _isCI = !!nodeProcess.env['CI'] || !!nodeProcess.env['BUILD_ARTIFACTSTAGINGDIRECTORY'] || !!nodeProcess.env['GITHUB_WORKSPACE']; _locale = LANGUAGE_DEFAULT; _language = LANGUAGE_DEFAULT; const rawNlsConfig = nodeProcess.env['VSCODE_NLS_CONFIG']; diff --git a/src/vs/base/common/policy.ts b/src/vs/base/common/policy.ts index befbff12794..e814b6e8e12 100644 --- a/src/vs/base/common/policy.ts +++ b/src/vs/base/common/policy.ts @@ -28,10 +28,19 @@ export interface IPolicy { readonly previewFeature?: boolean; /** - * Default value for a 'previewFeature' policy. Default is `false`. - * Remarks: - * A default value is only relevant when previewFeature is `true`. - * In all other instances, a value is required when setting a policy. - */ + * The value that a preview feature will use when its corresponding policy is active. + * + * Only applicable when `previewFeature: true`. When a preview feature's policy is enabled, + * this value determines what value the feature receives. + * + * For example: + * - If `defaultValue: true`, the feature's setting is locked to `true` WHEN the policy is in effect. + * - If `defaultValue: 'foo'`, the feature's setting is locked to 'foo' WHEN the policy is in effect. + * + * If omitted, 'false' is the assumed value. + * + * Note: This is unrelated to VS Code settings and their default values. This specifically controls + * the value of a preview feature's setting when policy is overriding it. + */ readonly defaultValue?: string | number | boolean; } diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 4f18173228a..0743b45df6c 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -337,12 +337,13 @@ export interface IDefaultChatAgent { readonly upgradePlanUrl: string; readonly signUpUrl: string; - readonly providerId: string; - readonly providerName: string; - readonly enterpriseProviderId: string; - readonly enterpriseProviderName: string; - readonly alternativeProviderId: string; - readonly alternativeProviderName: string; + readonly provider: { + default: { id: string; name: string }; + enterprise: { id: string; name: string }; + google: { id: string; name: string }; + apple: { id: string; name: string }; + }; + readonly providerUriSetting: string; readonly providerScopes: string[][]; diff --git a/src/vs/base/common/worker/webWorker.ts b/src/vs/base/common/worker/webWorker.ts index 666bd15aa03..999f490c590 100644 --- a/src/vs/base/common/worker/webWorker.ts +++ b/src/vs/base/common/worker/webWorker.ts @@ -315,7 +315,7 @@ export class WebWorkerClient extends Disposable implements IWe ) { super(); - this._worker = worker; + this._worker = this._register(worker); this._register(this._worker.onMessage((msg) => { this._protocol.handleMessage(msg); })); diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts index cd2a7ceda50..cacc1bb404b 100644 --- a/src/vs/base/test/browser/markdownRenderer.test.ts +++ b/src/vs/base/test/browser/markdownRenderer.test.ts @@ -958,6 +958,14 @@ suite('MarkdownRenderer', () => { assert.deepStrictEqual(newTokens, tokens); }); + test('square braces in text', () => { + const incomplete = 'hello [what] is going on'; + const tokens = marked.marked.lexer(incomplete); + const newTokens = fillInIncompleteTokens(tokens); + + assert.deepStrictEqual(newTokens, tokens); + }); + test('complete link', () => { const incomplete = 'text [link](http://microsoft.com)'; const tokens = marked.marked.lexer(incomplete); diff --git a/src/vs/base/test/browser/ui/tree/asyncDataTree.test.ts b/src/vs/base/test/browser/ui/tree/asyncDataTree.test.ts index 985c97e9d1a..4eb5e2b7e28 100644 --- a/src/vs/base/test/browser/ui/tree/asyncDataTree.test.ts +++ b/src/vs/base/test/browser/ui/tree/asyncDataTree.test.ts @@ -307,7 +307,7 @@ suite('AsyncDataTree', function () { assert(!aNode.collapsed); assert.equal(aNode.children.length, 1); assert.equal(aNode.children[0].element.id, 'b'); - const bChild = container.querySelector('.monaco-list-row:nth-child(2)') as HTMLElement | undefined; + const bChild = container.querySelector('.monaco-list-row:nth-child(2)'); assert.equal(bChild?.textContent, 'b'); tree.collapse(a); assert(aNode.collapsed); @@ -319,8 +319,8 @@ suite('AsyncDataTree', function () { assert.equal(aNodeUpdated1.children.length, 0); let didCheckNoChildren = false; const event = tree.onDidChangeCollapseState(e => { - const child = container.querySelector('.monaco-list-row:nth-child(2)') as HTMLElement | undefined; - assert.equal(child, undefined); + const child = container.querySelector('.monaco-list-row:nth-child(2)'); + assert.equal(child, null); didCheckNoChildren = true; }); await tree.expand(aUpdated1); @@ -331,7 +331,7 @@ suite('AsyncDataTree', function () { assert(!aNodeUpdated2.collapsed); assert.equal(aNodeUpdated2.children.length, 1); assert.equal(aNodeUpdated2.children[0].element.id, 'c'); - const child = container.querySelector('.monaco-list-row:nth-child(2)') as HTMLElement | undefined; + const child = container.querySelector('.monaco-list-row:nth-child(2)'); assert.equal(child?.textContent, 'c'); }); @@ -364,7 +364,7 @@ suite('AsyncDataTree', function () { assert(!aNode.collapsed); assert.equal(aNode.children.length, 1); assert.equal(aNode.children[0].element.id, 'b'); - const bChild = container.querySelector('.monaco-list-row:nth-child(2)') as HTMLElement | undefined; + const bChild = container.querySelector('.monaco-list-row:nth-child(2)'); assert.equal(bChild?.textContent, 'b'); tree.collapse(a); assert(aNode.collapsed); @@ -375,7 +375,7 @@ suite('AsyncDataTree', function () { assert.equal(aNodeUpdated1.children.length, 1); let didCheckSameChildren = false; const event = tree.onDidChangeCollapseState(e => { - const child = container.querySelector('.monaco-list-row:nth-child(2)') as HTMLElement | undefined; + const child = container.querySelector('.monaco-list-row:nth-child(2)'); assert.equal(child?.textContent, 'b'); didCheckSameChildren = true; }); @@ -387,7 +387,7 @@ suite('AsyncDataTree', function () { assert(!aNodeUpdated2.collapsed); assert.equal(aNodeUpdated2.children.length, 1); assert.equal(aNodeUpdated2.children[0].element.id, 'b'); - const child = container.querySelector('.monaco-list-row:nth-child(2)') as HTMLElement | undefined; + const child = container.querySelector('.monaco-list-row:nth-child(2)'); assert.equal(child?.textContent, 'b'); }); diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index dc6983c8b0d..90721608230 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -507,9 +507,18 @@ class CodeMain { } if (args.chat) { - // If we are started with chat subcommand, the current working - // directory is always the path to open - args._ = [cwd()]; + if (args.chat['new-window']) { + // Apply `--new-window` flag to the main arguments + args['new-window'] = true; + } else if (args.chat['reuse-window']) { + // Apply `--reuse-window` flag to the main arguments + args['reuse-window'] = true; + } else { + // Unless we are started with specific instructions about + // new windows or reusing existing ones, always take the + // current working directory as workspace to open. + args._ = [cwd()]; + } } return args; diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 87bc0f1cb16..ae32ea5de9b 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -124,6 +124,11 @@ import { IExtensionGalleryManifestService } from '../../../platform/extensionMan import { ExtensionGalleryManifestIPCService } from '../../../platform/extensionManagement/common/extensionGalleryManifestServiceIpc.js'; import { ISharedWebContentExtractorService } from '../../../platform/webContentExtractor/common/webContentExtractor.js'; import { SharedWebContentExtractorService } from '../../../platform/webContentExtractor/node/sharedWebContentExtractorService.js'; +import { McpManagementService } from '../../../platform/mcp/node/mcpManagementService.js'; +import { IMcpGalleryService, IMcpManagementService } from '../../../platform/mcp/common/mcpManagement.js'; +import { IMcpResourceScannerService, McpResourceScannerService } from '../../../platform/mcp/common/mcpResourceScannerService.js'; +import { McpGalleryService } from '../../../platform/mcp/common/mcpGalleryService.js'; +import { McpManagementChannel } from '../../../platform/mcp/common/mcpManagementIpc.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -334,6 +339,11 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { services.set(IAllowedExtensionsService, new SyncDescriptor(AllowedExtensionsService, undefined, true)); services.set(INativeServerExtensionManagementService, new SyncDescriptor(ExtensionManagementService, undefined, true)); + // MCP Management + services.set(IMcpGalleryService, new SyncDescriptor(McpGalleryService, undefined, true)); + services.set(IMcpResourceScannerService, new SyncDescriptor(McpResourceScannerService, undefined, true)); + services.set(IMcpManagementService, new SyncDescriptor(McpManagementService, undefined, true)); + // Extension Gallery services.set(IExtensionGalleryManifestService, new ExtensionGalleryManifestIPCService(this.server, productService)); services.set(IExtensionGalleryService, new SyncDescriptor(ExtensionGalleryService, undefined, true)); @@ -388,6 +398,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { const channel = new ExtensionManagementChannel(accessor.get(IExtensionManagementService), () => null); this.server.registerChannel('extensions', channel); + // Mcp Management + const mcpManagementChannel = new McpManagementChannel(accessor.get(IMcpManagementService), () => null); + this.server.registerChannel('mcpManagement', mcpManagementChannel); + // Language Packs const languagePacksChannel = ProxyChannel.fromService(accessor.get(ILanguagePackService), this._store); this.server.registerChannel('languagePacks', languagePacksChannel); diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 6d2e397a68d..d89f3cfe530 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -242,6 +242,19 @@ export async function main(argv: string[]): Promise { }); } + // Handle --transient option + if (args['transient']) { + const tempParentDir = randomPath(tmpdir(), 'vscode'); + const tempUserDataDir = join(tempParentDir, 'data'); + const tempExtensionsDir = join(tempParentDir, 'extensions'); + + addArg(argv, '--user-data-dir', tempUserDataDir); + addArg(argv, '--extensions-dir', tempExtensionsDir); + addArg(argv, '--disable-updates'); + + console.log(`Warning: state is temporarily stored in: "${tempParentDir}"`); + } + const hasReadStdinArg = args._.some(arg => arg === '-') || args.chat?._.some(arg => arg === '-'); if (hasReadStdinArg) { // remove the "-" argument when we read from stdin diff --git a/src/vs/code/node/cliProcessMain.ts b/src/vs/code/node/cliProcessMain.ts index ad1cc2ae608..74a137b05b1 100644 --- a/src/vs/code/node/cliProcessMain.ts +++ b/src/vs/code/node/cliProcessMain.ts @@ -68,9 +68,10 @@ import { AllowedExtensionsService } from '../../platform/extensionManagement/com import { McpManagementCli } from '../../platform/mcp/common/mcpManagementCli.js'; import { IExtensionGalleryManifestService } from '../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { ExtensionGalleryManifestService } from '../../platform/extensionManagement/common/extensionGalleryManifestService.js'; -import { IMcpManagementService } from '../../platform/mcp/common/mcpManagement.js'; -import { McpManagementService } from '../../platform/mcp/common/mcpManagementService.js'; +import { IMcpGalleryService, IMcpManagementService } from '../../platform/mcp/common/mcpManagement.js'; +import { McpManagementService } from '../../platform/mcp/node/mcpManagementService.js'; import { IMcpResourceScannerService, McpResourceScannerService } from '../../platform/mcp/common/mcpResourceScannerService.js'; +import { McpGalleryService } from '../../platform/mcp/common/mcpGalleryService.js'; class CliMain extends Disposable { @@ -229,6 +230,7 @@ class CliMain extends Disposable { // MCP services.set(IMcpResourceScannerService, new SyncDescriptor(McpResourceScannerService, undefined, true)); + services.set(IMcpGalleryService, new SyncDescriptor(McpGalleryService, undefined, true)); services.set(IMcpManagementService, new SyncDescriptor(McpManagementService, undefined, true)); // Telemetry diff --git a/src/vs/editor/browser/editorBrowser.ts b/src/vs/editor/browser/editorBrowser.ts index 3de37886d17..c9284cd6662 100644 --- a/src/vs/editor/browser/editorBrowser.ts +++ b/src/vs/editor/browser/editorBrowser.ts @@ -1098,7 +1098,7 @@ export interface ICodeEditor extends editorCommon.IEditor { getTopForPosition(lineNumber: number, column: number): number; /** - * Get the line height for the line number. + * Get the line height for a model position. */ getLineHeightForPosition(position: IPosition): number; diff --git a/src/vs/editor/browser/gpu/atlas/textureAtlas.ts b/src/vs/editor/browser/gpu/atlas/textureAtlas.ts index 252a44596ef..13127f80e87 100644 --- a/src/vs/editor/browser/gpu/atlas/textureAtlas.ts +++ b/src/vs/editor/browser/gpu/atlas/textureAtlas.ts @@ -170,7 +170,7 @@ export class TextureAtlas extends Disposable { throw new BugIndicatingError('Cannot warm atlas without color map'); } this._warmUpTask.value?.clear(); - const taskQueue = this._warmUpTask.value = new IdleTaskQueue(); + const taskQueue = this._warmUpTask.value = this._instantiationService.createInstance(IdleTaskQueue); // Warm up using roughly the larger glyphs first to help optimize atlas allocation // A-Z for (let code = CharCode.A; code <= CharCode.Z; code++) { diff --git a/src/vs/editor/browser/gpu/renderStrategy/fullFileRenderStrategy.ts b/src/vs/editor/browser/gpu/renderStrategy/fullFileRenderStrategy.ts index 6db8ab9e476..9588c78c49c 100644 --- a/src/vs/editor/browser/gpu/renderStrategy/fullFileRenderStrategy.ts +++ b/src/vs/editor/browser/gpu/renderStrategy/fullFileRenderStrategy.ts @@ -10,7 +10,7 @@ import { CursorColumns } from '../../../common/core/cursorColumns.js'; import type { IViewLineTokens } from '../../../common/tokens/lineTokens.js'; import { ViewEventType, type ViewConfigurationChangedEvent, type ViewDecorationsChangedEvent, type ViewLineMappingChangedEvent, type ViewLinesChangedEvent, type ViewLinesDeletedEvent, type ViewLinesInsertedEvent, type ViewScrollChangedEvent, type ViewThemeChangedEvent, type ViewTokensChangedEvent, type ViewZonesChangedEvent } from '../../../common/viewEvents.js'; import type { ViewportData } from '../../../common/viewLayout/viewLinesViewportData.js'; -import type { InlineDecoration, ViewLineRenderingData } from '../../../common/viewModel.js'; +import type { ViewLineRenderingData } from '../../../common/viewModel.js'; import type { ViewContext } from '../../../common/viewModel/viewContext.js'; import type { ViewLineOptions } from '../../viewParts/viewLines/viewLineOptions.js'; import type { ITextureAtlasPageGlyph } from '../atlas/atlas.js'; @@ -22,6 +22,7 @@ import { quadVertices } from '../gpuUtils.js'; import { GlyphRasterizer } from '../raster/glyphRasterizer.js'; import { ViewGpuContext } from '../viewGpuContext.js'; import { BaseRenderStrategy } from './baseRenderStrategy.js'; +import { InlineDecoration } from '../../../common/viewModel/inlineDecorations.js'; const enum Constants { IndicesPerCell = 6, diff --git a/src/vs/editor/browser/gpu/renderStrategy/viewportRenderStrategy.ts b/src/vs/editor/browser/gpu/renderStrategy/viewportRenderStrategy.ts index d051c007716..9dbfa48a53f 100644 --- a/src/vs/editor/browser/gpu/renderStrategy/viewportRenderStrategy.ts +++ b/src/vs/editor/browser/gpu/renderStrategy/viewportRenderStrategy.ts @@ -11,7 +11,8 @@ import { CursorColumns } from '../../../common/core/cursorColumns.js'; import type { IViewLineTokens } from '../../../common/tokens/lineTokens.js'; import { type ViewConfigurationChangedEvent, type ViewDecorationsChangedEvent, type ViewLineMappingChangedEvent, type ViewLinesChangedEvent, type ViewLinesDeletedEvent, type ViewLinesInsertedEvent, type ViewScrollChangedEvent, type ViewThemeChangedEvent, type ViewTokensChangedEvent, type ViewZonesChangedEvent } from '../../../common/viewEvents.js'; import type { ViewportData } from '../../../common/viewLayout/viewLinesViewportData.js'; -import type { InlineDecoration, ViewLineRenderingData } from '../../../common/viewModel.js'; +import type { ViewLineRenderingData } from '../../../common/viewModel.js'; +import { InlineDecoration } from '../../../common/viewModel/inlineDecorations.js'; import type { ViewContext } from '../../../common/viewModel/viewContext.js'; import type { ViewLineOptions } from '../../viewParts/viewLines/viewLineOptions.js'; import type { ITextureAtlasPageGlyph } from '../atlas/atlas.js'; diff --git a/src/vs/editor/browser/gpu/taskQueue.ts b/src/vs/editor/browser/gpu/taskQueue.ts index a6cf28c8c86..7d7e7318900 100644 --- a/src/vs/editor/browser/gpu/taskQueue.ts +++ b/src/vs/editor/browser/gpu/taskQueue.ts @@ -5,6 +5,8 @@ import { getActiveWindow } from '../../../base/browser/dom.js'; import { Disposable, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../platform/log/common/log.js'; /** * Copyright (c) 2022 The xterm.js authors. All rights reserved. @@ -41,7 +43,9 @@ abstract class TaskQueue extends Disposable implements ITaskQueue { private _idleCallback?: number; private _i = 0; - constructor() { + constructor( + @ILogService private readonly _logService: ILogService + ) { super(); this._register(toDisposable(() => this.clear())); } @@ -101,7 +105,7 @@ abstract class TaskQueue extends Disposable implements ITaskQueue { // Warn when the time exceeding the deadline is over 20ms, if this happens in practice the // task should be split into sub-tasks to ensure the UI remains responsive. if (lastDeadlineRemaining - taskDuration < -20) { - console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`); + this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`); } this._start(); return; @@ -161,8 +165,10 @@ export const IdleTaskQueue = ('requestIdleCallback' in getActiveWindow()) ? Idle export class DebouncedIdleTask { private _queue: ITaskQueue; - constructor() { - this._queue = new IdleTaskQueue(); + constructor( + @IInstantiationService instantiationService: IInstantiationService + ) { + this._queue = instantiationService.createInstance(IdleTaskQueue); } public set(task: () => boolean | void): void { diff --git a/src/vs/editor/browser/gpu/viewGpuContext.ts b/src/vs/editor/browser/gpu/viewGpuContext.ts index 953e7c23f7a..4333d262ff6 100644 --- a/src/vs/editor/browser/gpu/viewGpuContext.ts +++ b/src/vs/editor/browser/gpu/viewGpuContext.ts @@ -22,8 +22,8 @@ import type { ViewContext } from '../../common/viewModel/viewContext.js'; import { DecorationCssRuleExtractor } from './css/decorationCssRuleExtractor.js'; import { Event } from '../../../base/common/event.js'; import { EditorOption, type IEditorOptions } from '../../common/config/editorOptions.js'; -import { InlineDecorationType } from '../../common/viewModel.js'; import { DecorationStyleCache } from './css/decorationStyleCache.js'; +import { InlineDecorationType } from '../../common/viewModel/inlineDecorations.js'; export class ViewGpuContext extends Disposable { /** diff --git a/src/vs/editor/browser/services/editorWorkerService.ts b/src/vs/editor/browser/services/editorWorkerService.ts index 77cc168f558..3475a737bec 100644 --- a/src/vs/editor/browser/services/editorWorkerService.ts +++ b/src/vs/editor/browser/services/editorWorkerService.ts @@ -33,6 +33,8 @@ import { mainWindow } from '../../../base/browser/window.js'; import { WindowIntervalTimer } from '../../../base/browser/dom.js'; import { WorkerTextModelSyncClient } from '../../common/services/textModelSync/textModelSync.impl.js'; import { EditorWorkerHost } from '../../common/services/editorWorkerHost.js'; +import { StringEdit } from '../../common/core/edits/stringEdit.js'; +import { OffsetRange } from '../../common/core/ranges/offsetRange.js'; /** * Stop the worker if it was not needed for 5 min. @@ -180,6 +182,17 @@ export abstract class EditorWorkerService extends Disposable implements IEditorW } } + public async computeStringEditFromDiff(original: string, modified: string, options: { maxComputationTimeMs: number }, algorithm: DiffAlgorithmName): Promise { + try { + const worker = await this._workerWithResources([]); + const edit = await worker.$computeStringDiff(original, modified, options, algorithm); + return StringEdit.fromJson(edit); + } catch (e) { + onUnexpectedError(e); + return StringEdit.replace(OffsetRange.ofLength(original.length), modified); // approximation + } + } + public canNavigateValueSet(resource: URI): boolean { return (canSyncModel(this._modelService, resource)); } @@ -414,7 +427,7 @@ export class EditorWorkerClient extends Disposable implements IEditorWorkerClien private _disposed = false; constructor( - private readonly _workerDescriptorOrWorker: IWebWorkerDescriptor | Worker, + private readonly _workerDescriptorOrWorker: IWebWorkerDescriptor | Worker | Promise, keepIdleModels: boolean, @IModelService modelService: IModelService, ) { diff --git a/src/vs/editor/browser/services/hoverService/hoverService.ts b/src/vs/editor/browser/services/hoverService/hoverService.ts index 523378c4c79..676e4b31545 100644 --- a/src/vs/editor/browser/services/hoverService/hoverService.ts +++ b/src/vs/editor/browser/services/hoverService/hoverService.ts @@ -20,16 +20,17 @@ import { IAccessibilityService } from '../../../../platform/accessibility/common import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { ContextViewHandler } from '../../../../platform/contextview/browser/contextViewService.js'; -import type { IHoverLifecycleOptions, IHoverOptions, IHoverWidget, IManagedHover, IManagedHoverContentOrFactory, IManagedHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; +import { isManagedHoverTooltipMarkdownString, type IHoverLifecycleOptions, type IHoverOptions, type IHoverWidget, type IManagedHover, type IManagedHoverContentOrFactory, type IManagedHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; import type { IHoverDelegate, IHoverDelegateTarget } from '../../../../base/browser/ui/hover/hoverDelegate.js'; import { ManagedHoverWidget } from './updatableHoverWidget.js'; import { timeout, TimeoutTimer } from '../../../../base/common/async.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { isNumber } from '../../../../base/common/types.js'; +import { isNumber, isString } from '../../../../base/common/types.js'; import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { stripIcons } from '../../../../base/common/iconLabels.js'; export class HoverService extends Disposable implements IHoverService { declare readonly _serviceBrand: undefined; @@ -368,6 +369,10 @@ export class HoverService extends Disposable implements IHoverService { // TODO: Investigate performance of this function. There seems to be a lot of content created // and thrown away on start up setupManagedHover(hoverDelegate: IHoverDelegate, targetElement: HTMLElement, content: IManagedHoverContentOrFactory, options?: IManagedHoverOptions | undefined): IManagedHover { + if (hoverDelegate.showNativeHover) { + return setupNativeHover(targetElement, content); + } + targetElement.setAttribute('custom-hover', 'true'); if (targetElement.title !== '') { @@ -520,6 +525,36 @@ function getHoverIdFromContent(content: string | HTMLElement | IMarkdownString): return content.value; } +function getStringContent(contentOrFactory: IManagedHoverContentOrFactory): string | undefined { + const content = typeof contentOrFactory === 'function' ? contentOrFactory() : contentOrFactory; + if (isString(content)) { + // Icons don't render in the native hover so we strip them out + return stripIcons(content); + } + if (isManagedHoverTooltipMarkdownString(content)) { + return content.markdownNotSupportedFallback; + } + return undefined; +} + +function setupNativeHover(targetElement: HTMLElement, content: IManagedHoverContentOrFactory): IManagedHover { + function updateTitle(title: string | undefined) { + if (title) { + targetElement.setAttribute('title', title); + } else { + targetElement.removeAttribute('title'); + } + } + + updateTitle(getStringContent(content)); + return { + update: (content) => updateTitle(getStringContent(content)), + show: () => { }, + hide: () => { }, + dispose: () => updateTitle(undefined), + }; +} + class HoverContextViewDelegate implements IDelegate { // Render over all other context views diff --git a/src/vs/editor/browser/services/inlineCompletionsService.ts b/src/vs/editor/browser/services/inlineCompletionsService.ts index 0573984ea22..33bb81c2e51 100644 --- a/src/vs/editor/browser/services/inlineCompletionsService.ts +++ b/src/vs/editor/browser/services/inlineCompletionsService.ts @@ -14,6 +14,7 @@ import { InstantiationType, registerSingleton } from '../../../platform/instanti import { createDecorator, ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { IQuickInputService, IQuickPickItem } from '../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; export const IInlineCompletionsService = createDecorator('IInlineCompletionsService'); @@ -69,7 +70,10 @@ export class InlineCompletionsService extends Disposable implements IInlineCompl private _timer: WindowIntervalTimer; - constructor(@IContextKeyService private _contextKeyService: IContextKeyService) { + constructor( + @IContextKeyService private _contextKeyService: IContextKeyService, + @ITelemetryService private _telemetryService: ITelemetryService, + ) { super(); this._timer = this._register(new WindowIntervalTimer()); @@ -83,26 +87,35 @@ export class InlineCompletionsService extends Disposable implements IInlineCompl } setSnoozeDuration(durationMs: number): void { + if (durationMs < 0) { + throw new BugIndicatingError(`Invalid snooze duration: ${durationMs}. Duration must be non-negative.`); + } + if (durationMs === 0) { + this.cancelSnooze(); + return; + } + const wasSnoozing = this.isSnoozing(); + const timeLeft = this.snoozeTimeLeft; + this._snoozeTimeEnd = Date.now() + durationMs; - const isSnoozing = this.isSnoozing(); - if (wasSnoozing !== isSnoozing) { - this._onDidChangeIsSnoozing.fire(isSnoozing); + if (!wasSnoozing) { + this._onDidChangeIsSnoozing.fire(true); } - if (isSnoozing) { - this._timer.cancelAndSet( - () => { - if (!this.isSnoozing()) { - this._onDidChangeIsSnoozing.fire(false); - } else { - throw new BugIndicatingError('Snooze timer did not fire as expected'); - } - }, - this.snoozeTimeLeft + 1, - ); - } + this._timer.cancelAndSet( + () => { + if (!this.isSnoozing()) { + this._onDidChangeIsSnoozing.fire(false); + } else { + throw new BugIndicatingError('Snooze timer did not fire as expected'); + } + }, + this.snoozeTimeLeft + 1, + ); + + this._reportSnooze(durationMs - timeLeft, durationMs); } isSnoozing(): boolean { @@ -111,11 +124,28 @@ export class InlineCompletionsService extends Disposable implements IInlineCompl cancelSnooze(): void { if (this.isSnoozing()) { + this._reportSnooze(-this.snoozeTimeLeft, 0); this._snoozeTimeEnd = undefined; this._timer.cancel(); this._onDidChangeIsSnoozing.fire(false); } } + + private _reportSnooze(deltaMs: number, totalMs: number): void { + const deltaSeconds = Math.round(deltaMs / 1000); + const totalSeconds = Math.round(totalMs / 1000); + type WorkspaceStatsClassification = { + owner: 'benibenj'; + comment: 'Snooze duration for inline completions'; + deltaSeconds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The duration by which the snooze has changed, in seconds.' }; + totalSeconds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The total duration for which inline completions are snoozed, in seconds.' }; + }; + type WorkspaceStatsEvent = { + deltaSeconds: number; + totalSeconds: number; + }; + this._telemetryService.publicLog2('inlineCompletions.snooze', { deltaSeconds, totalSeconds }); + } } registerSingleton(IInlineCompletionsService, InlineCompletionsService, InstantiationType.Delayed); diff --git a/src/vs/editor/browser/view/renderingContext.ts b/src/vs/editor/browser/view/renderingContext.ts index 6fe17c1a9f7..fdb24034701 100644 --- a/src/vs/editor/browser/view/renderingContext.ts +++ b/src/vs/editor/browser/view/renderingContext.ts @@ -6,7 +6,8 @@ import { Position } from '../../common/core/position.js'; import { Range } from '../../common/core/range.js'; import { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js'; -import { IViewLayout, ViewModelDecoration } from '../../common/viewModel.js'; +import { IViewLayout } from '../../common/viewModel.js'; +import { ViewModelDecoration } from '../../common/viewModel/viewModelDecoration.js'; export interface IViewLines { linesVisibleRangesForRange(range: Range, includeNewLines: boolean): LineVisibleRanges[] | null; diff --git a/src/vs/editor/browser/view/viewUserInputEvents.ts b/src/vs/editor/browser/view/viewUserInputEvents.ts index 8a8bf0aa8ff..817af6a30fb 100644 --- a/src/vs/editor/browser/view/viewUserInputEvents.ts +++ b/src/vs/editor/browser/view/viewUserInputEvents.ts @@ -5,9 +5,9 @@ import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { IEditorMouseEvent, IMouseTarget, IMouseTargetViewZoneData, IPartialEditorMouseEvent, MouseTargetType } from '../editorBrowser.js'; -import { ICoordinatesConverter } from '../../common/viewModel.js'; import { IMouseWheelEvent } from '../../../base/browser/mouseEvent.js'; import { Position } from '../../common/core/position.js'; +import { ICoordinatesConverter } from '../../common/coordinatesConverter.js'; export interface EventCallback { (event: T): void; diff --git a/src/vs/editor/browser/viewParts/decorations/decorations.ts b/src/vs/editor/browser/viewParts/decorations/decorations.ts index 6ae2287ccfc..6fc2d3e0fec 100644 --- a/src/vs/editor/browser/viewParts/decorations/decorations.ts +++ b/src/vs/editor/browser/viewParts/decorations/decorations.ts @@ -9,8 +9,8 @@ import { HorizontalRange, RenderingContext } from '../../view/renderingContext.j import { EditorOption } from '../../../common/config/editorOptions.js'; import { Range } from '../../../common/core/range.js'; import * as viewEvents from '../../../common/viewEvents.js'; -import { ViewModelDecoration } from '../../../common/viewModel.js'; import { ViewContext } from '../../../common/viewModel/viewContext.js'; +import { ViewModelDecoration } from '../../../common/viewModel/viewModelDecoration.js'; export class DecorationsOverlay extends DynamicViewOverlay { diff --git a/src/vs/editor/browser/viewParts/minimap/minimap.ts b/src/vs/editor/browser/viewParts/minimap/minimap.ts index 63919280735..744fa07466e 100644 --- a/src/vs/editor/browser/viewParts/minimap/minimap.ts +++ b/src/vs/editor/browser/viewParts/minimap/minimap.ts @@ -26,7 +26,7 @@ import { RenderingContext, RestrictedRenderingContext } from '../../view/renderi import { ViewContext } from '../../../common/viewModel/viewContext.js'; import { EditorTheme } from '../../../common/editorTheme.js'; import * as viewEvents from '../../../common/viewEvents.js'; -import { ViewLineData, ViewModelDecoration } from '../../../common/viewModel.js'; +import { ViewLineData } from '../../../common/viewModel.js'; import { minimapSelection, minimapBackground, minimapForegroundOpacity, editorForeground } from '../../../../platform/theme/common/colorRegistry.js'; import { ModelDecorationMinimapOptions } from '../../../common/model/textModel.js'; import { Selection } from '../../../common/core/selection.js'; @@ -37,6 +37,7 @@ import { MinimapPosition, MinimapSectionHeaderStyle, TextModelResolvedOptions } import { createSingleCallFunction } from '../../../../base/common/functional.js'; import { LRUCache } from '../../../../base/common/map.js'; import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; +import { ViewModelDecoration } from '../../../common/viewModel/viewModelDecoration.js'; /** * The orthogonal distance to the slider at which dragging "resets". This implements "snapping" diff --git a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts index 962c31a4bef..a715d6df138 100644 --- a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts +++ b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts @@ -13,13 +13,13 @@ import { FloatHorizontalRange, VisibleRanges } from '../../view/renderingContext import { LineDecoration } from '../../../common/viewLayout/lineDecorations.js'; import { CharacterMapping, ForeignElementType, RenderLineInput, renderViewLine, DomPosition, RenderWhitespace } from '../../../common/viewLayout/viewLineRenderer.js'; import { ViewportData } from '../../../common/viewLayout/viewLinesViewportData.js'; -import { InlineDecorationType } from '../../../common/viewModel.js'; import { isHighContrast } from '../../../../platform/theme/common/theme.js'; import { EditorFontLigatures } from '../../../common/config/editorOptions.js'; import { DomReadingContext } from './domReadingContext.js'; import type { ViewLineOptions } from './viewLineOptions.js'; import { ViewGpuContext } from '../../gpu/viewGpuContext.js'; import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; +import { InlineDecorationType } from '../../../common/viewModel/inlineDecorations.js'; const canUseFastRenderedViewLine = (function () { if (platform.isNative) { diff --git a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index efcbb6a7e50..34333bc3f85 100644 --- a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -607,8 +607,11 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE return -1; } const viewModel = this._modelData.viewModel; - if (viewModel.coordinatesConverter.modelPositionIsVisible(Position.lift(position))) { - return viewModel.viewLayout.getLineHeightForLineNumber(position.lineNumber); + const coordinatesConverter = viewModel.coordinatesConverter; + const pos = Position.lift(position); + if (coordinatesConverter.modelPositionIsVisible(pos)) { + const viewPosition = coordinatesConverter.convertModelPositionToViewPosition(pos); + return viewModel.viewLayout.getLineHeightForLineNumber(viewPosition.lineNumber); } return 0; } diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts index 0a2fd9eda46..cc9a23d23b3 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts @@ -26,11 +26,11 @@ import { Position } from '../../../../../common/core/position.js'; import { DetailedLineRangeMapping } from '../../../../../common/diff/rangeMapping.js'; import { ScrollType } from '../../../../../common/editorCommon.js'; import { BackgroundTokenizationState } from '../../../../../common/tokenizationTextModelPart.js'; -import { InlineDecoration, InlineDecorationType } from '../../../../../common/viewModel.js'; import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; import { DiffEditorOptions } from '../../diffEditorOptions.js'; import { Range } from '../../../../../common/core/range.js'; +import { InlineDecoration, InlineDecorationType } from '../../../../../common/viewModel/inlineDecorations.js'; /** * Ensures both editors have the same height by aligning unchanged lines. 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 ac4aee78443..99afc414d65 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts @@ -13,7 +13,8 @@ import { ModelLineProjectionData } from '../../../../../common/modelLineProjecti import { IViewLineTokens, LineTokens } from '../../../../../common/tokens/lineTokens.js'; import { LineDecoration } from '../../../../../common/viewLayout/lineDecorations.js'; import { RenderLineInput, renderViewLine } from '../../../../../common/viewLayout/viewLineRenderer.js'; -import { InlineDecoration, ViewLineRenderingData } from '../../../../../common/viewModel.js'; +import { ViewLineRenderingData } from '../../../../../common/viewModel.js'; +import { InlineDecoration } from '../../../../../common/viewModel/inlineDecorations.js'; const ttPolicy = createTrustedTypesPolicy('diffEditorWidget', { createHTML: value => value }); diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 78efae7449f..88a8b184032 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -1256,6 +1256,9 @@ export function clampedFloat(value: any, defaultValue: T, mini class EditorFloatOption extends SimpleEditorOption { + public readonly minimum: number | undefined; + public readonly maximum: number | undefined; + public static clamp(n: number, min: number, max: number): number { if (n < min) { return min; @@ -1279,13 +1282,17 @@ class EditorFloatOption extends SimpleEditorOption number; - constructor(id: K, name: PossibleKeyName, defaultValue: number, validationFn: (value: number) => number, schema?: IConfigurationPropertySchema) { + constructor(id: K, name: PossibleKeyName, defaultValue: number, validationFn: (value: number) => number, schema?: IConfigurationPropertySchema, minimum?: number, maximum?: number) { if (typeof schema !== 'undefined') { schema.type = 'number'; schema.default = defaultValue; + schema.minimum = minimum; + schema.maximum = maximum; } super(id, name, defaultValue, schema); this.validationFn = validationFn; + this.minimum = minimum; + this.maximum = maximum; } public override validate(input: any): number { @@ -2798,7 +2805,7 @@ export class EditorLayoutInfoComputer extends ComputedEditorOption { EditorOption.lineHeight, 'lineHeight', EDITOR_FONT_DEFAULTS.lineHeight, x => EditorFloatOption.clamp(x, 0, 150), - { markdownDescription: nls.localize('lineHeight', "Controls the line height. \n - Use 0 to automatically compute the line height from the font size.\n - Values between 0 and 8 will be used as a multiplier with the font size.\n - Values greater than or equal to 8 will be used as effective values.") } + { markdownDescription: nls.localize('lineHeight', "Controls the line height. \n - Use 0 to automatically compute the line height from the font size.\n - Values between 0 and 8 will be used as a multiplier with the font size.\n - Values greater than or equal to 8 will be used as effective values.") }, + 0, + 150 ); } @@ -5732,7 +5741,10 @@ export const EditorOptions = { tags: ['accessibility'] })), allowVariableLineHeights: register(new EditorBooleanOption( - EditorOption.allowVariableLineHeights, 'allowVariableLineHeights', true + EditorOption.allowVariableLineHeights, 'allowVariableLineHeights', true, + { + description: nls.localize('allowVariableLineHeights', "Controls whether to allow using variable line heights in the editor.") + } )), allowVariableFonts: register(new EditorBooleanOption( EditorOption.allowVariableFonts, 'allowVariableFonts', true, diff --git a/src/vs/editor/common/coordinatesConverter.ts b/src/vs/editor/common/coordinatesConverter.ts new file mode 100644 index 00000000000..dd7cd41995d --- /dev/null +++ b/src/vs/editor/common/coordinatesConverter.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Position } from './core/position.js'; +import { Range } from './core/range.js'; +import { ITextModel, PositionAffinity } from './model.js'; + +export interface ICoordinatesConverter { + // View -> Model conversion and related methods + convertViewPositionToModelPosition(viewPosition: Position): Position; + convertViewRangeToModelRange(viewRange: Range): Range; + validateViewPosition(viewPosition: Position, expectedModelPosition: Position): Position; + validateViewRange(viewRange: Range, expectedModelRange: Range): Range; + + // Model -> View conversion and related methods + /** + * @param allowZeroLineNumber Should it return 0 when there are hidden lines at the top and the position is in the hidden area? + * @param belowHiddenRanges When the model position is in a hidden area, should it return the first view position after or before? + */ + convertModelPositionToViewPosition(modelPosition: Position, affinity?: PositionAffinity, allowZeroLineNumber?: boolean, belowHiddenRanges?: boolean): Position; + /** + * @param affinity Only has an effect if the range is empty. + */ + convertModelRangeToViewRange(modelRange: Range, affinity?: PositionAffinity): Range; + modelPositionIsVisible(modelPosition: Position): boolean; + getModelLineViewLineCount(modelLineNumber: number): number; + getViewLineNumberOfModelPosition(modelLineNumber: number, modelColumn: number): number; +} + +export class IdentityCoordinatesConverter implements ICoordinatesConverter { + + private readonly _model: ITextModel; + + constructor(model: ITextModel) { + this._model = model; + } + + private _validPosition(pos: Position): Position { + return this._model.validatePosition(pos); + } + + private _validRange(range: Range): Range { + return this._model.validateRange(range); + } + + // View -> Model conversion and related methods + + public convertViewPositionToModelPosition(viewPosition: Position): Position { + return this._validPosition(viewPosition); + } + + public convertViewRangeToModelRange(viewRange: Range): Range { + return this._validRange(viewRange); + } + + public validateViewPosition(_viewPosition: Position, expectedModelPosition: Position): Position { + return this._validPosition(expectedModelPosition); + } + + public validateViewRange(_viewRange: Range, expectedModelRange: Range): Range { + return this._validRange(expectedModelRange); + } + + // Model -> View conversion and related methods + + public convertModelPositionToViewPosition(modelPosition: Position): Position { + return this._validPosition(modelPosition); + } + + public convertModelRangeToViewRange(modelRange: Range): Range { + return this._validRange(modelRange); + } + + public modelPositionIsVisible(modelPosition: Position): boolean { + const lineCount = this._model.getLineCount(); + if (modelPosition.lineNumber < 1 || modelPosition.lineNumber > lineCount) { + // invalid arguments + return false; + } + return true; + } + + public modelRangeIsVisible(modelRange: Range): boolean { + const lineCount = this._model.getLineCount(); + if (modelRange.startLineNumber < 1 || modelRange.startLineNumber > lineCount) { + // invalid arguments + return false; + } + if (modelRange.endLineNumber < 1 || modelRange.endLineNumber > lineCount) { + // invalid arguments + return false; + } + return true; + } + + public getModelLineViewLineCount(modelLineNumber: number): number { + return 1; + } + + public getViewLineNumberOfModelPosition(modelLineNumber: number, modelColumn: number): number { + return modelLineNumber; + } +} diff --git a/src/vs/editor/common/core/edits/edit.ts b/src/vs/editor/common/core/edits/edit.ts index 75a8cb5d263..80cbdb4aa10 100644 --- a/src/vs/editor/common/core/edits/edit.ts +++ b/src/vs/editor/common/core/edits/edit.ts @@ -48,7 +48,7 @@ export abstract class BaseEdit, TEdit extends BaseE * Normalizes the edit by removing empty replacements and joining touching replacements (if the replacements allow joining). * Two edits have an equal normalized edit if and only if they have the same effect on any input. * - * ![](./docs/BaseEdit_normalize.dio.png) + * ![](https://raw.githubusercontent.com/microsoft/vscode/refs/heads/main/src/vs/editor/common/core/edits/docs/BaseEdit_normalize.drawio.png) * * Invariant: * ``` @@ -90,7 +90,7 @@ export abstract class BaseEdit, TEdit extends BaseE /** * Combines two edits into one with the same effect. * - * ![](./docs/BaseEdit_compose.dio.png) + * ![](https://raw.githubusercontent.com/microsoft/vscode/refs/heads/main/src/vs/editor/common/core/edits/docs/BaseEdit_compose.drawio.png) * * Invariant: * ``` @@ -183,6 +183,22 @@ export abstract class BaseEdit, TEdit extends BaseE return this._createNew(result).normalize(); } + public decomposeSplit(shouldBeInE1: (repl: T) => boolean): { e1: TEdit; e2: TEdit } { + const e1: T[] = []; + const e2: T[] = []; + + let e2delta = 0; + for (const edit of this.replacements) { + if (shouldBeInE1(edit)) { + e1.push(edit); + e2delta += edit.getNewLength() - edit.replaceRange.length; + } else { + e2.push(edit.slice(edit.replaceRange.delta(e2delta), new OffsetRange(0, edit.getNewLength()))); + } + } + return { e1: this._createNew(e1), e2: this._createNew(e2) }; + } + /** * Returns the range of each replacement in the applied value. */ diff --git a/src/vs/editor/common/core/edits/stringEdit.ts b/src/vs/editor/common/core/edits/stringEdit.ts index f7763163166..db78f3bf8b6 100644 --- a/src/vs/editor/common/core/edits/stringEdit.ts +++ b/src/vs/editor/common/core/edits/stringEdit.ts @@ -5,58 +5,26 @@ import { commonPrefixLength, commonSuffixLength } from '../../../../base/common/strings.js'; import { OffsetRange } from '../ranges/offsetRange.js'; +import { StringText } from '../text/abstractText.js'; import { BaseEdit, BaseReplacement } from './edit.js'; -/** - * Represents a set of replacements to a string. - * All these replacements are applied at once. -*/ -export class StringEdit extends BaseEdit { - public static readonly empty = new StringEdit([]); - public static create(replacements: readonly StringReplacement[]): StringEdit { - return new StringEdit(replacements); +export abstract class BaseStringEdit = BaseStringReplacement, TEdit extends BaseStringEdit = BaseStringEdit> extends BaseEdit { + get TReplacement(): T { + throw new Error('TReplacement is not defined for BaseStringEdit'); } - public static single(replacement: StringReplacement): StringEdit { - return new StringEdit([replacement]); - } - - public static replace(range: OffsetRange, replacement: string): StringEdit { - return new StringEdit([new StringReplacement(range, replacement)]); - } - - public static insert(offset: number, replacement: string): StringEdit { - return new StringEdit([new StringReplacement(OffsetRange.emptyAt(offset), replacement)]); - } - - public static delete(range: OffsetRange): StringEdit { - return new StringEdit([new StringReplacement(range, '')]); - } - - public static fromJson(data: ISerializedStringEdit): StringEdit { - return new StringEdit(data.map(StringReplacement.fromJson)); - } - - public static compose(edits: readonly StringEdit[]): StringEdit { + public static composeOrUndefined(edits: readonly T[]): T | undefined { if (edits.length === 0) { - return StringEdit.empty; + return undefined; } let result = edits[0]; for (let i = 1; i < edits.length; i++) { - result = result.compose(edits[i]); + result = result.compose(edits[i]) as any; } return result; } - constructor(replacements: readonly StringReplacement[]) { - super(replacements); - } - - protected override _createNew(replacements: readonly StringReplacement[]): StringEdit { - return new StringEdit(replacements); - } - public apply(base: string): string { const resultText: string[] = []; let pos = 0; @@ -137,11 +105,7 @@ export class StringEdit extends BaseEdit { } public toJson(): ISerializedStringEdit { - return this.replacements.map(e => ({ - txt: e.newText, - pos: e.replaceRange.start, - len: e.replaceRange.length, - })); + return this.replacements.map(e => e.toJson()); } public isNeutralOn(text: string): boolean { @@ -162,39 +126,41 @@ export class StringEdit extends BaseEdit { public normalizeEOL(eol: '\r\n' | '\n'): StringEdit { return new StringEdit(this.replacements.map(edit => edit.normalizeEOL(eol))); } + + /** + * If `e1.apply(source) === e2.apply(source)`, then `e1.normalizeOnSource(source).equals(e2.normalizeOnSource(source))`. + */ + public normalizeOnSource(source: string): StringEdit { + const result = this.apply(source); + + const edit = StringReplacement.replace(OffsetRange.ofLength(source.length), result); + const e = edit.removeCommonSuffixAndPrefix(source); + if (e.isEmpty) { + return StringEdit.empty; + } + return e.toEdit(); + } + + removeCommonSuffixAndPrefix(source: string): TEdit { + return this._createNew(this.replacements.map(e => e.removeCommonSuffixAndPrefix(source))).normalize(); + } + + applyOnText(docContents: StringText): StringText { + return new StringText(this.apply(docContents.value)); + } + + public mapData>(f: (replacement: T) => TData): AnnotatedStringEdit { + return new AnnotatedStringEdit( + this.replacements.map(e => new AnnotatedStringReplacement( + e.replaceRange, + e.newText, + f(e) + )) + ); + } } -/** - * Warning: Be careful when changing this type, as it is used for serialization! -*/ -export type ISerializedStringEdit = ISerializedStringReplacement[]; - -/** - * Warning: Be careful when changing this type, as it is used for serialization! -*/ -export interface ISerializedStringReplacement { - txt: string; - pos: number; - len: number; -} - -export class StringReplacement extends BaseReplacement { - public static insert(offset: number, text: string): StringReplacement { - return new StringReplacement(OffsetRange.emptyAt(offset), text); - } - - public static replace(range: OffsetRange, text: string): StringReplacement { - return new StringReplacement(range, text); - } - - public static delete(range: OffsetRange): StringReplacement { - return new StringReplacement(range, ''); - } - - public static fromJson(data: ISerializedStringReplacement): StringReplacement { - return new StringReplacement(OffsetRange.ofStartAndLength(data.pos, data.len), data.txt); - } - +export abstract class BaseStringReplacement = BaseStringReplacement> extends BaseReplacement { constructor( range: OffsetRange, public readonly newText: string, @@ -202,20 +168,8 @@ export class StringReplacement extends BaseReplacement { super(range); } - override equals(other: StringReplacement): boolean { - return this.replaceRange.equals(other.replaceRange) && this.newText === other.newText; - } - getNewLength(): number { return this.newText.length; } - tryJoinTouching(other: StringReplacement): StringReplacement | undefined { - return new StringReplacement(this.replaceRange.joinRightTouching(other.replaceRange), this.newText + other.newText); - } - - slice(range: OffsetRange, rangeInReplacement: OffsetRange): StringReplacement { - return new StringReplacement(range, rangeInReplacement.substring(this.newText)); - } - override toString(): string { return `${this.replaceRange} -> "${this.newText}"`; } @@ -254,6 +208,163 @@ export class StringReplacement extends BaseReplacement { const newText = this.newText.replace(/\r\n|\n/g, eol); return new StringReplacement(this.replaceRange, newText); } + + public removeCommonSuffixAndPrefix(source: string): T { + return this.removeCommonSuffix(source).removeCommonPrefix(source); + } + + public removeCommonPrefix(source: string): T { + const oldText = this.replaceRange.substring(source); + + const prefixLen = commonPrefixLength(oldText, this.newText); + if (prefixLen === 0) { + return this as unknown as T; + } + + return this.slice(this.replaceRange.deltaStart(prefixLen), new OffsetRange(prefixLen, this.newText.length)); + } + + public removeCommonSuffix(source: string): T { + const oldText = this.replaceRange.substring(source); + + const suffixLen = commonSuffixLength(oldText, this.newText); + if (suffixLen === 0) { + return this as unknown as T; + } + return this.slice(this.replaceRange.deltaEnd(-suffixLen), new OffsetRange(0, this.newText.length - suffixLen)); + } + + public toEdit(): StringEdit { + return new StringEdit([this]); + } + + public toJson(): ISerializedStringReplacement { + return ({ + txt: this.newText, + pos: this.replaceRange.start, + len: this.replaceRange.length, + }); + } +} + + +/** + * Represents a set of replacements to a string. + * All these replacements are applied at once. +*/ +export class StringEdit extends BaseStringEdit { + public static readonly empty = new StringEdit([]); + + public static create(replacements: readonly StringReplacement[]): StringEdit { + return new StringEdit(replacements); + } + + public static single(replacement: StringReplacement): StringEdit { + return new StringEdit([replacement]); + } + + public static replace(range: OffsetRange, replacement: string): StringEdit { + return new StringEdit([new StringReplacement(range, replacement)]); + } + + public static insert(offset: number, replacement: string): StringEdit { + return new StringEdit([new StringReplacement(OffsetRange.emptyAt(offset), replacement)]); + } + + public static delete(range: OffsetRange): StringEdit { + return new StringEdit([new StringReplacement(range, '')]); + } + + public static fromJson(data: ISerializedStringEdit): StringEdit { + return new StringEdit(data.map(StringReplacement.fromJson)); + } + + public static compose(edits: readonly StringEdit[]): StringEdit { + if (edits.length === 0) { + return StringEdit.empty; + } + let result = edits[0]; + for (let i = 1; i < edits.length; i++) { + result = result.compose(edits[i]); + } + return result; + } + + /** + * The replacements are applied in order! + * Equals `StringEdit.compose(replacements.map(r => r.toEdit()))`, but is much more performant. + */ + public static composeSequentialReplacements(replacements: readonly StringReplacement[]): StringEdit { + let edit = StringEdit.empty; + let curEditReplacements: StringReplacement[] = []; // These are reverse sorted + + for (const r of replacements) { + const last = curEditReplacements.at(-1); + if (!last || r.replaceRange.isBefore(last.replaceRange)) { + // Detect subsequences of reverse sorted replacements + curEditReplacements.push(r); + } else { + // Once the subsequence is broken, compose the current replacements and look for a new subsequence. + edit = edit.compose(StringEdit.create(curEditReplacements.reverse())); + curEditReplacements = [r]; + } + } + + edit = edit.compose(StringEdit.create(curEditReplacements.reverse())); + return edit; + } + + constructor(replacements: readonly StringReplacement[]) { + super(replacements); + } + + protected override _createNew(replacements: readonly StringReplacement[]): StringEdit { + return new StringEdit(replacements); + } +} + +/** + * Warning: Be careful when changing this type, as it is used for serialization! +*/ +export type ISerializedStringEdit = ISerializedStringReplacement[]; + +/** + * Warning: Be careful when changing this type, as it is used for serialization! +*/ +export interface ISerializedStringReplacement { + txt: string; + pos: number; + len: number; +} + +export class StringReplacement extends BaseStringReplacement { + public static insert(offset: number, text: string): StringReplacement { + return new StringReplacement(OffsetRange.emptyAt(offset), text); + } + + public static replace(range: OffsetRange, text: string): StringReplacement { + return new StringReplacement(range, text); + } + + public static delete(range: OffsetRange): StringReplacement { + return new StringReplacement(range, ''); + } + + public static fromJson(data: ISerializedStringReplacement): StringReplacement { + return new StringReplacement(OffsetRange.ofStartAndLength(data.pos, data.len), data.txt); + } + + override equals(other: StringReplacement): boolean { + return this.replaceRange.equals(other.replaceRange) && this.newText === other.newText; + } + + override tryJoinTouching(other: StringReplacement): StringReplacement | undefined { + return new StringReplacement(this.replaceRange.joinRightTouching(other.replaceRange), this.newText + other.newText); + } + + override slice(range: OffsetRange, rangeInReplacement: OffsetRange): StringReplacement { + return new StringReplacement(range, rangeInReplacement.substring(this.newText)); + } } export function applyEditsToRanges(sortedRanges: OffsetRange[], edit: StringEdit): OffsetRange[] { @@ -322,3 +433,106 @@ export function applyEditsToRanges(sortedRanges: OffsetRange[], edit: StringEdit return result; } + +/** + * Represents data associated to a single edit, which survives certain edit operations. +*/ +export interface IEditData { + join(other: T): T | undefined; +} + +export class VoidEditData implements IEditData { + join(other: VoidEditData): VoidEditData | undefined { + return this; + } +} + +/** + * Represents a set of replacements to a string. + * All these replacements are applied at once. +*/ +export class AnnotatedStringEdit> extends BaseStringEdit, AnnotatedStringEdit> { + public static readonly empty = new AnnotatedStringEdit([]); + + public static create>(replacements: readonly AnnotatedStringReplacement[]): AnnotatedStringEdit { + return new AnnotatedStringEdit(replacements); + } + + public static single>(replacement: AnnotatedStringReplacement): AnnotatedStringEdit { + return new AnnotatedStringEdit([replacement]); + } + + public static replace>(range: OffsetRange, replacement: string, data: T): AnnotatedStringEdit { + return new AnnotatedStringEdit([new AnnotatedStringReplacement(range, replacement, data)]); + } + + public static insert>(offset: number, replacement: string, data: T): AnnotatedStringEdit { + return new AnnotatedStringEdit([new AnnotatedStringReplacement(OffsetRange.emptyAt(offset), replacement, data)]); + } + + public static delete>(range: OffsetRange, data: T): AnnotatedStringEdit { + return new AnnotatedStringEdit([new AnnotatedStringReplacement(range, '', data)]); + } + + public static compose>(edits: readonly AnnotatedStringEdit[]): AnnotatedStringEdit { + if (edits.length === 0) { + return AnnotatedStringEdit.empty; + } + let result = edits[0]; + for (let i = 1; i < edits.length; i++) { + result = result.compose(edits[i]); + } + return result; + } + + constructor(replacements: readonly AnnotatedStringReplacement[]) { + super(replacements); + } + + protected override _createNew(replacements: readonly AnnotatedStringReplacement[]): AnnotatedStringEdit { + return new AnnotatedStringEdit(replacements); + } + + toStringEdit(): StringEdit { + return new StringEdit(this.replacements.map(e => new StringReplacement(e.replaceRange, e.newText))); + } +} + +export class AnnotatedStringReplacement> extends BaseStringReplacement> { + public static insert>(offset: number, text: string, data: T): AnnotatedStringReplacement { + return new AnnotatedStringReplacement(OffsetRange.emptyAt(offset), text, data); + } + + public static replace>(range: OffsetRange, text: string, data: T): AnnotatedStringReplacement { + return new AnnotatedStringReplacement(range, text, data); + } + + public static delete>(range: OffsetRange, data: T): AnnotatedStringReplacement { + return new AnnotatedStringReplacement(range, '', data); + } + + constructor( + range: OffsetRange, + newText: string, + public readonly data: T + ) { + super(range, newText); + } + + override equals(other: AnnotatedStringReplacement): boolean { + return this.replaceRange.equals(other.replaceRange) && this.newText === other.newText && this.data === other.data; + } + + tryJoinTouching(other: AnnotatedStringReplacement): AnnotatedStringReplacement | undefined { + const joined = this.data.join(other.data); + if (joined === undefined) { + return undefined; + } + return new AnnotatedStringReplacement(this.replaceRange.joinRightTouching(other.replaceRange), this.newText + other.newText, joined); + } + + slice(range: OffsetRange, rangeInReplacement?: OffsetRange): AnnotatedStringReplacement { + return new AnnotatedStringReplacement(range, rangeInReplacement ? rangeInReplacement.substring(this.newText) : this.newText, this.data); + } +} + diff --git a/src/vs/editor/common/core/text/positionToOffset.ts b/src/vs/editor/common/core/text/positionToOffset.ts index 97d6ee3bf7c..07447565fd6 100644 --- a/src/vs/editor/common/core/text/positionToOffset.ts +++ b/src/vs/editor/common/core/text/positionToOffset.ts @@ -17,3 +17,8 @@ _setPositionOffsetTransformerDependencies({ TextEdit: TextEdit, TextLength: TextLength, }); + +// TODO@hediet this is dept and needs to go. See https://github.com/microsoft/vscode/issues/251126. +export function ensureDependenciesAreSet(): void { + // Noop +} diff --git a/src/vs/editor/common/cursor/cursor.ts b/src/vs/editor/common/cursor/cursor.ts index b413a448457..0078457a348 100644 --- a/src/vs/editor/common/cursor/cursor.ts +++ b/src/vs/editor/common/cursor/cursor.ts @@ -20,9 +20,9 @@ import { ITextModel, TrackedRangeStickiness, IModelDeltaDecoration, ICursorState import { RawContentChangedType, ModelInjectedTextChangedEvent, InternalModelContentChangeEvent } from '../textModelEvents.js'; import { VerticalRevealType, ViewCursorStateChangedEvent, ViewRevealRangeRequestEvent } from '../viewEvents.js'; import { dispose, Disposable } from '../../../base/common/lifecycle.js'; -import { ICoordinatesConverter } from '../viewModel.js'; import { CursorStateChangedEvent, ViewModelEventsCollector } from '../viewModelEventDispatcher.js'; import { TextModelEditReason, EditReasons } from '../textModelEditReason.js'; +import { ICoordinatesConverter } from '../coordinatesConverter.js'; export class CursorsController extends Disposable { diff --git a/src/vs/editor/common/cursor/cursorContext.ts b/src/vs/editor/common/cursor/cursorContext.ts index 30c25a6d626..01e77928fcf 100644 --- a/src/vs/editor/common/cursor/cursorContext.ts +++ b/src/vs/editor/common/cursor/cursorContext.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { ITextModel } from '../model.js'; -import { ICoordinatesConverter } from '../viewModel.js'; import { CursorConfiguration, ICursorSimpleModel } from '../cursorCommon.js'; +import { ICoordinatesConverter } from '../coordinatesConverter.js'; export class CursorContext { _cursorContextBrand: void = undefined; diff --git a/src/vs/editor/common/diff/rangeMapping.ts b/src/vs/editor/common/diff/rangeMapping.ts index c5a66c6a8d9..487ec42833b 100644 --- a/src/vs/editor/common/diff/rangeMapping.ts +++ b/src/vs/editor/common/diff/rangeMapping.ts @@ -194,6 +194,17 @@ function isValidLineNumber(lineNumber: number, lines: string[]): boolean { * Also contains inner range mappings. */ export class DetailedLineRangeMapping extends LineRangeMapping { + public static toTextEdit(mapping: readonly DetailedLineRangeMapping[], modified: AbstractText): TextEdit { + const replacements: TextReplacement[] = []; + for (const m of mapping) { + for (const r of m.innerChanges ?? []) { + const replacement = r.toTextEdit(modified); + replacements.push(replacement); + } + } + return new TextEdit(replacements); + } + public static fromRangeMappings(rangeMappings: RangeMapping[]): DetailedLineRangeMapping { const originalRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.originalRange))); const modifiedRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.modifiedRange))); diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 80995eee9b8..54e9a802648 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -905,6 +905,9 @@ export interface InlineCompletionsProvider { +export interface IWorkerContext { /** * A proxy to the main thread host object. */ @@ -201,6 +204,24 @@ export class EditorWorker implements IDisposable, IWorkerTextModelSyncChannelSer return diffComputer.computeDiff().changes; } + public $computeStringDiff(original: string, modified: string, options: { maxComputationTimeMs: number }, algorithm: DiffAlgorithmName): ISerializedStringEdit { + const diffAlgorithm: ILinesDiffComputer = algorithm === 'advanced' ? linesDiffComputers.getDefault() : linesDiffComputers.getLegacy(); + + ensureDependenciesAreSet(); + + const originalText = new StringText(original); + const originalLines = originalText.getLines(); + const modifiedText = new StringText(modified); + const modifiedLines = modifiedText.getLines(); + + const result = diffAlgorithm.computeDiff(originalLines, modifiedLines, { ignoreTrimWhitespace: false, maxComputationTimeMs: options.maxComputationTimeMs, computeMoves: false, extendToSubwords: false }); + + const textEdit = DetailedLineRangeMapping.toTextEdit(result.changes, modifiedText); + const strEdit = originalText.getTransformer().getStringEdit(textEdit); + + return strEdit.toJson(); + } + // ---- END diff -------------------------------------------------------------------------- diff --git a/src/vs/editor/common/services/editorWorker.ts b/src/vs/editor/common/services/editorWorker.ts index fc0f44fa458..6b0720d60ff 100644 --- a/src/vs/editor/common/services/editorWorker.ts +++ b/src/vs/editor/common/services/editorWorker.ts @@ -12,6 +12,7 @@ import { UnicodeHighlighterOptions } from './unicodeTextModelHighlighter.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import type { EditorWorker } from './editorWebWorker.js'; import { SectionHeader, FindSectionHeaderOptions } from './findSectionHeaders.js'; +import { StringEdit } from '../core/edits/stringEdit.js'; export const IEditorWorkerService = createDecorator('editorWorkerService'); @@ -32,6 +33,8 @@ export interface IEditorWorkerService { computeMoreMinimalEdits(resource: URI, edits: TextEdit[] | null | undefined, pretty?: boolean): Promise; computeHumanReadableDiff(resource: URI, edits: TextEdit[] | null | undefined): Promise; + computeStringEditFromDiff(original: string, modified: string, options: { maxComputationTimeMs: number }, algorithm: DiffAlgorithmName): Promise; + canComputeWordRanges(resource: URI): boolean; computeWordRanges(resource: URI, range: IRange): Promise<{ [word: string]: IRange[] } | null>; diff --git a/src/vs/editor/common/standalone/standaloneEnums.ts b/src/vs/editor/common/standalone/standaloneEnums.ts index 8585cfba39b..92537269ac1 100644 --- a/src/vs/editor/common/standalone/standaloneEnums.ts +++ b/src/vs/editor/common/standalone/standaloneEnums.ts @@ -991,4 +991,4 @@ export enum WrappingIndent { * DeepIndent => wrapped lines get +2 indentation toward the parent. */ DeepIndent = 3 -} +} \ No newline at end of file diff --git a/src/vs/editor/common/textModelEditReason.ts b/src/vs/editor/common/textModelEditReason.ts index 9cd02aa1f4c..7f3f5516d97 100644 --- a/src/vs/editor/common/textModelEditReason.ts +++ b/src/vs/editor/common/textModelEditReason.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { ProviderId } from './languages.js'; + const privateSymbol = Symbol('TextModelEditReason'); export class TextModelEditReason { @@ -33,9 +35,14 @@ export class TextModelEditReason { * Converts the metadata to a key string. * Only includes properties/values that have `level` many `$` prefixes or less. */ - public toKey(level: number): string { + public toKey(level: number, filter: { [TKey in ITextModelEditReasonMetadataKeys]?: boolean } = {}): string { const metadata = this.metadata; const keys = Object.entries(metadata).filter(([key, value]) => { + const filterVal = (filter as Record)[key]; + if (filterVal !== undefined) { + return filterVal; + } + const prefixCount = (key.match(/\$/g) || []).length; return prefixCount <= level && value !== undefined && value !== null && value !== ''; }).map(([key, value]) => `${key}:${value}`); @@ -68,21 +75,21 @@ export const EditReasons = { } as const); }, - inlineCompletionAccept(data: { nes: boolean; requestUuid: string; extensionId: string }) { + inlineCompletionAccept(data: { nes: boolean; requestUuid: string; providerId?: ProviderId }) { return createEditReason({ source: 'inlineCompletionAccept', $nes: data.nes, - $extensionId: data.extensionId, + ...toProperties(data.providerId), $$requestUuid: data.requestUuid, } as const); }, - inlineCompletionPartialAccept(data: { nes: boolean; requestUuid: string; extensionId: string; type: 'word' | 'line' }) { + inlineCompletionPartialAccept(data: { nes: boolean; requestUuid: string; providerId?: ProviderId; type: 'word' | 'line' }) { return createEditReason({ source: 'inlineCompletionPartialAccept', type: data.type, $nes: data.nes, - $extensionId: data.extensionId, + ...toProperties(data.providerId), $$requestUuid: data.requestUuid, } as const); }, @@ -108,13 +115,26 @@ export const EditReasons = { eolChange: () => createEditReason({ source: 'eolChange' } as const), applyEdits: () => createEditReason({ source: 'applyEdits' } as const), snippet: () => createEditReason({ source: 'snippet' } as const), - suggest: (data: { extensionId: string | undefined }) => createEditReason({ source: 'suggest', $extensionId: data.extensionId } as const), + suggest: (data: { providerId: ProviderId | undefined }) => createEditReason({ source: 'suggest', ...toProperties(data.providerId) } as const), - codeAction: (data: { kind: string | undefined; extensionId: string | undefined }) => createEditReason({ source: 'codeAction', $kind: data.kind, $extensionId: data.extensionId } as const) + codeAction: (data: { kind: string | undefined; providerId: ProviderId | undefined }) => createEditReason({ source: 'codeAction', $kind: data.kind, ...toProperties(data.providerId) } as const) }; +function toProperties(version: ProviderId | undefined) { + if (!version) { + return {}; + } + return { + $extensionId: version.extensionId, + $extensionVersion: version.extensionVersion, + $providerId: version.providerId, + }; +} + type Values = T[keyof T]; type ITextModelEditReasonMetadata = Values<{ [TKey in keyof typeof EditReasons]: ReturnType['metadataT'] }>; +type ITextModelEditReasonMetadataKeys = Values<{ [TKey in keyof typeof EditReasons]: keyof ReturnType['metadataT'] }>; + function avoidPathRedaction(str: string | undefined): string | undefined { if (str === undefined) { diff --git a/src/vs/editor/common/viewLayout/lineDecorations.ts b/src/vs/editor/common/viewLayout/lineDecorations.ts index c607968187d..7641e62c859 100644 --- a/src/vs/editor/common/viewLayout/lineDecorations.ts +++ b/src/vs/editor/common/viewLayout/lineDecorations.ts @@ -5,8 +5,8 @@ import * as strings from '../../../base/common/strings.js'; import { Constants } from '../../../base/common/uint.js'; +import { InlineDecoration, InlineDecorationType } from '../viewModel/inlineDecorations.js'; import { LinePartMetadata } from './linePart.js'; -import { InlineDecoration, InlineDecorationType } from '../viewModel.js'; export class LineDecoration { _lineDecorationBrand: void = undefined; diff --git a/src/vs/editor/common/viewLayout/viewLineRenderer.ts b/src/vs/editor/common/viewLayout/viewLineRenderer.ts index 5911aa3dad1..1b1cf84e5f0 100644 --- a/src/vs/editor/common/viewLayout/viewLineRenderer.ts +++ b/src/vs/editor/common/viewLayout/viewLineRenderer.ts @@ -9,9 +9,9 @@ import * as strings from '../../../base/common/strings.js'; import { IViewLineTokens } from '../tokens/lineTokens.js'; import { StringBuilder } from '../core/stringBuilder.js'; import { LineDecoration, LineDecorationsNormalizer } from './lineDecorations.js'; -import { InlineDecorationType } from '../viewModel.js'; import { LinePart, LinePartMetadata } from './linePart.js'; import { OffsetRange } from '../core/ranges/offsetRange.js'; +import { InlineDecorationType } from '../viewModel/inlineDecorations.js'; export const enum RenderWhitespace { None = 0, diff --git a/src/vs/editor/common/viewLayout/viewLinesViewportData.ts b/src/vs/editor/common/viewLayout/viewLinesViewportData.ts index 91eaceca811..5d6accce9de 100644 --- a/src/vs/editor/common/viewLayout/viewLinesViewportData.ts +++ b/src/vs/editor/common/viewLayout/viewLinesViewportData.ts @@ -5,7 +5,8 @@ import { Range } from '../core/range.js'; import { Selection } from '../core/selection.js'; -import { IPartialViewLinesViewportData, IViewModel, IViewWhitespaceViewportData, ViewLineRenderingData, ViewModelDecoration } from '../viewModel.js'; +import { IPartialViewLinesViewportData, IViewModel, IViewWhitespaceViewportData, ViewLineRenderingData } from '../viewModel.js'; +import { ViewModelDecoration } from '../viewModel/viewModelDecoration.js'; /** * Contains all data needed to render at a specific viewport. diff --git a/src/vs/editor/common/viewModel.ts b/src/vs/editor/common/viewModel.ts index 3071cbf0ff3..d17eb6b6152 100644 --- a/src/vs/editor/common/viewModel.ts +++ b/src/vs/editor/common/viewModel.ts @@ -6,18 +6,20 @@ import * as arrays from '../../base/common/arrays.js'; import { IScrollPosition, Scrollable } from '../../base/common/scrollable.js'; import * as strings from '../../base/common/strings.js'; +import { ICoordinatesConverter } from './coordinatesConverter.js'; import { IPosition, Position } from './core/position.js'; import { Range } from './core/range.js'; import { CursorConfiguration, CursorState, EditOperationType, IColumnSelectData, ICursorSimpleModel, PartialCursorState } from './cursorCommon.js'; import { CursorChangeReason } from './cursorEvents.js'; import { INewScrollPosition, ScrollType } from './editorCommon.js'; import { EditorTheme } from './editorTheme.js'; -import { EndOfLinePreference, IGlyphMarginLanesModel, IModelDecorationOptions, ITextModel, PositionAffinity } from './model.js'; +import { EndOfLinePreference, IGlyphMarginLanesModel, IModelDecorationOptions, ITextModel } from './model.js'; import { ILineBreaksComputer, InjectedText } from './modelLineProjectionData.js'; import { BracketGuideOptions, IActiveIndentGuideInfo, IndentGuide } from './textModelGuides.js'; import { IViewLineTokens } from './tokens/lineTokens.js'; import { ViewEventHandler } from './viewEventHandler.js'; import { VerticalRevealType } from './viewEvents.js'; +import { InlineDecoration, SingleLineInlineDecoration } from './viewModel/inlineDecorations.js'; export interface IViewModel extends ICursorSimpleModel { @@ -222,28 +224,6 @@ export class Viewport { } } -export interface ICoordinatesConverter { - // View -> Model conversion and related methods - convertViewPositionToModelPosition(viewPosition: Position): Position; - convertViewRangeToModelRange(viewRange: Range): Range; - validateViewPosition(viewPosition: Position, expectedModelPosition: Position): Position; - validateViewRange(viewRange: Range, expectedModelRange: Range): Range; - - // Model -> View conversion and related methods - /** - * @param allowZeroLineNumber Should it return 0 when there are hidden lines at the top and the position is in the hidden area? - * @param belowHiddenRanges When the model position is in a hidden area, should it return the first view position after or before? - */ - convertModelPositionToViewPosition(modelPosition: Position, affinity?: PositionAffinity, allowZeroLineNumber?: boolean, belowHiddenRanges?: boolean): Position; - /** - * @param affinity Only has an effect if the range is empty. - */ - convertModelRangeToViewRange(modelRange: Range, affinity?: PositionAffinity): Range; - modelPositionIsVisible(modelPosition: Position): boolean; - getModelLineViewLineCount(modelLineNumber: number): number; - getViewLineNumberOfModelPosition(modelLineNumber: number, modelColumn: number): number; -} - export class MinimapLinesRenderingData { public readonly tabSize: number; public readonly data: Array; @@ -398,40 +378,6 @@ export class ViewLineRenderingData { } } -export const enum InlineDecorationType { - Regular = 0, - Before = 1, - After = 2, - RegularAffectingLetterSpacing = 3 -} - -export class InlineDecoration { - constructor( - public readonly range: Range, - public readonly inlineClassName: string, - public readonly type: InlineDecorationType - ) { - } -} - -export class SingleLineInlineDecoration { - constructor( - public readonly startOffset: number, - public readonly endOffset: number, - public readonly inlineClassName: string, - public readonly inlineClassNameAffectsLetterSpacing: boolean - ) { - } - - toInlineDecoration(lineNumber: number): InlineDecoration { - return new InlineDecoration( - new Range(lineNumber, this.startOffset + 1, lineNumber, this.endOffset + 1), - this.inlineClassName, - this.inlineClassNameAffectsLetterSpacing ? InlineDecorationType.RegularAffectingLetterSpacing : InlineDecorationType.Regular - ); - } -} - export class ViewModelDecoration { _viewModelDecorationBrand: void = undefined; diff --git a/src/vs/editor/common/viewModel/inlineDecorations.ts b/src/vs/editor/common/viewModel/inlineDecorations.ts new file mode 100644 index 00000000000..c33336342f0 --- /dev/null +++ b/src/vs/editor/common/viewModel/inlineDecorations.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +export const enum InlineDecorationType { + Regular = 0, + Before = 1, + After = 2, + RegularAffectingLetterSpacing = 3 +} + +export class InlineDecoration { + constructor( + public readonly range: Range, + public readonly inlineClassName: string, + public readonly type: InlineDecorationType + ) { } +} + +export class SingleLineInlineDecoration { + constructor( + public readonly startOffset: number, + public readonly endOffset: number, + public readonly inlineClassName: string, + public readonly inlineClassNameAffectsLetterSpacing: boolean + ) { + } + + toInlineDecoration(lineNumber: number): InlineDecoration { + return new InlineDecoration( + new Range(lineNumber, this.startOffset + 1, lineNumber, this.endOffset + 1), + this.inlineClassName, + this.inlineClassNameAffectsLetterSpacing ? InlineDecorationType.RegularAffectingLetterSpacing : InlineDecorationType.Regular + ); + } +} diff --git a/src/vs/editor/common/viewModel/modelLineProjection.ts b/src/vs/editor/common/viewModel/modelLineProjection.ts index b01a1de2898..d98e4aa98cf 100644 --- a/src/vs/editor/common/viewModel/modelLineProjection.ts +++ b/src/vs/editor/common/viewModel/modelLineProjection.ts @@ -9,7 +9,8 @@ import { IRange } from '../core/range.js'; import { EndOfLinePreference, ITextModel, PositionAffinity } from '../model.js'; import { LineInjectedText } from '../textModelEvents.js'; import { InjectedText, ModelLineProjectionData } from '../modelLineProjectionData.js'; -import { SingleLineInlineDecoration, ViewLineData } from '../viewModel.js'; +import { ViewLineData } from '../viewModel.js'; +import { SingleLineInlineDecoration } from './inlineDecorations.js'; export interface IModelLineProjection { isVisible(): boolean; diff --git a/src/vs/editor/common/viewModel/viewModelDecoration.ts b/src/vs/editor/common/viewModel/viewModelDecoration.ts new file mode 100644 index 00000000000..9d1e7a9ec72 --- /dev/null +++ b/src/vs/editor/common/viewModel/viewModelDecoration.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IModelDecoration, IModelDecorationOptions, ITextModel } from '../model.js'; +import { Range } from '../core/range.js'; +import { StandardTokenType } from '../encodedTokenAttributes.js'; + +export class ViewModelDecoration { + _viewModelDecorationBrand: void = undefined; + + public readonly range: Range; + public readonly options: IModelDecorationOptions; + + constructor(range: Range, options: IModelDecorationOptions) { + this.range = range; + this.options = options; + } +} + +export function isModelDecorationVisible(model: ITextModel, decoration: IModelDecoration): boolean { + if (decoration.options.hideInCommentTokens && isModelDecorationInComment(model, decoration)) { + return false; + } + + if (decoration.options.hideInStringTokens && isModelDecorationInString(model, decoration)) { + return false; + } + + return true; +} + +export function isModelDecorationInComment(model: ITextModel, decoration: IModelDecoration): boolean { + return testTokensInRange( + model, + decoration.range, + (tokenType) => tokenType === StandardTokenType.Comment + ); +} + +export function isModelDecorationInString(model: ITextModel, decoration: IModelDecoration): boolean { + return testTokensInRange( + model, + decoration.range, + (tokenType) => tokenType === StandardTokenType.String + ); +} + +/** + * Calls the callback for every token that intersects the range. + * If the callback returns `false`, iteration stops and `false` is returned. + * Otherwise, `true` is returned. + */ +function testTokensInRange(model: ITextModel, range: Range, callback: (tokenType: StandardTokenType) => boolean): boolean { + for (let lineNumber = range.startLineNumber; lineNumber <= range.endLineNumber; lineNumber++) { + const lineTokens = model.tokenization.getLineTokens(lineNumber); + const isFirstLine = lineNumber === range.startLineNumber; + const isEndLine = lineNumber === range.endLineNumber; + + let tokenIdx = isFirstLine ? lineTokens.findTokenIndexAtOffset(range.startColumn - 1) : 0; + while (tokenIdx < lineTokens.getCount()) { + if (isEndLine) { + const startOffset = lineTokens.getStartOffset(tokenIdx); + if (startOffset > range.endColumn - 1) { + break; + } + } + + const callbackResult = callback(lineTokens.getStandardTokenType(tokenIdx)); + if (!callbackResult) { + return false; + } + tokenIdx++; + } + } + return true; +} + diff --git a/src/vs/editor/common/viewModel/viewModelDecorations.ts b/src/vs/editor/common/viewModel/viewModelDecorations.ts index beab19d6d79..3a1da29ed75 100644 --- a/src/vs/editor/common/viewModel/viewModelDecorations.ts +++ b/src/vs/editor/common/viewModel/viewModelDecorations.ts @@ -9,9 +9,10 @@ import { Range } from '../core/range.js'; import { IEditorConfiguration } from '../config/editorConfiguration.js'; import { IModelDecoration, ITextModel, PositionAffinity } from '../model.js'; import { IViewModelLines } from './viewModelLines.js'; -import { ICoordinatesConverter, InlineDecoration, InlineDecorationType, ViewModelDecoration } from '../viewModel.js'; import { filterFontDecorations, filterValidationDecorations } from '../config/editorOptions.js'; -import { StandardTokenType } from '../encodedTokenAttributes.js'; +import { isModelDecorationVisible, ViewModelDecoration } from './viewModelDecoration.js'; +import { InlineDecoration, InlineDecorationType } from './inlineDecorations.js'; +import { ICoordinatesConverter } from '../coordinatesConverter.js'; export interface IDecorationsViewportData { /** @@ -186,61 +187,3 @@ export class ViewModelDecorations implements IDisposable { }; } } - -export function isModelDecorationVisible(model: ITextModel, decoration: IModelDecoration): boolean { - if (decoration.options.hideInCommentTokens && isModelDecorationInComment(model, decoration)) { - return false; - } - - if (decoration.options.hideInStringTokens && isModelDecorationInString(model, decoration)) { - return false; - } - - return true; -} - -export function isModelDecorationInComment(model: ITextModel, decoration: IModelDecoration): boolean { - return testTokensInRange( - model, - decoration.range, - (tokenType) => tokenType === StandardTokenType.Comment - ); -} - -export function isModelDecorationInString(model: ITextModel, decoration: IModelDecoration): boolean { - return testTokensInRange( - model, - decoration.range, - (tokenType) => tokenType === StandardTokenType.String - ); -} - -/** - * Calls the callback for every token that intersects the range. - * If the callback returns `false`, iteration stops and `false` is returned. - * Otherwise, `true` is returned. - */ -function testTokensInRange(model: ITextModel, range: Range, callback: (tokenType: StandardTokenType) => boolean): boolean { - for (let lineNumber = range.startLineNumber; lineNumber <= range.endLineNumber; lineNumber++) { - const lineTokens = model.tokenization.getLineTokens(lineNumber); - const isFirstLine = lineNumber === range.startLineNumber; - const isEndLine = lineNumber === range.endLineNumber; - - let tokenIdx = isFirstLine ? lineTokens.findTokenIndexAtOffset(range.startColumn - 1) : 0; - while (tokenIdx < lineTokens.getCount()) { - if (isEndLine) { - const startOffset = lineTokens.getStartOffset(tokenIdx); - if (startOffset > range.endColumn - 1) { - break; - } - } - - const callbackResult = callback(lineTokens.getStandardTokenType(tokenIdx)); - if (!callbackResult) { - return false; - } - tokenIdx++; - } - } - return true; -} diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 7e65e7a1d45..dd4f4602edb 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -34,7 +34,7 @@ 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, ILineHeightChangeAccessor, IViewModel, IWhitespaceChangeAccessor, MinimapLinesRenderingData, OverviewRulerDecorationsGroup, ViewLineData, ViewLineRenderingData, ViewModelDecoration } from '../viewModel.js'; +import { ILineHeightChangeAccessor, IViewModel, IWhitespaceChangeAccessor, MinimapLinesRenderingData, OverviewRulerDecorationsGroup, ViewLineData, ViewLineRenderingData, ViewModelDecoration } from '../viewModel.js'; import { ViewModelDecorations } from './viewModelDecorations.js'; import { FocusChangedEvent, HiddenAreasChangedEvent, ModelContentChangedEvent, ModelDecorationsChangedEvent, ModelFontChangedEvent, ModelLanguageChangedEvent, ModelLanguageConfigurationChangedEvent, ModelLineHeightChangedEvent, ModelOptionsChangedEvent, ModelTokensChangedEvent, OutgoingViewModelEvent, ReadOnlyEditAttemptEvent, ScrollChangedEvent, ViewModelEventDispatcher, ViewModelEventsCollector, ViewZonesChangedEvent, WidgetFocusChangedEvent } from '../viewModelEventDispatcher.js'; import { IViewModelLines, ViewModelLinesFromModelAsIs, ViewModelLinesFromProjectedModel } from './viewModelLines.js'; @@ -42,6 +42,8 @@ import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { GlyphMarginLanesModel } from './glyphLanesModel.js'; import { ICustomLineHeightData } from '../viewLayout/lineHeights.js'; import { TextModelEditReason } from '../textModelEditReason.js'; +import { InlineDecoration } from './inlineDecorations.js'; +import { ICoordinatesConverter } from '../coordinatesConverter.js'; const USE_IDENTITY_LINES_COLLECTION = true; diff --git a/src/vs/editor/common/viewModel/viewModelLines.ts b/src/vs/editor/common/viewModel/viewModelLines.ts index d66abd71aad..74eef52bafa 100644 --- a/src/vs/editor/common/viewModel/viewModelLines.ts +++ b/src/vs/editor/common/viewModel/viewModelLines.ts @@ -17,7 +17,8 @@ import * as viewEvents from '../viewEvents.js'; import { createModelLineProjection, IModelLineProjection } from './modelLineProjection.js'; import { ILineBreaksComputer, ModelLineProjectionData, InjectedText, ILineBreaksComputerFactory } from '../modelLineProjectionData.js'; import { ConstantTimePrefixSumComputer } from '../model/prefixSumComputer.js'; -import { ICoordinatesConverter, ViewLineData } from '../viewModel.js'; +import { ViewLineData } from '../viewModel.js'; +import { ICoordinatesConverter, IdentityCoordinatesConverter } from '../coordinatesConverter.js'; export interface IViewModelLines extends IDisposable { createCoordinatesConverter(): ICoordinatesConverter; @@ -1120,7 +1121,7 @@ export class ViewModelLinesFromModelAsIs implements IViewModelLines { } public createCoordinatesConverter(): ICoordinatesConverter { - return new IdentityCoordinatesConverter(this); + return new IdentityCoordinatesConverter(this.model); } public getHiddenAreas(): Range[] { @@ -1255,77 +1256,3 @@ export class ViewModelLinesFromModelAsIs implements IViewModelLines { return null; } } - -class IdentityCoordinatesConverter implements ICoordinatesConverter { - private readonly _lines: ViewModelLinesFromModelAsIs; - - constructor(lines: ViewModelLinesFromModelAsIs) { - this._lines = lines; - } - - private _validPosition(pos: Position): Position { - return this._lines.model.validatePosition(pos); - } - - private _validRange(range: Range): Range { - return this._lines.model.validateRange(range); - } - - // View -> Model conversion and related methods - - public convertViewPositionToModelPosition(viewPosition: Position): Position { - return this._validPosition(viewPosition); - } - - public convertViewRangeToModelRange(viewRange: Range): Range { - return this._validRange(viewRange); - } - - public validateViewPosition(_viewPosition: Position, expectedModelPosition: Position): Position { - return this._validPosition(expectedModelPosition); - } - - public validateViewRange(_viewRange: Range, expectedModelRange: Range): Range { - return this._validRange(expectedModelRange); - } - - // Model -> View conversion and related methods - - public convertModelPositionToViewPosition(modelPosition: Position): Position { - return this._validPosition(modelPosition); - } - - public convertModelRangeToViewRange(modelRange: Range): Range { - return this._validRange(modelRange); - } - - public modelPositionIsVisible(modelPosition: Position): boolean { - const lineCount = this._lines.model.getLineCount(); - if (modelPosition.lineNumber < 1 || modelPosition.lineNumber > lineCount) { - // invalid arguments - return false; - } - return true; - } - - public modelRangeIsVisible(modelRange: Range): boolean { - const lineCount = this._lines.model.getLineCount(); - if (modelRange.startLineNumber < 1 || modelRange.startLineNumber > lineCount) { - // invalid arguments - return false; - } - if (modelRange.endLineNumber < 1 || modelRange.endLineNumber > lineCount) { - // invalid arguments - return false; - } - return true; - } - - public getModelLineViewLineCount(modelLineNumber: number): number { - return 1; - } - - public getViewLineNumberOfModelPosition(modelLineNumber: number, modelColumn: number): number { - return modelLineNumber; - } -} diff --git a/src/vs/editor/contrib/codeAction/browser/codeAction.ts b/src/vs/editor/contrib/codeAction/browser/codeAction.ts index eaad94fdaf5..4680bee9c5b 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeAction.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeAction.ts @@ -310,7 +310,7 @@ export async function applyCodeAction( code: 'undoredo.codeAction', respectAutoSaveConfig: codeActionReason !== ApplyCodeActionReason.OnSave, showPreview: options?.preview, - reason: EditReasons.codeAction({ kind: item.action.kind, extensionId: item.provider?.extensionId }), + reason: EditReasons.codeAction({ kind: item.action.kind, providerId: languages.ProviderId.fromExtensionId(item.provider?.extensionId) }), }); if (!result.isApplied) { diff --git a/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts b/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts index 756e14ca80f..d7447d18bd3 100644 --- a/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts +++ b/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts @@ -196,14 +196,6 @@ export class ContextMenuController implements IEditorContribution { return; } - // Disable hover - const oldHoverSetting = this._editor.getOption(EditorOption.hover); - this._editor.updateOptions({ - hover: { - enabled: false - } - }); - let anchor: IMouseEvent | IAnchor | null = event; if (!anchor) { // Ensure selection is visible @@ -251,9 +243,6 @@ export class ContextMenuController implements IEditorContribution { onHide: (wasCancelled: boolean) => { this._contextMenuIsBeingShownCount--; - this._editor.updateOptions({ - hover: oldHoverSetting - }); } }); } diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts index f995539bb62..3c01e74c1a6 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts @@ -409,19 +409,14 @@ export class CopyPasteController extends Disposable implements IEditorContributi this._pasteProgressManager.showWhile(selections[0].getEndPosition(), localize('pasteIntoEditorProgress', "Running paste handlers. Click to cancel and do basic paste"), p, { cancel: async () => { - try { - p.cancel(); - - if (editorStateCts.token.isCancellationRequested) { - return; - } - - await this.applyDefaultPasteHandler(dataTransfer, metadata, editorStateCts.token, clipboardEvent); - } finally { - editorStateCts.dispose(); + p.cancel(); + if (editorStateCts.token.isCancellationRequested) { + return; } + + await this.applyDefaultPasteHandler(dataTransfer, metadata, editorStateCts.token, clipboardEvent); } - }).then(() => { + }).finally(() => { editorStateCts.dispose(); }); this._currentPasteOperation = p; diff --git a/src/vs/editor/contrib/hover/browser/contentHoverController.ts b/src/vs/editor/contrib/hover/browser/contentHoverController.ts index c4c3bfd82ab..0cf939159b7 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverController.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverController.ts @@ -23,6 +23,7 @@ import './hover.css'; import { Emitter } from '../../../../base/common/event.js'; import { isOnColorDecorator } from '../../colorPicker/browser/hoverColorPicker/hoverColorPicker.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; // sticky hover widget which doesn't disappear on focus out and such const _sticky = false @@ -54,8 +55,11 @@ export class ContentHoverController extends Disposable implements IEditorContrib private _hoverSettings!: IHoverSettings; private _isMouseDown: boolean = false; + private _ignoreMouseEvents: boolean = false; + constructor( private readonly _editor: ICodeEditor, + @IContextMenuService _contextMenuService: IContextMenuService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IKeybindingService private readonly _keybindingService: IKeybindingService ) { @@ -67,6 +71,13 @@ export class ContentHoverController extends Disposable implements IEditorContrib } }, 0 )); + this._register(_contextMenuService.onDidShowContextMenu(() => { + this.hideContentHover(); + this._ignoreMouseEvents = true; + })); + this._register(_contextMenuService.onDidHideContextMenu(() => { + this._ignoreMouseEvents = false; + })); this._hookListeners(); this._register(this._editor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => { if (e.hasChanged(EditorOption.hover)) { @@ -115,12 +126,18 @@ export class ContentHoverController extends Disposable implements IEditorContrib } private _onEditorScrollChanged(e: IScrollEvent): void { + if (this._ignoreMouseEvents) { + return; + } if (e.scrollTopChanged || e.scrollLeftChanged) { this.hideContentHover(); } } private _onEditorMouseDown(mouseEvent: IEditorMouseEvent): void { + if (this._ignoreMouseEvents) { + return; + } this._isMouseDown = true; const shouldKeepHoverWidgetVisible = this._shouldKeepHoverWidgetVisible(mouseEvent); if (shouldKeepHoverWidgetVisible) { @@ -141,10 +158,16 @@ export class ContentHoverController extends Disposable implements IEditorContrib } private _onEditorMouseUp(): void { + if (this._ignoreMouseEvents) { + return; + } this._isMouseDown = false; } private _onEditorMouseLeave(mouseEvent: IPartialEditorMouseEvent): void { + if (this._ignoreMouseEvents) { + return; + } if (this.shouldKeepOpenOnEditorMouseMoveOrLeave) { return; } @@ -198,6 +221,9 @@ export class ContentHoverController extends Disposable implements IEditorContrib } private _onEditorMouseMove(mouseEvent: IEditorMouseEvent): void { + if (this._ignoreMouseEvents) { + return; + } this._mouseMoveEvent = mouseEvent; const shouldKeepCurrentHover = this._shouldKeepCurrentHover(mouseEvent); if (shouldKeepCurrentHover) { @@ -236,6 +262,9 @@ export class ContentHoverController extends Disposable implements IEditorContrib } private _onKeyDown(e: IKeyboardEvent): void { + if (this._ignoreMouseEvents) { + return; + } if (!this._contentWidget) { return; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts index 4f203a95a52..00683c3398e 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts @@ -195,6 +195,7 @@ export class AcceptInlineCompletion extends EditorAction { EditorContextKeys.tabMovesFocus.toNegated(), SuggestContext.Visible.toNegated(), EditorContextKeys.hoverFocused.toNegated(), + InlineCompletionContextKeys.hasSelection.toNegated(), InlineCompletionContextKeys.inlineSuggestionHasIndentationLessThanTabSize, ), diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts index 0ae14aef6d5..7aab79fce9c 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts @@ -37,7 +37,6 @@ import { ObservableContextKeyService } from '../utils.js'; import { InlineCompletionsView } from '../view/inlineCompletionsView.js'; import { inlineSuggestCommitId } from './commandIds.js'; import { InlineCompletionContextKeys } from './inlineCompletionContextKeys.js'; -import { IInlineCompletionsService } from '../../../../browser/services/inlineCompletionsService.js'; export class InlineCompletionsController extends Disposable { private static readonly _instances = new Set(); @@ -96,7 +95,6 @@ export class InlineCompletionsController extends Disposable { @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, - @IInlineCompletionsService private readonly _inlineCompletionsService: IInlineCompletionsService, ) { super(); this._editorObs = observableCodeEditor(this.editor); @@ -112,8 +110,7 @@ export class InlineCompletionsController extends Disposable { this._contextKeyService.onDidChangeContext, () => this._contextKeyService.getContext(this.editor.getDomNode()).getValue('editorDictation.inProgress') === true ); - const isSnoozing = observableFromEvent(this, this._inlineCompletionsService.onDidChangeIsSnoozing, () => this._inlineCompletionsService.isSnoozing()); - this._enabled = derived(this, reader => this._enabledInConfig.read(reader) && !isSnoozing.read(reader) && (!this._isScreenReaderEnabled.read(reader) || !this._editorDictationInProgress.read(reader))); + this._enabled = derived(this, reader => this._enabledInConfig.read(reader) && (!this._isScreenReaderEnabled.read(reader) || !this._editorDictationInProgress.read(reader))); this._debounceValue = this._debounceService.for( this._languageFeaturesService.inlineCompletionsProvider, 'InlineCompletionsDebounce', diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/ghostText.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/ghostText.ts index 30f4e475890..4629b406532 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/ghostText.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/ghostText.ts @@ -9,9 +9,9 @@ import { Position } from '../../../../common/core/position.js'; import { Range } from '../../../../common/core/range.js'; import { TextReplacement, TextEdit } from '../../../../common/core/edits/textEdit.js'; import { LineDecoration } from '../../../../common/viewLayout/lineDecorations.js'; -import { InlineDecoration } from '../../../../common/viewModel.js'; import { ColumnRange } from '../../../../common/core/ranges/columnRange.js'; import { assertFn, checkAdjacentItems } from '../../../../../base/common/assert.js'; +import { InlineDecoration } from '../../../../common/viewModel/inlineDecorations.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 964b23884c8..e206d0376c3 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -46,28 +46,47 @@ import { SuggestItemInfo } from './suggestWidgetAdapter.js'; import { TextModelEditReason, EditReasons } from '../../../../common/textModelEditReason.js'; import { ICodeEditorService } from '../../../../browser/services/codeEditorService.js'; import { InlineCompletionViewData, InlineCompletionViewKind } from '../view/inlineEdits/inlineEditsViewInterface.js'; +import { IInlineCompletionsService } from '../../../../browser/services/inlineCompletionsService.js'; +import { TypingInterval } from './typingSpeed.js'; export class InlineCompletionsModel extends Disposable { private readonly _source; - private readonly _isActive; - private readonly _onlyRequestInlineEditsSignal; - private readonly _forceUpdateExplicitlySignal; - private readonly _noDelaySignal; + private readonly _isActive = observableValue(this, false); + private readonly _onlyRequestInlineEditsSignal = observableSignal(this); + private readonly _forceUpdateExplicitlySignal = observableSignal(this); + private readonly _noDelaySignal = observableSignal(this); - private readonly _fetchSpecificProviderSignal; + 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; - public readonly primaryPosition; + private readonly _selectedInlineCompletionId = observableValue(this, undefined); + public readonly primaryPosition = derived(this, reader => this._positions.read(reader)[0] ?? new Position(1, 1)); - private _isAcceptingPartially; + private _isAcceptingPartially = false; + private readonly _appearedInsideViewport = derived(this, reader => { + const state = this.state.read(reader); + if (!state || !state.inlineCompletion) { + return false; + } + + const targetRange = state.inlineCompletion.targetRange; + const visibleRanges = this._editorObs.editor.getVisibleRanges(); + if (visibleRanges.length < 1) { + return false; + } + + const viewportRange = new Range(visibleRanges[0].startLineNumber, visibleRanges[0].startColumn, visibleRanges[visibleRanges.length - 1].endLineNumber, visibleRanges[visibleRanges.length - 1].endColumn); + return viewportRange.containsRange(targetRange); + }); public get isAcceptingPartially() { return this._isAcceptingPartially; } - private readonly _onDidAccept; - public readonly onDidAccept; + private readonly _onDidAccept = new Emitter(); + public readonly onDidAccept = this._onDidAccept.event; private readonly _editorObs; + private readonly _typing: TypingInterval; + private readonly _suggestPreviewEnabled; private readonly _suggestPreviewMode; private readonly _inlineSuggestMode; @@ -90,19 +109,12 @@ export class InlineCompletionsModel extends Disposable { @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, + @IInlineCompletionsService private readonly _inlineCompletionsService: IInlineCompletionsService ) { super(); - this.primaryPosition = derived(this, reader => this._positions.read(reader)[0] ?? new Position(1, 1)); this._source = this._register(this._instantiationService.createInstance(InlineCompletionsSource, this.textModel, this._textModelVersionId, this._debounceValue, this.primaryPosition)); - this._isActive = observableValue(this, false); - this._onlyRequestInlineEditsSignal = observableSignal(this); - this._forceUpdateExplicitlySignal = observableSignal(this); - this._noDelaySignal = observableSignal(this); - this._fetchSpecificProviderSignal = observableSignal(this); - this._selectedInlineCompletionId = observableValue(this, undefined); - this._isAcceptingPartially = false; - this._onDidAccept = new Emitter(); - this.onDidAccept = this._onDidAccept.event; + this.lastTriggerKind = this._source.inlineCompletions.map(this, v => v?.request?.context.triggerKind); + this._editorObs = observableCodeEditor(this._editor); this._suggestPreviewEnabled = this._editorObs.getOption(EditorOption.suggest).map(v => v.preview); this._suggestPreviewMode = this._editorObs.getOption(EditorOption.suggest).map(v => v.previewMode); @@ -111,454 +123,25 @@ export class InlineCompletionsModel extends Disposable { this._inlineEditsEnabled = this._editorObs.getOption(EditorOption.inlineSuggest).map(v => !!v.edits.enabled); this._inlineEditsShowCollapsedEnabled = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.edits.showCollapsed); this._triggerCommandOnProviderChange = this._editorObs.getOption(EditorOption.inlineSuggest).map(s => s.experimental.triggerCommandOnProviderChange); + this._typing = this._register(new TypingInterval(this.textModel)); - this._lastShownInlineCompletionInfo = undefined; - this._lastAcceptedInlineCompletionInfo = undefined; - this._didUndoInlineEdits = derivedHandleChanges({ - owner: this, - changeTracker: { - createChangeSummary: () => ({ didUndo: false }), - handleChange: (ctx, changeSummary) => { - changeSummary.didUndo = ctx.didChange(this._textModelVersionId) && !!ctx.change?.isUndoing; - return true; - } + this._register(this._inlineCompletionsService.onDidChangeIsSnoozing((isSnoozing) => { + if (isSnoozing) { + this.stop(); } - }, (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; - }); - this._preserveCurrentCompletionReasons = new Set([ - VersionIdChangeReason.Redo, - VersionIdChangeReason.Undo, - VersionIdChangeReason.AcceptWord, - ]); - this.dontRefetchSignal = observableSignal(this); - this._fetchInlineCompletionsPromise = derivedHandleChanges({ - owner: this, - changeTracker: { - createChangeSummary: () => ({ - dontRefetch: false, - preserveCurrentCompletion: false, - inlineCompletionTriggerKind: InlineCompletionTriggerKind.Automatic, - onlyRequestInlineEdits: false, - shouldDebounce: true, - provider: undefined as InlineCompletionsProvider | undefined, - textChange: false, - changeReason: '', - }), - handleChange: (ctx, changeSummary) => { - /** @description fetch inline completions */ - if (ctx.didChange(this._textModelVersionId)) { - if (this._preserveCurrentCompletionReasons.has(this._getReason(ctx.change))) { - changeSummary.preserveCurrentCompletion = true; - } - const detailedReasons = ctx.change?.detailedReasons ?? []; - changeSummary.changeReason = detailedReasons.length > 0 ? detailedReasons[0].getType() : ''; - changeSummary.textChange = 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 - this._noDelaySignal.read(reader); - 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(); - return undefined; - } - - this._textModelVersionId.read(reader); // Refetch on text change - - const suggestWidgetInlineCompletions = this._source.suggestWidgetInlineCompletions.get(); - const suggestItem = this._selectedSuggestItem.read(reader); - if (suggestWidgetInlineCompletions && !suggestItem) { - this._source.seedInlineCompletionsWithSuggestWidget(); - } - - if (changeSummary.dontRefetch) { - return Promise.resolve(true); - } - - if (this._didUndoInlineEdits.read(reader) && changeSummary.inlineCompletionTriggerKind !== InlineCompletionTriggerKind.Explicit) { - transaction(tx => { - this._source.clear(tx); - }); - return undefined; - } - - let reason: string = ''; - if (changeSummary.provider) { - reason += 'providerOnDidChange'; - } else if (changeSummary.inlineCompletionTriggerKind === InlineCompletionTriggerKind.Explicit) { - reason += 'explicit'; - } - if (changeSummary.changeReason) { - reason += reason.length > 0 ? `:${changeSummary.changeReason}` : changeSummary.changeReason; - } - - const requestInfo: InlineSuggestRequestInfo = { - editorType: this.editorType, - startTime: Date.now(), - languageId: this.textModel.getLanguageId(), - reason, - }; - - let context: InlineCompletionContextWithoutUuid = { - triggerKind: changeSummary.inlineCompletionTriggerKind, - selectedSuggestionInfo: suggestItem?.toSelectedSuggestionInfo(), - includeInlineCompletions: !changeSummary.onlyRequestInlineEdits, - includeInlineEdits: this._inlineEditsEnabled.read(reader), - }; - - if (context.triggerKind === InlineCompletionTriggerKind.Automatic && changeSummary.textChange) { - 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); - - const providers = changeSummary.provider ? [changeSummary.provider] : this._languageFeaturesService.inlineCompletionsProvider.all(this.textModel); - const suppressedProviderGroupIds = this._suppressedInlineCompletionGroupIds.get(); - const availableProviders = providers.filter(provider => !(provider.groupId && suppressedProviderGroupIds.has(provider.groupId))); - - return this._source.fetch(availableProviders, context, itemToPreserve?.identity, changeSummary.shouldDebounce, userJumpedToActiveCompletion, !!changeSummary.provider, requestInfo); - }); - - this._inlineCompletionItems = derivedOpts({ owner: this }, reader => { - const c = this._source.inlineCompletions.read(reader); - if (!c) { return undefined; } - const cursorPosition = this.primaryPosition.read(reader); - let inlineEdit: InlineEditItem | undefined = undefined; - const visibleCompletions: InlineCompletionItem[] = []; - for (const completion of c.inlineCompletions) { - if (!completion.isInlineEdit) { - if (completion.isVisible(this.textModel, cursorPosition)) { - visibleCompletions.push(completion); - } - } else { - inlineEdit = completion; - } - } - - if (visibleCompletions.length !== 0) { - // Don't show the inline edit if there is a visible completion - inlineEdit = undefined; - } - - return { - inlineCompletions: visibleCompletions, - inlineEdit, - }; - }); - this._filteredInlineCompletionItems = derivedOpts({ owner: this, equalsFn: itemsEquals() }, reader => { - const c = this._inlineCompletionItems.read(reader); - return c?.inlineCompletions ?? []; - }); - this.selectedInlineCompletionIndex = derived(this, (reader) => { - const selectedInlineCompletionId = this._selectedInlineCompletionId.read(reader); - const filteredCompletions = this._filteredInlineCompletionItems.read(reader); - const idx = this._selectedInlineCompletionId === undefined ? -1 - : filteredCompletions.findIndex(v => v.semanticId === selectedInlineCompletionId); - if (idx === -1) { - // Reset the selection so that the selection does not jump back when it appears again - this._selectedInlineCompletionId.set(undefined, undefined); - return 0; - } - return idx; - }); - this.selectedInlineCompletion = derived(this, (reader) => { - const filteredCompletions = this._filteredInlineCompletionItems.read(reader); - const idx = this.selectedInlineCompletionIndex.read(reader); - return filteredCompletions[idx]; - }); - this.activeCommands = derivedOpts({ owner: this, equalsFn: itemsEquals() }, - r => this.selectedInlineCompletion.read(r)?.source.inlineSuggestions.commands ?? [] - ); - this.lastTriggerKind = this._source.inlineCompletions.map(this, v => v?.request?.context.triggerKind); - this.inlineCompletionsCount = derived(this, reader => { - if (this.lastTriggerKind.read(reader) === InlineCompletionTriggerKind.Explicit) { - return this._filteredInlineCompletionItems.read(reader).length; - } else { - return undefined; - } - }); - this._hasVisiblePeekWidgets = derived(this, reader => this._editorObs.openedPeekWidgets.read(reader) > 0); - this.state = derivedOpts<{ - kind: 'ghostText'; - edits: readonly TextReplacement[]; - primaryGhostText: GhostTextOrReplacement; - ghostTexts: readonly GhostTextOrReplacement[]; - suggestItem: SuggestItemInfo | undefined; - inlineCompletion: InlineCompletionItem | undefined; - } | { - kind: 'inlineEdit'; - edits: readonly TextReplacement[]; - inlineEdit: InlineEdit; - inlineCompletion: InlineEditItem; - cursorAtInlineEdit: IObservable; - } | undefined>({ - owner: this, - equalsFn: (a, b) => { - if (!a || !b) { return a === b; } - - if (a.kind === 'ghostText' && b.kind === 'ghostText') { - return ghostTextsOrReplacementsEqual(a.ghostTexts, b.ghostTexts) - && a.inlineCompletion === b.inlineCompletion - && a.suggestItem === b.suggestItem; - } else if (a.kind === 'inlineEdit' && b.kind === 'inlineEdit') { - return a.inlineEdit.equals(b.inlineEdit); - } - return false; - } - }, (reader) => { - const model = this.textModel; - - const item = this._inlineCompletionItems.read(reader); - const inlineEditResult = item?.inlineEdit; - if (inlineEditResult) { - if (this._hasVisiblePeekWidgets.read(reader)) { - return undefined; - } - let edit = inlineEditResult.getSingleTextEdit(); - edit = singleTextRemoveCommonPrefix(edit, model); - - const cursorAtInlineEdit = this.primaryPosition.map(cursorPos => LineRange.fromRangeInclusive(inlineEditResult.targetRange).addMargin(1, 1).contains(cursorPos.lineNumber)); - - const commands = inlineEditResult.source.inlineSuggestions.commands; - const inlineEdit = new InlineEdit(edit, commands ?? [], inlineEditResult); - - const edits = inlineEditResult.updatedEdit; - const e = edits ? TextEdit.fromStringEdit(edits, new TextModelText(this.textModel)).replacements : [edit]; - - return { kind: 'inlineEdit', inlineEdit, inlineCompletion: inlineEditResult, edits: e, cursorAtInlineEdit }; - } - - const suggestItem = this._selectedSuggestItem.read(reader); - if (suggestItem) { - const suggestCompletionEdit = singleTextRemoveCommonPrefix(suggestItem.getSingleTextEdit(), model); - const augmentation = this._computeAugmentation(suggestCompletionEdit, reader); - - const isSuggestionPreviewEnabled = this._suggestPreviewEnabled.read(reader); - if (!isSuggestionPreviewEnabled && !augmentation) { return undefined; } - - const fullEdit = augmentation?.edit ?? suggestCompletionEdit; - const fullEditPreviewLength = augmentation ? augmentation.edit.text.length - suggestCompletionEdit.text.length : 0; - - const mode = this._suggestPreviewMode.read(reader); - const positions = this._positions.read(reader); - const edits = [fullEdit, ...getSecondaryEdits(this.textModel, positions, fullEdit)]; - const ghostTexts = edits - .map((edit, idx) => computeGhostText(edit, model, mode, positions[idx], fullEditPreviewLength)) - .filter(isDefined); - const primaryGhostText = ghostTexts[0] ?? new GhostText(fullEdit.range.endLineNumber, []); - return { kind: 'ghostText', edits, primaryGhostText, ghostTexts, inlineCompletion: augmentation?.completion, suggestItem }; - } else { - if (!this._isActive.read(reader)) { return undefined; } - const inlineCompletion = this.selectedInlineCompletion.read(reader); - if (!inlineCompletion) { return undefined; } - - const replacement = inlineCompletion.getSingleTextEdit(); - const mode = this._inlineSuggestMode.read(reader); - const positions = this._positions.read(reader); - const edits = [replacement, ...getSecondaryEdits(this.textModel, positions, replacement)]; - const ghostTexts = edits - .map((edit, idx) => computeGhostText(edit, model, mode, positions[idx], 0)) - .filter(isDefined); - if (!ghostTexts[0]) { return undefined; } - return { kind: 'ghostText', edits, primaryGhostText: ghostTexts[0], ghostTexts, inlineCompletion, suggestItem: undefined }; - } - }); - this.status = derived(this, reader => { - if (this._source.loading.read(reader)) { return 'loading'; } - const s = this.state.read(reader); - if (s?.kind === 'ghostText') { return 'ghostText'; } - if (s?.kind === 'inlineEdit') { return 'inlineEdit'; } - return 'noSuggestion'; - }); - this.inlineCompletionState = derived(this, reader => { - const s = this.state.read(reader); - if (!s || s.kind !== 'ghostText') { - return undefined; - } - if (this._editorObs.inComposition.read(reader)) { - return undefined; - } - return s; - }); - this.inlineEditState = derived(this, reader => { - const s = this.state.read(reader); - if (!s || s.kind !== 'inlineEdit') { - return undefined; - } - return s; - }); - this.inlineEditAvailable = derived(this, reader => { - const s = this.inlineEditState.read(reader); - return !!s; - }); - this.warning = derived(this, reader => { - return this.inlineCompletionState.read(reader)?.inlineCompletion?.warning; - }); - this.ghostTexts = derivedOpts({ owner: this, equalsFn: ghostTextsOrReplacementsEqual }, reader => { - const v = this.inlineCompletionState.read(reader); - if (!v) { - return undefined; - } - return v.ghostTexts; - }); - this.primaryGhostText = derivedOpts({ owner: this, equalsFn: ghostTextOrReplacementEquals }, reader => { - const v = this.inlineCompletionState.read(reader); - if (!v) { - return undefined; - } - return v?.primaryGhostText; - }); - - this._jumpedToId = observableValue(this, undefined); - this._inAcceptFlow = observableValue(this, false); - this.inAcceptFlow = this._inAcceptFlow; - - // When the suggestion appeared, was it inside the view port or not - const appearedInsideViewport = derived(this, reader => { - const state = this.state.read(reader); - if (!state || !state.inlineCompletion) { - return false; - } - - const targetRange = state.inlineCompletion.targetRange; - const visibleRanges = this._editorObs.editor.getVisibleRanges(); - if (visibleRanges.length < 1) { - return false; - } - - const viewportRange = new Range(visibleRanges[0].startLineNumber, visibleRanges[0].startColumn, visibleRanges[visibleRanges.length - 1].endLineNumber, visibleRanges[visibleRanges.length - 1].endColumn); - return viewportRange.containsRange(targetRange); - }); - - this.showCollapsed = derived(this, reader => { - const state = this.state.read(reader); - if (!state || state.kind !== 'inlineEdit') { - 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 - && !this._inAcceptFlow.read(reader); - }); - this._tabShouldIndent = derived(this, reader => { - if (this._inAcceptFlow.read(reader)) { - return false; - } - - function isMultiLine(range: Range): boolean { - return range.startLineNumber !== range.endLineNumber; - } - - function getNonIndentationRange(model: ITextModel, lineNumber: number): Range { - const columnStart = model.getLineIndentColumn(lineNumber); - const lastNonWsColumn = model.getLineLastNonWhitespaceColumn(lineNumber); - const columnEnd = Math.max(lastNonWsColumn, columnStart); - return new Range(lineNumber, columnStart, lineNumber, columnEnd); - } - - const selections = this._editorObs.selections.read(reader); - return selections?.some(s => { - if (s.isEmpty()) { - return this.textModel.getLineLength(s.startLineNumber) === 0; - } else { - return isMultiLine(s) || s.containsRange(getNonIndentationRange(this.textModel, s.startLineNumber)); - } - }); - }); - this.tabShouldJumpToInlineEdit = derived(this, reader => { - if (this._tabShouldIndent.read(reader)) { - return false; - } - - const s = this.inlineEditState.read(reader); - if (!s) { - return false; - } - - if (this.showCollapsed.read(reader)) { - return true; - } - - if (this._inAcceptFlow.read(reader) && appearedInsideViewport.read(reader)) { - return false; - } - - return !s.cursorAtInlineEdit.read(reader); - }); - this.tabShouldAcceptInlineEdit = derived(this, reader => { - const s = this.inlineEditState.read(reader); - if (!s) { - return false; - } - if (this.showCollapsed.read(reader)) { - return false; - } - if (this._inAcceptFlow.read(reader) && appearedInsideViewport.read(reader)) { - return true; - } - if (s.inlineCompletion.targetRange.startLineNumber === this._editorObs.cursorLineNumber.read(reader)) { - return true; - } - if (this._jumpedToId.read(reader) === s.inlineCompletion.semanticId) { - return true; - } - if (this._tabShouldIndent.read(reader)) { - return false; - } - - return s.cursorAtInlineEdit.read(reader); - }); + })); { // Determine editor type + const isNotebook = this.textModel.uri.scheme === 'vscode-notebook-cell'; const [diffEditor] = this._codeEditorService.listDiffEditors() .filter(d => d.getOriginalEditor().getId() === this._editor.getId() || d.getModifiedEditor().getId() === this._editor.getId()); - this.editorType = !!diffEditor ? InlineCompletionEditorType.DiffEditor : InlineCompletionEditorType.TextEditor; - this.isInDiffEditor = this.editorType === InlineCompletionEditorType.DiffEditor; + this.isInDiffEditor = !!diffEditor; + this.editorType = isNotebook ? InlineCompletionEditorType.Notebook + : this.isInDiffEditor ? InlineCompletionEditorType.DiffEditor + : InlineCompletionEditorType.TextEditor; } this._register(recomputeInitiallyAndOnChange(this._fetchInlineCompletionsPromise)); @@ -629,9 +212,30 @@ export class InlineCompletionsModel extends Disposable { this._didUndoInlineEdits.recomputeInitiallyAndOnChange(this._store); } - private _lastShownInlineCompletionInfo: { alternateTextModelVersionId: number; /* already freed! */ inlineCompletion: InlineSuggestionItem } | undefined; - private _lastAcceptedInlineCompletionInfo: { textModelVersionIdAfter: number; /* already freed! */ inlineCompletion: InlineSuggestionItem } | undefined; - private readonly _didUndoInlineEdits; + 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; @@ -667,7 +271,11 @@ export class InlineCompletionsModel extends Disposable { }; } - private readonly _preserveCurrentCompletionReasons; + private readonly _preserveCurrentCompletionReasons = new Set([ + VersionIdChangeReason.Redo, + VersionIdChangeReason.Undo, + VersionIdChangeReason.AcceptWord, + ]); private _getReason(e: IModelContentChangedEvent | undefined): VersionIdChangeReason { if (e?.isUndoing) { return VersionIdChangeReason.Undo; } @@ -676,9 +284,128 @@ export class InlineCompletionsModel extends Disposable { return VersionIdChangeReason.Other; } - public readonly dontRefetchSignal; + public readonly dontRefetchSignal = observableSignal(this); - private readonly _fetchInlineCompletionsPromise; + private readonly _fetchInlineCompletionsPromise = derivedHandleChanges({ + owner: this, + changeTracker: { + createChangeSummary: () => ({ + dontRefetch: false, + preserveCurrentCompletion: false, + inlineCompletionTriggerKind: InlineCompletionTriggerKind.Automatic, + onlyRequestInlineEdits: false, + shouldDebounce: true, + provider: undefined as InlineCompletionsProvider | undefined, + textChange: false, + changeReason: '', + }), + handleChange: (ctx, changeSummary) => { + /** @description fetch inline completions */ + if (ctx.didChange(this._textModelVersionId)) { + if (this._preserveCurrentCompletionReasons.has(this._getReason(ctx.change))) { + changeSummary.preserveCurrentCompletion = true; + } + const detailedReasons = ctx.change?.detailedReasons ?? []; + changeSummary.changeReason = detailedReasons.length > 0 ? detailedReasons[0].getType() : ''; + changeSummary.textChange = true; + } else if (ctx.didChange(this._forceUpdateExplicitlySignal)) { + changeSummary.preserveCurrentCompletion = true; + 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 + this._noDelaySignal.read(reader); + 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)) + && (!this._inlineCompletionsService.isSnoozing() || changeSummary.inlineCompletionTriggerKind === InlineCompletionTriggerKind.Explicit); + if (!shouldUpdate) { + this._source.cancelUpdate(); + return undefined; + } + + this._textModelVersionId.read(reader); // Refetch on text change + + const suggestWidgetInlineCompletions = this._source.suggestWidgetInlineCompletions.get(); + const suggestItem = this._selectedSuggestItem.read(reader); + if (suggestWidgetInlineCompletions && !suggestItem) { + this._source.seedInlineCompletionsWithSuggestWidget(); + } + + if (changeSummary.dontRefetch) { + return Promise.resolve(true); + } + + if (this._didUndoInlineEdits.read(reader) && changeSummary.inlineCompletionTriggerKind !== InlineCompletionTriggerKind.Explicit) { + transaction(tx => { + this._source.clear(tx); + }); + return undefined; + } + + let reason: string = ''; + if (changeSummary.provider) { + reason += 'providerOnDidChange'; + } else if (changeSummary.inlineCompletionTriggerKind === InlineCompletionTriggerKind.Explicit) { + reason += 'explicit'; + } + if (changeSummary.changeReason) { + reason += reason.length > 0 ? `:${changeSummary.changeReason}` : changeSummary.changeReason; + } + + const typingInterval = this._typing.getTypingInterval(); + const requestInfo: InlineSuggestRequestInfo = { + editorType: this.editorType, + startTime: Date.now(), + languageId: this.textModel.getLanguageId(), + reason, + typingInterval: typingInterval.averageInterval, + typingIntervalCharacterCount: typingInterval.characterCount, + }; + + let context: InlineCompletionContextWithoutUuid = { + triggerKind: changeSummary.inlineCompletionTriggerKind, + selectedSuggestionInfo: suggestItem?.toSelectedSuggestionInfo(), + includeInlineCompletions: !changeSummary.onlyRequestInlineEdits, + includeInlineEdits: this._inlineEditsEnabled.read(reader), + }; + + if (context.triggerKind === InlineCompletionTriggerKind.Automatic && changeSummary.textChange) { + 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); + + const providers = changeSummary.provider + ? { providers: [changeSummary.provider], label: 'single:' + changeSummary.provider.providerId?.toString() } + : { providers: this._languageFeaturesService.inlineCompletionsProvider.all(this.textModel), label: undefined }; + const suppressedProviderGroupIds = this._suppressedInlineCompletionGroupIds.get(); + const availableProviders = providers.providers.filter(provider => !(provider.groupId && suppressedProviderGroupIds.has(provider.groupId))); + + return this._source.fetch(availableProviders, providers.label, context, itemToPreserve?.identity, changeSummary.shouldDebounce, userJumpedToActiveCompletion, !!changeSummary.provider, requestInfo); + }); public async trigger(tx?: ITransaction, options?: { onlyFetchInlineEdits?: boolean; noDelay?: boolean }): Promise { subtransaction(tx, tx => { @@ -719,32 +446,190 @@ export class InlineCompletionsModel extends Disposable { }); } - private readonly _inlineCompletionItems; + private readonly _inlineCompletionItems = derivedOpts({ owner: this }, reader => { + const c = this._source.inlineCompletions.read(reader); + if (!c) { return undefined; } + const cursorPosition = this.primaryPosition.read(reader); + let inlineEdit: InlineEditItem | undefined = undefined; + const visibleCompletions: InlineCompletionItem[] = []; + for (const completion of c.inlineCompletions) { + if (!completion.isInlineEdit) { + if (completion.isVisible(this.textModel, cursorPosition)) { + visibleCompletions.push(completion); + } + } else { + inlineEdit = completion; + } + } - private readonly _filteredInlineCompletionItems; + if (visibleCompletions.length !== 0) { + // Don't show the inline edit if there is a visible completion + inlineEdit = undefined; + } - public readonly selectedInlineCompletionIndex; + return { + inlineCompletions: visibleCompletions, + inlineEdit, + }; + }); - public readonly selectedInlineCompletion; + private readonly _filteredInlineCompletionItems = derivedOpts({ owner: this, equalsFn: itemsEquals() }, reader => { + const c = this._inlineCompletionItems.read(reader); + return c?.inlineCompletions ?? []; + }); - public readonly activeCommands; + public readonly selectedInlineCompletionIndex = derived(this, (reader) => { + const selectedInlineCompletionId = this._selectedInlineCompletionId.read(reader); + const filteredCompletions = this._filteredInlineCompletionItems.read(reader); + const idx = this._selectedInlineCompletionId === undefined ? -1 + : filteredCompletions.findIndex(v => v.semanticId === selectedInlineCompletionId); + if (idx === -1) { + // Reset the selection so that the selection does not jump back when it appears again + this._selectedInlineCompletionId.set(undefined, undefined); + return 0; + } + return idx; + }); - public readonly lastTriggerKind: IObservable - ; + public readonly selectedInlineCompletion = derived(this, (reader) => { + const filteredCompletions = this._filteredInlineCompletionItems.read(reader); + const idx = this.selectedInlineCompletionIndex.read(reader); + return filteredCompletions[idx]; + }); - public readonly inlineCompletionsCount; + public readonly activeCommands = derivedOpts({ owner: this, equalsFn: itemsEquals() }, + r => this.selectedInlineCompletion.read(r)?.source.inlineSuggestions.commands ?? [] + ); - private readonly _hasVisiblePeekWidgets; + public readonly lastTriggerKind: IObservable; - public readonly state; + public readonly inlineCompletionsCount = derived(this, reader => { + if (this.lastTriggerKind.read(reader) === InlineCompletionTriggerKind.Explicit) { + return this._filteredInlineCompletionItems.read(reader).length; + } else { + return undefined; + } + }); - public readonly status; + private readonly _hasVisiblePeekWidgets = derived(this, reader => this._editorObs.openedPeekWidgets.read(reader) > 0); - public readonly inlineCompletionState; + public readonly state = derivedOpts<{ + kind: 'ghostText'; + edits: readonly TextReplacement[]; + primaryGhostText: GhostTextOrReplacement; + ghostTexts: readonly GhostTextOrReplacement[]; + suggestItem: SuggestItemInfo | undefined; + inlineCompletion: InlineCompletionItem | undefined; + } | { + kind: 'inlineEdit'; + edits: readonly TextReplacement[]; + inlineEdit: InlineEdit; + inlineCompletion: InlineEditItem; + cursorAtInlineEdit: IObservable; + } | undefined>({ + owner: this, + equalsFn: (a, b) => { + if (!a || !b) { return a === b; } - public readonly inlineEditState; + if (a.kind === 'ghostText' && b.kind === 'ghostText') { + return ghostTextsOrReplacementsEqual(a.ghostTexts, b.ghostTexts) + && a.inlineCompletion === b.inlineCompletion + && a.suggestItem === b.suggestItem; + } else if (a.kind === 'inlineEdit' && b.kind === 'inlineEdit') { + return a.inlineEdit.equals(b.inlineEdit); + } + return false; + } + }, (reader) => { + const model = this.textModel; - public readonly inlineEditAvailable; + const item = this._inlineCompletionItems.read(reader); + const inlineEditResult = item?.inlineEdit; + if (inlineEditResult) { + if (this._hasVisiblePeekWidgets.read(reader)) { + return undefined; + } + let edit = inlineEditResult.getSingleTextEdit(); + edit = singleTextRemoveCommonPrefix(edit, model); + + const cursorAtInlineEdit = this.primaryPosition.map(cursorPos => LineRange.fromRangeInclusive(inlineEditResult.targetRange).addMargin(1, 1).contains(cursorPos.lineNumber)); + + const commands = inlineEditResult.source.inlineSuggestions.commands; + const inlineEdit = new InlineEdit(edit, commands ?? [], inlineEditResult); + + const edits = inlineEditResult.updatedEdit; + const e = edits ? TextEdit.fromStringEdit(edits, new TextModelText(this.textModel)).replacements : [edit]; + + return { kind: 'inlineEdit', inlineEdit, inlineCompletion: inlineEditResult, edits: e, cursorAtInlineEdit }; + } + + const suggestItem = this._selectedSuggestItem.read(reader); + if (suggestItem) { + const suggestCompletionEdit = singleTextRemoveCommonPrefix(suggestItem.getSingleTextEdit(), model); + const augmentation = this._computeAugmentation(suggestCompletionEdit, reader); + + const isSuggestionPreviewEnabled = this._suggestPreviewEnabled.read(reader); + if (!isSuggestionPreviewEnabled && !augmentation) { return undefined; } + + const fullEdit = augmentation?.edit ?? suggestCompletionEdit; + const fullEditPreviewLength = augmentation ? augmentation.edit.text.length - suggestCompletionEdit.text.length : 0; + + const mode = this._suggestPreviewMode.read(reader); + const positions = this._positions.read(reader); + const edits = [fullEdit, ...getSecondaryEdits(this.textModel, positions, fullEdit)]; + const ghostTexts = edits + .map((edit, idx) => computeGhostText(edit, model, mode, positions[idx], fullEditPreviewLength)) + .filter(isDefined); + const primaryGhostText = ghostTexts[0] ?? new GhostText(fullEdit.range.endLineNumber, []); + return { kind: 'ghostText', edits, primaryGhostText, ghostTexts, inlineCompletion: augmentation?.completion, suggestItem }; + } else { + if (!this._isActive.read(reader)) { return undefined; } + const inlineCompletion = this.selectedInlineCompletion.read(reader); + if (!inlineCompletion) { return undefined; } + + const replacement = inlineCompletion.getSingleTextEdit(); + const mode = this._inlineSuggestMode.read(reader); + const positions = this._positions.read(reader); + const edits = [replacement, ...getSecondaryEdits(this.textModel, positions, replacement)]; + const ghostTexts = edits + .map((edit, idx) => computeGhostText(edit, model, mode, positions[idx], 0)) + .filter(isDefined); + if (!ghostTexts[0]) { return undefined; } + return { kind: 'ghostText', edits, primaryGhostText: ghostTexts[0], ghostTexts, inlineCompletion, suggestItem: undefined }; + } + }); + + public readonly status = derived(this, reader => { + if (this._source.loading.read(reader)) { return 'loading'; } + const s = this.state.read(reader); + if (s?.kind === 'ghostText') { return 'ghostText'; } + if (s?.kind === 'inlineEdit') { return 'inlineEdit'; } + return 'noSuggestion'; + }); + + public readonly inlineCompletionState = derived(this, reader => { + const s = this.state.read(reader); + if (!s || s.kind !== 'ghostText') { + return undefined; + } + if (this._editorObs.inComposition.read(reader)) { + return undefined; + } + return s; + }); + + public readonly inlineEditState = derived(this, reader => { + const s = this.state.read(reader); + if (!s || s.kind !== 'inlineEdit') { + return undefined; + } + return s; + }); + + public readonly inlineEditAvailable = derived(this, reader => { + const s = this.inlineEditState.read(reader); + return !!s; + }); private _computeAugmentation(suggestCompletion: TextReplacement, reader: IReader | undefined) { const model = this.textModel; @@ -766,19 +651,112 @@ export class InlineCompletionsModel extends Disposable { return augmentedCompletion; } - public readonly warning; + public readonly warning = derived(this, reader => { + return this.inlineCompletionState.read(reader)?.inlineCompletion?.warning; + }); - public readonly ghostTexts; + public readonly ghostTexts = derivedOpts({ owner: this, equalsFn: ghostTextsOrReplacementsEqual }, reader => { + const v = this.inlineCompletionState.read(reader); + if (!v) { + return undefined; + } + return v.ghostTexts; + }); - public readonly primaryGhostText; + public readonly primaryGhostText = derivedOpts({ owner: this, equalsFn: ghostTextOrReplacementEquals }, reader => { + const v = this.inlineCompletionState.read(reader); + if (!v) { + return undefined; + } + return v?.primaryGhostText; + }); - public readonly showCollapsed; + public readonly showCollapsed = derived(this, reader => { + const state = this.state.read(reader); + if (!state || state.kind !== 'inlineEdit') { + return false; + } - private readonly _tabShouldIndent; + if (state.inlineCompletion.displayLocation) { + return false; + } - public readonly tabShouldJumpToInlineEdit; + const isCurrentModelVersion = state.inlineCompletion.updatedEditModelVersion === this._textModelVersionId.read(reader); + return (this._inlineEditsShowCollapsedEnabled.read(reader) || !isCurrentModelVersion) + && this._jumpedToId.read(reader) !== state.inlineCompletion.semanticId + && !this._inAcceptFlow.read(reader); + }); - public readonly tabShouldAcceptInlineEdit; + private readonly _tabShouldIndent = derived(this, reader => { + if (this._inAcceptFlow.read(reader)) { + return false; + } + + function isMultiLine(range: Range): boolean { + return range.startLineNumber !== range.endLineNumber; + } + + function getNonIndentationRange(model: ITextModel, lineNumber: number): Range { + const columnStart = model.getLineIndentColumn(lineNumber); + const lastNonWsColumn = model.getLineLastNonWhitespaceColumn(lineNumber); + const columnEnd = Math.max(lastNonWsColumn, columnStart); + return new Range(lineNumber, columnStart, lineNumber, columnEnd); + } + + const selections = this._editorObs.selections.read(reader); + return selections?.some(s => { + if (s.isEmpty()) { + return this.textModel.getLineLength(s.startLineNumber) === 0; + } else { + return isMultiLine(s) || s.containsRange(getNonIndentationRange(this.textModel, s.startLineNumber)); + } + }); + }); + + public readonly tabShouldJumpToInlineEdit = derived(this, reader => { + if (this._tabShouldIndent.read(reader)) { + return false; + } + + const s = this.inlineEditState.read(reader); + if (!s) { + return false; + } + + if (this.showCollapsed.read(reader)) { + return true; + } + + if (this._inAcceptFlow.read(reader) && this._appearedInsideViewport.read(reader)) { + return false; + } + + return !s.cursorAtInlineEdit.read(reader); + }); + + public readonly tabShouldAcceptInlineEdit = derived(this, reader => { + const s = this.inlineEditState.read(reader); + if (!s) { + return false; + } + if (this.showCollapsed.read(reader)) { + return false; + } + if (this._inAcceptFlow.read(reader) && this._appearedInsideViewport.read(reader)) { + return true; + } + if (s.inlineCompletion.targetRange.startLineNumber === this._editorObs.cursorLineNumber.read(reader)) { + return true; + } + if (this._jumpedToId.read(reader) === s.inlineCompletion.semanticId) { + return true; + } + if (this._tabShouldIndent.read(reader)) { + return false; + } + + return s.cursorAtInlineEdit.read(reader); + }); public readonly isInDiffEditor; @@ -805,14 +783,14 @@ export class InlineCompletionsModel extends Disposable { return EditReasons.inlineCompletionPartialAccept({ nes: completion.isInlineEdit, requestUuid: completion.requestUuid, - extensionId: completion.source.provider.groupId ?? 'unknown', + providerId: completion.source.provider.providerId, type, }); } else { return EditReasons.inlineCompletionAccept({ nes: completion.isInlineEdit, requestUuid: completion.requestUuid, - extensionId: completion.source.provider.groupId ?? 'unknown', + providerId: completion.source.provider.providerId, }); } } @@ -1022,9 +1000,9 @@ export class InlineCompletionsModel extends Disposable { }; } - private readonly _jumpedToId; - private readonly _inAcceptFlow; - public readonly inAcceptFlow: IObservable; + private readonly _jumpedToId = observableValue(this, undefined); + private readonly _inAcceptFlow = observableValue(this, false); + public readonly inAcceptFlow: IObservable = this._inAcceptFlow; public jump(): void { const s = this.inlineEditState.get(); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts index 4d18532982a..767fecab227 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { compareUndefinedSmallest, numberComparator } from '../../../../../base/common/arrays.js'; +import { booleanComparator, compareBy, compareUndefinedSmallest, numberComparator } from '../../../../../base/common/arrays.js'; import { findLastMax } from '../../../../../base/common/arraysFind.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { equalsIfDefined, itemEquals } from '../../../../../base/common/equals.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { derived, IObservable, IObservableWithChange, ITransaction, observableValue, recordChanges, transaction } from '../../../../../base/common/observable.js'; +import { derived, IObservable, IObservableWithChange, ITransaction, observableValue, recordChangesLazy, transaction } from '../../../../../base/common/observable.js'; // eslint-disable-next-line local/code-no-deep-import-of-internal import { observableReducerSettable } from '../../../../../base/common/observableInternal/experimental/reducer.js'; import { isDefined } from '../../../../../base/common/types.js'; @@ -32,16 +32,42 @@ import { InlineCompletionContextWithoutUuid, InlineSuggestRequestInfo, provideIn export class InlineCompletionsSource extends Disposable { private static _requestId = 0; - private readonly _updateOperation; + private readonly _updateOperation = this._register(new MutableDisposable()); private readonly _loggingEnabled; private readonly _structuredFetchLogger; - private readonly _state; + private readonly _state = observableReducerSettable(this, { + initial: () => ({ + inlineCompletions: InlineCompletionsState.createEmpty(), + suggestWidgetInlineCompletions: InlineCompletionsState.createEmpty(), + }), + disposeFinal: (values) => { + values.inlineCompletions.dispose(); + values.suggestWidgetInlineCompletions.dispose(); + }, + changeTracker: recordChangesLazy(() => ({ versionId: this._versionId })), + update: (reader, previousValue, changes) => { + const edit = StringEdit.compose(changes.changes.map(c => c.change ? offsetEditFromContentChanges(c.change.changes) : StringEdit.empty).filter(isDefined)); - public readonly inlineCompletions; - public readonly suggestWidgetInlineCompletions; + 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, @@ -51,10 +77,9 @@ export class InlineCompletionsSource extends Disposable { @ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService, @ILogService private readonly _logService: ILogService, @IConfigurationService private readonly _configurationService: IConfigurationService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService ) { super(); - this._updateOperation = this._register(new MutableDisposable()); this._loggingEnabled = observableConfigValue('editor.inlineSuggest.logFetch', false, this._configurationService).recomputeInitiallyAndOnChange(this._store); this._structuredFetchLogger = this._register(this._instantiationService.createInstance(StructuredLogger.cast< { kind: 'start'; requestId: number; context: unknown } & IRecordableEditorLogEntry @@ -62,50 +87,18 @@ export class InlineCompletionsSource extends Disposable { >(), 'editor.inlineSuggest.logFetch.commandId' )); - this._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 = StringEdit.compose(changes.changes.map(c => c.change ? offsetEditFromContentChanges(c.change.changes) : StringEdit.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(); - } - } - }); - this.inlineCompletions = this._state.map(this, v => v.inlineCompletions); - this.suggestWidgetInlineCompletions = this._state.map(this, v => v.suggestWidgetInlineCompletions); - this.clearOperationOnTextModelChange = derived(this, reader => { - this._versionId.read(reader); - this._updateOperation.clear(); - return undefined; // always constant - }); - this._loadingCount = observableValue(this, 0); - this.loading = this._loadingCount.map(this, v => v > 0); this.clearOperationOnTextModelChange.recomputeInitiallyAndOnChange(this._store); } - public readonly clearOperationOnTextModelChange; + public readonly clearOperationOnTextModelChange = derived(this, reader => { + this._versionId.read(reader); + this._updateOperation.clear(); + return undefined; // always constant + }); private _log(entry: - { sourceId: string; kind: 'start'; requestId: number; context: unknown } & IRecordableEditorLogEntry + { sourceId: string; kind: 'start'; requestId: number; context: unknown; provider: string | undefined } & IRecordableEditorLogEntry | { sourceId: string; kind: 'end'; error: unknown; durationMs: number; result: unknown; requestId: number; didAllProvidersReturn: boolean } & IRecordableLogEntry ) { if (this._loggingEnabled.get()) { @@ -114,10 +107,19 @@ export class InlineCompletionsSource extends Disposable { this._structuredFetchLogger.log(entry); } - private readonly _loadingCount; - public readonly loading; + private readonly _loadingCount = observableValue(this, 0); + public readonly loading = this._loadingCount.map(this, v => v > 0); - public fetch(providers: InlineCompletionsProvider[], context: InlineCompletionContextWithoutUuid, activeInlineCompletion: InlineSuggestionIdentity | undefined, withDebounce: boolean, userJumpedToActiveCompletion: IObservable, providerhasChangedCompletion: boolean, requestInfo: InlineSuggestRequestInfo): Promise { + public fetch( + providers: InlineCompletionsProvider[], + providersLabel: string | undefined, + context: InlineCompletionContextWithoutUuid, + activeInlineCompletion: InlineSuggestionIdentity | undefined, + withDebounce: boolean, + userJumpedToActiveCompletion: IObservable, + providerhasChangedCompletion: boolean, + requestInfo: InlineSuggestRequestInfo + ): Promise { const position = this._cursorPosition.get(); const request = new UpdateRequest(position, context, this._textModel.getVersionId(), new Set(providers)); @@ -157,7 +159,16 @@ export class InlineCompletionsSource extends Disposable { const requestId = InlineCompletionsSource._requestId++; if (this._loggingEnabled.get() || this._structuredFetchLogger.isEnabled.get()) { - this._log({ sourceId: 'InlineCompletions.fetch', kind: 'start', requestId, modelUri: this._textModel.uri, modelVersion: this._textModel.getVersionId(), context: { triggerKind: context.triggerKind }, time: Date.now() }); + this._log({ + sourceId: 'InlineCompletions.fetch', + kind: 'start', + requestId, + modelUri: this._textModel.uri, + modelVersion: this._textModel.getVersionId(), + context: { triggerKind: context.triggerKind, suggestInfo: context.selectedSuggestionInfo ? true : undefined }, + time: Date.now(), + provider: providersLabel, + }); } const startTime = new Date(); @@ -404,7 +415,7 @@ class InlineCompletionsState extends Disposable { // Otherwise: prefer inline completion if there is a visible one : updatedSuggestions.some(i => !i.isInlineEdit && i.isVisible(textModel, cursorPosition)); - const updatedItems: InlineSuggestionItem[] = []; + let updatedItems: InlineSuggestionItem[] = []; for (const i of updatedSuggestions) { const oldItem = this._findByHash(i.hash); let item; @@ -418,6 +429,10 @@ class InlineCompletionsState extends Disposable { updatedItems.push(item); } } + + updatedItems.sort(compareBy(i => i.showInlineEditMenu, booleanComparator)); + updatedItems = distinctByKey(updatedItems, i => i.semanticId); + return new InlineCompletionsState(updatedItems, request); } @@ -426,6 +441,19 @@ class InlineCompletionsState extends Disposable { } } +/** Keeps the first item in case of duplicates. */ +function distinctByKey(items: T[], key: (item: T) => unknown): T[] { + const seen = new Set(); + return items.filter(item => { + const k = key(item); + if (seen.has(k)) { + return false; + } + seen.add(k); + return true; + }); +} + function moveToFront(item: T, items: T[]): T[] { const index = items.indexOf(item); if (index > -1) { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts index 00035fb46f7..ba2a25bd559 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts @@ -196,17 +196,19 @@ export class InlineCompletionItem extends InlineSuggestionItemBase { const insertText = data.insertText.replace(/\r\n|\r|\n/g, textModel.getEOL()); const edit = reshapeInlineCompletion(new StringReplacement(transformer.getOffsetRange(data.range), insertText), textModel); + const trimmedEdit = edit.removeCommonSuffixAndPrefix(textModel.getValue()); const textEdit = transformer.getSingleTextEdit(edit); const displayLocation = data.displayLocation ? InlineSuggestDisplayLocation.create(data.displayLocation, textModel) : undefined; - return new InlineCompletionItem(edit, textEdit, textEdit.range, data.snippetInfo, data.additionalTextEdits, data, identity, displayLocation); + return new InlineCompletionItem(edit, trimmedEdit, textEdit, textEdit.range, data.snippetInfo, data.additionalTextEdits, data, identity, displayLocation); } public readonly isInlineEdit = false; private constructor( private readonly _edit: StringReplacement, + private readonly _trimmedEdit: StringReplacement, private readonly _textEdit: TextReplacement, private readonly _originalRange: Range, public readonly snippetInfo: SnippetInfo | undefined, @@ -219,11 +221,16 @@ export class InlineCompletionItem extends InlineSuggestionItemBase { super(data, identity, displayLocation); } + override get hash(): string { + return JSON.stringify(this._trimmedEdit.toJson()); + } + override getSingleTextEdit(): TextReplacement { return this._textEdit; } override withIdentity(identity: InlineSuggestionIdentity): InlineCompletionItem { return new InlineCompletionItem( this._edit, + this._trimmedEdit, this._textEdit, this._originalRange, this.snippetInfo, @@ -251,8 +258,11 @@ export class InlineCompletionItem extends InlineSuggestionItemBase { } } + const trimmedEdit = newEdit.removeCommonSuffixAndPrefix(textModel.getValue()); + return new InlineCompletionItem( newEdit, + trimmedEdit, newTextEdit, this._originalRange, this.snippetInfo, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts index 56944ac1c37..64642022e7d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts @@ -104,6 +104,9 @@ export function provideInlineCompletions( runWhenCancelled(cancellationTokenSource.token, () => { return list.removeRef(cancelReason); }); + if (cancellationTokenSource.token.isCancellationRequested) { + return undefined; // The list is disposed now, so we cannot return the items! + } for (const item of result.items) { data.push(toInlineSuggestData(item, list, defaultReplaceRange, model, languageConfigurationService, contextWithUuid, requestInfo)); @@ -242,6 +245,8 @@ export type InlineSuggestRequestInfo = { editorType: InlineCompletionEditorType; languageId: string; reason: string; + typingInterval: number; + typingIntervalCharacterCount: number; }; export type InlineSuggestViewData = { @@ -348,6 +353,8 @@ export class InlineSuggestData { requestReason: this._requestInfo.reason, viewKind: this._viewData.viewKind, error: this._viewData.error, + typingInterval: this._requestInfo.typingInterval, + typingIntervalCharacterCount: this._requestInfo.typingIntervalCharacterCount, ...this._viewData.renderData, }; this.source.provider.handleEndOfLifetime(this.source.inlineSuggestions, this.sourceInlineCompletion, reason, summary); @@ -415,7 +422,8 @@ export interface IDisplayLocation { export enum InlineCompletionEditorType { TextEditor = 'textEditor', - DiffEditor = 'diffEditor' + DiffEditor = 'diffEditor', + Notebook = 'notebook', } /** diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/typingSpeed.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/typingSpeed.ts new file mode 100644 index 00000000000..9e42d5db345 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/typingSpeed.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { sum } from '../../../../../base/common/arrays.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ITextModel } from '../../../../common/model.js'; +import { IModelContentChangedEvent } from '../../../../common/textModelEvents.js'; + +interface TypingSession { + startTime: number; + endTime: number; + characterCount: number; // Effective character count for typing interval calculation +} + +interface TypingIntervalResult { + averageInterval: number; // Average milliseconds between keystrokes + characterCount: number; // Number of characters involved in the computation +} + +/** + * Tracks typing speed as average milliseconds between keystrokes. + * Higher values indicate slower typing. + */ +export class TypingInterval extends Disposable { + + private readonly _typingSessions: TypingSession[] = []; + private _currentSession: TypingSession | null = null; + private _lastChangeTime = 0; + private _cachedTypingIntervalResult: TypingIntervalResult | null = null; + private _cacheInvalidated = true; + + // Configuration constants + private static readonly MAX_SESSION_GAP_MS = 3_000; // 3 seconds max gap between keystrokes in a session + private static readonly MIN_SESSION_DURATION_MS = 1_000; // Minimum session duration to consider + private static readonly SESSION_HISTORY_LIMIT = 50; // Keep last 50 sessions for calculation + private static readonly TYPING_SPEED_WINDOW_MS = 300_000; // 5 minutes window for speed calculation + private static readonly MIN_CHARS_FOR_RELIABLE_SPEED = 20; // Minimum characters needed for reliable speed calculation + + /** + * Gets the current typing interval as average milliseconds between keystrokes + * and the number of characters involved in the computation. + * Higher interval values indicate slower typing. + * Returns { interval: 0, characterCount: 0 } if no typing data is available. + */ + public getTypingInterval(): TypingIntervalResult { + if (this._cacheInvalidated || this._cachedTypingIntervalResult === null) { + this._cachedTypingIntervalResult = this._calculateTypingInterval(); + this._cacheInvalidated = false; + } + return this._cachedTypingIntervalResult; + } + + constructor(private readonly _textModel: ITextModel) { + super(); + + this._register(this._textModel.onDidChangeContent(e => this._updateTypingSpeed(e))); + } + + private _updateTypingSpeed(change: IModelContentChangedEvent): void { + const now = Date.now(); + const characterCount = this._calculateEffectiveCharacterCount(change); + + // If too much time has passed since last change, start a new session + if (this._currentSession && (now - this._lastChangeTime) > TypingInterval.MAX_SESSION_GAP_MS) { + this._finalizeCurrentSession(); + } + + // Start new session if none exists + if (!this._currentSession) { + this._currentSession = { + startTime: now, + endTime: now, + characterCount: 0 + }; + } + + // Update current session + this._currentSession.endTime = now; + this._currentSession.characterCount += characterCount; + + this._lastChangeTime = now; + this._cacheInvalidated = true; + } + + private _calculateEffectiveCharacterCount(change: IModelContentChangedEvent): number { + const actualCharCount = this._getActualCharacterCount(change); + + // If this is actual user typing, count all characters + if (this._isUserTyping(change)) { + return actualCharCount; + } + + // For all other actions (paste, suggestions, etc.), count as 1 regardless of size + return actualCharCount > 0 ? 1 : 0; + } + + private _getActualCharacterCount(change: IModelContentChangedEvent): number { + let totalChars = 0; + for (const c of change.changes) { + // Count characters added or removed (use the larger of the two) + totalChars += Math.max(c.text.length, c.rangeLength); + } + return totalChars; + } + + private _isUserTyping(change: IModelContentChangedEvent): boolean { + // If no detailed reasons, assume user typing + if (!change.detailedReasons || change.detailedReasons.length === 0) { + return true; + } + + // Check if any of the reasons indicate actual user typing + for (const reason of change.detailedReasons) { + if (this._isUserTypingReason(reason)) { + return true; + } + } + + return false; + } + + private _isUserTypingReason(reason: any): boolean { + // Handle undo/redo - not considered user typing + if (reason.metadata.isUndoing || reason.metadata.isRedoing) { + return false; + } + + // Handle different source types + switch (reason.metadata.source) { + case 'cursor': { + // Direct user input via cursor + const kind = reason.metadata.kind; + return kind === 'type' || kind === 'compositionType' || kind === 'compositionEnd'; + } + + default: + // All other sources (paste, suggestions, code actions, etc.) are not user typing + return false; + } + } + + private _finalizeCurrentSession(): void { + if (!this._currentSession) { + return; + } + + const sessionDuration = this._currentSession.endTime - this._currentSession.startTime; + + // Only keep sessions that meet minimum duration and have actual content + if (sessionDuration >= TypingInterval.MIN_SESSION_DURATION_MS && this._currentSession.characterCount > 0) { + this._typingSessions.push(this._currentSession); + + // Limit session history + if (this._typingSessions.length > TypingInterval.SESSION_HISTORY_LIMIT) { + this._typingSessions.shift(); + } + } + + this._currentSession = null; + } + + private _calculateTypingInterval(): TypingIntervalResult { + // Finalize current session for calculation + if (this._currentSession) { + const tempSession = { ...this._currentSession }; + const sessionDuration = tempSession.endTime - tempSession.startTime; + if (sessionDuration >= TypingInterval.MIN_SESSION_DURATION_MS && tempSession.characterCount > 0) { + const allSessions = [...this._typingSessions, tempSession]; + return this._calculateSpeedFromSessions(allSessions); + } + } + + return this._calculateSpeedFromSessions(this._typingSessions); + } + + private _calculateSpeedFromSessions(sessions: TypingSession[]): TypingIntervalResult { + if (sessions.length === 0) { + return { averageInterval: 0, characterCount: 0 }; + } + + // Sort sessions by recency (most recent first) to ensure we get the most recent sessions + const sortedSessions = [...sessions].sort((a, b) => b.endTime - a.endTime); + + // First, try the standard window + const cutoffTime = Date.now() - TypingInterval.TYPING_SPEED_WINDOW_MS; + const recentSessions = sortedSessions.filter(session => session.endTime > cutoffTime); + const olderSessions = sortedSessions.splice(recentSessions.length); + + let totalChars = sum(recentSessions.map(session => session.characterCount)); + + // If we don't have enough characters in the standard window, expand to include older sessions + for (let i = 0; i < olderSessions.length && totalChars < TypingInterval.MIN_CHARS_FOR_RELIABLE_SPEED; i++) { + recentSessions.push(olderSessions[i]); + totalChars += olderSessions[i].characterCount; + } + + const totalTime = sum(recentSessions.map(session => session.endTime - session.startTime)); + if (totalTime === 0 || totalChars <= 1) { + return { averageInterval: 0, characterCount: totalChars }; + } + + // Calculate average milliseconds between keystrokes + const keystrokeIntervals = Math.max(1, totalChars - 1); + const avgMsBetweenKeystrokes = totalTime / keystrokeIntervals; + + return { + averageInterval: Math.round(avgMsBetweenKeystrokes), + characterCount: totalChars + }; + } + + /** + * Reset all typing speed data + */ + public reset(): void { + this._typingSessions.length = 0; + this._currentSession = null; + this._lastChangeTime = 0; + this._cachedTypingIntervalResult = null; + this._cacheInvalidated = true; + } + + public override dispose(): void { + this._finalizeCurrentSession(); + super.dispose(); + } +} 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 3d0f6602e06..0d5a7f290c8 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts @@ -25,7 +25,6 @@ import { IModelDeltaDecoration, ITextModel, InjectedTextCursorStops, PositionAff import { LineTokens } from '../../../../../common/tokens/lineTokens.js'; import { LineDecoration } from '../../../../../common/viewLayout/lineDecorations.js'; import { RenderLineInput, renderViewLine } from '../../../../../common/viewLayout/viewLineRenderer.js'; -import { InlineDecorationType } from '../../../../../common/viewModel.js'; import { GhostText, GhostTextReplacement, IGhostTextLine } from '../../model/ghostText.js'; import { RangeSingleLine } from '../../../../../common/core/ranges/rangeSingleLine.js'; import { ColumnRange } from '../../../../../common/core/ranges/columnRange.js'; @@ -35,6 +34,8 @@ import { IMouseEvent, StandardMouseEvent } from '../../../../../../base/browser/ import { CodeEditorWidget } from '../../../../../browser/widget/codeEditor/codeEditorWidget.js'; import { TokenWithTextArray } from '../../../../../common/tokens/tokenWithTextArray.js'; import { InlineCompletionViewData } from '../inlineEdits/inlineEditsViewInterface.js'; +import { InlineDecorationType } from '../../../../../common/viewModel/inlineDecorations.js'; +import { sum } from '../../../../../../base/common/arrays.js'; export interface IGhostTextWidgetModel { readonly targetTextModel: IObservable; @@ -125,16 +126,18 @@ export class GhostTextView extends Disposable { }; }); - const cursorColumn = this._editor.getSelection()?.getStartPosition().column; + const cursorColumn = this._editor.getSelection()?.getStartPosition().column!; + const disjointInlineTexts = inlineTextsWithTokens.filter(inline => inline.text !== ''); + const hasInsertionOnCurrentLine = disjointInlineTexts.length !== 0; const renderData: InlineCompletionViewData = { - cursorColumnDistance: cursorColumn !== undefined ? Math.abs((inlineTextsWithTokens.length > 0 ? inlineTextsWithTokens[0].column : 1) - cursorColumn) : -1, - cursorLineDistance: inlineTextsWithTokens.length > 0 ? 0 : additionalLines.findIndex(line => line.content !== ''), - lineCountOriginal: inlineTextsWithTokens.length > 0 ? 1 : 0, - lineCountModified: additionalLines.length + (inlineTextsWithTokens.length > 0 ? 1 : 0), + cursorColumnDistance: (hasInsertionOnCurrentLine ? disjointInlineTexts[0].column : 1) - cursorColumn, + cursorLineDistance: hasInsertionOnCurrentLine ? 0 : (additionalLines.findIndex(line => line.content !== '') + 1), + lineCountOriginal: hasInsertionOnCurrentLine ? 1 : 0, + lineCountModified: additionalLines.length + (hasInsertionOnCurrentLine ? 1 : 0), characterCountOriginal: 0, - characterCountModified: inlineTextsWithTokens.reduce((acc, inline) => acc + inline.text.length, 0) + tokenizedAdditionalLines.reduce((acc, line) => acc + line.content.getTextLength(), 0), - disjointReplacements: inlineTextsWithTokens.length + (additionalLines.length > 0 ? 1 : 0), - sameShapeReplacements: inlineTextsWithTokens.length > 1 && inlineTextsWithTokens.length === 0 ? inlineTextsWithTokens.every(inline => inline.text.length === inlineTextsWithTokens[0].text.length) : undefined, + characterCountModified: sum(disjointInlineTexts.map(inline => inline.text.length)) + sum(tokenizedAdditionalLines.map(line => line.content.getTextLength())), + disjointReplacements: disjointInlineTexts.length + (additionalLines.length > 0 ? 1 : 0), + sameShapeReplacements: disjointInlineTexts.length > 1 && tokenizedAdditionalLines.length === 0 ? disjointInlineTexts.every(inline => inline.text === disjointInlineTexts[0].text) : undefined, }; this._model.handleInlineCompletionShown.read(reader)?.(renderData); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts index fe32ff410c2..adc0faf9d00 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts @@ -82,17 +82,11 @@ export class InlineCompletionsView extends Disposable { this._register(createStyleSheetFromObservable(derived(reader => { const fontFamily = this._fontFamily.read(reader); - let fontSize: string = this._editor.getOption(EditorOption.fontSize) + 'px'; - const cursorSelection = this._editorObs.cursorSelection.read(reader); - if (cursorSelection) { - fontSize = this._editor.getFontSizeAtPosition(cursorSelection.getEndPosition()) ?? fontSize; - } return ` .monaco-editor .ghost-text-decoration, .monaco-editor .ghost-text-decoration-preview, .monaco-editor .ghost-text { font-family: ${fontFamily}; - font-size: ${fontSize}; }`; }))); 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 319d76cbddb..aec55db345a 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 @@ -20,7 +20,7 @@ import { LineRange } from '../../../../../../common/core/ranges/lineRange.js'; import { OffsetRange } from '../../../../../../common/core/ranges/offsetRange.js'; import { ILanguageService } from '../../../../../../common/languages/language.js'; import { LineTokens, TokenArray } from '../../../../../../common/tokens/lineTokens.js'; -import { InlineDecoration, InlineDecorationType } from '../../../../../../common/viewModel.js'; +import { InlineDecoration, InlineDecorationType } from '../../../../../../common/viewModel/inlineDecorations.js'; import { GhostText, GhostTextPart } from '../../../model/ghostText.js'; import { GhostTextView } from '../../ghostText/ghostTextView.js'; import { IInlineEditsView, InlineEditTabAction } from '../inlineEditsViewInterface.js'; 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 2a40722dd23..7d9a54433c1 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 @@ -23,7 +23,7 @@ import { LineRange } from '../../../../../../common/core/ranges/lineRange.js'; import { OffsetRange } from '../../../../../../common/core/ranges/offsetRange.js'; import { ILanguageService } from '../../../../../../common/languages/language.js'; import { LineTokens, TokenArray } from '../../../../../../common/tokens/lineTokens.js'; -import { InlineDecoration, InlineDecorationType } from '../../../../../../common/viewModel.js'; +import { InlineDecoration, InlineDecorationType } from '../../../../../../common/viewModel/inlineDecorations.js'; import { IInlineEditsView, InlineEditTabAction } from '../inlineEditsViewInterface.js'; import { getEditorBlendedColor, getModifiedBorderColor, getOriginalBorderColor, modifiedChangedLineBackgroundColor, originalBackgroundColor } from '../theme.js'; import { getEditorValidOverlayRect, getPrefixTrim, mapOutFalsy, rectToProps } from '../utils/utils.js'; diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts index 1e4f0f7c934..a8e0d0ea8b0 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts @@ -340,6 +340,7 @@ suite('Inline Completions', () => { test('when accepting word by word', async function () { // The user types the text as suggested and the provider reports a different suggestion. + // Even when triggering explicitly, we want to keep the suggestion. const provider = new MockInlineCompletionsProvider(); await withAsyncTestCodeEditorAndInlineCompletionsModel('', @@ -356,7 +357,7 @@ suite('Inline Completions', () => { await ctx.model.triggerExplicitly(); // reset to provider truth await timeout(10000); - assert.deepStrictEqual(ctx.context.getAndClearViewStates(), (["foo[ baz]"])); + assert.deepStrictEqual(ctx.context.getAndClearViewStates(), ([])); } ); }); diff --git a/src/vs/editor/contrib/suggest/browser/suggestController.ts b/src/vs/editor/contrib/suggest/browser/suggestController.ts index d831d235ae8..a7f75606aa8 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestController.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestController.ts @@ -24,7 +24,7 @@ import { Range } from '../../../common/core/range.js'; import { IEditorContribution, ScrollType } from '../../../common/editorCommon.js'; import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { ITextModel, TrackedRangeStickiness } from '../../../common/model.js'; -import { CompletionItemInsertTextRule, CompletionItemProvider, CompletionTriggerKind } from '../../../common/languages.js'; +import { CompletionItemInsertTextRule, CompletionItemProvider, CompletionTriggerKind, ProviderId } from '../../../common/languages.js'; import { SnippetController2 } from '../../snippet/browser/snippetController2.js'; import { SnippetParser } from '../../snippet/browser/snippetParser.js'; import { ISuggestMemoryService } from './suggestMemory.js'; @@ -459,7 +459,7 @@ export class SuggestController implements IEditorContribution { adjustWhitespace: !(item.completion.insertTextRules! & CompletionItemInsertTextRule.KeepWhitespace), clipboardText: event.model.clipboardText, overtypingCapturer: this._overtypingCapturer.value, - reason: EditReasons.suggest({ extensionId: item.extensionId?.value }), + reason: EditReasons.suggest({ providerId: ProviderId.fromExtensionId(item.extensionId?.value) }), }); if (!(flags & InsertFlags.NoAfterUndoStop)) { diff --git a/src/vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter.ts b/src/vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter.ts index b4dcc3a8116..cd2b8dead73 100644 --- a/src/vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter.ts +++ b/src/vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter.ts @@ -21,7 +21,6 @@ import { ModelDecorationOptions } from '../../../common/model/textModel.js'; import { UnicodeHighlighterOptions, UnicodeHighlighterReason, UnicodeHighlighterReasonKind, UnicodeTextModelHighlighter } from '../../../common/services/unicodeTextModelHighlighter.js'; import { IEditorWorkerService, IUnicodeHighlightsResult } from '../../../common/services/editorWorker.js'; import { ILanguageService } from '../../../common/languages/language.js'; -import { isModelDecorationInComment, isModelDecorationInString, isModelDecorationVisible } from '../../../common/viewModel/viewModelDecorations.js'; import { HoverAnchor, HoverAnchorType, HoverParticipantRegistry, IEditorHoverParticipant, IEditorHoverRenderContext, IHoverPart, IRenderedHoverParts } from '../../hover/browser/hoverTypes.js'; import { MarkdownHover, renderMarkdownHovers } from '../../hover/browser/markdownHoverParticipant.js'; import { BannerController } from './bannerController.js'; @@ -34,6 +33,7 @@ import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js' import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { safeIntl } from '../../../../base/common/date.js'; +import { isModelDecorationInComment, isModelDecorationInString, isModelDecorationVisible } from '../../../common/viewModel/viewModelDecoration.js'; export const warningIcon = registerIcon('extensions-warning-message', Codicon.warning, nls.localize('warningIcon', 'Icon shown with a warning message in the extensions editor.')); diff --git a/src/vs/editor/editor.worker.start.ts b/src/vs/editor/editor.worker.start.ts index a7304493087..2916b8ae38c 100644 --- a/src/vs/editor/editor.worker.start.ts +++ b/src/vs/editor/editor.worker.start.ts @@ -12,24 +12,37 @@ import { EditorWorkerHost } from './common/services/editorWorkerHost.js'; * @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`); +export function start(createClient: (ctx: IWorkerContext) => TClient): TClient { + let client: TClient | undefined; + const webWorkerServer = initialize((workerServer) => { + const editorWorkerHost = EditorWorkerHost.getChannel(workerServer); + + const host = new Proxy({}, { + get(target, prop, receiver) { + if (prop === 'then') { + // Don't forward the call when the proxy is returned in an async function and the runtime tries to .then it. + return undefined; + } + if (typeof prop !== 'string') { + throw new Error(`Not supported`); + } + return (...args: unknown[]) => { + return editorWorkerHost.$fhr(prop, args); + }; } - return (...args: unknown[]) => { - return editorWorkerHost.$fhr(prop, args); - }; - } + }); + + const ctx: IWorkerContext = { + host: host as THost, + getMirrorModels: () => { + return webWorkerServer.requestHandler.getModels(); + } + }; + + client = createClient(ctx); + + return new EditorWorker(client); }); - return { - host: host as THost, - getMirrorModels: () => { - return webWorkerServer.requestHandler.getModels(); - } - }; + return client!; } diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 89e3a9dd3d7..0703f81e39a 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -229,6 +229,7 @@ class StandaloneEnvironmentService implements IEnvironmentService { readonly debugExtensionHost: IExtensionHostDebugParams = { port: null, break: false }; readonly isExtensionDevelopment: boolean = false; readonly disableExtensions: boolean | string[] = false; + readonly disableExperiments: boolean = false; readonly enableExtensions?: readonly string[] | undefined = undefined; readonly extensionDevelopmentLocationURI?: URI[] | undefined = undefined; readonly extensionDevelopmentKind?: ExtensionKind[] | undefined = undefined; diff --git a/src/vs/editor/standalone/browser/standaloneWebWorker.ts b/src/vs/editor/standalone/browser/standaloneWebWorker.ts index f8aa5966d51..4cdd6cf5477 100644 --- a/src/vs/editor/standalone/browser/standaloneWebWorker.ts +++ b/src/vs/editor/standalone/browser/standaloneWebWorker.ts @@ -38,7 +38,7 @@ export interface IInternalWebWorkerOptions { /** * The worker. */ - worker: Worker; + worker: Worker | Promise; /** * An object that can be used by the web worker to make calls back to the main thread. */ @@ -61,6 +61,10 @@ class MonacoWebWorkerImpl extends EditorWorkerClient implement this._foreignProxy = this._getProxy().then(proxy => { return new Proxy({}, { get(target, prop, receiver) { + if (prop === 'then') { + // Don't forward the call when the proxy is returned in an async function and the runtime tries to .then it. + return undefined; + } if (typeof prop !== 'string') { throw new Error(`Not supported`); } diff --git a/src/vs/editor/test/browser/viewModel/viewModelDecorations.test.ts b/src/vs/editor/test/browser/viewModel/viewModelDecorations.test.ts index bed88ad1260..055998d1694 100644 --- a/src/vs/editor/test/browser/viewModel/viewModelDecorations.test.ts +++ b/src/vs/editor/test/browser/viewModel/viewModelDecorations.test.ts @@ -7,8 +7,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IEditorOptions } from '../../../common/config/editorOptions.js'; import { Range } from '../../../common/core/range.js'; -import { InlineDecoration, InlineDecorationType } from '../../../common/viewModel.js'; import { testViewModel } from './testViewModel.js'; +import { InlineDecoration, InlineDecorationType } from '../../../common/viewModel/inlineDecorations.js'; suite('ViewModelDecorations', () => { diff --git a/src/vs/editor/test/common/services/testEditorWorkerService.ts b/src/vs/editor/test/common/services/testEditorWorkerService.ts index 44a9d5fffc3..a03cd461405 100644 --- a/src/vs/editor/test/common/services/testEditorWorkerService.ts +++ b/src/vs/editor/test/common/services/testEditorWorkerService.ts @@ -10,6 +10,7 @@ import { TextEdit, IInplaceReplaceSupportResult, IColorInformation } from '../.. import { IDocumentDiff, IDocumentDiffProviderOptions } from '../../../common/diff/documentDiffProvider.js'; import { IChange } from '../../../common/diff/legacyLinesDiffComputer.js'; import { SectionHeader } from '../../../common/services/findSectionHeaders.js'; +import { StringEdit } from '../../../common/core/edits/stringEdit.js'; export class TestEditorWorkerService implements IEditorWorkerService { @@ -28,4 +29,8 @@ export class TestEditorWorkerService implements IEditorWorkerService { async navigateValueSet(resource: URI, range: IRange, up: boolean): Promise { return null; } async findSectionHeaders(uri: URI): Promise { return []; } async computeDefaultDocumentColors(uri: URI): Promise { return null; } + + computeStringEditFromDiff(original: string, modified: string, options: { maxComputationTimeMs: number }, algorithm: DiffAlgorithmName): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts b/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts index 59f87b95b43..efe951f3457 100644 --- a/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts +++ b/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { Range } from '../../../common/core/range.js'; import { DecorationSegment, LineDecoration, LineDecorationsNormalizer } from '../../../common/viewLayout/lineDecorations.js'; -import { InlineDecoration, InlineDecorationType } from '../../../common/viewModel.js'; +import { InlineDecoration, InlineDecorationType } from '../../../common/viewModel/inlineDecorations.js'; suite('Editor ViewLayout - ViewLineParts', () => { diff --git a/src/vs/editor/test/common/viewLayout/viewLineRenderer.test.ts b/src/vs/editor/test/common/viewLayout/viewLineRenderer.test.ts index 1d2a6ab5002..a4f66b6211c 100644 --- a/src/vs/editor/test/common/viewLayout/viewLineRenderer.test.ts +++ b/src/vs/editor/test/common/viewLayout/viewLineRenderer.test.ts @@ -11,9 +11,9 @@ import { MetadataConsts } from '../../../common/encodedTokenAttributes.js'; import { IViewLineTokens } from '../../../common/tokens/lineTokens.js'; import { LineDecoration } from '../../../common/viewLayout/lineDecorations.js'; import { CharacterMapping, DomPosition, RenderLineInput, RenderLineOutput2, renderViewLine2 as renderViewLine } from '../../../common/viewLayout/viewLineRenderer.js'; -import { InlineDecorationType } from '../../../common/viewModel.js'; import { TestLineToken, TestLineTokens } from '../core/testLineToken.js'; import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; +import { InlineDecorationType } from '../../../common/viewModel/inlineDecorations.js'; function createViewLineTokens(viewLineTokens: TestLineToken[]): IViewLineTokens { return new TestLineTokens(viewLineTokens); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index d87109d2f84..3fbbdb6d43c 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -1223,7 +1223,7 @@ declare namespace monaco.editor { /** * The worker. */ - worker: Worker; + worker: Worker | Promise; /** * An object that can be used by the web worker to make calls back to the main thread. */ @@ -1471,6 +1471,7 @@ declare namespace monaco.editor { modelVersionId?: number; relatedInformation?: IRelatedInformation[]; tags?: MarkerTag[]; + origin?: string | undefined; } /** @@ -1491,6 +1492,7 @@ declare namespace monaco.editor { modelVersionId?: number; relatedInformation?: IRelatedInformation[]; tags?: MarkerTag[]; + origin?: string | undefined; } /** @@ -6215,7 +6217,7 @@ declare namespace monaco.editor { */ getTopForPosition(lineNumber: number, column: number): number; /** - * Get the line height for the line number. + * Get the line height for a model position. */ getLineHeightForPosition(position: IPosition): number; /** @@ -7560,6 +7562,8 @@ declare namespace monaco.languages { characterCountModified?: number; disjointReplacements?: number; sameShapeReplacements?: boolean; + typingInterval: number; + typingIntervalCharacterCount: number; }; export interface CodeAction { @@ -8538,7 +8542,7 @@ declare namespace monaco.worker { getValue(): string; } - export interface IWorkerContext { + export interface IWorkerContext { /** * A proxy to the main thread host object. */ diff --git a/src/vs/platform/assignment/common/assignmentService.ts b/src/vs/platform/assignment/common/assignmentService.ts deleted file mode 100644 index 413bd60f7ff..00000000000 --- a/src/vs/platform/assignment/common/assignmentService.ts +++ /dev/null @@ -1,107 +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 type { IExperimentationTelemetry, ExperimentationService as TASClient, IKeyValueStorage } from 'tas-client-umd'; -import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { IProductService } from '../../product/common/productService.js'; -import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; -import { AssignmentFilterProvider, ASSIGNMENT_REFETCH_INTERVAL, ASSIGNMENT_STORAGE_KEY, IAssignmentService, TargetPopulation } from './assignment.js'; -import { importAMDNodeModule } from '../../../amdX.js'; -import { IEnvironmentService } from '../../environment/common/environment.js'; - -export abstract class BaseAssignmentService implements IAssignmentService { - _serviceBrand: undefined; - protected tasClient: Promise | undefined; - private networkInitialized = false; - private overrideInitDelay: Promise; - - protected get experimentsEnabled(): boolean { - return true; - } - - constructor( - private readonly machineId: string, - protected readonly configurationService: IConfigurationService, - protected readonly productService: IProductService, - protected readonly environmentService: IEnvironmentService, - protected telemetry: IExperimentationTelemetry, - private keyValueStorage?: IKeyValueStorage - ) { - const isTesting = environmentService.extensionTestsLocationURI !== undefined; - if (!isTesting && productService.tasConfig && this.experimentsEnabled && getTelemetryLevel(this.configurationService) === TelemetryLevel.USAGE) { - this.tasClient = this.setupTASClient(); - } - - // For development purposes, configure the delay until tas local tas treatment ovverrides are available - const overrideDelaySetting = this.configurationService.getValue('experiments.overrideDelay'); - const overrideDelay = typeof overrideDelaySetting === 'number' ? overrideDelaySetting : 0; - this.overrideInitDelay = new Promise(resolve => setTimeout(resolve, overrideDelay)); - } - - async getTreatment(name: string): Promise { - // For development purposes, allow overriding tas assignments to test variants locally. - await this.overrideInitDelay; - const override = this.configurationService.getValue('experiments.override.' + name); - if (override !== undefined) { - return override; - } - - if (!this.tasClient) { - return undefined; - } - - if (!this.experimentsEnabled) { - return undefined; - } - - let result: T | undefined; - const client = await this.tasClient; - - // The TAS client is initialized but we need to check if the initial fetch has completed yet - // If it is complete, return a cached value for the treatment - // If not, use the async call with `checkCache: true`. This will allow the module to return a cached value if it is present. - // Otherwise it will await the initial fetch to return the most up to date value. - if (this.networkInitialized) { - result = client.getTreatmentVariable('vscode', name); - } else { - result = await client.getTreatmentVariableAsync('vscode', name, true); - } - - result = client.getTreatmentVariable('vscode', name); - return result; - } - - private async setupTASClient(): Promise { - - const targetPopulation = this.productService.quality === 'stable' ? - TargetPopulation.Public : (this.productService.quality === 'exploration' ? - TargetPopulation.Exploration : TargetPopulation.Insiders); - - const filterProvider = new AssignmentFilterProvider( - this.productService.version, - this.productService.nameLong, - this.machineId, - targetPopulation - ); - - const tasConfig = this.productService.tasConfig!; - const tasClient = new (await importAMDNodeModule('tas-client-umd', 'lib/tas-client-umd.js')).ExperimentationService({ - filterProviders: [filterProvider], - telemetry: this.telemetry, - storageKey: ASSIGNMENT_STORAGE_KEY, - keyValueStorage: this.keyValueStorage, - assignmentContextTelemetryPropertyName: tasConfig.assignmentContextTelemetryPropertyName, - telemetryEventName: tasConfig.telemetryEventName, - endpoint: tasConfig.endpoint, - refetchInterval: ASSIGNMENT_REFETCH_INTERVAL, - }); - - await tasClient.initializePromise; - tasClient.initialFetch.then(() => this.networkInitialized = true); - - return tasClient; - } -} diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 6c74026f3d5..b097b84feed 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -28,6 +28,9 @@ export interface NativeParsedArgs { _: string[]; 'add-file'?: string[]; mode?: string; + maximize?: boolean; + 'reuse-window'?: boolean; + 'new-window'?: boolean; help?: boolean; }; @@ -104,6 +107,7 @@ export interface NativeParsedArgs { 'install-source'?: string; 'add-mcp'?: string[]; 'disable-updates'?: boolean; + 'transient'?: boolean; 'use-inmemory-secretstorage'?: boolean; 'password-store'?: string; 'disable-workspace-trust'?: boolean; @@ -135,6 +139,8 @@ export interface NativeParsedArgs { 'unresponsive-sample-period'?: string; 'enable-rdp-display-tracking'?: boolean; 'disable-layout-restore'?: boolean; + 'startup-experiment-group'?: string; + 'disable-experiments'?: boolean; // 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/common/environment.ts b/src/vs/platform/environment/common/environment.ts index b6e945496d9..c5f10d53040 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -83,8 +83,9 @@ export interface IEnvironmentService { verbose: boolean; isBuilt: boolean; - // --- telemetry + // --- telemetry/exp disableTelemetry: boolean; + disableExperiments: boolean; serviceMachineIdResource: URI; // --- Policy diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts index f60efc16786..e60b83f27d4 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -230,6 +230,9 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron @memoize get disableTelemetry(): boolean { return !!this.args['disable-telemetry']; } + @memoize + get disableExperiments(): boolean { return !!this.args['disable-experiments']; } + @memoize get disableWorkspaceTrust(): boolean { return !!this.args['disable-workspace-trust']; } diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 66896be5119..91cce390ae1 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -53,9 +53,12 @@ export const OPTIONS: OptionDescriptions> = { description: 'Pass in a prompt to run in a chat session in the current working directory.', options: { '_': { type: 'string[]', description: localize('prompt', "The prompt to use as chat.") }, - 'mode': { type: 'string', cat: 'o', alias: 'm', args: 'mode', description: localize('chatMode', "The mode to use for the chat session. Defaults to 'agent'.") }, + 'mode': { type: 'string', cat: 'o', alias: 'm', args: 'mode', description: localize('chatMode', "The mode to use for the chat session. Available options: 'ask', 'edit', 'agent', or the identifier of a custom mode. Defaults to 'agent'.") }, 'add-file': { type: 'string[]', cat: 'o', alias: 'a', args: 'path', description: localize('addFile', "Add files as context to the chat session.") }, - 'help': { type: 'boolean', cat: 'o', alias: 'h', description: localize('help', "Print usage.") } + 'maximize': { type: 'boolean', cat: 'o', description: localize('chatMaximize', "Maximize the chat session view.") }, + 'reuse-window': { type: 'boolean', cat: 'o', alias: 'r', description: localize('reuseWindowForChat', "Force to use the last active window for the chat session.") }, + 'new-window': { type: 'boolean', cat: 'o', alias: 'n', description: localize('newWindowForChat', "Force to open an empty window for the chat session.") }, + 'help': { type: 'boolean', alias: 'h', description: localize('help', "Print usage.") } } }, 'serve-web': { @@ -165,6 +168,7 @@ export const OPTIONS: OptionDescriptions> = { 'skip-welcome': { type: 'boolean' }, 'disable-telemetry': { type: 'boolean' }, 'disable-updates': { type: 'boolean' }, + 'transient': { type: 'boolean' }, 'use-inmemory-secretstorage': { type: 'boolean', deprecates: ['disable-keytar'] }, 'password-store': { type: 'string' }, 'disable-workspace-trust': { type: 'boolean' }, @@ -197,6 +201,8 @@ export const OPTIONS: OptionDescriptions> = { 'unresponsive-sample-period': { type: 'string' }, 'enable-rdp-display-tracking': { type: 'boolean' }, 'disable-layout-restore': { type: 'boolean' }, + 'disable-experiments': { type: 'boolean' }, + 'startup-experiment-group': { type: 'string', cat: 't', args: 'control|maximizedChat|splitEmptyEditorChat|splitWelcomeChat', description: localize('startupExperimentGroup', "Override the startup experiment group.") }, // chromium flags 'no-proxy-server': { type: 'boolean' }, diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index fc54caa8a08..1907be1803a 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -540,6 +540,7 @@ interface IRawExtensionsControlManifest { additionalInfo?: string; }>; search?: ISearchPrefferedResults[]; + autoUpdate?: IStringDictionary; } export abstract class AbstractExtensionGalleryService implements IExtensionGalleryService { @@ -593,7 +594,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const options = CancellationToken.isCancellationToken(arg1) ? {} : arg1 as IExtensionQueryOptions; const token = CancellationToken.isCancellationToken(arg1) ? arg1 : arg2 as CancellationToken; - const resourceApi = await this.getResourceApi(extensionGalleryManifest, !!options.updateCheck); + const resourceApi = await this.getResourceApi(extensionGalleryManifest); const result = resourceApi ? await this.getExtensionsUsingResourceApi(extensionInfos, options, resourceApi, extensionGalleryManifest, token) : await this.getExtensionsUsingQueryApi(extensionInfos, options, extensionGalleryManifest, token); @@ -625,35 +626,23 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return result; } - private async getResourceApi(extensionGalleryManifest: IExtensionGalleryManifest, updateCheck: boolean): Promise<{ uri: string; fallback?: string } | undefined> { - const latestVersionResource = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionLatestVersionUri); - if (!latestVersionResource) { - return undefined; - } - - if (this.productService.quality !== 'stable') { - return { - uri: latestVersionResource, - fallback: this.unpkgResourceApi - }; - } - - const value = updateCheck - ? await this.assignmentService?.getTreatment<'unpkg' | 'marketplace' | 'none'>('extensions.gallery.useResourceApi') ?? 'marketplace' - : await this.assignmentService?.getTreatment<'unpkg' | 'marketplace' | 'none'>('extensions.gallery.useLatestApi') ?? 'unpkg'; - - if (value === 'marketplace') { - return { - uri: latestVersionResource, - fallback: this.unpkgResourceApi - }; - } + private async getResourceApi(extensionGalleryManifest: IExtensionGalleryManifest): Promise<{ uri: string; fallback?: string } | undefined> { + const value = await this.assignmentService?.getTreatment<'unpkg' | 'marketplace'>('extensions.gallery.useResourceApi') ?? 'marketplace'; if (value === 'unpkg' && this.unpkgResourceApi) { return { uri: this.unpkgResourceApi }; } + const latestVersionResource = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionLatestVersionUri); + if (latestVersionResource) { + return { + uri: latestVersionResource, + fallback: this.unpkgResourceApi + }; + } + return undefined; + } private async getExtensionsUsingQueryApi(extensionInfos: ReadonlyArray, options: IExtensionQueryOptions, extensionGalleryManifest: IExtensionGalleryManifest, token: CancellationToken): Promise { @@ -1793,7 +1782,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle } if (!this.extensionsControlUrl) { - return { malicious: [], deprecated: {}, search: [] }; + return { malicious: [], deprecated: {}, search: [], autoUpdate: {} }; } const context = await this.requestService.request({ @@ -1810,6 +1799,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const malicious: Array = []; const deprecated: IStringDictionary = {}; const search: ISearchPrefferedResults[] = []; + const autoUpdate: IStringDictionary = result?.autoUpdate ?? {}; if (result) { for (const id of result.malicious) { if (!isString(id)) { @@ -1847,7 +1837,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle } } - return { malicious, deprecated, search }; + return { malicious, deprecated, search, autoUpdate }; } } diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index edcd714f880..990349a24cf 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -357,6 +357,7 @@ export interface IExtensionsControlManifest { readonly malicious: ReadonlyArray; readonly deprecated: IStringDictionary; readonly search: ISearchPrefferedResults[]; + readonly autoUpdate?: IStringDictionary; } export const enum InstallOperation { @@ -382,7 +383,6 @@ export interface IExtensionQueryOptions { compatible?: boolean; queryAllVersions?: boolean; source?: string; - updateCheck?: boolean; } export interface IExtensionGalleryCapabilities { diff --git a/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts b/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts index de38da03b0a..1e75ad85b3d 100644 --- a/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts +++ b/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts @@ -9,6 +9,7 @@ import { areSameExtensions, getExtensionId } from './extensionManagementUtil.js' import { IExtensionStorageService } from './extensionStorage.js'; import { ExtensionType } from '../../extensions/common/extensions.js'; import { ILogService } from '../../log/common/log.js'; +import * as semver from '../../../base/common/semver/semver.js'; /** * Migrates the installed unsupported nightly extension to a supported pre-release extension. It includes following: @@ -69,6 +70,29 @@ export async function migrateUnsupportedExtensions(extensionManagementService: I logService.error(error); } } + + if (extensionsControlManifest.autoUpdate) { + for (const [extensionId, version] of Object.entries(extensionsControlManifest.autoUpdate)) { + try { + const extensionToAutoUpdate = installed.find(i => areSameExtensions(i.identifier, { id: extensionId }) && semver.lte(i.manifest.version, version)); + if (!extensionToAutoUpdate) { + continue; + } + + const gallery = (await galleryService.getExtensions([{ id: extensionId, preRelease: extensionToAutoUpdate.preRelease }], { targetPlatform: await extensionManagementService.getTargetPlatform(), compatible: true }, CancellationToken.None))[0]; + if (!gallery) { + logService.info(`Skipping updating '${extensionToAutoUpdate.identifier.id}' extension because, the compatible target '${extensionId}' extension is not found`); + continue; + } + + await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: extensionToAutoUpdate.preRelease, isMachineScoped: extensionToAutoUpdate.isMachineScoped, operation: InstallOperation.Update, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); + logService.info(`Autoupdated '${extensionToAutoUpdate.identifier.id}' extension to '${gallery.version}' extension.`); + } catch (error) { + logService.error(error); + } + } + } + } catch (error) { logService.error(error); } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 7436d76f268..4592b9f0b96 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -344,6 +344,9 @@ const _allApiProposals = { tabInputTextMerge: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.tabInputTextMerge.d.ts', }, + taskExecutionTerminal: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskExecutionTerminal.d.ts', + }, taskPresentationGroup: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskPresentationGroup.d.ts', }, diff --git a/src/vs/platform/markers/common/markerService.ts b/src/vs/platform/markers/common/markerService.ts index 07e7c1ee34e..21fda6c76c4 100644 --- a/src/vs/platform/markers/common/markerService.ts +++ b/src/vs/platform/markers/common/markerService.ts @@ -232,7 +232,7 @@ export class MarkerService implements IMarkerService { message, source, startLineNumber, startColumn, endLineNumber, endColumn, relatedInformation, - tags, + tags, origin } = data; if (!message) { @@ -258,6 +258,7 @@ export class MarkerService implements IMarkerService { endColumn, relatedInformation, tags, + origin }; } diff --git a/src/vs/platform/markers/common/markers.ts b/src/vs/platform/markers/common/markers.ts index cc686747a83..5e5408eb57e 100644 --- a/src/vs/platform/markers/common/markers.ts +++ b/src/vs/platform/markers/common/markers.ts @@ -118,6 +118,7 @@ export interface IMarkerData { modelVersionId?: number; relatedInformation?: IRelatedInformation[]; tags?: MarkerTag[]; + origin?: string | undefined; } export interface IResourceMarker { @@ -139,6 +140,7 @@ export interface IMarker { modelVersionId?: number; relatedInformation?: IRelatedInformation[]; tags?: MarkerTag[]; + origin?: string | undefined; } export interface MarkerStatistics { diff --git a/src/vs/platform/mcp/common/mcpGalleryService.ts b/src/vs/platform/mcp/common/mcpGalleryService.ts index 00388af6715..5f397f8d1f2 100644 --- a/src/vs/platform/mcp/common/mcpGalleryService.ts +++ b/src/vs/platform/mcp/common/mcpGalleryService.ts @@ -87,15 +87,15 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService return galleryServers; } - async getMcpServer(name: string): Promise { + async getMcpServers(names: string[]): Promise { const mcpUrl = this.getMcpGalleryUrl() ?? this.productService.extensionsGallery?.mcpUrl; if (!mcpUrl) { - return undefined; + return []; } const { servers } = await this.fetchGallery(mcpUrl, CancellationToken.None); - const server = servers.find(item => item.name === name); - return server ? this.toGalleryMcpServer(server) : undefined; + const filteredServers = servers.filter(item => names.includes(item.name)); + return filteredServers.map(item => this.toGalleryMcpServer(item)); } async getManifest(gallery: IGalleryMcpServer, token: CancellationToken): Promise { @@ -177,6 +177,22 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService } } + let icon: { light: string; dark: string } | undefined; + if (this.productService.extensionsGallery?.mcpUrl !== this.getMcpGalleryUrl()) { + if (item.iconUrl) { + icon = { + light: item.iconUrl, + dark: item.iconUrl + }; + } + if (item.iconUrlLight && item.iconUrlDark) { + icon = { + light: item.iconUrlLight, + dark: item.iconUrlDark + }; + } + } + return { id: item.id ?? item.name, name: item.name, @@ -187,6 +203,7 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService lastUpdated: item.version_detail ? Date.parse(item.version_detail.release_date) : undefined, repositoryUrl: item.repository?.url, codicon: item.codicon, + icon, readmeUrl: item.readmeUrl, manifestUrl: this.getManifestUrl(item), packageTypes: item.package_types ?? [], diff --git a/src/vs/platform/mcp/common/mcpManagement.ts b/src/vs/platform/mcp/common/mcpManagement.ts index 46688f2802a..5817b455e1f 100644 --- a/src/vs/platform/mcp/common/mcpManagement.ts +++ b/src/vs/platform/mcp/common/mcpManagement.ts @@ -10,6 +10,8 @@ import { SortBy, SortOrder } from '../../extensionManagement/common/extensionMan import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IMcpServerConfiguration, IMcpServerVariable } from './mcpPlatformTypes.js'; +export type InstallSource = 'gallery' | 'local'; + export interface ILocalMcpServer { readonly name: string; readonly config: IMcpServerConfiguration; @@ -30,6 +32,7 @@ export interface ILocalMcpServer { }; readonly codicon?: string; readonly manifest?: IMcpServerManifest; + readonly source: InstallSource; } export interface IMcpServerInput { @@ -133,7 +136,7 @@ export interface IMcpGalleryService { readonly _serviceBrand: undefined; isEnabled(): boolean; query(options?: IQueryOptions, token?: CancellationToken): Promise; - getMcpServer(server: string): Promise; + getMcpServers(servers: string[]): Promise; getManifest(extension: IGalleryMcpServer, token: CancellationToken): Promise; getReadme(extension: IGalleryMcpServer, token: CancellationToken): Promise; } @@ -189,6 +192,7 @@ export interface IMcpManagementService { getInstalled(mcpResource?: URI): Promise; install(server: IInstallableMcpServer, options?: InstallOptions): Promise; installFromGallery(server: IGalleryMcpServer, options?: InstallOptions): Promise; + updateMetadata(local: ILocalMcpServer, server: IGalleryMcpServer, profileLocation?: URI): Promise; uninstall(server: ILocalMcpServer, options?: UninstallOptions): Promise; } diff --git a/src/vs/platform/mcp/common/mcpManagementIpc.ts b/src/vs/platform/mcp/common/mcpManagementIpc.ts index d1fe377df58..0ed514d443e 100644 --- a/src/vs/platform/mcp/common/mcpManagementIpc.ts +++ b/src/vs/platform/mcp/common/mcpManagementIpc.ts @@ -106,6 +106,9 @@ export class McpManagementChannel implements IServerChannel { case 'uninstall': { return this.service.uninstall(transformIncomingServer(args[0], uriTransformer), transformIncomingOptions(args[1], uriTransformer)); } + case 'updateMetadata': { + return this.service.updateMetadata(transformIncomingServer(args[0], uriTransformer), args[1], transformIncomingURI(args[2], uriTransformer)); + } } throw new Error('Invalid call'); @@ -156,4 +159,8 @@ export class McpManagementChannelClient extends Disposable implements IMcpManage return Promise.resolve(this.channel.call('getInstalled', [mcpResource])) .then(servers => servers.map(server => transformIncomingServer(server, null))); } + + updateMetadata(local: ILocalMcpServer, gallery: IGalleryMcpServer, mcpResource?: URI): Promise { + return Promise.resolve(this.channel.call('updateMetadata', [local, gallery, mcpResource])).then(local => transformIncomingServer(local, null)); + } } diff --git a/src/vs/platform/mcp/common/mcpManagementService.ts b/src/vs/platform/mcp/common/mcpManagementService.ts index 9fe50af63dc..84466d3893d 100644 --- a/src/vs/platform/mcp/common/mcpManagementService.ts +++ b/src/vs/platform/mcp/common/mcpManagementService.ts @@ -40,6 +40,7 @@ export interface ILocalMcpServerInfo { manifest?: IMcpServerManifest; readmeUrl?: URI; location?: URI; + licenseUrl?: string; } export abstract class AbstractMcpResourceManagementService extends Disposable implements IMcpManagementService { @@ -81,16 +82,16 @@ export abstract class AbstractMcpResourceManagementService extends Disposable im private initialize(): Promise { if (!this.initializePromise) { this.initializePromise = (async () => { - this.local = await this.populateLocalServer(); + this.local = await this.populateLocalServers(); this.startWatching(); })(); } return this.initializePromise; } - private async populateLocalServer(): Promise> { + private async populateLocalServers(): Promise> { + this.logService.trace('AbstractMcpResourceManagementService#populateLocalServers', this.mcpResource.toString()); const local = new Map(); - this.logService.info('MCP Management Service: fetchInstalled', this.mcpResource.toString()); try { const scannedMcpServers = await this.mcpResourceScannerService.scanMcpServers(this.mcpResource, this.target); if (scannedMcpServers.servers) { @@ -117,7 +118,7 @@ export abstract class AbstractMcpResourceManagementService extends Disposable im protected async updateLocal(): Promise { try { - const current = await this.populateLocalServer(); + const current = await this.populateLocalServers(); const added: ILocalMcpServer[] = []; const updated: ILocalMcpServer[] = []; @@ -184,7 +185,8 @@ export abstract class AbstractMcpResourceManagementService extends Disposable im readmeUrl: mcpServerInfo.readmeUrl, icon: mcpServerInfo.icon, codicon: mcpServerInfo.codicon, - manifest: mcpServerInfo.manifest + manifest: mcpServerInfo.manifest, + source: config.gallery ? 'gallery' : 'local' }; } @@ -382,12 +384,14 @@ export abstract class AbstractMcpResourceManagementService extends Disposable im } abstract installFromGallery(server: IGalleryMcpServer, options?: InstallOptions): Promise; + abstract updateMetadata(local: ILocalMcpServer, server: IGalleryMcpServer, profileLocation: URI): Promise; protected abstract getLocalServerInfo(name: string, mcpServerConfig: IMcpServerConfiguration): Promise; + protected abstract installFromUri(uri: URI, options?: Omit): Promise; } export class McpUserResourceManagementService extends AbstractMcpResourceManagementService implements IMcpManagementService { - private readonly mcpLocation: URI; + protected readonly mcpLocation: URI; constructor( mcpResource: URI, @@ -408,30 +412,8 @@ export class McpUserResourceManagementService extends AbstractMcpResourceManagem this._onInstallMcpServer.fire({ name: server.name, mcpResource: this.mcpResource }); try { - const manifest = await this.mcpGalleryService.getManifest(server, CancellationToken.None); - const location = this.getLocation(server.name, server.version); - const manifestPath = this.uriIdentityService.extUri.joinPath(location, 'manifest.json'); - await this.fileService.writeFile(manifestPath, VSBuffer.fromString(JSON.stringify({ - id: server.id, - name: server.name, - displayName: server.displayName, - description: server.description, - version: server.version, - publisher: server.publisher, - publisherDisplayName: server.publisherDisplayName, - repository: server.repositoryUrl, - licenseUrl: server.licenseUrl, - icon: server.icon, - codicon: server.codicon, - ...manifest, - }))); - - if (server.readmeUrl) { - const readme = await this.mcpGalleryService.getReadme(server, CancellationToken.None); - await this.fileService.writeFile(this.uriIdentityService.extUri.joinPath(location, 'README.md'), VSBuffer.fromString(readme)); - } + const manifest = await this.updateMetadataFromGallery(server); const { config, inputs } = this.toScannedMcpServerAndInputs(manifest, options?.packageType); - const installable: IInstallableMcpServer = { name: server.name, config: { @@ -456,6 +438,44 @@ export class McpUserResourceManagementService extends AbstractMcpResourceManagem } } + async updateMetadata(local: ILocalMcpServer, gallery: IGalleryMcpServer): Promise { + await this.updateMetadataFromGallery(gallery); + await this.updateLocal(); + const updatedLocal = (await this.getInstalled()).find(s => s.name === local.name); + if (!updatedLocal) { + throw new Error(`Failed to find MCP server: ${local.name}`); + } + return updatedLocal; + } + + private async updateMetadataFromGallery(gallery: IGalleryMcpServer): Promise { + const manifest = await this.mcpGalleryService.getManifest(gallery, CancellationToken.None); + const location = this.getLocation(gallery.name, gallery.version); + const manifestPath = this.uriIdentityService.extUri.joinPath(location, 'manifest.json'); + const local: ILocalMcpServerInfo = { + id: gallery.id, + name: gallery.name, + displayName: gallery.displayName, + description: gallery.description, + version: gallery.version, + publisher: gallery.publisher, + publisherDisplayName: gallery.publisherDisplayName, + repositoryUrl: gallery.repositoryUrl, + licenseUrl: gallery.licenseUrl, + icon: gallery.icon, + codicon: gallery.codicon, + manifest, + }; + await this.fileService.writeFile(manifestPath, VSBuffer.fromString(JSON.stringify(local))); + + if (gallery.readmeUrl) { + const readme = await this.mcpGalleryService.getReadme(gallery, CancellationToken.None); + await this.fileService.writeFile(this.uriIdentityService.extUri.joinPath(location, 'README.md'), VSBuffer.fromString(readme)); + } + + return manifest; + } + protected async getLocalServerInfo(name: string, mcpServerConfig: IMcpServerConfiguration): Promise { let storedMcpServerInfo: ILocalMcpServerInfo | undefined; let location: URI | undefined; @@ -479,13 +499,18 @@ export class McpUserResourceManagementService extends AbstractMcpResourceManagem return storedMcpServerInfo; } - private getLocation(name: string, version?: string): URI { + protected getLocation(name: string, version?: string): URI { name = name.replace('/', '.'); return this.uriIdentityService.extUri.joinPath(this.mcpLocation, version ? `${name}-${version}` : name); } + protected override installFromUri(uri: URI, options?: Omit): Promise { + throw new Error('Method not supported.'); + } + } + export class McpManagementService extends Disposable implements IMcpManagementService { readonly _serviceBrand: undefined; @@ -509,7 +534,7 @@ export class McpManagementService extends Disposable implements IMcpManagementSe constructor( @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, - @IInstantiationService private readonly instantiationService: IInstantiationService, + @IInstantiationService protected readonly instantiationService: IInstantiationService, ) { super(); } @@ -518,7 +543,7 @@ export class McpManagementService extends Disposable implements IMcpManagementSe let mcpResourceManagementService = this.mcpResourceManagementServices.get(mcpResource); if (!mcpResourceManagementService) { const disposables = new DisposableStore(); - const service = disposables.add(this.instantiationService.createInstance(McpUserResourceManagementService, mcpResource)); + const service = disposables.add(this.createMcpResourceManagementService(mcpResource)); disposables.add(service.onInstallMcpServer(e => this._onInstallMcpServer.fire(e))); disposables.add(service.onDidInstallMcpServers(e => this._onDidInstallMcpServers.fire(e))); disposables.add(service.onDidUpdateMcpServers(e => this._onDidUpdateMcpServers.fire(e))); @@ -549,10 +574,18 @@ export class McpManagementService extends Disposable implements IMcpManagementSe return this.getMcpResourceManagementService(mcpResourceUri).installFromGallery(server, options); } + async updateMetadata(local: ILocalMcpServer, gallery: IGalleryMcpServer, mcpResource?: URI): Promise { + return this.getMcpResourceManagementService(mcpResource || this.userDataProfilesService.defaultProfile.mcpResource).updateMetadata(local, gallery); + } + override dispose(): void { this.mcpResourceManagementServices.forEach(service => service.dispose()); this.mcpResourceManagementServices.clear(); super.dispose(); } + protected createMcpResourceManagementService(mcpResource: URI): McpUserResourceManagementService { + return this.instantiationService.createInstance(McpUserResourceManagementService, mcpResource); + } + } diff --git a/src/vs/platform/mcp/node/mcpManagementService.ts b/src/vs/platform/mcp/node/mcpManagementService.ts new file mode 100644 index 00000000000..13a417d03b2 --- /dev/null +++ b/src/vs/platform/mcp/node/mcpManagementService.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 { URI } from '../../../base/common/uri.js'; +import { IEnvironmentService } from '../../environment/common/environment.js'; +import { IFileService } from '../../files/common/files.js'; +import { ILogService } from '../../log/common/log.js'; +import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; +import { IMcpGalleryService, IMcpManagementService } from '../common/mcpManagement.js'; +import { McpUserResourceManagementService as CommonMcpUserResourceManagementService, McpManagementService as CommonMcpManagementService } from '../common/mcpManagementService.js'; +import { IMcpResourceScannerService } from '../common/mcpResourceScannerService.js'; + + +export class McpUserResourceManagementService extends CommonMcpUserResourceManagementService { + constructor( + mcpResource: URI, + @IMcpGalleryService mcpGalleryService: IMcpGalleryService, + @IFileService fileService: IFileService, + @IUriIdentityService uriIdentityService: IUriIdentityService, + @ILogService logService: ILogService, + @IMcpResourceScannerService mcpResourceScannerService: IMcpResourceScannerService, + @IEnvironmentService environmentService: IEnvironmentService + ) { + super(mcpResource, mcpGalleryService, fileService, uriIdentityService, logService, mcpResourceScannerService, environmentService); + } +} + +export class McpManagementService extends CommonMcpManagementService implements IMcpManagementService { + protected override createMcpResourceManagementService(mcpResource: URI): McpUserResourceManagementService { + return this.instantiationService.createInstance(McpUserResourceManagementService, mcpResource); + } +} diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 858ade15e78..67e82e14f1c 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -130,6 +130,8 @@ export interface ICommonNativeHostService { saveWindowSplash(splash: IPartsSplash): Promise; + setBackgroundThrottling(allowed: boolean): Promise; + /** * Make the window focused. * @param options specify the specific window to focus and the focus mode. diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index f3d2fbc9a57..caed0fa6dc5 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -371,6 +371,14 @@ export class NativeHostMainService extends Disposable implements INativeHostMain this.themeMainService.saveWindowSplash(windowId, window?.openedWorkspace, splash); } + async setBackgroundThrottling(windowId: number | undefined, allowed: boolean): Promise { + const window = this.codeWindowById(windowId); + + this.logService.trace(`Setting background throttling for window ${windowId} to '${allowed}'`); + + window?.win?.webContents?.setBackgroundThrottling(allowed); + } + //#endregion diff --git a/src/vs/platform/opener/browser/link.ts b/src/vs/platform/opener/browser/link.ts index ff830dc05f2..848727fea0b 100644 --- a/src/vs/platform/opener/browser/link.ts +++ b/src/vs/platform/opener/browser/link.ts @@ -128,9 +128,7 @@ export class Link extends Disposable { } private setTooltip(title: string | undefined): void { - if (this.hoverDelegate.showNativeHover) { - this.el.title = title ?? ''; - } else if (!this.hover && title) { + if (!this.hover && title) { this.hover = this._register(this._hoverService.setupManagedHover(this.hoverDelegate, this.el, title)); } else if (this.hover) { this.hover.update(title); diff --git a/src/vs/platform/telemetry/common/telemetry.ts b/src/vs/platform/telemetry/common/telemetry.ts index 6bfcb9159cd..5f82501a81e 100644 --- a/src/vs/platform/telemetry/common/telemetry.ts +++ b/src/vs/platform/telemetry/common/telemetry.ts @@ -53,6 +53,10 @@ export interface ITelemetryService { setExperimentProperty(name: string, value: string): void; } +export function telemetryLevelEnabled(service: ITelemetryService, level: TelemetryLevel): boolean { + return service.telemetryLevel >= level; +} + export interface ITelemetryEndpoint { id: string; aiKey: string; diff --git a/src/vs/platform/terminal/common/terminalPlatformConfiguration.ts b/src/vs/platform/terminal/common/terminalPlatformConfiguration.ts index e5763c0dce1..7d8d78ba4fe 100644 --- a/src/vs/platform/terminal/common/terminalPlatformConfiguration.ts +++ b/src/vs/platform/terminal/common/terminalPlatformConfiguration.ts @@ -5,7 +5,7 @@ import { getAllCodicons } from '../../../base/common/codicons.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../base/common/jsonSchema.js'; -import { isWindows, OperatingSystem, Platform, PlatformToString } from '../../../base/common/platform.js'; +import { OperatingSystem, Platform, PlatformToString } from '../../../base/common/platform.js'; import { localize } from '../../../nls.js'; import { ConfigurationScope, Extensions, IConfigurationNode, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; import { Registry } from '../../registry/common/platform.js'; @@ -336,10 +336,9 @@ const terminalPlatformConfiguration: IConfigurationNode = { }, [TerminalSettingId.InheritEnv]: { scope: ConfigurationScope.APPLICATION, - description: localize('terminal.integrated.inheritEnv', "Whether new shells should inherit their environment from VS Code, which may source a login shell to ensure $PATH and other development variables are initialized."), + description: localize('terminal.integrated.inheritEnv', "Whether new shells should inherit their environment from VS Code, which may source a login shell to ensure $PATH and other development variables are initialized. This has no effect on Windows."), type: 'boolean', - // False by default on Windows to prevent powershell inheritance issues (#251446) - default: isWindows ? false : true, + default: true }, [TerminalSettingId.PersistentSessionScrollback]: { scope: ConfigurationScope.APPLICATION, diff --git a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts index 6d3402bba05..50b07e60807 100644 --- a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts +++ b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts @@ -345,6 +345,8 @@ export class ThemeMainService extends Disposable implements IThemeMainService { } else { if (auxiliaryBarDefaultVisibility === 'visible' || auxiliaryBarDefaultVisibility === 'visibleInWorkspace') { auxiliaryBarWidth = override.layoutInfo.auxiliaryBarWidth || partSplash.layoutInfo.auxiliaryBarWidth || ThemeMainService.DEFAULT_BAR_WIDTH; + } else if (auxiliaryBarDefaultVisibility === 'maximized' || auxiliaryBarDefaultVisibility === 'maximizedInWorkspace') { + auxiliaryBarWidth = Number.MAX_SAFE_INTEGER; // marker for a maximised auxiliary bar } else { auxiliaryBarWidth = 0; } diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 31d0dc59843..3030b1c3198 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -8,7 +8,7 @@ import { DeferredPromise, RunOnceScheduler, timeout, Delayer } from '../../../ba import { CancellationToken } from '../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { FileAccess, Schemas } from '../../../base/common/network.js'; import { getMarks, mark } from '../../../base/common/performance.js'; import { isBigSurOrNewer, isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; @@ -84,6 +84,29 @@ const enum ReadyState { READY } +class DockBadgeManager { + + static readonly INSTANCE = new DockBadgeManager(); + + private readonly windows = new Set(); + + acquireBadge(window: IBaseWindow): IDisposable { + this.windows.add(window.id); + + electron.app.setBadgeCount(isLinux ? 1 /* only numbers supported */ : undefined /* generic dot */); + + return { + dispose: () => { + this.windows.delete(window.id); + + if (this.windows.size === 0) { + electron.app.setBadgeCount(0); + } + } + }; + } +} + export abstract class BaseWindow extends Disposable implements IBaseWindow { //#region Events @@ -325,18 +348,18 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { case FocusMode.Notify: if (isMacintosh) { - this.setFocusNotificationBadge(undefined /* generic dot */); + this.showFocusNotificationBadge(); // On macOS we have direct API to bounce the dock icon electron.app.dock?.bounce('informational'); } else if (isWindows) { - this.setFocusNotificationBadge(undefined /* generic dot */); + this.showFocusNotificationBadge(); // On Windows, calling focus() will bounce the taskbar icon // https://github.com/electron/electron/issues/2867 this.win?.focus(); } else if (isLinux) { - this.setFocusNotificationBadge(1 /* only number supported */); + this.showFocusNotificationBadge(); // On Linux, there seems to be no way to bounce the taskbar icon // as calling focus() will actually steal focus away. @@ -352,18 +375,16 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { } } - private hasFocusNotificationBadge = false; + private readonly focusNotificationBadgeDisposable = this._register(new MutableDisposable()); - private setFocusNotificationBadge(count?: number): void { - electron.app.setBadgeCount(count); - this.hasFocusNotificationBadge = true; + private showFocusNotificationBadge(): void { + if (!this.focusNotificationBadgeDisposable.value) { + this.focusNotificationBadgeDisposable.value = DockBadgeManager.INSTANCE.acquireBadge(this); + } } private clearFocusNotificationBadge(): void { - if (this.hasFocusNotificationBadge) { - electron.app.setBadgeCount(0); - this.hasFocusNotificationBadge = false; - } + this.focusNotificationBadgeDisposable.clear(); } private doFocusWindow() { diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index d26a157c8e3..f1849bb1cc1 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -161,7 +161,7 @@ export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowSt }; if (isWindows) { - const borderSetting = windowSettings?.border ?? 'default'; + const borderSetting = windowSettings?.border || 'default'; if (borderSetting !== 'default') { if (borderSetting === 'off') { options.accentColor = false; diff --git a/src/vs/server/node/serverEnvironmentService.ts b/src/vs/server/node/serverEnvironmentService.ts index eb588059c16..092618c6846 100644 --- a/src/vs/server/node/serverEnvironmentService.ts +++ b/src/vs/server/node/serverEnvironmentService.ts @@ -37,6 +37,7 @@ export const serverOptions: OptionDescriptions> = { 'user-data-dir': OPTIONS['user-data-dir'], 'enable-smoke-test-driver': OPTIONS['enable-smoke-test-driver'], 'disable-telemetry': OPTIONS['disable-telemetry'], + 'disable-experiments': OPTIONS['disable-experiments'], 'disable-workspace-trust': OPTIONS['disable-workspace-trust'], 'file-watcher-polling': { type: 'string', deprecates: ['fileWatcherPolling'] }, 'log': OPTIONS['log'], @@ -160,6 +161,7 @@ export interface ServerParsedArgs { 'enable-smoke-test-driver'?: boolean; 'disable-telemetry'?: boolean; + 'disable-experiments'?: boolean; 'file-watcher-polling'?: string; 'log'?: string[]; diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index bdb0dcb629f..c4f5bc2ce92 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -86,7 +86,7 @@ import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeM import { IExtensionGalleryManifestService } from '../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { ExtensionGalleryManifestIPCService } from '../../platform/extensionManagement/common/extensionGalleryManifestServiceIpc.js'; import { IMcpGalleryService, IMcpManagementService } from '../../platform/mcp/common/mcpManagement.js'; -import { McpManagementService } from '../../platform/mcp/common/mcpManagementService.js'; +import { McpManagementService } from '../../platform/mcp/node/mcpManagementService.js'; import { McpGalleryService } from '../../platform/mcp/common/mcpGalleryService.js'; import { IMcpResourceScannerService, McpResourceScannerService } from '../../platform/mcp/common/mcpResourceScannerService.js'; import { McpManagementChannel } from '../../platform/mcp/common/mcpManagementIpc.js'; diff --git a/src/vs/workbench/api/browser/mainThreadDiagnostics.ts b/src/vs/workbench/api/browser/mainThreadDiagnostics.ts index a484e898e01..d4d46de4755 100644 --- a/src/vs/workbench/api/browser/mainThreadDiagnostics.ts +++ b/src/vs/workbench/api/browser/mainThreadDiagnostics.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IMarkerService, IMarkerData } from '../../../platform/markers/common/markers.js'; +import { IMarkerService, IMarkerData, type IMarker } from '../../../platform/markers/common/markers.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { MainThreadDiagnosticsShape, MainContext, ExtHostDiagnosticsShape, ExtHostContext } from '../common/extHost.protocol.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js'; +import { ResourceMap } from '../../../base/common/map.js'; @extHostNamedCustomer(MainContext.MainThreadDiagnostics) export class MainThreadDiagnostics implements MainThreadDiagnosticsShape { @@ -18,6 +19,9 @@ export class MainThreadDiagnostics implements MainThreadDiagnosticsShape { private readonly _proxy: ExtHostDiagnosticsShape; private readonly _markerListener: IDisposable; + private static ExtHostCounter: number = 1; + private readonly extHostId: string; + constructor( extHostContext: IExtHostContext, @IMarkerService private readonly _markerService: IMarkerService, @@ -26,11 +30,27 @@ export class MainThreadDiagnostics implements MainThreadDiagnosticsShape { this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostDiagnostics); this._markerListener = this._markerService.onMarkerChanged(this._forwardMarkers, this); + this.extHostId = `extHost${MainThreadDiagnostics.ExtHostCounter++}`; } dispose(): void { this._markerListener.dispose(); - this._activeOwners.forEach(owner => this._markerService.changeAll(owner, [])); + for (const owner of this._activeOwners) { + const markersData: ResourceMap = new ResourceMap(); + for (const marker of this._markerService.read({ owner })) { + let data = markersData.get(marker.resource); + if (data === undefined) { + data = []; + markersData.set(marker.resource, data); + } + if (marker.origin !== this.extHostId) { + data.push(marker); + } + } + for (const [resource, local] of markersData.entries()) { + this._markerService.changeOne(owner, resource, local); + } + } this._activeOwners.clear(); } @@ -41,9 +61,9 @@ export class MainThreadDiagnostics implements MainThreadDiagnosticsShape { if (allMarkerData.length === 0) { data.push([resource, []]); } else { - const forgeinMarkerData = allMarkerData.filter(marker => !this._activeOwners.has(marker.owner)); - if (forgeinMarkerData.length > 0) { - data.push([resource, forgeinMarkerData]); + const foreignMarkerData = allMarkerData.filter(marker => marker?.origin !== this.extHostId); + if (foreignMarkerData.length > 0) { + data.push([resource, foreignMarkerData]); } } } @@ -65,6 +85,9 @@ export class MainThreadDiagnostics implements MainThreadDiagnosticsShape { if (marker.code && typeof marker.code !== 'string') { marker.code.target = URI.revive(marker.code.target); } + if (marker.origin === undefined) { + marker.origin = this.extHostId; + } } } this._markerService.changeOne(owner, this._uriIdentService.asCanonicalUri(URI.revive(uri)), markers); diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index ec0b527c801..cacae156aa7 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -657,6 +657,8 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread viewKind: lifetimeSummary.viewKind, requestReason: lifetimeSummary.requestReason, error: lifetimeSummary.error, + typingInterval: lifetimeSummary.typingInterval, + typingIntervalCharacterCount: lifetimeSummary.typingIntervalCharacterCount, languageId: lifetimeSummary.languageId, cursorColumnDistance: lifetimeSummary.cursorColumnDistance, cursorLineDistance: lifetimeSummary.cursorLineDistance, @@ -685,6 +687,7 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread } }, groupId: groupId ?? extensionId, + providerId: new languages.ProviderId(extensionId, extensionVersion, groupId), yieldsToGroupIds: yieldsToExtensionIds, debounceDelayMs, displayName, @@ -1308,10 +1311,11 @@ type InlineCompletionEndOfLifeEvent = { requestReason: string; languageId: string; error: string | undefined; + typingInterval: number; + typingIntervalCharacterCount: number; superseded: boolean; editorType: string; viewKind: string | undefined; - // render info cursorColumnDistance: number | undefined; cursorLineDistance: number | undefined; lineCountOriginal: number | undefined; @@ -1337,6 +1341,8 @@ type InlineCompletionsEndOfLifeClassification = { languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language ID of the document where the inline completion was shown' }; requestReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The reason for the inline completion request' }; error: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The error message if the inline completion failed' }; + typingInterval: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The average typing interval of the user at the moment the inline completion was requested' }; + typingIntervalCharacterCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The character count involved in the typing interval calculation' }; superseded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the inline completion was superseded by another one' }; editorType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of the editor where the inline completion was shown' }; viewKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The kind of the view where the inline completion was shown' }; diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index 985f83c6fdb..dbd50bc28ab 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -13,6 +13,7 @@ import Severity from '../../../base/common/severity.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import * as nls from '../../../nls.js'; import { IDialogService, IPromptButton } from '../../../platform/dialogs/common/dialogs.js'; +import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { LogLevel } from '../../../platform/log/common/log.js'; import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; @@ -91,6 +92,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { const serverDefinitions = observableValue('mcpServers', servers); const handle = this._mcpRegistry.registerCollection({ ...collection, + source: new ExtensionIdentifier(collection.extensionId), resolveServerLanch: collection.canResolveLaunch ? (async def => { const r = await this._proxy.$resolveMcpLaunch(collection.id, def.label); return r ? McpServerLaunch.fromSerialized(r) : undefined; diff --git a/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts b/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts index 492e2cfb793..2d5ede8fd61 100644 --- a/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts +++ b/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts @@ -11,6 +11,7 @@ import { ITerminalService, type ITerminalInstance } from '../../contrib/terminal import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; import { extHostNamedCustomer, type IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { TerminalShellExecutionCommandLineConfidence } from '../common/extHostTypes.js'; +import { IExtensionService } from '../../services/extensions/common/extensions.js'; @extHostNamedCustomer(MainContext.MainThreadTerminalShellIntegration) export class MainThreadTerminalShellIntegration extends Disposable implements MainThreadTerminalShellIntegrationShape { @@ -19,7 +20,8 @@ export class MainThreadTerminalShellIntegration extends Disposable implements Ma constructor( extHostContext: IExtHostContext, @ITerminalService private readonly _terminalService: ITerminalService, - @IWorkbenchEnvironmentService workbenchEnvironmentService: IWorkbenchEnvironmentService + @IWorkbenchEnvironmentService workbenchEnvironmentService: IWorkbenchEnvironmentService, + @IExtensionService private readonly _extensionService: IExtensionService ) { super(); @@ -111,6 +113,10 @@ export class MainThreadTerminalShellIntegration extends Disposable implements Ma } private _enableShellIntegration(instance: ITerminalInstance): void { + this._extensionService.activateByEvent('onTerminalShellIntegration:*'); + if (instance.shellType) { + this._extensionService.activateByEvent(`onTerminalShellIntegration:${instance.shellType}`); + } this._proxy.$shellIntegrationChange(instance.instanceId); const cwdDetection = instance.capabilities.get(TerminalCapability.CwdDetection); if (cwdDetection) { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 5e1572b7576..0f44ca6f3c1 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1358,6 +1358,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostTask.taskExecutions; }, onDidStartTask: (listeners, thisArgs?, disposables?) => { + if (!isProposedApiEnabled(extension, 'taskExecutionTerminal')) { + if (thisArgs) { + thisArgs.terminal = undefined; + } + } return _asExtensionEvent(extHostTask.onDidStartTask)(listeners, thisArgs, disposables); }, onDidEndTask: (listeners, thisArgs?, disposables?) => { diff --git a/src/vs/workbench/api/common/extHostCommands.ts b/src/vs/workbench/api/common/extHostCommands.ts index 0bda2e82e46..d914f21a29d 100644 --- a/src/vs/workbench/api/common/extHostCommands.ts +++ b/src/vs/workbench/api/common/extHostCommands.ts @@ -287,6 +287,10 @@ export class ExtHostCommands implements ExtHostCommandsShape { if (!command.extension) { return; } + if (id.startsWith('code.copilot.logStructured')) { + // This command is very active. See https://github.com/microsoft/vscode/issues/254153. + return; + } type ExtensionActionTelemetry = { extensionId: string; id: TelemetryTrustedValue; diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index f2fcab92ffc..1ff04676524 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -26,7 +26,6 @@ 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 { } @@ -200,7 +199,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { targetExtensions: metadata.extensions, isDefault: metadata.isDefault, isUserSelectable: metadata.isUserSelectable, - modelPickerCategory: metadata.category ?? DEFAULT_MODEL_PICKER_CATEGORY, + modelPickerCategory: metadata.category, capabilities: metadata.capabilities, }); diff --git a/src/vs/workbench/api/common/extHostTask.ts b/src/vs/workbench/api/common/extHostTask.ts index bf0d6e41fe2..34fe6fc1a11 100644 --- a/src/vs/workbench/api/common/extHostTask.ts +++ b/src/vs/workbench/api/common/extHostTask.ts @@ -360,6 +360,7 @@ namespace TaskFilterDTO { class TaskExecutionImpl implements vscode.TaskExecution { readonly #tasks: ExtHostTaskBase; + private _terminal: vscode.Terminal | undefined; constructor(tasks: ExtHostTaskBase, readonly _id: string, private readonly _task: vscode.Task) { this.#tasks = tasks; @@ -378,6 +379,14 @@ class TaskExecutionImpl implements vscode.TaskExecution { public fireDidEndProcess(value: tasks.ITaskProcessEndedDTO): void { } + + public get terminal(): vscode.Terminal | undefined { + return this._terminal; + } + + public set terminal(term: vscode.Terminal | undefined) { + this._terminal = term; + } } export interface HandlerData { @@ -497,8 +506,14 @@ export abstract class ExtHostTaskBase implements ExtHostTaskShape, IExtHostTask } this._lastStartedTask = execution.id; + const taskExecution = await this.getTaskExecution(execution); + const terminal = this._terminalService.getTerminalById(terminalId)?.value; + if (taskExecution) { + taskExecution.terminal = terminal; + } + this._onDidExecuteTask.fire({ - execution: await this.getTaskExecution(execution) + execution: taskExecution }); } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index c4e454261a3..5ea598a3f50 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -1422,9 +1422,6 @@ export class DocumentSymbol extends AbstractDocumentSymbol { } static override[Symbol.hasInstance](candidate: unknown): boolean { - if (!isObject(candidate)) { - throw new TypeError(); - } return candidate instanceof AbstractDocumentSymbol || candidate instanceof SymbolInformationAndDocumentSymbol; } diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 78db93aa0b5..caadf4f7ac2 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -7,7 +7,6 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } import { Event, Emitter } from '../../base/common/event.js'; import { EventType, addDisposableListener, getClientArea, position, size, IDimension, isAncestorUsingFlowTo, computeScreenAwareSize, getActiveDocument, getWindows, getActiveWindow, isActiveDocument, getWindow, getWindowId, getActiveElement, Dimension } from '../../base/browser/dom.js'; import { onDidChangeFullscreen, isFullscreen, isWCOEnabled } from '../../base/browser/browser.js'; -import { IWorkingCopyBackupService } from '../services/workingCopy/common/workingCopyBackup.js'; import { isWindows, isLinux, isMacintosh, isWeb, isIOS } from '../../base/common/platform.js'; import { EditorInputCapabilities, GroupIdentifier, isResourceEditorInput, IUntypedEditorInput, pathsToEditors } from '../common/editor.js'; import { SidebarPart } from './parts/sidebar/sidebarPart.js'; @@ -15,7 +14,7 @@ import { PanelPart } from './parts/panel/panelPart.js'; import { Position, Parts, PartOpensMaximizedOptions, IWorkbenchLayoutService, positionFromString, positionToString, partOpensMaximizedFromString, 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'; +import { IConfigurationChangeEvent, IConfigurationService, isConfigured } from '../../platform/configuration/common/configuration.js'; import { ITitleService } from '../services/title/browser/titleService.js'; import { ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; import { StartupKind, ILifecycleService } from '../services/lifecycle/common/lifecycle.js'; @@ -286,7 +285,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi private titleService!: ITitleService; private viewDescriptorService!: IViewDescriptorService; private contextService!: IWorkspaceContextService; - private workingCopyBackupService!: IWorkingCopyBackupService; private notificationService!: INotificationService; private themeService!: IThemeService; private statusBarService!: IStatusbarService; @@ -314,7 +312,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.hostService = accessor.get(IHostService); this.contextService = accessor.get(IWorkspaceContextService); this.storageService = accessor.get(IStorageService); - this.workingCopyBackupService = accessor.get(IWorkingCopyBackupService); this.themeService = accessor.get(IThemeService); this.extensionService = accessor.get(IExtensionService); this.logService = accessor.get(ILogService); @@ -422,7 +419,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } // Theme changes - this._register(this.themeService.onDidColorThemeChange(() => this.updateWindowsBorder())); + this._register(this.themeService.onDidColorThemeChange(() => this.updateWindowBorder())); // Window active / focus changes this._register(this.hostService.onDidChangeFocus(focused => this.onWindowFocusChanged(focused))); @@ -511,7 +508,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi // Propagate to grid this.workbenchGrid.setViewVisible(this.titleBarPartView, shouldShowCustomTitleBar(this.configurationService, mainWindow, this.state.runtime.menuBar.toggled)); - this.updateWindowsBorder(true); + this.updateWindowBorder(true); } } @@ -521,7 +518,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.state.runtime.activeContainerId = activeContainerId; // Indicate active window border - this.updateWindowsBorder(); + this.updateWindowBorder(); this._onDidChangeActiveContainer.fire(); } @@ -530,7 +527,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi private onWindowFocusChanged(hasFocus: boolean): void { if (this.state.runtime.hasFocus !== hasFocus) { this.state.runtime.hasFocus = hasFocus; - this.updateWindowsBorder(); + this.updateWindowBorder(); } } @@ -585,7 +582,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.adjustPartPositions(position, panelAlignment, panelPosition); } - private updateWindowsBorder(skipLayout = false) { + private updateWindowBorder(skipLayout = false) { if ( isWeb || isWindows || // not working well with zooming (border often not visible) @@ -633,7 +630,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi private initLayoutState(lifecycleService: ILifecycleService, fileService: IFileService, coreExperimentationService: ICoreExperimentationService): void { this._mainContainerDimension = getClientArea(this.parent, DEFAULT_WINDOW_DIMENSIONS); // running with fallback to ensure no error is thrown (https://github.com/microsoft/vscode/issues/240242) - this.stateModel = new LayoutStateModel(this.storageService, this.configurationService, this.contextService, coreExperimentationService, this.environmentService); + this.stateModel = new LayoutStateModel(this.storageService, this.configurationService, this.contextService, coreExperimentationService, this.environmentService, this.viewDescriptorService); this.stateModel.load({ mainContainerDimension: this._mainContainerDimension, resetLayout: Boolean(this.layoutOptions?.resetLayout) @@ -746,7 +743,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } // Window border - this.updateWindowsBorder(true); + this.updateWindowBorder(true); } private getDefaultLayoutViews(environmentService: IBrowserWorkbenchEnvironmentService, storageService: IStorageService): string[] | undefined { @@ -837,11 +834,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return []; // do not open any empty untitled file if we restored groups/editors from previous session } - const hasBackups = await this.workingCopyBackupService.hasBackups(); - if (hasBackups) { - return []; // do not open any empty untitled file if we have backups to restore - } - return [{ editor: { resource: undefined } // open empty untitled file }]; @@ -1110,6 +1102,14 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi // Await for promises that we recorded to update // our ready and restored states properly. Promises.settled(layoutReadyPromises).finally(() => { + + // Focus the active maximized part in case we have + // not yet focused a specific element and panel + // or auxiliary bar are maximized. + if (getActiveElement() === mainWindow.document.body && (this.isPanelMaximized() || this.isAuxiliaryBarMaximized())) { + this.focus(); + } + this.whenReadyPromise.complete(); Promises.settled(layoutRestoredPromises).finally(() => { @@ -1565,16 +1565,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.workbenchGrid = workbenchGrid; this.workbenchGrid.edgeSnapping = this.state.runtime.mainWindowFullscreen; - if (this.stateModel.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_WAS_LAST_MAXIMIZED)) { - // TODO@benibenj this is a workaround for the grid not being able to - // restore the maximized auxiliary bar on startup when it was maximised - // It seems that since editor and panel are hidden, the parent node is - // also hidden and not present, breaking the layout. - // Workaround is to make editor visible so that its parent view gets - // added properly and then enter maximized mode of auxiliary bar. - this.setAuxiliaryBarMaximized(true, true /* fromInit */); - } - for (const part of [titleBar, editorPart, activityBar, panelPart, sideBar, statusBar, auxiliaryBarPart, bannerPart]) { this._register(part.onDidVisibilityChange(visible => { if (!this.inMaximizedAuxiliaryBarTransition) { @@ -2023,63 +2013,42 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } } - private maximizedAuxiliaryBarState: { - sideBarVisible: boolean; - editorVisible: boolean; - panelVisible: boolean; - auxiliaryBarVisible: boolean; - } | undefined = undefined; - private inMaximizedAuxiliaryBarTransition = false; isAuxiliaryBarMaximized(): boolean { - return !!this.maximizedAuxiliaryBarState; + return this.stateModel.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_WAS_LAST_MAXIMIZED); } toggleMaximizedAuxiliaryBar(): void { this.setAuxiliaryBarMaximized(!this.isAuxiliaryBarMaximized()); } - setAuxiliaryBarMaximized(maximized: boolean, fromInit?: boolean): boolean { + setAuxiliaryBarMaximized(maximized: boolean): boolean { if ( - this.inMaximizedAuxiliaryBarTransition || // prevent re-entrance - (!maximized && !this.maximizedAuxiliaryBarState) // return early if not maximizing and no state + this.inMaximizedAuxiliaryBarTransition || // prevent re-entrance + (maximized === this.isAuxiliaryBarMaximized()) // return early if state is already present ) { return false; } if (maximized) { - let state: typeof this.maximizedAuxiliaryBarState; - if (fromInit) { - - // TODO workaround for a bug with grid, see above in `createWorkbenchLayout` - const stateMixin = { editorVisible: true }; - this.setEditorHidden(false); - // TODO workaround - - state = { - ...this.stateModel.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_VISIBILITY), - ...stateMixin - }; - } else { - state = { - sideBarVisible: this.isVisible(Parts.SIDEBAR_PART), - editorVisible: this.isVisible(Parts.EDITOR_PART), - panelVisible: this.isVisible(Parts.PANEL_PART), - auxiliaryBarVisible: this.isVisible(Parts.AUXILIARYBAR_PART) - }; - } - this.maximizedAuxiliaryBarState = state; + const state = { + sideBarVisible: this.isVisible(Parts.SIDEBAR_PART), + editorVisible: this.isVisible(Parts.EDITOR_PART), + panelVisible: this.isVisible(Parts.PANEL_PART), + auxiliaryBarVisible: this.isVisible(Parts.AUXILIARYBAR_PART) + }; + this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_WAS_LAST_MAXIMIZED, true); this.inMaximizedAuxiliaryBarTransition = true; try { if (!state.auxiliaryBarVisible) { this.setAuxiliaryBarHidden(false); } - if (!fromInit) { - const size = this.workbenchGrid.getViewSize(this.auxiliaryBarPartView).width; - this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_SIZE, size); - } + + const size = this.workbenchGrid.getViewSize(this.auxiliaryBarPartView).width; + this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_SIZE, size); + if (state.sideBarVisible) { this.setSideBarHidden(true); } @@ -2090,15 +2059,13 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.setEditorHidden(true); } - if (!fromInit) { - this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_VISIBILITY, state); - } + this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_VISIBILITY, state); } finally { this.inMaximizedAuxiliaryBarTransition = false; } } else { - const state = assertReturnsDefined(this.maximizedAuxiliaryBarState); - this.maximizedAuxiliaryBarState = undefined; + const state = assertReturnsDefined(this.stateModel.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_VISIBILITY)); + this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_WAS_LAST_MAXIMIZED, false); this.inMaximizedAuxiliaryBarTransition = true; try { @@ -2118,8 +2085,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.focusPart(Parts.AUXILIARYBAR_PART); - this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_WAS_LAST_MAXIMIZED, maximized); - this._onDidChangeAuxiliaryBarMaximized.fire(); return true; @@ -2377,7 +2342,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.state.runtime.maximized.delete(targetWindowId); } - this.updateWindowsBorder(); + this.updateWindowBorder(); this._onDidChangeWindowMaximized.fire({ windowId: targetWindowId, maximized }); } @@ -2451,7 +2416,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return { type: 'branch', data: result, - size: availableHeight + size: availableHeight, + visible: result.some(node => node.visible) }; } @@ -2496,10 +2462,12 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi auxiliaryBar: auxiliaryBarNextToEditor ? nodes.auxiliaryBar : undefined }, availableHeight - panelSize, editorSectionWidth); + const data = panelPostion === Position.BOTTOM ? [editorNodes, nodes.panel] : [nodes.panel, editorNodes]; result.push({ type: 'branch', - data: panelPostion === Position.BOTTOM ? [editorNodes, nodes.panel] : [nodes.panel, editorNodes], - size: editorSectionWidth + data, + size: editorSectionWidth, + visible: data.some(node => node.visible) }); if (!sideBarNextToEditor) { @@ -2800,15 +2768,28 @@ class LayoutStateModel extends Disposable { private readonly stateCache = new Map(); + private readonly isNew: { + [StorageScope.WORKSPACE]: boolean; + [StorageScope.PROFILE]: boolean; + [StorageScope.APPLICATION]: boolean; + }; + constructor( private readonly storageService: IStorageService, private readonly configurationService: IConfigurationService, private readonly contextService: IWorkspaceContextService, private readonly coreExperimentationService: ICoreExperimentationService, - private readonly environmentService: IBrowserWorkbenchEnvironmentService + private readonly environmentService: IBrowserWorkbenchEnvironmentService, + private readonly viewDescriptorService: IViewDescriptorService ) { super(); + this.isNew = { + [StorageScope.WORKSPACE]: this.storageService.isNew(StorageScope.WORKSPACE), + [StorageScope.PROFILE]: this.storageService.isNew(StorageScope.PROFILE), + [StorageScope.APPLICATION]: this.storageService.isNew(StorageScope.APPLICATION) + }; + this._register(this.configurationService.onDidChangeConfiguration(configurationChange => this.updateStateFromLegacySettings(configurationChange))); } @@ -2868,15 +2849,36 @@ class LayoutStateModel extends Disposable { LayoutStateKeys.SIDEBAR_HIDDEN.defaultValue = workbenchState === WorkbenchState.EMPTY; LayoutStateKeys.AUXILIARYBAR_SIZE.defaultValue = Math.min(300, mainContainerDimension.width / 4); LayoutStateKeys.AUXILIARYBAR_HIDDEN.defaultValue = (() => { + + // TODO@bpasero: lots of hacks here to not force open the auxiliary sidebar + // when no Chat view is present within: + // - revisit this when/if the default value of workbench.secondarySideBar.defaultVisibility changes + // - revisit this when Chat is available in serverless web + // - drop the need to probe for chat.setupContext + // - drop the need to probe for view location of workbench.panel.chat.view.copilot const configuration = this.configurationService.inspect(WorkbenchLayoutSettings.AUXILIARYBAR_DEFAULT_VISIBILITY); - if (configuration.defaultValue !== 'hidden' && isWeb && !this.environmentService.remoteAuthority) { - return true; // TODO@bpasero revisit this when Chat is available in serverless web + if (configuration.defaultValue !== 'hidden' && !isConfigured(configuration)) { + if (isWeb && !this.environmentService.remoteAuthority) { + return true; // Chat view is not enabled + } + + const context = this.storageService.getObject<{ hidden?: boolean; disabled?: boolean; installed?: boolean }>('chat.setupContext', StorageScope.PROFILE); + if (context && ((context.installed && context.disabled) || (!context.installed && context.hidden))) { + return true; // Chat view is hidden by user choice + } + + const location = this.viewDescriptorService.getViewLocationById('workbench.panel.chat.view.copilot'); + if (location === ViewContainerLocation.Sidebar || location === ViewContainerLocation.Panel) { + return true; // Chat view is not located in the auxiliary bar + } } switch (this.configurationService.getValue(WorkbenchLayoutSettings.AUXILIARYBAR_DEFAULT_VISIBILITY)) { + case 'maximized': case 'visible': return false; case 'visibleInWorkspace': + case 'maximizedInWorkspace': return workbenchState === WorkbenchState.EMPTY; default: return true; @@ -2925,7 +2927,7 @@ class LayoutStateModel extends Disposable { } }); - // With experimental treatment for new users + // Auxiliary bar: With experimental treatment for new users if ( this.storageService.isNew(StorageScope.APPLICATION) && this.contextService.getWorkbenchState() === WorkbenchState.EMPTY && @@ -2936,27 +2938,25 @@ class LayoutStateModel extends Disposable { ) ) { if (experiment.value.experimentGroup === StartupExperimentGroup.MaximizedChat) { - this.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_VISIBILITY, { - sideBarVisible: !this.getRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN), - panelVisible: !this.getRuntimeValue(LayoutStateKeys.PANEL_HIDDEN), - editorVisible: !this.getRuntimeValue(LayoutStateKeys.EDITOR_HIDDEN), - auxiliaryBarVisible: !this.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN) - }); - - this.setRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN, true); - this.setRuntimeValue(LayoutStateKeys.PANEL_HIDDEN, true); - this.setRuntimeValue(LayoutStateKeys.EDITOR_HIDDEN, true); - this.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN, false); - - this.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_SIZE, this.getInitializationValue(LayoutStateKeys.AUXILIARYBAR_SIZE)); - this.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_WAS_LAST_MAXIMIZED, true); + this.applyAuxiliaryBarMaximizedOverride(); } else if ( experiment.value.experimentGroup === StartupExperimentGroup.SplitEmptyEditorChat || experiment.value.experimentGroup === StartupExperimentGroup.SplitWelcomeChat ) { const mainContainerDimension = configuration.mainContainerDimension; this.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN, false); - this.setInitializationValue(LayoutStateKeys.AUXILIARYBAR_SIZE, mainContainerDimension.width / 2); + this.setInitializationValue(LayoutStateKeys.AUXILIARYBAR_SIZE, Math.ceil(mainContainerDimension.width / (1.618 * 1.618 /* golden ratio */))); + } + } + + // Auxiliary bar: Based on setting for new workspaces + else if (this.isNew[StorageScope.WORKSPACE]) { + const defaultAuxiliaryBarVisibility = this.configurationService.getValue(WorkbenchLayoutSettings.AUXILIARYBAR_DEFAULT_VISIBILITY); + if ( + defaultAuxiliaryBarVisibility === 'maximized' || + (defaultAuxiliaryBarVisibility === 'maximizedInWorkspace' && this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY) + ) { + this.applyAuxiliaryBarMaximizedOverride(); } } @@ -2970,6 +2970,23 @@ class LayoutStateModel extends Disposable { } } + private applyAuxiliaryBarMaximizedOverride(): void { + this.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_VISIBILITY, { + sideBarVisible: !this.getRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN), + panelVisible: !this.getRuntimeValue(LayoutStateKeys.PANEL_HIDDEN), + editorVisible: !this.getRuntimeValue(LayoutStateKeys.EDITOR_HIDDEN), + auxiliaryBarVisible: !this.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN) + }); + + this.setRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN, true); + this.setRuntimeValue(LayoutStateKeys.PANEL_HIDDEN, true); + this.setRuntimeValue(LayoutStateKeys.EDITOR_HIDDEN, true); + this.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN, false); + + this.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_LAST_NON_MAXIMIZED_SIZE, this.getInitializationValue(LayoutStateKeys.AUXILIARYBAR_SIZE)); + this.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_WAS_LAST_MAXIMIZED, true); + } + save(workspace: boolean, global: boolean): void { let key: keyof typeof LayoutStateKeys; @@ -3051,13 +3068,15 @@ class LayoutStateModel extends Disposable { } private loadKeyFromStorage(key: WorkbenchLayoutStateKey): T | undefined { - let value: any = this.storageService.get(`${LayoutStateModel.STORAGE_PREFIX}${key.name}`, key.scope); + const value = this.storageService.get(`${LayoutStateModel.STORAGE_PREFIX}${key.name}`, key.scope); if (value !== undefined) { + this.isNew[key.scope] = false; // remember that we had previous state for this scope + switch (typeof key.defaultValue) { - case 'boolean': value = value === 'true'; break; - case 'number': value = parseInt(value); break; - case 'object': value = JSON.parse(value); break; + case 'boolean': return (value === 'true') as T; + case 'number': return parseInt(value) as T; + case 'object': return JSON.parse(value) as T; } } diff --git a/src/vs/workbench/browser/parts/compositePart.ts b/src/vs/workbench/browser/parts/compositePart.ts index 099641bd5e3..60d91880a83 100644 --- a/src/vs/workbench/browser/parts/compositePart.ts +++ b/src/vs/workbench/browser/parts/compositePart.ts @@ -89,7 +89,7 @@ export abstract class CompositePart extends Part { protected readonly registry: CompositeRegistry, private readonly activeCompositeSettingsKey: string, private readonly defaultCompositeId: string, - private readonly nameForTelemetry: string, + protected readonly nameForTelemetry: string, private readonly compositeCSSClass: string, private readonly titleForegroundColor: string | undefined, private readonly titleBorderColor: string | undefined, diff --git a/src/vs/workbench/browser/parts/editor/editorActions.ts b/src/vs/workbench/browser/parts/editor/editorActions.ts index d7452326923..6d9baf325b2 100644 --- a/src/vs/workbench/browser/parts/editor/editorActions.ts +++ b/src/vs/workbench/browser/parts/editor/editorActions.ts @@ -1178,7 +1178,10 @@ export class ToggleMaximizeEditorGroupAction extends Action2 { when: EditorPartMaximizedEditorGroupContext }], icon: Codicon.screenFull, - toggled: EditorPartMaximizedEditorGroupContext, + toggled: { + condition: EditorPartMaximizedEditorGroupContext, + title: localize('unmaximizeGroup', "Unmaximize Group") + }, }); } diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index 8cb89b2e576..9622c739865 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -365,7 +365,8 @@ export abstract class AbstractPaneCompositePart extends CompositePart = this._register(new DisposableMap()); @@ -625,7 +625,7 @@ export abstract class ViewPane extends Pane implements IView { } protected layoutBody(height: number, width: number): void { - this.viewWelcomeController.layout(height, width); + this.viewWelcomeController?.layout(height, width); } onDidScrollRoot() { @@ -634,7 +634,6 @@ export abstract class ViewPane extends Pane implements IView { getProgressIndicator() { if (this.progressBar === undefined) { - // Progress bar this.progressBar = this._register(new ProgressBar(this.element, defaultProgressBarStyles)); this.progressBar.hide(); } @@ -660,7 +659,7 @@ export abstract class ViewPane extends Pane implements IView { } focus(): void { - if (this.viewWelcomeController.enabled) { + if (this.viewWelcomeController?.enabled) { this.viewWelcomeController.focus(); } else if (this.element) { this.element.focus(); diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 3a756fb1ae8..7298b6962d0 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -536,14 +536,16 @@ const registry = Registry.as(ConfigurationExtensions.Con }, 'workbench.secondarySideBar.defaultVisibility': { 'type': 'string', - 'enum': ['hidden', 'visibleInWorkspace', 'visible'], + 'enum': ['hidden', 'visibleInWorkspace', 'visible', 'maximizedInWorkspace', 'maximized'], '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.") + localize('workbench.secondarySideBar.defaultVisibility.visible', "The secondary side bar is visible by default."), + localize('workbench.secondarySideBar.defaultVisibility.maximizedInWorkspace', "The secondary side bar is visible and maximized by default if a workspace is opened."), + localize('workbench.secondarySideBar.defaultVisibility.maximized', "The secondary side bar is visible and maximized by default.") ] }, 'workbench.secondarySideBar.showLabels': { diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index 55a0db1fc70..e3b386ba967 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -15,6 +15,7 @@ import { Schemas } from '../../base/common/network.js'; import { EditorInput } from './editor/editorInput.js'; import { IEditorResolverService } from '../services/editor/common/editorResolverService.js'; import { DEFAULT_EDITOR_ASSOCIATION } from './editor.js'; +import { DiffEditorInput } from './editor/diffEditorInput.js'; //#region < --- Workbench --- > @@ -303,13 +304,30 @@ export function applyAvailableEditorIds(contextKey: IContextKey, editor: return; } - const editorResource = editor.resource; - if (editorResource?.scheme === Schemas.untitled && editor.editorId !== DEFAULT_EDITOR_ASSOCIATION.id) { - // Non text editor untitled files cannot be easily serialized between extensions - // so instead we disable this context key to prevent common commands that act on the active editor - contextKey.set(''); - } else { - const editors = editorResource ? editorResolverService.getEditors(editorResource).map(editor => editor.id) : []; - contextKey.set(editors.join(',')); - } + const editors = getAvailableEditorIds(editor, editorResolverService); + contextKey.set(editors.join(',')); +} + +function getAvailableEditorIds(editor: EditorInput, editorResolverService: IEditorResolverService): string[] { + // Non text editor untitled files cannot be easily serialized between + // extensions so instead we disable this context key to prevent common + // commands that act on the active editor. + if (editor.resource?.scheme === Schemas.untitled && editor.editorId !== DEFAULT_EDITOR_ASSOCIATION.id) { + return []; + } + + // Diff editors. The original and modified resources of a diff editor + // *should* be the same, but calculate the set intersection just to be safe. + if (editor instanceof DiffEditorInput) { + const original = getAvailableEditorIds(editor.original, editorResolverService); + const modified = new Set(getAvailableEditorIds(editor.modified, editorResolverService)); + return original.filter(editor => modified.has(editor)); + } + + // Normal editors. + if (editor.resource) { + return editorResolverService.getEditors(editor.resource).map(editor => editor.id); + } + + return []; } diff --git a/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts b/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts index b150e3daeaa..693975bc45a 100644 --- a/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts +++ b/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts @@ -7,7 +7,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; -import { IExtensionManifest, IExtensionDescription } from '../../../../platform/extensions/common/extensions.js'; +import { IExtensionManifest } from '../../../../platform/extensions/common/extensions.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; @@ -20,7 +20,6 @@ import { IAuthenticationUsageService } from '../../../services/authentication/br import { ManageAccountPreferencesForMcpServerAction } from './actions/manageAccountPreferencesForMcpServerAction.js'; import { ManageTrustedMcpServersForAccountAction } from './actions/manageTrustedMcpServersForAccountAction.js'; import { RemoveDynamicAuthenticationProvidersAction } from './actions/manageDynamicAuthenticationProvidersAction.js'; -import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IAuthenticationQueryService } from '../../../services/authentication/common/authenticationQuery.js'; import { IMcpRegistry } from '../../mcp/common/mcpRegistryTypes.js'; import { autorun } from '../../../../base/common/observable.js'; @@ -116,61 +115,61 @@ class AuthenticationUsageContribution implements IWorkbenchContribution { } } -class AuthenticationExtensionsContribution extends Disposable implements IWorkbenchContribution { - static ID = 'workbench.contrib.authenticationExtensions'; +// class AuthenticationExtensionsContribution extends Disposable implements IWorkbenchContribution { +// static ID = 'workbench.contrib.authenticationExtensions'; - constructor( - @IExtensionService private readonly _extensionService: IExtensionService, - @IAuthenticationQueryService private readonly _authenticationQueryService: IAuthenticationQueryService, - @IAuthenticationService private readonly _authenticationService: IAuthenticationService - ) { - super(); - void this.run(); - this._register(this._extensionService.onDidChangeExtensions(this._onDidChangeExtensions, this)); - this._register( - Event.any( - this._authenticationService.onDidChangeDeclaredProviders, - this._authenticationService.onDidRegisterAuthenticationProvider - )(() => this._cleanupRemovedExtensions()) - ); - } +// constructor( +// @IExtensionService private readonly _extensionService: IExtensionService, +// @IAuthenticationQueryService private readonly _authenticationQueryService: IAuthenticationQueryService, +// @IAuthenticationService private readonly _authenticationService: IAuthenticationService +// ) { +// super(); +// void this.run(); +// this._register(this._extensionService.onDidChangeExtensions(this._onDidChangeExtensions, this)); +// this._register( +// Event.any( +// this._authenticationService.onDidChangeDeclaredProviders, +// this._authenticationService.onDidRegisterAuthenticationProvider +// )(() => this._cleanupRemovedExtensions()) +// ); +// } - async run(): Promise { - await this._extensionService.whenInstalledExtensionsRegistered(); - this._cleanupRemovedExtensions(); - } +// async run(): Promise { +// await this._extensionService.whenInstalledExtensionsRegistered(); +// this._cleanupRemovedExtensions(); +// } - private _onDidChangeExtensions(delta: { readonly added: readonly IExtensionDescription[]; readonly removed: readonly IExtensionDescription[] }): void { - if (delta.removed.length > 0) { - this._cleanupRemovedExtensions(delta.removed); - } - } +// private _onDidChangeExtensions(delta: { readonly added: readonly IExtensionDescription[]; readonly removed: readonly IExtensionDescription[] }): void { +// if (delta.removed.length > 0) { +// this._cleanupRemovedExtensions(delta.removed); +// } +// } - private _cleanupRemovedExtensions(removedExtensions?: readonly IExtensionDescription[]): void { - const extensionIdsToRemove = removedExtensions - ? new Set(removedExtensions.map(e => e.identifier.value)) - : new Set(this._extensionService.extensions.map(e => e.identifier.value)); +// private _cleanupRemovedExtensions(removedExtensions?: readonly IExtensionDescription[]): void { +// const extensionIdsToRemove = removedExtensions +// ? new Set(removedExtensions.map(e => e.identifier.value)) +// : new Set(this._extensionService.extensions.map(e => e.identifier.value)); - // If we are cleaning up specific removed extensions, we only remove those. - const isTargetedCleanup = !!removedExtensions; +// // If we are cleaning up specific removed extensions, we only remove those. +// const isTargetedCleanup = !!removedExtensions; - const providerIds = this._authenticationQueryService.getProviderIds(); - for (const providerId of providerIds) { - this._authenticationQueryService.provider(providerId).forEachAccount(account => { - account.extensions().forEach(extension => { - const shouldRemove = isTargetedCleanup - ? extensionIdsToRemove.has(extension.extensionId) - : !extensionIdsToRemove.has(extension.extensionId); +// const providerIds = this._authenticationQueryService.getProviderIds(); +// for (const providerId of providerIds) { +// this._authenticationQueryService.provider(providerId).forEachAccount(account => { +// account.extensions().forEach(extension => { +// const shouldRemove = isTargetedCleanup +// ? extensionIdsToRemove.has(extension.extensionId) +// : !extensionIdsToRemove.has(extension.extensionId); - if (shouldRemove) { - extension.removeUsage(); - extension.setAccessAllowed(false); - } - }); - }); - } - } -} +// if (shouldRemove) { +// extension.removeUsage(); +// extension.setAccessAllowed(false); +// } +// }); +// }); +// } +// } +// } class AuthenticationMcpContribution extends Disposable implements IWorkbenchContribution { static ID = 'workbench.contrib.authenticationMcp'; @@ -216,5 +215,5 @@ class AuthenticationMcpContribution extends Disposable implements IWorkbenchCont registerWorkbenchContribution2(AuthenticationContribution.ID, AuthenticationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AuthenticationUsageContribution.ID, AuthenticationUsageContribution, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(AuthenticationExtensionsContribution.ID, AuthenticationExtensionsContribution, WorkbenchPhase.Eventually); +// registerWorkbenchContribution2(AuthenticationExtensionsContribution.ID, AuthenticationExtensionsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(AuthenticationMcpContribution.ID, AuthenticationMcpContribution, WorkbenchPhase.Eventually); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index b81bac0ea46..e14d3084197 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -29,6 +29,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com 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 { IFileService } from '../../../../../platform/files/common/files.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'; @@ -37,7 +38,7 @@ 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 { ActiveEditorContext, 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'; @@ -46,7 +47,6 @@ 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 { McpCommandIds } from '../../../mcp/common/mcpCommandIds.js'; import { IChatAgentService } from '../../common/chatAgents.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { IChatEditingSession, ModifiedFileEntryState } from '../../common/chatEditingService.js'; @@ -141,6 +141,7 @@ abstract class OpenChatGlobalAction extends Action2 { const instaService = accessor.get(IInstantiationService); const commandService = accessor.get(ICommandService); const chatModeService = accessor.get(IChatModeService); + const fileService = accessor.get(IFileService); let chatWidget = widgetService.lastFocusedWidget; // When this was invoked to switch to a mode via keybinding, and some chat widget is focused, use that one. @@ -175,7 +176,9 @@ abstract class OpenChatGlobalAction extends Action2 { } if (opts?.attachFiles) { for (const file of opts.attachFiles) { - chatWidget.attachmentModel.addFile(file); + if (await fileService.exists(file)) { + chatWidget.attachmentModel.addFile(file); + } } } if (opts?.query) { @@ -345,12 +348,18 @@ export function registerChatActions() { super({ id: `workbench.action.chat.history`, title: localize2('chat.history.label', "Show Chats..."), - menu: { - id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', ChatViewId), - group: 'navigation', - order: 2 - }, + menu: [ + { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', ChatViewId), + group: 'navigation', + order: 2 + }, + { + id: MenuId.EditorTitle, + when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), + }, + ], category: CHAT_CATEGORY, icon: Codicon.history, f1: true, @@ -664,7 +673,7 @@ export function registerChatActions() { } }); - const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${defaultChat.completionsAdvancedSetting}.authProvider`, defaultChat.enterpriseProviderId)); + const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${defaultChat.completionsAdvancedSetting}.authProvider`, defaultChat.provider?.enterprise.id)); registerAction2(class extends Action2 { constructor() { super({ @@ -872,19 +881,9 @@ Update \`.github/copilot-instructions.md\` for the user, then ask for feedback o title: localize2('config.label', "Configure Chat..."), group: 'navigation', when: ContextKeyExpr.equals('view', ChatViewId), - icon: Codicon.settings, + icon: Codicon.settingsGear, order: 6 }); - - MenuRegistry.appendMenuItem(CHAT_CONFIG_MENU_ID, { - command: { - id: McpCommandIds.ShowInstalled, - title: localize2('mcp.servers', "MCP Servers") - }, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), - order: 14, - group: '0_level' - }); } export function stringifyItem(item: IChatRequestViewModel | IChatResponseViewModel, includeName = true): string { @@ -902,7 +901,7 @@ const defaultChat = { documentationUrl: product.defaultChatAgent?.documentationUrl ?? '', manageSettingsUrl: product.defaultChatAgent?.manageSettingsUrl ?? '', managePlanUrl: product.defaultChatAgent?.managePlanUrl ?? '', - enterpriseProviderId: product.defaultChatAgent?.enterpriseProviderId ?? '', + provider: product.defaultChatAgent?.provider ?? { enterprise: { id: '' } }, completionsAdvancedSetting: product.defaultChatAgent?.completionsAdvancedSetting ?? '', completionsMenuCommand: product.defaultChatAgent?.completionsMenuCommand ?? '', }; @@ -1087,7 +1086,7 @@ const menuContext = ContextKeyExpr.and( ChatContextKeys.Setup.disabled.negate() ); -const title = localize('copilot', "Copilot"); +const title = localize('ai actions', "AI Actions"); MenuRegistry.appendMenuItem(MenuId.EditorContext, { submenu: MenuId.ChatTextEditorMenu, @@ -1109,3 +1108,30 @@ MenuRegistry.appendMenuItem(MenuId.TerminalInstanceContext, { title, when: menuContext }); + +// --- Chat Default Visibility + +registerAction2(class ToggleDefaultVisibilityAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.toggleDefaultVisibility', + title: localize2('chat.toggleDefaultVisibility.label', "Show View by Default"), + precondition: ChatContextKeys.panelLocation.isEqualTo(ViewContainerLocation.AuxiliaryBar), + toggled: ContextKeyExpr.equals('config.workbench.secondarySideBar.defaultVisibility', 'hidden').negate(), + f1: false, + menu: { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', ChatViewId), + order: 0, + group: '5_configure' + }, + }); + } + + async run(accessor: ServicesAccessor) { + const configurationService = accessor.get(IConfigurationService); + + const currentValue = configurationService.getValue<'hidden' | unknown>('workbench.secondarySideBar.defaultVisibility'); + configurationService.updateValue('workbench.secondarySideBar.defaultVisibility', currentValue !== 'hidden' ? 'hidden' : 'visible'); + } +}); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 726c4d43304..8fcb946111c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -377,6 +377,28 @@ class OpenModelPickerAction extends Action2 { } } +class OpenModePickerAction extends Action2 { + static readonly ID = 'workbench.action.chat.openModePicker'; + + constructor() { + super({ + id: OpenModePickerAction.ID, + title: localize2('interactive.openModePicker.label', "Open Mode Picker"), + category: CHAT_CATEGORY, + f1: false, + precondition: ChatContextKeys.enabled, + }); + } + + override async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (widget) { + widget.input.openModePicker(); + } + } +} + export const ChangeChatModelActionId = 'workbench.action.chat.changeModel'; class ChangeChatModelAction extends Action2 { static readonly ID = ChangeChatModelActionId; @@ -504,14 +526,14 @@ export class CreateRemoteAgentJobAction extends Action2 { super({ id: CreateRemoteAgentJobAction.ID, - // TODO(joshspicer): Generalize title - title: localize2('actions.chat.createRemoteJob', "Push to Copilot coding agent"), + // TODO(joshspicer): Generalize title, pull from contribution + title: localize2('actions.chat.createRemoteJob', "Delegate to coding agent"), icon: Codicon.cloudUpload, precondition, toggled: { condition: ChatContextKeys.remoteJobCreating, icon: Codicon.sync, - tooltip: localize('remoteJobCreating', "Pushing to Copilot coding agent"), + tooltip: localize('remoteJobCreating', "Delegating to coding agent"), }, menu: { id: MenuId.ChatExecute, @@ -543,13 +565,19 @@ export class CreateRemoteAgentJobAction extends Action2 { return; } - const userPrompt = widget.getInput(); - widget.setInput(); const chatModel = widget.viewModel?.model; if (!chatModel) { return; } + + const userPrompt = widget.getInput(); + if (!userPrompt) { + return; + } + + widget.input.acceptInput(true); + const chatRequests = chatModel.getRequests(); const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); @@ -617,7 +645,7 @@ export class CreateRemoteAgentJobAction extends Action2 { chatModel.acceptResponseProgress(addedRequest, { kind: 'progressMessage', content: new MarkdownString( - localize('creatingRemoteJob', "Pushing state to coding agent"), + localize('creatingRemoteJob', "Delegating to coding agent"), CreateRemoteAgentJobAction.markdownStringTrustedOptions ) }); @@ -643,6 +671,12 @@ export class CreateRemoteAgentJobAction extends Action2 { chatModel.acceptResponseProgress(addedRequest, { content, kind: 'markdownContent' }); chatModel.setResponse(addedRequest, {}); chatModel.completeResponse(addedRequest); + + // Clear chat (start a new chat) + if (resultMarkdown) { + widget.clear(); + } + } finally { remoteJobCreatingKey.set(false); } @@ -769,7 +803,11 @@ export class CancelAction extends Action2 { icon: Codicon.stopCircle, menu: [{ id: MenuId.ChatExecute, - when: ContextKeyExpr.and(ChatContextKeys.isRequestPaused.negate(), ChatContextKeys.requestInProgress), + when: ContextKeyExpr.and( + ChatContextKeys.isRequestPaused.negate(), + ChatContextKeys.requestInProgress, + ChatContextKeys.remoteJobCreating.negate() + ), order: 4, group: 'navigation', }, @@ -852,6 +890,7 @@ export function registerChatExecuteActions() { registerAction2(ToggleRequestPausedAction); registerAction2(SwitchToNextModelAction); registerAction2(OpenModelPickerAction); + registerAction2(OpenModePickerAction); registerAction2(ChangeChatModelAction); registerAction2(CancelEdit); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index 6c0e276c612..23e25bdad55 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -262,9 +262,8 @@ export function registerChatTitleActions() { chatService.resendRequest(request!, { userSelectedModelId: languageModelId, - userSelectedTools: widget?.getUserSelectedTools(), attempt: (request?.attempt ?? -1) + 1, - mode: widget?.input.currentModeKind, + ...widget?.getModeRequestOptions(), }); } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index cf3f1b4cdcc..1b3f8d46fcc 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -6,20 +6,20 @@ 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { assertType } from '../../../../../base/common/types.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; -import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; +import { ExtensionEditorTab, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { McpCommandIds } from '../../../mcp/common/mcpCommandIds.js'; import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; -import { IMcpServer, IMcpService, McpConnectionState } from '../../../mcp/common/mcpTypes.js'; +import { IMcpServer, IMcpService, IMcpWorkbenchService, McpConnectionState, McpServerEditorTab } from '../../../mcp/common/mcpTypes.js'; import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../common/languageModelToolsService.js'; import { ConfigureToolSets } from '../tools/toolSetsContribution.js'; @@ -60,8 +60,9 @@ export async function showToolsPicker( const mcpService = accessor.get(IMcpService); const mcpRegistry = accessor.get(IMcpRegistry); const commandService = accessor.get(ICommandService); - const extensionWorkbenchService = accessor.get(IExtensionsWorkbenchService); + const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService); const editorService = accessor.get(IEditorService); + const mcpWorkbenchService = accessor.get(IMcpWorkbenchService); const toolsService = accessor.get(ILanguageModelToolsService); const mcpServerByTool = new Map(); @@ -89,7 +90,7 @@ export async function showToolsPicker( const addMcpPick: CallbackPick = { type: 'item', label: localize('addServer', "Add MCP Server..."), iconClass: ThemeIcon.asClassName(Codicon.add), pickable: false, run: () => commandService.executeCommand(McpCommandIds.AddConfiguration) }; const configureToolSetsPick: CallbackPick = { type: 'item', label: localize('configToolSet', "Configure Tool Sets..."), iconClass: ThemeIcon.asClassName(Codicon.gear), pickable: false, run: () => commandService.executeCommand(ConfigureToolSets.ID) }; - const addExpPick: CallbackPick = { type: 'item', label: localize('addExtension', "Install Extension..."), iconClass: ThemeIcon.asClassName(Codicon.add), pickable: false, run: () => extensionWorkbenchService.openSearch('@tag:language-model-tools') }; + const addExpPick: CallbackPick = { type: 'item', label: localize('addExtension', "Install Extension..."), iconClass: ThemeIcon.asClassName(Codicon.add), pickable: false, run: () => extensionsWorkbenchService.openSearch('@tag:language-model-tools') }; const addPick: CallbackPick = { type: 'item', label: localize('addAny', "Add More Tools..."), iconClass: ThemeIcon.asClassName(Codicon.add), pickable: false, run: async () => { const pick = await quickPickService.pick( @@ -144,7 +145,13 @@ export async function showToolsPicker( toolBuckets.set(key, bucket); const collection = mcpRegistry.collections.get().find(c => c.id === mcpServer.collection.id); - if (collection?.presentation?.origin) { + if (collection?.source) { + buttons.push({ + iconClass: ThemeIcon.asClassName(Codicon.settingsGear), + tooltip: localize('configMcpCol', "Configure {0}", collection.label), + action: () => collection.source ? collection.source instanceof ExtensionIdentifier ? extensionsWorkbenchService.open(collection.source.value, { tab: ExtensionEditorTab.Features, feature: 'mcp' }) : mcpWorkbenchService.open(collection.source, { tab: McpServerEditorTab.Configuration }) : undefined + }); + } else if (collection?.presentation?.origin) { buttons.push({ iconClass: ThemeIcon.asClassName(Codicon.settingsGear), tooltip: localize('configMcpCol', "Configure {0}", collection.label), @@ -391,10 +398,11 @@ export async function showToolsPicker( if (item.source.type === 'mcp') { mcpToolSets.add(item); - if (Iterable.every(item.getTools(), tool => result.get(tool))) { + const toolsInSet = Array.from(item.getTools()); + if (toolsInSet.length && toolsInSet.every(tool => result.get(tool))) { // ALL tools from the MCP tool set are here, replace them with just the toolset // but only when computing the final result - for (const tool of item.getTools()) { + for (const tool of toolsInSet) { result.delete(tool); } result.set(item, true); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts index 1694d4242bc..b9829ffa9a0 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { basename, dirname } from '../../../../../base/common/resources.js'; @@ -17,6 +19,7 @@ import { IModelService } from '../../../../../editor/common/services/model.js'; import { localize } from '../../../../../nls.js'; import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { FileKind, IFileService } from '../../../../../platform/files/common/files.js'; @@ -25,6 +28,8 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ResourceLabels } from '../../../../browser/labels.js'; import { ResourceContextKey } from '../../../../common/contextkeys.js'; import { IChatRequestImplicitVariableEntry } from '../../common/chatVariableEntries.js'; +import { IChatWidgetService } from '../chat.js'; +import { ChatAttachmentModel } from '../chatAttachmentModel.js'; export class ImplicitContextAttachmentWidget extends Disposable { public readonly domNode: HTMLElement; @@ -34,6 +39,7 @@ export class ImplicitContextAttachmentWidget extends Disposable { constructor( private readonly attachment: IChatRequestImplicitVariableEntry, private readonly resourceLabels: ResourceLabels, + private readonly attachmentModel: ChatAttachmentModel, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @ILabelService private readonly labelService: ILabelService, @@ -42,6 +48,8 @@ export class ImplicitContextAttachmentWidget extends Disposable { @ILanguageService private readonly languageService: ILanguageService, @IModelService private readonly modelService: IModelService, @IHoverService private readonly hoverService: IHoverService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IConfigurationService private readonly configService: IConfigurationService ) { super(); @@ -80,17 +88,56 @@ export class ImplicitContextAttachmentWidget extends Disposable { this.domNode.ariaLabel = ariaLabel; this.domNode.tabIndex = 0; - const hintLabel = localize('hint.label.current', "Current {0}", attachmentTypeName); + const isSuggestedEnabled = this.configService.getValue('chat.implicitContext.suggestedContext'); + const hintLabel = !this.attachment.isSelection && !isSuggestedEnabled ? 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 {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) => { - e.stopPropagation(); // prevent it from triggering the click handler on the parent immediately after rerendering - this.attachment.enabled = !this.attachment.enabled; - })); + + if (isSuggestedEnabled) { + if (!this.attachment.isSelection) { + 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.x : Codicon.plus; + this.renderDisposables.add(toggleButton.onDidClick((e) => { + e.stopPropagation(); + e.preventDefault(); + if (!this.attachment.enabled) { + this.convertToRegularAttachment(); + } + this.attachment.enabled = false; + })); + } + + if (!this.attachment.enabled && this.attachment.isSelection) { + this.domNode.classList.remove('disabled'); + } + + this.renderDisposables.add(dom.addDisposableListener(this.domNode, dom.EventType.CLICK, e => { + if (!this.attachment.enabled && !this.attachment.isSelection) { + this.convertToRegularAttachment(); + } + })); + + this.renderDisposables.add(dom.addDisposableListener(this.domNode, dom.EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { + if (!this.attachment.enabled && !this.attachment.isSelection) { + e.preventDefault(); + e.stopPropagation(); + this.convertToRegularAttachment(); + } + } + })); + } else { + 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) => { + e.stopPropagation(); // prevent it from triggering the click handler on the parent immediately after rerendering + this.attachment.enabled = !this.attachment.enabled; + })); + } // Context menu const scopedContextKeyService = this.renderDisposables.add(this.contextKeyService.createScoped(this.domNode)); @@ -112,4 +159,13 @@ export class ImplicitContextAttachmentWidget extends Disposable { }); })); } + + private convertToRegularAttachment(): void { + if (!this.attachment.value) { + return; + } + const file = URI.isUri(this.attachment.value) ? this.attachment.value : this.attachment.value.uri; + this.attachmentModel.addFile(file); + this.chatWidgetService.lastFocusedWidget?.focusInput(); + } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 41a4e58260e..f81e2548152 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -170,6 +170,12 @@ configurationRegistry.registerConfiguration({ 'panel': 'always', } }, + 'chat.implicitContext.suggestedContext': { + type: 'boolean', + tags: ['experimental'], + markdownDescription: nls.localize('chat.implicitContext.suggestedContext', "Controls whether the new implicit context flow is shown. In Ask and Edit modes, the context will automatically be included. In Agent mode context will be suggested as an attachment. Selections are always included as context."), + default: true, + }, 'chat.editing.autoAcceptDelay': { type: 'number', markdownDescription: nls.localize('chat.editing.autoAcceptDelay', "Delay after which changes made by chat are automatically accepted. Values are in seconds, `0` means disabled and `100` seconds is the maximum."), @@ -213,7 +219,9 @@ configurationRegistry.registerConfiguration({ }, 'chat.tools.autoApprove': { default: false, - description: nls.localize('chat.tools.autoApprove', "Controls whether tool use should be automatically approved."), + // Description is added in for policy parser. See https://github.com/microsoft/vscode/issues/254526 + description: nls.localize('chat.tools.autoApprove.description', "Controls whether tool use should be automatically approved. Allow all tools to run automatically without user confirmation, overriding any tool-specific settings such as terminal auto-approval. Use with caution: carefully review selected tools and be extra wary of possible sources of prompt injection!"), + markdownDescription: nls.localize('chat.tools.autoApprove.markdownDescription', "Controls whether tool use should be automatically approved.\n\nAllows _all_ tools to run automatically without user confirmation, overriding any tool-specific settings such as terminal auto-approval.\n\nUse with caution: carefully review selected tools and be extra wary of possible sources of prompt injection!"), type: 'boolean', tags: ['experimental'], policy: { @@ -254,6 +262,12 @@ configurationRegistry.registerConfiguration({ default: 'inline', tags: ['experimental', 'onExp'], }, + 'chat.emptyChatState.enabled': { + type: 'boolean', + default: true, + description: nls.localize('chat.emptyChatState', "Shows a modified empty chat state with hints in the input placeholder text."), + tags: ['experimental', 'onExp'], + }, [mcpEnabledSection]: { type: 'boolean', description: nls.localize('chat.mcp.enabled', "Enables integration with Model Context Protocol servers to provide additional tools and functionality."), @@ -324,6 +338,12 @@ configurationRegistry.registerConfiguration({ defaultValue: false } }, + [ChatConfiguration.EnableMath]: { + type: 'boolean', + description: nls.localize('chat.mathEnabled.description', "Enable math rendering in chat responses using Katex."), + default: false, + tags: ['preview'], + }, [mcpDiscoverySection]: { oneOf: [ { type: 'boolean' }, @@ -337,7 +357,7 @@ configurationRegistry.registerConfiguration({ } ], 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."), + markdownDescription: nls.localize('mcp.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."), }, [mcpGalleryServiceUrlConfig]: { type: 'string', @@ -464,7 +484,7 @@ configurationRegistry.registerConfiguration({ }, 'chat.setup.signInDialogVariant': { // TODO@bpasero remove me eventually type: 'string', - enum: ['default', 'alternate-first', 'alternate-color', 'alternate-monochrome'], + enum: ['default', 'apple'], description: nls.localize('chat.signInDialogVariant', "Control variations of the sign-in dialog."), default: 'default', tags: ['onExp', 'experimental'] diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 25e0792faeb..ccdd8ffcc65 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -17,6 +17,7 @@ import { IChatAgentCommand, IChatAgentData } from '../common/chatAgents.js'; import { IChatResponseModel } from '../common/chatModel.js'; import { IParsedChatRequest } from '../common/chatParserTypes.js'; import { CHAT_PROVIDER_ID } from '../common/chatParticipantContribTypes.js'; +import { IChatSendRequestOptions } from '../common/chatService.js'; import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel } from '../common/chatViewModel.js'; import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; import { ChatAttachmentModel } from './chatAttachmentModel.js'; @@ -208,7 +209,7 @@ export interface IChatWidget { focusLastMessage(): void; focusInput(): void; hasInputFocus(): boolean; - getUserSelectedTools(): Record | undefined; + getModeRequestOptions(): Partial; getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined; getCodeBlockInfosForResponse(response: IChatResponseViewModel): IChatCodeBlockInfo[]; getFileTreeInfosForResponse(response: IChatResponseViewModel): IChatFileTreeInfo[]; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts index e03563b06c1..b2f292e7614 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts @@ -60,12 +60,12 @@ export class ChatAttachmentsContentPart extends Disposable { attachment.omittedState = isAttachmentPartialOrOmitted ? OmittedState.Full : attachment.omittedState; widget = this.instantiationService.createInstance(ImageAttachmentWidget, resource, attachment, undefined, { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels, hoverDelegate); } else if (isPromptFileVariableEntry(attachment)) { - if (attachment.isHidden) { + if (attachment.automaticallyAdded) { continue; } widget = this.instantiationService.createInstance(PromptFileAttachmentWidget, attachment, undefined, { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels, hoverDelegate); } else if (isPromptTextVariableEntry(attachment)) { - if (attachment.isHidden) { + if (attachment.automaticallyAdded) { continue; } widget = this.instantiationService.createInstance(PromptTextAttachmentWidget, attachment, undefined, { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels, hoverDelegate); diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts index 249d3343bcc..3a215c401c6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationContentPart.ts @@ -56,7 +56,7 @@ export class ChatConfirmationContentPart extends Disposable implements IChatCont const widget = chatWidgetService.getWidgetBySessionId(element.sessionId); options.userSelectedModelId = widget?.input.currentLanguageModel; options.mode = widget?.input.currentModeKind; - options.userSelectedTools = widget?.getUserSelectedTools(); + Object.assign(options, widget?.getModeRequestOptions()); if (await this.chatService.sendRequest(element.sessionId, prompt, options)) { confirmation.isUsed = true; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatErrorConfirmationPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatErrorConfirmationPart.ts index 654d2256b53..1d4f9d18389 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatErrorConfirmationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatErrorConfirmationPart.ts @@ -61,8 +61,7 @@ export class ChatErrorConfirmationContentPart extends Disposable implements ICha options.confirmation = buttonData.label; const widget = chatWidgetService.getWidgetBySessionId(element.sessionId); options.userSelectedModelId = widget?.input.currentLanguageModel; - options.userSelectedTools = widget?.getUserSelectedTools(); - options.mode = widget?.input.currentModeKind; + Object.assign(options, widget?.getModeRequestOptions()); if (await chatService.sendRequest(element.sessionId, prompt, options)) { this._onDidChangeHeight.fire(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts index d784f6341e5..ebae5f2da05 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { MarkedOptions } from '../../../../../base/browser/markdownRenderer.js'; import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { coalesce } from '../../../../../base/common/arrays.js'; import { findLast } from '../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; +import { Lazy } from '../../../../../base/common/lazy.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../base/common/observable.js'; import { equalsIgnoreCase } from '../../../../../base/common/strings.js'; @@ -24,6 +27,7 @@ import { ITextModelService } from '../../../../../editor/common/services/resolve import { localize } from '../../../../../nls.js'; import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../../platform/files/common/files.js'; @@ -37,6 +41,7 @@ import { IChatProgressRenderableResponseContent } from '../../common/chatModel.j import { IChatMarkdownContent, IChatService, IChatUndoStop } from '../../common/chatService.js'; import { isRequestVM, isResponseVM } from '../../common/chatViewModel.js'; import { CodeBlockEntry, CodeBlockModelCollection } from '../../common/codeBlockModelCollection.js'; +import { ChatConfiguration } from '../../common/constants.js'; import { IChatCodeBlockInfo } from '../chat.js'; import { IChatRendererDelegate } from '../chatListRenderer.js'; import { ChatMarkdownDecorationsRenderer } from '../chatMarkdownDecorationsRenderer.js'; @@ -46,6 +51,8 @@ import '../media/chatCodeBlockPill.css'; import { IDisposableReference, ResourcePool } from './chatCollections.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatExtensionsContentPart } from './chatExtensionsContentPart.js'; +import { MarkedKatexSupport } from './markedKatexSupport.js'; +import './media/chatMarkdownPart.css'; const $ = dom.$; @@ -55,6 +62,68 @@ export interface IChatMarkdownContentPartOptions { export class ChatMarkdownContentPart extends Disposable implements IChatContentPart { private static idPool = 0; + + private static tempSanitizerRule = new Lazy(() => { + // Create a CSSStyleDeclaration object via a style sheet rule + const styleSheet = new CSSStyleSheet(); + styleSheet.insertRule(`.temp{}`); + const rule = styleSheet.cssRules[0]; + if (!(rule instanceof CSSStyleRule)) { + throw new Error('Invalid CSS rule'); + } + return rule.style; + }); + + private static sanitizeStyles(styleString: string, allowedProperties: readonly string[]): string { + const style = this.tempSanitizerRule.value; + style.cssText = styleString; + + const sanitizedProps = []; + + for (let i = 0; i < style.length; i++) { + const prop = style[i]; + if (allowedProperties.includes(prop)) { + const value = style.getPropertyValue(prop); + // Allow through lists of numbers with units or bare words like 'block' + // Main goal is to block things like 'url()'. + if (/^(([\d\.\-]+\w*\s?)+|\w+)$/.test(value)) { + sanitizedProps.push(`${prop}: ${value}`); + } + } + } + + return sanitizedProps.join('; '); + } + + private static sanitizeKatexStyles(styleString: string): string { + const allowedProperties = [ + 'display', + 'position', + 'font-family', + 'font-style', + 'font-weight', + 'font-size', + 'height', + 'width', + 'margin', + 'padding', + 'top', + 'left', + 'right', + 'bottom', + 'vertical-align', + 'transform', + 'border', + 'color', + 'white-space', + 'text-align', + 'line-height', + 'float', + 'clear', + ]; + return this.sanitizeStyles(styleString, allowedProperties); + } + public readonly codeblocksPartId = String(++ChatMarkdownContentPart.idPool); public readonly domNode: HTMLElement; private readonly allRefs: IDisposableReference[] = []; @@ -75,6 +144,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP private readonly codeBlockModelCollection: CodeBlockModelCollection, private readonly rendererOptions: IChatMarkdownContentPartOptions, @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService configurationService: IConfigurationService, @ITextModelService private readonly textModelService: ITextModelService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { @@ -91,128 +161,169 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP let globalCodeBlockIndexStart = codeBlockStartIndex; let thisPartCodeBlockIndexStart = 0; - // Don't set to 'false' for responses, respect defaults - const markedOpts = isRequestVM(element) ? { - gfm: true, - breaks: true, - } : undefined; + this.domNode = document.createElement('div'); + this.domNode.classList.add('chat-markdown-part'); - 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(' 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 codeblockEntry: CodeBlockEntry | undefined; - if (equalsIgnoreCase(languageId, localFileLanguageId)) { - try { - const parsedBody = parseLocalFileData(text); - range = parsedBody.range && Range.lift(parsedBody.range); - textModel = this.textModelService.createModelReference(parsedBody.uri).then(ref => ref.object.textEditorModel); - } catch (e) { - return $('div'); + const enableMath = configurationService.getValue(ChatConfiguration.EnableMath); + + const doRenderMarkdown = () => { + const markedExtensions = enableMath + ? coalesce([MarkedKatexSupport.getExtension(dom.getWindow(context.container), { + throwOnError: false + })]) + : []; + + // Don't set to 'false' for responses, respect defaults + const markedOpts: MarkedOptions = isRequestVM(element) ? { + gfm: true, + breaks: true, + markedExtensions, + } : { + markedExtensions, + }; + + const result = this._register(renderer.render(markdown.content, { + sanitizerOptions: { + allowedTags: [ + ...dom.basicMarkupHtmlTags, + ...dom.trustedMathMlTags, + ], + customAttrSanitizer: (attrName, attrValue) => { + if (attrName === 'class') { + return true; // TODO: allows all classes for now since we don't have a list of possible katex classes + } else if (attrName === 'style') { + return ChatMarkdownContentPart.sanitizeKatexStyles(attrValue); + } + + return false; + }, + }, + fillInIncompleteTokens, + codeBlockRendererSync: (languageId, text, raw) => { + const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || codeblockHasClosingBackticks(raw); + if ((!text || (text.startsWith(' this._onDidChangeHeight.fire())); - - const ownerMarkdownPartId = this.codeblocksPartId; - const info: IChatCodeBlockInfo = new class implements IChatCodeBlockInfo { - readonly ownerMarkdownPartId = ownerMarkdownPartId; - readonly codeBlockIndex = globalIndex; - readonly elementId = element.id; - 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 - // async and the uri might be undefined when it's read immediately - return ref.object.uri; - } - readonly uriPromise = textModel.then(model => model.uri); - public focus() { - ref.object.focus(); - } - }(); - this.codeblocks.push(info); - orderedDisposablesList.push(ref); - return ref.object.element; - } else { - const requestId = isRequestVM(element) ? element.id : element.requestId; - const ref = this.renderCodeBlockPill(element.sessionId, requestId, inUndoStop, codeBlockInfo.codemapperUri, !isCodeBlockComplete); - if (isResponseVM(codeBlockInfo.element)) { - // TODO@joyceerhl: remove this code when we change the codeblockUri API to make the URI available synchronously - this.codeBlockModelCollection.update(codeBlockInfo.element.sessionId, codeBlockInfo.element, codeBlockInfo.codeBlockIndex, { text, languageId: codeBlockInfo.languageId, isComplete: isCodeBlockComplete }).then((e) => { - // Update the existing object's codemapperUri - this.codeblocks[codeBlockInfo.codeBlockPartIndex].codemapperUri = e.codemapperUri; - this._onDidChangeHeight.fire(); - }); + if (languageId === 'vscode-extensions') { + const chatExtensions = this._register(instantiationService.createInstance(ChatExtensionsContentPart, { kind: 'extensions', extensions: text.split(',') })); + this._register(chatExtensions.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + return chatExtensions.domNode; } - this.allRefs.push(ref); - const ownerMarkdownPartId = this.codeblocksPartId; - const info: IChatCodeBlockInfo = new class implements IChatCodeBlockInfo { - readonly ownerMarkdownPartId = ownerMarkdownPartId; - readonly codeBlockIndex = globalIndex; - readonly elementId = element.id; - readonly isStreaming = !isCodeBlockComplete; - readonly codemapperUri = codeblockEntry?.codemapperUri; - readonly chatSessionId = element.sessionId; - public get uri() { - return undefined; + const globalIndex = globalCodeBlockIndexStart++; + const thisPartIndex = thisPartCodeBlockIndexStart++; + let textModel: Promise; + let range: Range | undefined; + let vulns: readonly IMarkdownVulnerability[] | undefined; + let codeblockEntry: CodeBlockEntry | undefined; + if (equalsIgnoreCase(languageId, localFileLanguageId)) { + try { + const parsedBody = parseLocalFileData(text); + range = parsedBody.range && Range.lift(parsedBody.range); + textModel = this.textModelService.createModelReference(parsedBody.uri).then(ref => ref.object.textEditorModel); + } catch (e) { + return $('div'); } - readonly uriPromise = Promise.resolve(undefined); - public focus() { - return ref.object.element.focus(); + } else { + const sessionId = isResponseVM(element) || isRequestVM(element) ? element.sessionId : ''; + const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, globalIndex); + const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, globalIndex, { text, languageId, isComplete: isCodeBlockComplete }); + vulns = modelEntry.vulns; + codeblockEntry = fastUpdateModelEntry; + textModel = modelEntry.model; + } + + const hideToolbar = isResponseVM(element) && element.errorDetails?.responseIsFiltered; + const renderOptions = { + ...this.rendererOptions.codeBlockRenderOptions, + }; + if (hideToolbar !== undefined) { + renderOptions.hideToolbar = hideToolbar; + } + const codeBlockInfo: ICodeBlockData = { languageId, textModel, codeBlockIndex: globalIndex, codeBlockPartIndex: thisPartIndex, element, range, parentContextKeyService: contextKeyService, vulns, codemapperUri: codeblockEntry?.codemapperUri, renderOptions, chatSessionId: element.sessionId }; + + if (element.isCompleteAddedRequest || !codeblockEntry?.codemapperUri || !codeblockEntry.isEdit) { + const ref = this.renderCodeBlock(codeBlockInfo, text, isCodeBlockComplete, currentWidth); + this.allRefs.push(ref); + + // Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping) + // not during a renderElement OR a progressive render (when we will be firing this event anyway at the end of the render) + this._register(ref.object.onDidChangeContentHeight(() => this._onDidChangeHeight.fire())); + + const ownerMarkdownPartId = this.codeblocksPartId; + const info: IChatCodeBlockInfo = new class implements IChatCodeBlockInfo { + readonly ownerMarkdownPartId = ownerMarkdownPartId; + readonly codeBlockIndex = globalIndex; + readonly elementId = element.id; + 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 + // async and the uri might be undefined when it's read immediately + return ref.object.uri; + } + readonly uriPromise = textModel.then(model => model.uri); + public focus() { + ref.object.focus(); + } + }(); + this.codeblocks.push(info); + orderedDisposablesList.push(ref); + return ref.object.element; + } else { + const requestId = isRequestVM(element) ? element.id : element.requestId; + const ref = this.renderCodeBlockPill(element.sessionId, requestId, inUndoStop, codeBlockInfo.codemapperUri, !isCodeBlockComplete); + if (isResponseVM(codeBlockInfo.element)) { + // TODO@joyceerhl: remove this code when we change the codeblockUri API to make the URI available synchronously + this.codeBlockModelCollection.update(codeBlockInfo.element.sessionId, codeBlockInfo.element, codeBlockInfo.codeBlockIndex, { text, languageId: codeBlockInfo.languageId, isComplete: isCodeBlockComplete }).then((e) => { + // Update the existing object's codemapperUri + this.codeblocks[codeBlockInfo.codeBlockPartIndex].codemapperUri = e.codemapperUri; + this._onDidChangeHeight.fire(); + }); } - }(); - this.codeblocks.push(info); - orderedDisposablesList.push(ref); - return ref.object.element; - } - }, - asyncRenderCallback: () => this._onDidChangeHeight.fire(), - }, markedOpts)); + this.allRefs.push(ref); + const ownerMarkdownPartId = this.codeblocksPartId; + const info: IChatCodeBlockInfo = new class implements IChatCodeBlockInfo { + readonly ownerMarkdownPartId = ownerMarkdownPartId; + readonly codeBlockIndex = globalIndex; + readonly elementId = element.id; + readonly isStreaming = !isCodeBlockComplete; + readonly codemapperUri = codeblockEntry?.codemapperUri; + readonly chatSessionId = element.sessionId; + public get uri() { + return undefined; + } + readonly uriPromise = Promise.resolve(undefined); + public focus() { + return ref.object.element.focus(); + } + }(); + this.codeblocks.push(info); + orderedDisposablesList.push(ref); + return ref.object.element; + } + }, + asyncRenderCallback: () => this._onDidChangeHeight.fire(), + }, markedOpts)); - const markdownDecorationsRenderer = instantiationService.createInstance(ChatMarkdownDecorationsRenderer); - this._register(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(markdown, result.element)); + const markdownDecorationsRenderer = instantiationService.createInstance(ChatMarkdownDecorationsRenderer); + this._register(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(markdown, result.element)); - orderedDisposablesList.reverse().forEach(d => this._register(d)); - this.domNode = result.element; + orderedDisposablesList.reverse().forEach(d => this._register(d)); + + this.domNode.replaceChildren(...result.element.children); + }; + + if (enableMath && !MarkedKatexSupport.getExtension(dom.getWindow(context.container))) { + // Need to load async + MarkedKatexSupport.loadExtension(dom.getWindow(context.container)).then(() => { + doRenderMarkdown(); + }); + } else { + doRenderMarkdown(); + } } private renderCodeBlockPill(sessionId: string, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined, isStreaming: boolean): IDisposableReference { @@ -306,7 +417,7 @@ function codeblockHasClosingBackticks(str: string): boolean { return !!str.match(/\n```+$/); } -class CollapsedCodeBlock extends Disposable { +export class CollapsedCodeBlock extends Disposable { public readonly element: HTMLElement; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/markedKatexSupport.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/markedKatexSupport.ts new file mode 100644 index 00000000000..6d35651a9f5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/markedKatexSupport.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 { importAMDNodeModule, resolveAmdNodeModulePath } from '../../../../../amdX.js'; +import { CodeWindow } from '../../../../../base/browser/window.js'; +import { Lazy } from '../../../../../base/common/lazy.js'; +import type * as marked from '../../../../../base/common/marked/marked.js'; + +export class MarkedKatexSupport { + + private static _katex?: typeof import('katex').default; + private static _katexPromise = new Lazy(async () => { + this._katex = await importAMDNodeModule('katex', 'dist/katex.min.js'); + return this._katex; + }); + + public static getExtension(window: CodeWindow, options: MarkedKatexExtension.MarkedKatexOptions = {}): marked.MarkedExtension | undefined { + if (!this._katex) { + return undefined; + } + + this.ensureKatexStyles(window); + return MarkedKatexExtension.extension(this._katex, options); + } + + public static async loadExtension(window: CodeWindow, options: MarkedKatexExtension.MarkedKatexOptions = {}): Promise { + const katex = await this._katexPromise.value; + this.ensureKatexStyles(window); + return MarkedKatexExtension.extension(katex, options); + } + + public static ensureKatexStyles(window: CodeWindow) { + const doc = window.document; + if (!doc.querySelector('link.katex')) { + const katexStyle = document.createElement('link'); + katexStyle.classList.add('katex'); + katexStyle.rel = 'stylesheet'; + katexStyle.href = resolveAmdNodeModulePath('katex', 'dist/katex.min.css'); + doc.head.appendChild(katexStyle); + } + } +} + + +namespace MarkedKatexExtension { + type KatexOptions = import('katex').KatexOptions; + + // From https://github.com/UziTech/marked-katex-extension/blob/main/src/index.js + export interface MarkedKatexOptions extends KatexOptions { + /** + * If true, the extension will try to parse $ and $$ even if there are no spaces before and after $ or $$. + * This is non-standard behavior and may not work with all markdown parsers. + */ + nonStandard?: boolean; + } + + const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1(?=[\s?!\.,:'\uff1f\uff01\u3002\uff0c\uff1a']|$)/; + const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1/; // Non-standard, even if there are no spaces before and after $ or $$, try to parse + + const blockRule = /^(\${1,2})\n((?:\\[^]|[^\\])+?)\n\1(?:\n|$)/; + + export function extension(katex: typeof import('katex').default, options: MarkedKatexOptions = {}): marked.MarkedExtension { + return { + extensions: [ + inlineKatex(options, createRenderer(katex, options, false)), + blockKatex(options, createRenderer(katex, options, true)), + ], + }; + } + + function createRenderer(katex: typeof import('katex').default, options: MarkedKatexOptions, newlineAfter: boolean): marked.RendererExtensionFunction { + return (token: marked.Tokens.Generic) => { + return katex.renderToString(token.text, { + ...options, + displayMode: token.displayMode, + }) + (newlineAfter ? '\n' : ''); + }; + } + + function inlineKatex(options: MarkedKatexOptions, renderer: marked.RendererExtensionFunction): marked.TokenizerAndRendererExtension { + const nonStandard = options && options.nonStandard; + const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule; + return { + name: 'inlineKatex', + level: 'inline', + start(src: string) { + let index; + let indexSrc = src; + + while (indexSrc) { + index = indexSrc.indexOf('$'); + if (index === -1) { + return; + } + const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ' '; + if (f) { + const possibleKatex = indexSrc.substring(index); + + if (possibleKatex.match(ruleReg)) { + return index; + } + } + + indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ''); + } + return; + }, + tokenizer(src: string, tokens: marked.Token[]) { + const match = src.match(ruleReg); + if (match) { + return { + type: 'inlineKatex', + raw: match[0], + text: match[2].trim(), + displayMode: match[1].length === 2, + }; + } + return; + }, + renderer, + }; + } + + function blockKatex(options: MarkedKatexOptions, renderer: marked.RendererExtensionFunction): marked.TokenizerAndRendererExtension { + return { + name: 'blockKatex', + level: 'block', + tokenizer(src: string, tokens: marked.Token[]) { + const match = src.match(blockRule); + if (match) { + return { + type: 'blockKatex', + raw: match[0], + text: match[2].trim(), + displayMode: match[1].length === 2, + }; + } + return; + }, + renderer, + }; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatMarkdownPart.css b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatMarkdownPart.css new file mode 100644 index 00000000000..a36ae69bc02 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatMarkdownPart.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-markdown-part .katex-display { + overflow-x: scroll; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index da36386ccaa..e002e7999e6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -251,11 +251,15 @@ export class ToolConfirmationSubPart extends BaseChatToolInvocationSubPart { const messageSeeMoreObserver = this._register(new ElementSizeObserver(elements.message, undefined)); const updateSeeMoreDisplayed = () => { const show = messageSeeMoreObserver.getHeight() > SHOW_MORE_MESSAGE_HEIGHT_TRIGGER; - elements.messageContainer.classList.toggle('can-see-more', show); + if (elements.messageContainer.classList.contains('can-see-more') !== show) { + elements.messageContainer.classList.toggle('can-see-more', show); + this._onDidChangeHeight.fire(); + } }; this._register(dom.addDisposableListener(elements.showMore, 'click', () => { elements.messageContainer.classList.toggle('can-see-more', false); + this._onDidChangeHeight.fire(); messageSeeMoreObserver.dispose(); })); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index daa14ed166e..ec609baba4c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -326,7 +326,7 @@ registerAction2(class RemoveAction extends Action2 { id: MenuId.ChatMessageTitle, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.isRequest, ChatContextKeys.currentlyEditing.negate(), ContextKeyExpr.equals(`config.${ChatConfiguration.EditRequests}`, 'input').negate()) + when: ContextKeyExpr.and(ContextKeyExpr.equals(`config.${ChatConfiguration.EditRequests}`, 'input').negate()) } ] }); @@ -435,7 +435,7 @@ registerAction2(class EditAction extends Action2 { id: MenuId.ChatMessageTitle, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.isRequest, ChatContextKeys.currentlyEditing.negate(), ContextKeyExpr.or(ContextKeyExpr.equals(`config.${ChatConfiguration.EditRequests}`, 'hover'), ContextKeyExpr.equals(`config.${ChatConfiguration.EditRequests}`, 'input'))) + when: ContextKeyExpr.and(ContextKeyExpr.or(ContextKeyExpr.equals(`config.${ChatConfiguration.EditRequests}`, 'hover'), ContextKeyExpr.equals(`config.${ChatConfiguration.EditRequests}`, 'input'))) } ] }); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts index f8d12d6a5a4..b2f4fa3b9d4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts @@ -25,7 +25,6 @@ import { IDocumentDiff } from '../../../../../editor/common/diff/documentDiffPro import { DetailedLineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js'; import { IModelDeltaDecoration, ITextModel, MinimapPosition, OverviewRulerLane, TrackedRangeStickiness } from '../../../../../editor/common/model.js'; import { ModelDecorationOptions } from '../../../../../editor/common/model/textModel.js'; -import { InlineDecoration, InlineDecorationType } from '../../../../../editor/common/viewModel.js'; import { localize } from '../../../../../nls.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { MenuWorkbenchToolBar, HiddenItemStrategy } from '../../../../../platform/actions/browser/toolbar.js'; @@ -40,6 +39,7 @@ import { IModifiedFileEntry, IModifiedFileEntryChangeHunk, IModifiedFileEntryEdi import { isTextDiffEditorForEntry } from './chatEditing.js'; import { IEditorDecorationsCollection } from '../../../../../editor/common/editorCommon.js'; import { ChatAgentLocation } from '../../common/constants.js'; +import { InlineDecoration, InlineDecorationType } from '../../../../../editor/common/viewModel/inlineDecorations.js'; export interface IDocumentDiff2 extends IDocumentDiff { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts index ce1a59573c5..6afb9cce2b5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts @@ -275,10 +275,6 @@ class ChatEditorOverlayWidget extends Disposable { }); } - override get actionRunner(): IActionRunner { - return super.actionRunner; - } - protected override getTooltip(): string | undefined { const value = super.getTooltip(); if (!value) { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 2c68f6c901b..6151ec33707 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -3,31 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -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 { CancellationToken } from '../../../../../base/common/cancellation.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Iterable } from '../../../../../base/common/iterator.js'; -import { Disposable, DisposableStore, dispose } from '../../../../../base/common/lifecycle.js'; +import { Disposable, dispose } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; -import { autorun, derived, derivedOpts, IObservable, IReader, ITransaction, ObservablePromise, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { autorun, IObservable, IReader, ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { IBulkEditService } from '../../../../../editor/browser/services/bulkEditService.js'; import { TextEdit } from '../../../../../editor/common/languages.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { ITextModel } from '../../../../../editor/common/model.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 { 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 { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -35,17 +30,15 @@ import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEdito import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { INotebookService } from '../../../notebook/common/notebookService.js'; -import { ChatEditingSessionState, ChatEditKind, getMultiDiffSourceUri, IChatEditingSession, IEditSessionEntryDiff, IModifiedEntryTelemetryInfo, IModifiedFileEntry, ISnapshotEntry, IStreamingEdits, ModifiedFileEntryState } from '../../common/chatEditingService.js'; -import { IChatRequestDisablement, IChatResponseModel } from '../../common/chatModel.js'; +import { ChatEditingSessionState, ChatEditKind, getMultiDiffSourceUri, IChatEditingSession, IModifiedEntryTelemetryInfo, IModifiedFileEntry, ISnapshotEntry, IStreamingEdits, ModifiedFileEntryState } from '../../common/chatEditingService.js'; +import { IChatResponseModel } from '../../common/chatModel.js'; import { IChatService } from '../../common/chatService.js'; import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js'; import { ChatEditingSessionStorage, IChatEditingSessionSnapshot, IChatEditingSessionStop, StoredSessionState } from './chatEditingSessionStorage.js'; import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; -import { ChatEditingModifiedNotebookDiff } from './notebook/chatEditingModifiedNotebookDiff.js'; - -const POST_EDIT_STOP_ID = 'd19944f6-f46c-4e17-911b-79a8e843c7c0'; // randomly generated +import { ChatEditingTimeline } from './chatEditingTimeline.js'; class ThrottledSequencer extends Sequencer { @@ -81,19 +74,6 @@ class ThrottledSequencer extends Sequencer { } } -function getMaxHistoryIndex(history: readonly IChatEditingSessionSnapshot[]) { - const lastHistory = history.at(-1); - return lastHistory ? lastHistory.startIndex + lastHistory.stops.length : 0; -} - -function snapshotsEqualForDiff(a: ISnapshotEntry | undefined, b: ISnapshotEntry | undefined) { - if (!a || !b) { - return a === b; - } - - return isEqual(a.snapshotUri, b.snapshotUri) && a.current === b.current; -} - function getCurrentAndNextStop(requestId: string, stopId: string | undefined, history: readonly IChatEditingSessionSnapshot[]) { const snapshotIndex = history.findIndex(s => s.requestId === requestId); if (snapshotIndex === -1) { return undefined; } @@ -104,7 +84,7 @@ function getCurrentAndNextStop(requestId: string, stopId: string | undefined, hi const current = snapshot.stops[stopIndex].entries; const next = stopIndex < snapshot.stops.length - 1 ? snapshot.stops[stopIndex + 1].entries - : snapshot.postEdit || history[snapshotIndex + 1]?.stops[0].entries; + : history[snapshotIndex + 1]?.stops[0].entries; if (!next) { @@ -114,43 +94,9 @@ 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); - private readonly _linearHistory = observableValue(this, []); - private readonly _linearHistoryIndex = observableValue(this, 0); + private readonly _timeline: ChatEditingTimeline; /** * Contains the contents of a file when the AI first began doing edits to it. @@ -169,27 +115,8 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return this._state; } - public readonly canUndo = derived((r) => { - if (this.state.read(r) !== ChatEditingSessionState.Idle) { - return false; - } - const linearHistoryIndex = this._linearHistoryIndex.read(r); - return linearHistoryIndex > 0; - }); - - public readonly canRedo = derived((r) => { - if (this.state.read(r) !== ChatEditingSessionState.Idle) { - return false; - } - const linearHistoryIndex = this._linearHistoryIndex.read(r); - return linearHistoryIndex < getMaxHistoryIndex(this._linearHistory.read(r)); - }); - - // public hiddenRequestIds = derived((r) => { - // const linearHistory = this._linearHistory.read(r); - // const linearHistoryIndex = this._linearHistoryIndex.read(r); - // return linearHistory.slice(linearHistoryIndex).map(s => s.requestId).filter((r): r is string => !!r); - // }); + public readonly canUndo: IObservable; + public readonly canRedo: IObservable; private readonly _onDidDispose = new Emitter(); get onDidDispose() { @@ -210,12 +137,19 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio @IEditorService private readonly _editorService: IEditorService, @IChatService private readonly _chatService: IChatService, @INotebookService private readonly _notebookService: INotebookService, - @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, - @IConfigurationService private readonly _configurationService: IConfigurationService, @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, ) { super(); - this._ignoreTrimWhitespaceObservable = observableConfigValue('diffEditor.ignoreTrimWhitespace', true, this._configurationService); + this._timeline = _instantiationService.createInstance(ChatEditingTimeline); + this.canRedo = this._timeline.canRedo.map((hasHistory, reader) => + hasHistory && this._state.read(reader) === ChatEditingSessionState.Idle); + this.canUndo = this._timeline.canUndo.map((hasHistory, reader) => + hasHistory && this._state.read(reader) === ChatEditingSessionState.Idle); + + this._register(autorun(reader => { + const disabled = this._timeline.requestDisablement.read(reader); + this._chatService.getSession(this.chatSessionId)?.setDisabledRequests(disabled); + })); } public async init(): Promise { @@ -224,11 +158,10 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio for (const [uri, content] of restoredSessionState.initialFileContents) { this._initialFileContents.set(uri, content); } - this._pendingSnapshot = restoredSessionState.pendingSnapshot; await this._restoreSnapshot(restoredSessionState.recentSnapshot, false); - transaction(async tx => { - this._linearHistory.set(restoredSessionState.linearHistory, tx); - this._linearHistoryIndex.set(restoredSessionState.linearHistoryIndex, tx); + transaction(tx => { + this._pendingSnapshot.set(restoredSessionState.pendingSnapshot, tx); + this._timeline.restoreFromState({ history: restoredSessionState.linearHistory, index: restoredSessionState.linearHistoryIndex }, tx); this._state.set(ChatEditingSessionState.Idle, tx); }); } else { @@ -259,222 +192,47 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio public storeState(): Promise { const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionId); + const timelineState = this._timeline.getStateForPersistence(); const state: StoredSessionState = { initialFileContents: this._initialFileContents, - pendingSnapshot: this._pendingSnapshot, + pendingSnapshot: this._pendingSnapshot.get(), recentSnapshot: this._createSnapshot(undefined, undefined), - linearHistoryIndex: this._linearHistoryIndex.get(), - linearHistory: this._linearHistory.get(), + linearHistoryIndex: timelineState.index, + linearHistory: timelineState.history, }; return storage.storeState(state); } - private _findSnapshot(requestId: string): IChatEditingSessionSnapshot | undefined { - return this._linearHistory.get().find(s => s.requestId === requestId); - } - - private _findEditStop(requestId: string, undoStop: string | undefined) { - const snapshot = this._findSnapshot(requestId); - if (!snapshot) { - return undefined; - } - const idx = snapshot.stops.findIndex(s => s.stopId === undoStop); - return idx === -1 ? undefined : { stop: snapshot.stops[idx], snapshot, historyIndex: snapshot.startIndex + idx }; - } - private _ensurePendingSnapshot() { - this._pendingSnapshot ??= this._createSnapshot(undefined, undefined); - } - - private _diffsBetweenStops = new Map>(); - private _fullDiffs = new Map>(); - - private readonly _ignoreTrimWhitespaceObservable: IObservable; - - /** - * Gets diff for text entries between stops. - * @param entriesContent Observable that observes either snapshot entry - * @param modelUrisObservable Observable that observes only the snapshot URIs. - */ - private _entryDiffBetweenTextStops( - entriesContent: IObservable<{ before: ISnapshotEntry; after: ISnapshotEntry } | undefined>, - modelUrisObservable: IObservable<[URI, URI] | undefined>, - ): IObservable | undefined> { - const modelRefsPromise = derived(this, (reader) => { - const modelUris = modelUrisObservable.read(reader); - if (!modelUris) { return undefined; } - - const store = reader.store.add(new DisposableStore()); - const promise = Promise.all(modelUris.map(u => this._textModelService.createModelReference(u))).then(refs => { - if (store.isDisposed) { - refs.forEach(r => r.dispose()); - } else { - refs.forEach(r => store.add(r)); - } - - return refs; - }); - - return new ObservablePromise(promise); - }); - - return derived((reader): ObservablePromise | undefined => { - const refs2 = modelRefsPromise.read(reader)?.promiseResult.read(reader); - const refs = refs2?.data; - if (!refs) { - return; - } - - const entries = entriesContent.read(reader); // trigger re-diffing when contents change - - if (entries?.before && ChatEditingModifiedNotebookEntry.canHandleSnapshot(entries.before)) { - const diffService = this._instantiationService.createInstance(ChatEditingModifiedNotebookDiff, entries.before, entries.after); - return new ObservablePromise(diffService.computeDiff()); - - } - const ignoreTrimWhitespace = this._ignoreTrimWhitespaceObservable.read(reader); - const promise = this._editorWorkerService.computeDiff( - refs[0].object.textEditorModel.uri, - refs[1].object.textEditorModel.uri, - { ignoreTrimWhitespace, computeMoves: false, maxComputationTimeMs: 3000 }, - 'advanced' - ).then((diff): IEditSessionEntryDiff => { - const entryDiff: IEditSessionEntryDiff = { - originalURI: refs[0].object.textEditorModel.uri, - modifiedURI: refs[1].object.textEditorModel.uri, - identical: !!diff?.identical, - quitEarly: !diff || diff.quitEarly, - added: 0, - removed: 0, - }; - if (diff) { - for (const change of diff.changes) { - entryDiff.removed += change.original.endLineNumberExclusive - change.original.startLineNumber; - entryDiff.added += change.modified.endLineNumberExclusive - change.modified.startLineNumber; - } - } - - return entryDiff; - }); - - return new ObservablePromise(promise); - }); - } - - 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 = 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); - if (!before || !after) { return undefined; } - return { before, after }; - }, - ); - - // Separate observable for model refs to avoid unnecessary disposal - const modelUrisObservable = derivedOpts<[URI, URI] | undefined>({ equalsFn: (a, b) => arraysEqual(a, b, isEqual) }, reader => { - const entriesValue = entries.read(reader); - if (!entriesValue) { return undefined; } - return [entriesValue.before.snapshotUri, entriesValue.after.snapshotUri]; - }); - - const diff = this._entryDiffBetweenTextStops(entries, modelUrisObservable); - - return derived(reader => { - return diff.read(reader)?.promiseResult.read(reader)?.data || undefined; - }); + const prev = this._pendingSnapshot.get(); + if (!prev) { + this._pendingSnapshot.set(this._createSnapshot(undefined, undefined), undefined); + } } 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; - } 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; - } + return this._timeline.getEntryDiffBetweenStops(uri, requestId, stopId); } 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[] = []; - for (const entry of this._linearHistory.get()) { - if (entry.startIndex >= linearHistoryPtr) { - // all further entries are being dropped - break; - } else if (linearHistoryPtr - entry.startIndex < entry.stops.length) { - newLinearHistory.push({ requestId: entry.requestId, stops: entry.stops.slice(0, linearHistoryPtr - entry.startIndex), startIndex: entry.startIndex, postEdit: undefined }); - } else { - newLinearHistory.push(entry); - } - } - - const lastEntry = newLinearHistory.at(-1); - if (requestId && lastEntry?.requestId === requestId) { - // mirror over the saved postEdit modifications - if (lastEntry.postEdit && undoStop) { - const rebaseUri = (uri: URI) => URI.parse(uri.toString().replaceAll(POST_EDIT_STOP_ID, undoStop)); - for (const [uri, prev] of lastEntry.postEdit.entries()) { - snapshot.entries.set(uri, { ...prev, snapshotUri: rebaseUri(prev.snapshotUri), resource: rebaseUri(prev.resource) }); - } - } - - newLinearHistory[newLinearHistory.length - 1] = { ...lastEntry, stops: [...lastEntry.stops, snapshot], postEdit: undefined }; - } else { - newLinearHistory.push({ requestId, startIndex: lastEntry ? lastEntry.startIndex + lastEntry.stops.length : 0, stops: [snapshot], postEdit: undefined }); - } - - transaction((tx) => { - const last = newLinearHistory[newLinearHistory.length - 1]; - this._linearHistory.set(newLinearHistory, tx); - this._linearHistoryIndex.set(last.startIndex + last.stops.length, tx); - }); + this._timeline.pushSnapshot( + requestId, + undoStop, + makeEmpty ? ChatEditingTimeline.createEmptySnapshot(undoStop) : this._createSnapshot(requestId, undoStop), + ); } - private _createEmptySnapshot(undoStop: string | undefined): IChatEditingSessionStop { - return { - stopId: undoStop, - entries: new ResourceMap(), - }; - } - - private _createSnapshot(requestId: string | undefined, undoStop: string | undefined): IChatEditingSessionStop { + private _createSnapshot(requestId: string | undefined, stopId: string | undefined): IChatEditingSessionStop { const entries = new ResourceMap(); for (const entry of this._entriesObs.get()) { - entries.set(entry.modifiedURI, entry.createSnapshot(requestId, undoStop)); + entries.set(entry.modifiedURI, entry.createSnapshot(requestId, stopId)); } - - return { - stopId: undoStop, - entries, - }; + return { stopId, entries }; } public getSnapshot(requestId: string, undoStop: string | undefined, snapshotUri: URI): ISnapshotEntry | undefined { - const entries = undoStop === POST_EDIT_STOP_ID - ? this._findSnapshot(requestId)?.postEdit - : this._findEditStop(requestId, undoStop)?.stop.entries; + const stopRef = this._timeline.getSnapshotForRestore(requestId, undoStop); + const entries = stopRef?.stop.entries; return entries && [...entries.values()].find((e) => isEqual(e.snapshotUri, snapshotUri)); } @@ -488,29 +246,32 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } public getSnapshotUri(requestId: string, uri: URI, stopId: string | undefined): URI | undefined { - const stops = getCurrentAndNextStop(requestId, stopId, this._linearHistory.get()); + // This should be encapsulated in the timeline, but for now, fallback to legacy logic if needed. + // TODO: Move this logic into a timeline method if required by the design. + const timelineState = this._timeline.getStateForPersistence(); + const stops = getCurrentAndNextStop(requestId, stopId, timelineState.history); return stops?.next.get(uri)?.snapshotUri; } /** * A snapshot representing the state of the working set before a new request has been sent */ - private _pendingSnapshot: IChatEditingSessionStop | undefined; + private _pendingSnapshot = observableValue(this, undefined); + public async restoreSnapshot(requestId: string | undefined, stopId: string | undefined): Promise { if (requestId !== undefined) { - const stopRef = this._findEditStop(requestId, stopId); + const stopRef = this._timeline.getSnapshotForRestore(requestId, stopId); if (stopRef) { this._ensurePendingSnapshot(); - this._linearHistoryIndex.set(stopRef.historyIndex, undefined); await this._restoreSnapshot(stopRef.stop); - this._updateRequestHiddenState(); + stopRef.apply(); } } else { - const pendingSnapshot = this._pendingSnapshot; + const pendingSnapshot = this._pendingSnapshot.get(); if (!pendingSnapshot) { return; // We don't have a pending snapshot that we can restore } - this._pendingSnapshot = undefined; + this._pendingSnapshot.set(undefined, undefined); await this._restoreSnapshot(pendingSnapshot, undefined); } } @@ -697,66 +458,28 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }; } - private _getHistoryEntryByLinearIndex(index: number) { - const history = this._linearHistory.get(); - const searchedIndex = binarySearch2(history.length, (e) => history[e].startIndex - index); - const entry = history[searchedIndex < 0 ? (~searchedIndex) - 1 : searchedIndex]; - if (!entry || index - entry.startIndex >= entry.stops.length) { - return undefined; - } - - return { - entry, - stop: entry.stops[index - entry.startIndex] - }; - } - async undoInteraction(): Promise { - const newIndex = this._linearHistoryIndex.get() - 1; - const previousSnapshot = this._getHistoryEntryByLinearIndex(newIndex); - if (!previousSnapshot) { + const undo = this._timeline.getUndoSnapshot(); + if (!undo) { return; } - this._ensurePendingSnapshot(); - await this._restoreSnapshot(previousSnapshot.stop); - this._linearHistoryIndex.set(newIndex, undefined); - this._updateRequestHiddenState(); + await this._restoreSnapshot(undo.stop); + undo.apply(); } async redoInteraction(): Promise { - const maxIndex = getMaxHistoryIndex(this._linearHistory.get()); - const newIndex = this._linearHistoryIndex.get() + 1; - if (newIndex > maxIndex) { - return; - } - - const nextSnapshot = newIndex === maxIndex ? this._pendingSnapshot : this._getHistoryEntryByLinearIndex(newIndex)?.stop; + const redo = this._timeline.getRedoSnapshot(); + const nextSnapshot = redo?.stop || this._pendingSnapshot.get(); if (!nextSnapshot) { return; } await this._restoreSnapshot(nextSnapshot); - this._linearHistoryIndex.set(newIndex, undefined); - this._updateRequestHiddenState(); - } - - - private _updateRequestHiddenState() { - const history = this._linearHistory.get(); - const index = this._linearHistoryIndex.get(); - - const undoRequests: IChatRequestDisablement[] = []; - for (const entry of history) { - if (!entry.requestId) { - // ignored - } else if (entry.startIndex >= index) { - undoRequests.push({ requestId: entry.requestId }); - } else if (entry.startIndex + entry.stops.length > index) { - undoRequests.push({ requestId: entry.requestId, afterUndoStop: entry.stops[index - entry.startIndex].stopId }); - } + if (redo) { + redo.apply(); + } else { + this._pendingSnapshot.set(undefined, undefined); } - - this._chatService.getSession(this.chatSessionId)?.setDisabledRequests(undoRequests); } private async _acceptStreamingEditsStart(responseModel: IChatResponseModel, undoStop: string | undefined, resource: URI) { @@ -764,74 +487,11 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio transaction((tx) => { this._state.set(ChatEditingSessionState.StreamingEdits, tx); entry.acceptStreamingEditsStart(responseModel, tx); - this.ensureEditInUndoStopMatches(responseModel.requestId, undoStop, entry, false, tx); + this._timeline.ensureEditInUndoStopMatches(responseModel.requestId, undoStop, entry, false, tx); }); } - /** - * Ensures the state of the file in the given snapshot matches the current - * state of the {@param entry}. This is used to handle concurrent file edits. - * - * Given the case of two different edits, we will place and undo stop right - * before we `textEditGroup` in the underlying markdown stream, but at the - * time those are added the edits haven't been made yet, so both files will - * simply have the unmodified state. - * - * This method is called after each edit, so after the first file finishes - * being edits, it will update its content in the second undo snapshot such - * that it can be undone successfully. - * - * We ensure that the same file is not concurrently edited via the - * {@link _streamingEditLocks}, avoiding race conditions. - * - * @param next If true, this will edit the snapshot _after_ the undo stop - */ - private ensureEditInUndoStopMatches(requestId: string, undoStop: string | undefined, entry: AbstractChatEditingModifiedFileEntry, next: boolean, tx: ITransaction | undefined) { - const history = this._linearHistory.get(); - const snapIndex = history.findIndex(s => s.requestId === requestId); - if (snapIndex === -1) { - return; - } - - const snap = history[snapIndex]; - let stopIndex = snap.stops.findIndex(s => s.stopId === undoStop); - if (stopIndex === -1) { - return; - } - - // 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._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(); - newHistory[snapIndex] = { ...snap, postEdit }; - this._linearHistory.set(newHistory, tx); - } - return; - } - stopIndex++; - } - - const stop = snap.stops[stopIndex]; - if (entry.equalsSnapshot(stop.entries.get(entry.modifiedURI))) { - return; - } - - const newMap = new ResourceMap(stop.entries); - newMap.set(entry.modifiedURI, entry.createSnapshot(requestId, stop.stopId)); - - const newStop = snap.stops.slice(); - newStop[stopIndex] = { ...stop, entries: newMap }; - - const newHistory = history.slice(); - newHistory[snapIndex] = { ...snap, stops: newStop }; - this._linearHistory.set(newHistory, tx); - } - 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); } @@ -848,7 +508,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } private async _resolve(requestId: string, undoStop: string | undefined, resource: URI): Promise { - const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => k !== resource.toString()); if (!hasOtherTasks) { this._state.set(ChatEditingSessionState.Idle, undefined); @@ -859,7 +518,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return; } - this.ensureEditInUndoStopMatches(requestId, undoStop, entry, /* next= */ true, undefined); + this._timeline.ensureEditInUndoStopMatches(requestId, undoStop, entry, /* next= */ true, undefined); return entry.acceptStreamingEditsEnd(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts index d755618c797..4a01ea62583 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { StringSHA1 } from '../../../../../base/common/hash.js'; +import { hashAsync } 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'; @@ -66,11 +66,11 @@ export class ChatEditingSessionStorage { if ('stops' in snapshot) { return snapshot; } - return { requestId: snapshot.requestId, stops: [{ stopId: undefined, entries: snapshot.entries }], postEdit: undefined }; + return { requestId: snapshot.requestId, stops: [{ stopId: undefined, entries: snapshot.entries }] }; }; 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) }; + return { startIndex, requestId: snapshot.requestId, stops }; }; const deserializeSnapshotEntry = async (entry: ISnapshotEntryDTO) => { return { @@ -147,36 +147,47 @@ export class ChatEditingSessionStorage { } } - 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 contentWritePromises = new Map>(); + + // saves a file content under a path containing a hash of the content. + // Returns the hash to represent the content. + const writeContent = async (content: string): Promise => { + const buffer = VSBuffer.fromString(content); + const hash = (await hashAsync(buffer)).substring(0, 7); + if (!existingContents.has(hash)) { + await this._fileService.writeFile(joinPath(contentsFolder, hash), buffer); + } + return hash; }; - const serializeResourceMap = (resourceMap: ResourceMap, serialize: (value: T) => any): ResourceMapDTO => { - return Array.from(resourceMap.entries()).map(([resourceURI, value]) => [resourceURI.toString(), serialize(value)]); + const addFileContent = async (content: string): Promise => { + let storedContentHash = contentWritePromises.get(content); + if (!storedContentHash) { + storedContentHash = writeContent(content); + contentWritePromises.set(content, storedContentHash); + } + return storedContentHash; }; - const serializeChatEditingSessionStop = (stop: IChatEditingSessionStop): IChatEditingSessionStopDTO => { + const serializeResourceMap = async (resourceMap: ResourceMap, serialize: (value: T) => Promise): Promise> => { + return await Promise.all(Array.from(resourceMap.entries()).map(async ([resourceURI, value]) => [resourceURI.toString(), await serialize(value)])); + }; + const serializeChatEditingSessionStop = async (stop: IChatEditingSessionStop): Promise => { return { stopId: stop.stopId, - entries: Array.from(stop.entries.values()).map(serializeSnapshotEntry) + entries: await Promise.all(Array.from(stop.entries.values()).map(serializeSnapshotEntry)) }; }; - const serializeChatEditingSessionSnapshot = (snapshot: IChatEditingSessionSnapshot): IChatEditingSessionSnapshotDTO2 => { + const serializeChatEditingSessionSnapshot = async (snapshot: IChatEditingSessionSnapshot): Promise => { return { requestId: snapshot.requestId, - stops: snapshot.stops.map(serializeChatEditingSessionStop), - postEdit: snapshot.postEdit ? Array.from(snapshot.postEdit.values()).map(serializeSnapshotEntry) : undefined + stops: await Promise.all(snapshot.stops.map(serializeChatEditingSessionStop)), }; }; - const serializeSnapshotEntry = (entry: ISnapshotEntry): ISnapshotEntryDTO => { + const serializeSnapshotEntry = async (entry: ISnapshotEntry): Promise => { return { resource: entry.resource.toString(), languageId: entry.languageId, - originalHash: addFileContent(entry.original), - currentHash: addFileContent(entry.current), + originalHash: await addFileContent(entry.original), + currentHash: await addFileContent(entry.current), state: entry.state, snapshotUri: entry.snapshotUri.toString(), telemetryInfo: { requestId: entry.telemetryInfo.requestId, agentId: entry.telemetryInfo.agentId, command: entry.telemetryInfo.command } @@ -187,20 +198,14 @@ export class ChatEditingSessionStorage { const data: IChatEditingSessionDTO = { version: STORAGE_VERSION, sessionId: this.chatSessionId, - linearHistory: state.linearHistory.map(serializeChatEditingSessionSnapshot), + linearHistory: await Promise.all(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), + initialFileContents: await serializeResourceMap(state.initialFileContents, value => addFileContent(value)), + pendingSnapshot: state.pendingSnapshot ? await serializeChatEditingSessionStop(state.pendingSnapshot) : undefined, + recentSnapshot: await 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)); - } - } + this._logService.debug(`chatEditingSession: Storing editing session at ${storageFolder.toString()}: ${contentWritePromises.size} files`); await this._fileService.writeFile(joinPath(storageFolder, STORAGE_STATE_FILE), VSBuffer.fromString(JSON.stringify(data))); } catch (e) { @@ -236,9 +241,6 @@ export interface IChatEditingSessionSnapshot { * 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 { @@ -263,7 +265,6 @@ interface IChatEditingSessionSnapshotDTO { interface IChatEditingSessionSnapshotDTO2 { readonly requestId: string | undefined; readonly stops: IChatEditingSessionStopDTO[]; - readonly postEdit: ISnapshotEntryDTO[] | undefined; } interface ISnapshotEntryDTO { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTimeline.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTimeline.ts new file mode 100644 index 00000000000..1d802d4ee77 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTimeline.ts @@ -0,0 +1,486 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + + +import { equals as arraysEqual, binarySearch2 } from '../../../../../base/common/arrays.js'; +import { equals as objectsEqual } from '../../../../../base/common/objects.js'; +import { findLast } from '../../../../../base/common/arraysFind.js'; +import { Iterable } from '../../../../../base/common/iterator.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { derived, derivedOpts, IObservable, ITransaction, ObservablePromise, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.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 { IEditSessionEntryDiff, ISnapshotEntry } from '../../common/chatEditingService.js'; +import { IChatRequestDisablement } from '../../common/chatModel.js'; +import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; +import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js'; +import { IChatEditingSessionSnapshot, IChatEditingSessionStop } from './chatEditingSessionStorage.js'; +import { ChatEditingModifiedNotebookDiff } from './notebook/chatEditingModifiedNotebookDiff.js'; + +/** + * Timeline/undo-redo stack for ChatEditingSession. + */ +export class ChatEditingTimeline { + public static readonly POST_EDIT_STOP_ID = 'd19944f6-f46c-4e17-911b-79a8e843c7c0'; // randomly generated + public static createEmptySnapshot(undoStop: string | undefined): IChatEditingSessionStop { + return { + stopId: undoStop, + entries: new ResourceMap(), + }; + } + + private readonly _linearHistory = observableValue(this, []); + private readonly _linearHistoryIndex = observableValue(this, 0); + + private readonly _diffsBetweenStops = new Map>(); + private readonly _fullDiffs = new Map>(); + private readonly _ignoreTrimWhitespaceObservable: IObservable; + + public readonly canUndo: IObservable; + public readonly canRedo: IObservable; + + public readonly requestDisablement = derivedOpts({ equalsFn: (a, b) => arraysEqual(a, b, objectsEqual) }, reader => { + const history = this._linearHistory.read(reader); + const index = this._linearHistoryIndex.read(reader); + const undoRequests: IChatRequestDisablement[] = []; + for (const entry of history) { + if (!entry.requestId) { + // ignored + } else if (entry.startIndex >= index) { + undoRequests.push({ requestId: entry.requestId }); + } else if (entry.startIndex + entry.stops.length > index) { + undoRequests.push({ requestId: entry.requestId, afterUndoStop: entry.stops[(index - 1) - entry.startIndex].stopId }); + } + } + return undoRequests; + }); + + constructor( + @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + @ITextModelService private readonly _textModelService: ITextModelService, + ) { + this._ignoreTrimWhitespaceObservable = observableConfigValue('diffEditor.ignoreTrimWhitespace', true, configurationService); + + this.canUndo = derived(r => { + const linearHistoryIndex = this._linearHistoryIndex.read(r); + return linearHistoryIndex > 1; + }); + this.canRedo = derived(r => { + const linearHistoryIndex = this._linearHistoryIndex.read(r); + return linearHistoryIndex < getMaxHistoryIndex(this._linearHistory.read(r)); + }); + } + + /** + * Restore the timeline from a saved state (history array and index). + */ + public restoreFromState(state: { history: readonly IChatEditingSessionSnapshot[]; index: number }, tx: ITransaction): void { + this._linearHistory.set(state.history, tx); + this._linearHistoryIndex.set(state.index, tx); + } + + /** + * Get the snapshot and history index for restoring, given requestId and stopId. + * If requestId is undefined, returns undefined (pending snapshot is managed by session). + */ + public getSnapshotForRestore(requestId: string | undefined, stopId: string | undefined): { stop: IChatEditingSessionStop; apply(): void } | undefined { + if (requestId === undefined) { + return undefined; + } + const stopRef = this.findEditStop(requestId, stopId); + if (!stopRef) { + return undefined; + } + + // When rolling back to the first snapshot taken for a request, mark the + // entire request as undone. + const toIndex = stopRef.stop.stopId === undefined ? stopRef.historyIndex : stopRef.historyIndex + 1; + return { + stop: stopRef.stop, + apply: () => this._linearHistoryIndex.set(toIndex, undefined) + }; + } + + /** + * Ensures the state of the file in the given snapshot matches the current + * state of the {@param entry}. This is used to handle concurrent file edits. + * + * Given the case of two different edits, we will place and undo stop right + * before we `textEditGroup` in the underlying markdown stream, but at the + * time those are added the edits haven't been made yet, so both files will + * simply have the unmodified state. + * + * This method is called after each edit, so after the first file finishes + * being edits, it will update its content in the second undo snapshot such + * that it can be undone successfully. + * + * We ensure that the same file is not concurrently edited via the + * {@link _streamingEditLocks}, avoiding race conditions. + * + * @param next If true, this will edit the snapshot _after_ the undo stop + */ + public ensureEditInUndoStopMatches( + requestId: string, + undoStop: string | undefined, + entry: Pick, + next: boolean, + tx: ITransaction | undefined + ) { + const history = this._linearHistory.get(); + const snapIndex = history.findIndex((s) => s.requestId === requestId); + if (snapIndex === -1) { + return; + } + + const snap = { ...history[snapIndex] }; + let stopIndex = snap.stops.findIndex((s) => s.stopId === undoStop); + if (stopIndex === -1) { + return; + } + + let linearHistoryIndexIncr = 0; + if (next) { + if (stopIndex === snap.stops.length - 1) { + if (snap.stops[stopIndex].stopId === ChatEditingTimeline.POST_EDIT_STOP_ID) { + throw new Error('cannot duplicate post-edit stop'); + } + + snap.stops = snap.stops.concat(ChatEditingTimeline.createEmptySnapshot(ChatEditingTimeline.POST_EDIT_STOP_ID)); + linearHistoryIndexIncr++; + } + stopIndex++; + } + + const stop = snap.stops[stopIndex]; + if (entry.equalsSnapshot(stop.entries.get(entry.modifiedURI))) { + return; + } + + const newMap = new ResourceMap(stop.entries); + newMap.set(entry.modifiedURI, entry.createSnapshot(requestId, stop.stopId)); + + const newStop = snap.stops.slice(); + newStop[stopIndex] = { ...stop, entries: newMap }; + snap.stops = newStop; + + const newHistory = history.slice(); + newHistory[snapIndex] = snap; + + this._linearHistory.set(newHistory, tx); + if (linearHistoryIndexIncr) { + this._linearHistoryIndex.set(this._linearHistoryIndex.get() + linearHistoryIndexIncr, tx); + } + } + + /** + * Get the undo snapshot (previous in history), or undefined if at start. + * If the timeline is at the end of the history, it will return the last stop + * pushed into the history. + */ + public getUndoSnapshot(): { stop: IChatEditingSessionStop; apply(): void } | undefined { + return this.getUndoRedoSnapshot(-1); + } + + /** + * Get the redo snapshot (next in history), or undefined if at end. + */ + public getRedoSnapshot(): { stop: IChatEditingSessionStop; apply(): void } | undefined { + return this.getUndoRedoSnapshot(1); + } + + private getUndoRedoSnapshot(direction: number) { + let idx = this._linearHistoryIndex.get() - 1; + const max = getMaxHistoryIndex(this._linearHistory.get()); + const startEntry = this.getHistoryEntryByLinearIndex(idx); + let entry = startEntry; + if (!startEntry) { + return undefined; + } + + do { + idx += direction; + entry = this.getHistoryEntryByLinearIndex(idx); + } while ( + idx + direction < max && + idx + direction >= 0 && + entry && + !(direction === -1 && entry.entry.requestId !== startEntry.entry.requestId) && + !stopProvidesNewData(startEntry.stop, entry.stop) + ); + + if (entry) { + return { stop: entry.stop, apply: () => this._linearHistoryIndex.set(idx + 1, undefined) }; + } + + return undefined; + } + + /** + * Get the state for persistence (history and index). + */ + public getStateForPersistence(): { history: readonly IChatEditingSessionSnapshot[]; index: number } { + return { history: this._linearHistory.get(), index: this._linearHistoryIndex.get() }; + } + + private findSnapshot(requestId: string): IChatEditingSessionSnapshot | undefined { + return this._linearHistory.get().find((s) => s.requestId === requestId); + } + + private findEditStop(requestId: string, undoStop: string | undefined) { + const snapshot = this.findSnapshot(requestId); + if (!snapshot) { + return undefined; + } + const idx = snapshot.stops.findIndex((s) => s.stopId === undoStop); + return idx === -1 ? undefined : { stop: snapshot.stops[idx], snapshot, historyIndex: snapshot.startIndex + idx }; + } + + private getHistoryEntryByLinearIndex(index: number) { + const history = this._linearHistory.get(); + const searchedIndex = binarySearch2(history.length, (e) => history[e].startIndex - index); + const entry = history[searchedIndex < 0 ? (~searchedIndex) - 1 : searchedIndex]; + if (!entry || index - entry.startIndex >= entry.stops.length) { + return undefined; + } + return { + entry, + stop: entry.stops[index - entry.startIndex] + }; + } + + public pushSnapshot(requestId: string, undoStop: string | undefined, snapshot: IChatEditingSessionStop) { + const linearHistoryPtr = this._linearHistoryIndex.get(); + const newLinearHistory: IChatEditingSessionSnapshot[] = []; + for (const entry of this._linearHistory.get()) { + if (entry.startIndex >= linearHistoryPtr) { + break; + } else if (linearHistoryPtr - entry.startIndex < entry.stops.length) { + newLinearHistory.push({ requestId: entry.requestId, stops: entry.stops.slice(0, linearHistoryPtr - entry.startIndex), startIndex: entry.startIndex }); + } else { + newLinearHistory.push(entry); + } + } + + const lastEntry = newLinearHistory.at(-1); + if (requestId && lastEntry?.requestId === requestId) { + const hadPostEditStop = lastEntry.stops.at(-1)?.stopId === ChatEditingTimeline.POST_EDIT_STOP_ID && undoStop; + if (hadPostEditStop) { + const rebaseUri = (uri: URI) => uri.with({ query: uri.query.replace(ChatEditingTimeline.POST_EDIT_STOP_ID, undoStop) }); + for (const [uri, prev] of lastEntry.stops.at(-1)!.entries) { + snapshot.entries.set(uri, { ...prev, snapshotUri: rebaseUri(prev.snapshotUri), resource: rebaseUri(prev.resource) }); + } + } + newLinearHistory[newLinearHistory.length - 1] = { + ...lastEntry, + stops: [...hadPostEditStop ? lastEntry.stops.slice(0, -1) : lastEntry.stops, snapshot] + }; + } else { + newLinearHistory.push({ requestId, startIndex: lastEntry ? lastEntry.startIndex + lastEntry.stops.length : 0, stops: [snapshot] }); + } + + transaction((tx) => { + const last = newLinearHistory[newLinearHistory.length - 1]; + this._linearHistory.set(newLinearHistory, tx); + this._linearHistoryIndex.set(last.startIndex + last.stops.length, tx); + }); + } + + /** + * Gets diff for text entries between stops. + * @param entriesContent Observable that observes either snapshot entry + * @param modelUrisObservable Observable that observes only the snapshot URIs. + */ + private _entryDiffBetweenTextStops( + entriesContent: IObservable<{ before: ISnapshotEntry; after: ISnapshotEntry } | undefined>, + modelUrisObservable: IObservable<[URI, URI] | undefined>, + ): IObservable | undefined> { + const modelRefsPromise = derived(this, (reader) => { + const modelUris = modelUrisObservable.read(reader); + if (!modelUris) { return undefined; } + + const store = reader.store.add(new DisposableStore()); + const promise = Promise.all(modelUris.map(u => this._textModelService.createModelReference(u))).then(refs => { + if (store.isDisposed) { + refs.forEach(r => r.dispose()); + } else { + refs.forEach(r => store.add(r)); + } + + return refs; + }); + + return new ObservablePromise(promise); + }); + + return derived((reader): ObservablePromise | undefined => { + const refs2 = modelRefsPromise.read(reader)?.promiseResult.read(reader); + const refs = refs2?.data; + if (!refs) { + return; + } + + const entries = entriesContent.read(reader); // trigger re-diffing when contents change + + if (entries?.before && ChatEditingModifiedNotebookEntry.canHandleSnapshot(entries.before)) { + const diffService = this._instantiationService.createInstance(ChatEditingModifiedNotebookDiff, entries.before, entries.after); + return new ObservablePromise(diffService.computeDiff()); + + } + const ignoreTrimWhitespace = this._ignoreTrimWhitespaceObservable.read(reader); + const promise = this._editorWorkerService.computeDiff( + refs[0].object.textEditorModel.uri, + refs[1].object.textEditorModel.uri, + { ignoreTrimWhitespace, computeMoves: false, maxComputationTimeMs: 3000 }, + 'advanced' + ).then((diff): IEditSessionEntryDiff => { + const entryDiff: IEditSessionEntryDiff = { + originalURI: refs[0].object.textEditorModel.uri, + modifiedURI: refs[1].object.textEditorModel.uri, + identical: !!diff?.identical, + quitEarly: !diff || diff.quitEarly, + added: 0, + removed: 0, + }; + if (diff) { + for (const change of diff.changes) { + entryDiff.removed += change.original.endLineNumberExclusive - change.original.startLineNumber; + entryDiff.added += change.modified.endLineNumberExclusive - change.modified.startLineNumber; + } + } + + return entryDiff; + }); + + return new ObservablePromise(promise); + }); + } + + 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 = 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); + if (!before || !after) { return undefined; } + return { before, after }; + }, + ); + + // Separate observable for model refs to avoid unnecessary disposal + const modelUrisObservable = derivedOpts<[URI, URI] | undefined>({ equalsFn: (a, b) => arraysEqual(a, b, isEqual) }, reader => { + const entriesValue = entries.read(reader); + if (!entriesValue) { return undefined; } + return [entriesValue.before.snapshotUri, entriesValue.after.snapshotUri]; + }); + + const diff = this._entryDiffBetweenTextStops(entries, modelUrisObservable); + + return derived(reader => { + return diff.read(reader)?.promiseResult.read(reader)?.data || undefined; + }); + } + + 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; + } 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; + } + } +} + +function stopProvidesNewData(origin: IChatEditingSessionStop, target: IChatEditingSessionStop) { + return Iterable.some(target.entries, ([uri, e]) => origin.entries.get(uri)?.current !== e.current); +} + +function getMaxHistoryIndex(history: readonly IChatEditingSessionSnapshot[]) { + const lastHistory = history.at(-1); + return lastHistory ? lastHistory.startIndex + lastHistory.stops.length : 0; +} + +function snapshotsEqualForDiff(a: ISnapshotEntry | undefined, b: ISnapshotEntry | undefined) { + if (!a || !b) { + return a === b; + } + + return isEqual(a.snapshotUri, b.snapshotUri) && a.current === b.current; +} + +function getCurrentAndNextStop(requestId: string, stopId: string | undefined, history: readonly IChatEditingSessionSnapshot[]) { + const snapshotIndex = history.findIndex(s => s.requestId === requestId); + if (snapshotIndex === -1) { return undefined; } + const snapshot = history[snapshotIndex]; + const stopIndex = snapshot.stops.findIndex(s => s.stopId === stopId); + if (stopIndex === -1) { return undefined; } + + const currentStop = snapshot.stops[stopIndex]; + const current = currentStop.entries; + const nextStop = stopIndex < snapshot.stops.length - 1 + ? snapshot.stops[stopIndex + 1] + : undefined; + if (!nextStop) { + return undefined; + } + + return { current, currentStopId: currentStop.stopId, next: nextStop.entries, nextStopId: nextStop.stopId }; +} + +function getFirstAndLastStop(uri: URI, history: readonly IChatEditingSessionSnapshot[]) { + 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; + let lastStopWithUriId: string | undefined; + for (let i = history.length - 1; i >= 0; i--) { + const snapshot = history[i]; + const stop = findLast(snapshot.stops, s => s.entries.has(uri)); + if (stop) { + lastStopWithUri = stop.entries; + lastStopWithUriId = stop.stopId; + break; + } + } + + if (!firstStopWithUri || !lastStopWithUri) { + return undefined; + } + + return { current: firstStopWithUri.entries, currentStopId: firstStopWithUri.stopId, next: lastStopWithUri, nextStopId: lastStopWithUriId! }; +} 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 3d70ef8377b..112875ab2f9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts @@ -57,8 +57,9 @@ export class SnapshotComparer { private readonly data: NotebookData; private readonly transientOptions: TransientOptions | undefined; constructor(initialCotent: string) { - this.transientOptions = deserializeSnapshot(initialCotent).transientOptions; - this.data = deserializeSnapshot(initialCotent).data; + const { transientOptions, data } = deserializeSnapshot(initialCotent); + this.transientOptions = transientOptions; + this.data = data; } isEqual(notebook: NotebookData | NotebookTextModel): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/notebookCellChanges.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/notebookCellChanges.ts index 376d0ed8d5f..f978bec7d68 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/notebookCellChanges.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/notebookCellChanges.ts @@ -83,6 +83,8 @@ export function countChanges(changes: ICellDiffInfo[]): number { } export function sortCellChanges(changes: ICellDiffInfo[]): ICellDiffInfo[] { + const indexes = new Map(); + changes.forEach((c, i) => indexes.set(c, i)); return [...changes].sort((a, b) => { // For unchanged and modified, use modifiedCellIndex if ((a.type === 'unchanged' || a.type === 'modified') && @@ -101,10 +103,22 @@ export function sortCellChanges(changes: ICellDiffInfo[]): ICellDiffInfo[] { } if (a.type === 'delete' && b.type === 'insert') { - return -1; + // If the deleted cell comes before the inserted cell, we want the delete to come first + // As this means the cell was deleted before it was inserted + // We would like to see the deleted cell first in the list + // Else in the UI it would look weird to see an inserted cell before a deleted cell, + // When the users operation was to first delete the cell and then insert a new one + // I.e. this is merely just a simple way to ensure we have a stable sort. + return indexes.get(a)! - indexes.get(b)!; } if (a.type === 'insert' && b.type === 'delete') { - return 1; + // If the deleted cell comes before the inserted cell, we want the delete to come first + // As this means the cell was deleted before it was inserted + // We would like to see the deleted cell first in the list + // Else in the UI it would look weird to see an inserted cell before a deleted cell, + // When the users operation was to first delete the cell and then insert a new one + // I.e. this is merely just a simple way to ensure we have a stable sort. + return indexes.get(a)! - indexes.get(b)!; } if ((a.type === 'delete' && b.type !== 'insert') || (a.type !== 'insert' && b.type === 'delete')) { diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index cfc2154c3b4..c7f6b08e50f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -102,6 +102,8 @@ 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'; +import { isEqual } from '../../../../base/common/resources.js'; +import { isLocation } from '../../../../editor/common/languages.js'; const $ = dom.$; @@ -169,7 +171,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge contextArr.add(...this.attachmentModel.attachments); - if (this.implicitContext?.enabled && this.implicitContext.value) { + if ((this.implicitContext?.enabled && this.implicitContext?.value) || (isLocation(this.implicitContext?.value) && this.configurationService.getValue('chat.implicitContext.suggestedContext'))) { const implicitChatVariables = this.implicitContext.toBaseEntries(); contextArr.add(...implicitChatVariables); } @@ -271,6 +273,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatModeKindKey: IContextKey; private modelWidget: ModelPickerActionItem | undefined; + private modeWidget: ModePickerActionItem | undefined; private readonly _waitForPersistedLanguageModel: MutableDisposable; private _onDidChangeCurrentLanguageModel: Emitter; @@ -441,6 +444,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (this._inputEditor) { this._inputEditor.updateOptions({ ariaLabel: this._getAriaLabel() }); } + + if (this.implicitContext && this.configurationService.getValue('chat.implicitContext.suggestedContext')) { + this.implicitContext.enabled = this._currentModeObservable.get() !== ChatMode.Agent; + } })); this._register(this._onDidChangeCurrentLanguageModel.event(() => { if (this._currentLanguageModel?.metadata.name) { @@ -452,7 +459,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const mode = this._currentModeObservable.read(r); const model = mode.model?.read(r); if (model) { - this.switchModelByName(model); + this.switchModelByQualifiedName(model); } })); } @@ -517,9 +524,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - public switchModelByName(modelName: string): boolean { + public switchModelByQualifiedName(qualifiedModelName: string): boolean { const models = this.getModels(); - const model = models.find(m => m.metadata.name === modelName); + const model = models.find(m => ILanguageModelChatMetadata.asQualifiedName(m.metadata) === qualifiedModelName); if (model) { this.setCurrentLanguageModel(model); return true; @@ -540,6 +547,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.modelWidget?.show(); } + public openModePicker(): void { + this.modeWidget?.show(); + } + private checkModelSupported(): void { if (this._currentLanguageModel && !this.modelSupportedForDefaultAgent(this._currentLanguageModel)) { this.setCurrentLanguageModelToDefault(); @@ -1149,7 +1160,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const delegate: IModePickerDelegate = { currentMode: this._currentModeObservable }; - return this.instantiationService.createInstance(ModePickerActionItem, action, delegate); + return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate); } return undefined; @@ -1296,8 +1307,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._indexOfLastOpenedContext = -1; } - if (this.implicitContext?.value) { - const implicitPart = store.add(this.instantiationService.createInstance(ImplicitContextAttachmentWidget, this.implicitContext, this._contextResourceLabels)); + const isSuggestedEnabled = this.configurationService.getValue('chat.implicitContext.suggestedContext'); + + if (this.implicitContext?.value && !isSuggestedEnabled) { + const implicitPart = store.add(this.instantiationService.createInstance(ImplicitContextAttachmentWidget, this.implicitContext, this._contextResourceLabels, this.attachmentModel)); container.appendChild(implicitPart.domNode); } @@ -1350,6 +1363,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); } + const implicitUri = this.implicitContext?.value; + const isUri = URI.isUri(implicitUri); + + if (isSuggestedEnabled && implicitUri && (isUri || isLocation(implicitUri))) { + const targetUri = isUri ? implicitUri : implicitUri.uri; + const currentlyAttached = attachments.some(([, attachment]) => URI.isUri(attachment.value) && isEqual(attachment.value, targetUri)); + + const shouldShowImplicit = isUri ? !currentlyAttached : implicitUri.range; + if (shouldShowImplicit) { + const implicitPart = store.add(this.instantiationService.createInstance(ImplicitContextAttachmentWidget, this.implicitContext, this._contextResourceLabels, this._attachmentModel)); + container.appendChild(implicitPart.domNode); + } + } if (oldHeight !== this.attachmentsContainer.offsetHeight) { this._onDidChangeHeight.fire(); @@ -1364,9 +1390,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._indexOfLastAttachedContextDeletedWithKeyboard = index; } - this._attachmentModel.delete(attachment.id); + + if (this.configurationService.getValue('chat.implicitContext.enableImplicitContext')) { + // if currently opened file is deleted, do not show implicit context + const implicitValue = URI.isUri(this.implicitContext?.value) && URI.isUri(attachment.value) && isEqual(this.implicitContext.value, attachment.value); + + if (this.implicitContext?.isFile && implicitValue) { + this.implicitContext.enabled = false; + } + } + if (this._attachmentModel.size === 0) { this.focus(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts index 753223f595c..e6e7d2f3ac7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @@ -430,7 +430,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.editRequests') === 'hover'); - templateData.elementDisposables.add(dom.addDisposableListener(templateData.rowContainer, dom.EventType.CLICK, () => { - if (this.viewModel?.editing && element.id !== this.viewModel.editing.id && element === this.templateDataByRequestId.get(element.id)?.currentElement) { + templateData.elementDisposables.add(dom.addDisposableListener(templateData.rowContainer, dom.EventType.CLICK, (e) => { + const current = templateData.currentElement; + if (current && this.viewModel?.editing && current.id !== this.viewModel.editing.id) { + e.stopPropagation(); + e.preventDefault(); this._onDidFocusOutside.fire(); } })); @@ -633,7 +640,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { const ev = new StandardKeyboardEvent(e); if (ev.equals(KeyCode.Space) || ev.equals(KeyCode.Enter)) { - if (this.viewModel?.editing?.id !== element.id && !this.viewModel?.requestInProgress) { + if (this.viewModel?.editing?.id !== element.id) { + ev.preventDefault(); + ev.stopPropagation(); this._onDidClickRequest.fire(templateData); } } @@ -1222,7 +1231,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.editRequests') === 'inline' && !this.disableEdits) { markdownPart.domNode.classList.add('clickable'); markdownPart.addDisposable(dom.addDisposableListener(markdownPart.domNode, dom.EventType.CLICK, (e: MouseEvent) => { - if (this.viewModel?.editing?.id !== element.id && !this.viewModel?.requestInProgress) { + if (this.viewModel?.editing?.id !== element.id) { + const selection = dom.getWindow(templateData.rowContainer).getSelection(); + if (selection && !selection.isCollapsed && selection.toString().length > 0) { + return; + } + e.preventDefault(); + e.stopPropagation(); this._onDidClickRequest.fire(templateData); } })); diff --git a/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts index a8aa156871e..2c47b9f4faf 100644 --- a/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts @@ -77,6 +77,7 @@ export class ChatMarkdownRenderer extends MarkdownRenderer { sanitizerOptions: { replaceWithPlaintext: true, allowedTags: allowedHtmlTags, + ...options?.sanitizerOptions, allowedProductProtocols: [product.urlProtocol] } }; diff --git a/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts b/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts index ef3f732ed15..5b73948d32c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts @@ -6,7 +6,6 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { derived, IObservable, observableFromEvent, ObservableMap } from '../../../../base/common/observable.js'; -import { isObject } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ObservableMemento, observableMemento } from '../../../../platform/observable/common/observableMemento.js'; @@ -37,7 +36,7 @@ export enum ToolsScope { export class ChatSelectedTools extends Disposable { - private readonly _selectedTools: ObservableMemento; + private readonly _selectedTools: ObservableMemento; private readonly _sessionStates = new ObservableMap(); @@ -64,48 +63,8 @@ export class ChatSelectedTools extends Disposable { ) { super(); - const storedTools = observableMemento({ - defaultValue: new Map(), - toStorage: (value) => { - const data = { - disabledToolSets: [] as string[], - disabledTools: [] as string[], - }; - for (const [item, enabled] of value) { - if (!enabled) { - if (item instanceof ToolSet) { - data.disabledToolSets.push(item.id); - } else { - data.disabledTools.push(item.id); - } - } - } - return JSON.stringify(data); - }, - fromStorage: (value) => { - const obj = JSON.parse(value) as StoredData; - const map = new Map(); - if (!obj || !isObject(obj)) { - return map; - } - if (Array.isArray(obj.disabledToolSets)) { - for (const toolSetId of obj.disabledToolSets) { - const toolset = this._toolsService.getToolSet(toolSetId); - if (toolset) { - map.set(toolset, false); - } - } - } - if (Array.isArray(obj.disabledTools)) { - for (const toolId of obj.disabledTools) { - const tool = this._toolsService.getTool(toolId); - if (tool) { - map.set(tool, false); - } - } - } - return map; - }, + const storedTools = observableMemento({ + defaultValue: { disabledToolSets: [], disabledTools: [] }, key: 'chat/selectedTools', }); @@ -119,27 +78,36 @@ export class ChatSelectedTools extends Disposable { */ get entriesMap(): IObservable { return derived(r => { + const map = new Map(); + const currentMode = this._mode.read(r); let currentMap = this._sessionStates.get(currentMode.id); - let defaultEnablement = false; if (!currentMap && currentMode.kind === ChatModeKind.Agent && currentMode.customTools) { - currentMap = this._toolsService.toToolAndToolSetEnablementMap(new Set(currentMode.customTools.read(r))); - } - if (!currentMap) { - currentMap = this._selectedTools.read(r); - defaultEnablement = true; + currentMap = this._toolsService.toToolAndToolSetEnablementMap(currentMode.customTools.read(r)); } + if (currentMap) { + for (const tool of this._allTools.read(r)) { + if (tool.canBeReferencedInPrompt) { + map.set(tool, currentMap.get(tool) === true); // false if not present + } + } + for (const toolSet of this._toolsService.toolSets.read(r)) { + map.set(toolSet, currentMap.get(toolSet) === true); // false if not present + } + } else { + const currData = this._selectedTools.read(r); + const disabledToolSets = new Set(currData.disabledToolSets ?? []); + const disabledTools = new Set(currData.disabledTools ?? []); - // create a complete map of all tools and tool sets - const map = new Map(); - const tools = this._allTools.read(r).filter(t => t.canBeReferencedInPrompt); - for (const tool of tools) { - map.set(tool, currentMap.get(tool) ?? defaultEnablement); - } - const toolSets = this._toolsService.toolSets.read(r); - for (const toolSet of toolSets) { - map.set(toolSet, currentMap.get(toolSet) ?? defaultEnablement); + for (const tool of this._allTools.read(r)) { + if (tool.canBeReferencedInPrompt) { + map.set(tool, !disabledTools.has(tool.id)); + } + } + for (const toolSet of this._toolsService.toolSets.read(r)) { + map.set(toolSet, !disabledToolSets.has(toolSet.id)); + } } return map; }); @@ -180,7 +148,17 @@ export class ChatSelectedTools extends Disposable { this.updateCustomModeTools(mode.uri.get(), enablementMap); return; } - this._selectedTools.set(enablementMap, undefined); + const storedData = { disabledToolSets: [] as string[], disabledTools: [] as string[] }; + for (const [item, enabled] of enablementMap) { + if (!enabled) { + if (item instanceof ToolSet) { + storedData.disabledToolSets.push(item.id); + } else { + storedData.disabledTools.push(item.id); + } + } + } + this._selectedTools.set(storedData, undefined); } async updateCustomModeTools(uri: URI, enablementMap: IToolAndToolSetEnablementMap): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup.ts b/src/vs/workbench/contrib/chat/browser/chatSetup.ts index fff7e526613..8a619025797 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup.ts @@ -42,7 +42,7 @@ 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 { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../common/views.js'; import { IActivityService, ProgressBadge } from '../../../services/activity/common/activity.js'; @@ -80,12 +80,7 @@ const defaultChat = { publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', manageOveragesUrl: product.defaultChatAgent?.manageOverageUrl ?? '', upgradePlanUrl: product.defaultChatAgent?.upgradePlanUrl ?? '', - signUpUrl: product.defaultChatAgent?.signUpUrl ?? '', - providerName: product.defaultChatAgent?.providerName ?? '', - enterpriseProviderId: product.defaultChatAgent?.enterpriseProviderId ?? '', - enterpriseProviderName: product.defaultChatAgent?.enterpriseProviderName ?? '', - alternativeProviderId: product.defaultChatAgent?.alternativeProviderId ?? '', - alternativeProviderName: product.defaultChatAgent?.alternativeProviderName ?? '', + provider: product.defaultChatAgent?.provider ?? { default: { id: '', name: '' }, enterprise: { id: '', name: '' }, apple: { id: '', name: '' }, google: { id: '', name: '' } }, providerUriSetting: product.defaultChatAgent?.providerUriSetting ?? '', providerScopes: product.defaultChatAgent?.providerScopes ?? [[]], manageSettingsUrl: product.defaultChatAgent?.manageSettingsUrl ?? '', @@ -133,7 +128,7 @@ class SetupAgent extends Disposable implements IChatAgentImplementation { break; } - return SetupAgent.doRegisterAgent(instantiationService, chatAgentService, id, `${defaultChat.providerName} Copilot`, true, description, location, mode, context, controller); + return SetupAgent.doRegisterAgent(instantiationService, chatAgentService, id, `${defaultChat.provider?.default.name} Copilot`, true, description, location, mode, context, controller); }); } @@ -191,6 +186,7 @@ class SetupAgent extends Disposable implements IChatAgentImplementation { } private static readonly SETUP_NEEDED_MESSAGE = new MarkdownString(localize('settingUpCopilotNeeded', "You need to set up Copilot and be signed in to use Chat.")); + private static readonly TRUST_NEEDED_MESSAGE = new MarkdownString(localize('trustNeeded', "You need to trust this workspace to use Chat.")); private readonly _onUnresolvableError = this._register(new Emitter()); readonly onUnresolvableError = this._onUnresolvableError.event; @@ -205,7 +201,8 @@ class SetupAgent extends Disposable implements IChatAgentImplementation { @ILogService private readonly logService: ILogService, @IConfigurationService private readonly configurationService: IConfigurationService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService ) { super(); } @@ -304,9 +301,9 @@ class SetupAgent extends Disposable implements IChatAgentImplementation { if (ready === 'error' || ready === 'timedout') { let warningMessage: string; if (ready === 'timedout') { - warningMessage = localize('copilotTookLongWarning', "Copilot took too long to get ready. Please ensure you are signed in to {0} and that the extension `{1}` is installed and enabled.", defaultChat.providerName, defaultChat.chatExtensionId); + warningMessage = localize('copilotTookLongWarning', "Copilot took too long to get ready. Please ensure you are signed in to {0} and that the extension `{1}` is installed and enabled.", defaultChat.provider?.default.name, defaultChat.chatExtensionId); } else { - warningMessage = localize('copilotFailedWarning', "Copilot failed to get ready. Please ensure you are signed in to {0} and that the extension `{1}` is installed and enabled.", defaultChat.providerName, defaultChat.chatExtensionId); + warningMessage = localize('copilotFailedWarning', "Copilot failed to get ready. Please ensure you are signed in to {0} and that the extension `{1}` is installed and enabled.", defaultChat.provider?.default.name, defaultChat.chatExtensionId); } progress({ @@ -325,9 +322,9 @@ class SetupAgent extends Disposable implements IChatAgentImplementation { } await chatService.resendRequest(requestModel, { + ...widget?.getModeRequestOptions(), mode, userSelectedModelId: languageModel, - userSelectedTools: widget?.getUserSelectedTools() }); } @@ -350,14 +347,14 @@ class SetupAgent extends Disposable implements IChatAgentImplementation { // check that tools other than setup. and internal tools are registered. for (const tool of languageModelToolsService.getTools()) { - if (tool.source.type !== 'internal') { + if (tool.id.startsWith('copilot_')) { return; // we have tools! } } return Event.toPromise(Event.filter(languageModelToolsService.onDidChangeTools, () => { for (const tool of languageModelToolsService.getTools()) { - if (tool.source.type !== 'internal') { + if (tool.id.startsWith('copilot_')) { return true; // we have tools! } } @@ -395,7 +392,7 @@ class SetupAgent extends Disposable implements IChatAgentImplementation { 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)), + content: new MarkdownString(localize('setupChatSignIn2', "Signing in to {0}.", ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider?.enterprise.id ? defaultChat.provider?.enterprise.name : defaultChat.provider?.default.name)), }); break; case ChatSetupStep.Installing: @@ -439,7 +436,7 @@ class SetupAgent extends Disposable implements IChatAgentImplementation { else { progress({ kind: 'markdownContent', - content: SetupAgent.SETUP_NEEDED_MESSAGE, + content: this.workspaceTrustManagementService.isWorkspaceTrusted() ? SetupAgent.SETUP_NEEDED_MESSAGE : SetupAgent.TRUST_NEEDED_MESSAGE }); } @@ -572,8 +569,8 @@ enum ChatSetupStrategy { DefaultSetup = 1, SetupWithoutEnterpriseProvider = 2, SetupWithEnterpriseProvider = 3, - SetupWithAccountCreate = 4, - SetupWithAlternateProvider = 5 + SetupWithGoogleProvider = 4, + SetupWithAppleProvider = 5 } type ChatSetupResultValue = boolean /* success */ | undefined /* canceled */; @@ -590,7 +587,7 @@ class ChatSetup { let instance = ChatSetup.instance; if (!instance) { instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { - return new ChatSetup(context, controller, instantiationService, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IConfigurationService), accessor.get(IViewsService), accessor.get(IOpenerService)); + return new ChatSetup(context, controller, instantiationService, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IConfigurationService), accessor.get(IViewsService), accessor.get(IWorkspaceTrustRequestService)); }); } @@ -612,7 +609,7 @@ class ChatSetup { @ILogService private readonly logService: ILogService, @IConfigurationService private readonly configurationService: IConfigurationService, @IViewsService private readonly viewsService: IViewsService, - @IOpenerService private readonly openerService: IOpenerService, + @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService ) { } skipDialog(): void { @@ -639,6 +636,16 @@ class ChatSetup { const dialogSkipped = this.skipDialogOnce; this.skipDialogOnce = false; + const trusted = await this.workspaceTrustRequestService.requestWorkspaceTrust({ + message: localize('copilotWorkspaceTrust', "Copilot is currently only supported in trusted workspaces.") + }); + if (!trusted) { + this.context.update({ later: true }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotTrusted', installDuration: 0, signUpErrorCode: undefined, provider: undefined }); + + return { dialogSkipped, success: undefined /* canceled */ }; + } + let setupStrategy: ChatSetupStrategy; if (dialogSkipped || isProUser(this.chatEntitlementService.entitlement) || this.chatEntitlementService.entitlement === ChatEntitlement.Free) { setupStrategy = ChatSetupStrategy.DefaultSetup; // existing pro/free users setup without a dialog @@ -646,7 +653,7 @@ class ChatSetup { setupStrategy = await this.showDialog(); } - if (setupStrategy === ChatSetupStrategy.DefaultSetup && ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.enterpriseProviderId) { + if (setupStrategy === ChatSetupStrategy.DefaultSetup && ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider?.enterprise.id) { setupStrategy = ChatSetupStrategy.SetupWithEnterpriseProvider; // users with a configured provider go through provider setup } @@ -660,23 +667,23 @@ class ChatSetup { try { switch (setupStrategy) { case ChatSetupStrategy.SetupWithEnterpriseProvider: - success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: true, useAlternateProvider: false }); + success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: true, useSocialProvider: undefined }); break; case ChatSetupStrategy.SetupWithoutEnterpriseProvider: - success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: false, useAlternateProvider: false }); + success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: false, useSocialProvider: undefined }); break; - case ChatSetupStrategy.SetupWithAlternateProvider: - success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: false, useAlternateProvider: true }); + case ChatSetupStrategy.SetupWithAppleProvider: + success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: false, useSocialProvider: 'apple' }); + break; + case ChatSetupStrategy.SetupWithGoogleProvider: + success = await this.controller.value.setupWithProvider({ useEnterpriseProvider: false, useSocialProvider: 'google' }); break; case ChatSetupStrategy.DefaultSetup: success = await this.controller.value.setup(); break; - case ChatSetupStrategy.SetupWithAccountCreate: - this.openerService.open(URI.parse(defaultChat.signUpUrl)); - return this.doRun(options); // open dialog again case ChatSetupStrategy.Canceled: this.context.update({ later: true }); - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedMaybeLater', installDuration: 0, signUpErrorCode: undefined }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedMaybeLater', installDuration: 0, signUpErrorCode: undefined, provider: undefined }); break; } } catch (error) { @@ -690,7 +697,7 @@ class ChatSetup { private async showDialog(): Promise { const disposables = new DisposableStore(); - const dialogVariant = this.configurationService.getValue<'default' | 'alternate-first' | 'alternate-color' | 'alternate-monochrome' | unknown>('chat.setup.signInDialogVariant'); + const dialogVariant = this.configurationService.getValue<'default' | 'apple' | unknown>('chat.setup.signInDialogVariant'); const buttons = this.getButtons(dialogVariant); const dialog = disposables.add(new Dialog( @@ -716,63 +723,41 @@ class ChatSetup { return buttons[button]?.[1] ?? ChatSetupStrategy.Canceled; } - private getButtons(variant: 'default' | 'alternate-first' | 'alternate-color' | 'alternate-monochrome' | unknown): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { - let buttons: Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]>; + private getButtons(variant: 'default' | 'apple' | unknown): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { + type ContinueWithButton = [string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]; + const styleButton = (...classes: string[]) => ({ styleButton: (button: IButton) => button.element.classList.add(...classes) }); + let buttons: Array; if (this.context.state.entitlement === ChatEntitlement.Unknown) { - let alternateProvider: 'off' | 'monochrome' | 'colorful' | 'first' = 'off'; - if (defaultChat.alternativeProviderId) { - if (this.configurationService.getValue('chat.setup.signInWithAlternateProvider')) { - alternateProvider = 'colorful'; // TODO@bpasero remove me soon - } + const defaultProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider?.default.name), ChatSetupStrategy.SetupWithoutEnterpriseProvider, styleButton('continue-button', 'default')]; + const defaultProviderLink: ContinueWithButton = [defaultProviderButton[0], defaultProviderButton[1], styleButton('link-button')]; - switch (variant) { - case 'alternate-first': - alternateProvider = 'first'; - break; - case 'alternate-color': - alternateProvider = 'colorful'; - break; - case 'alternate-monochrome': - alternateProvider = 'monochrome'; - break; - } - } + const enterpriseProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider?.enterprise.name), ChatSetupStrategy.SetupWithEnterpriseProvider, styleButton('continue-button', 'default')]; + const enterpriseProviderLink: ContinueWithButton = [enterpriseProviderButton[0], enterpriseProviderButton[1], styleButton('link-button')]; - if (ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.enterpriseProviderId) { + const googleProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider?.google.name), ChatSetupStrategy.SetupWithGoogleProvider, styleButton('continue-button', 'google')]; + const appleProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider?.apple.name), ChatSetupStrategy.SetupWithAppleProvider, styleButton('continue-button', 'apple')]; + + if (ChatEntitlementRequests.providerId(this.configurationService) !== defaultChat.provider?.enterprise.id) { buttons = coalesce([ - [localize('continueWith', "Continue with {0}", defaultChat.enterpriseProviderName), ChatSetupStrategy.SetupWithEnterpriseProvider, { - styleButton: button => button.element.classList.add('continue-button', 'default') - }], - alternateProvider !== 'off' ? [localize('continueWith', "Continue with {0}", defaultChat.alternativeProviderName), ChatSetupStrategy.SetupWithAlternateProvider, { - styleButton: button => button.element.classList.add('continue-button', 'alternate', alternateProvider) - }] : undefined, - [localize('signInWithProvider', "Sign in with a {0} account", defaultChat.providerName), ChatSetupStrategy.SetupWithoutEnterpriseProvider, { - styleButton: button => button.element.classList.add('link-button') - }] + defaultProviderButton, + googleProviderButton, + variant === 'apple' ? appleProviderButton : undefined, + enterpriseProviderLink ]); } else { buttons = coalesce([ - [localize('continueWith', "Continue with {0}", defaultChat.providerName), ChatSetupStrategy.SetupWithoutEnterpriseProvider, { - styleButton: button => button.element.classList.add('continue-button', 'default') - }], - alternateProvider !== 'off' ? [localize('continueWith', "Continue with {0}", defaultChat.alternativeProviderName), ChatSetupStrategy.SetupWithAlternateProvider, { - styleButton: button => button.element.classList.add('continue-button', 'alternate', alternateProvider) - }] : undefined, - [localize('signInWithProvider', "Sign in with a {0} account", defaultChat.enterpriseProviderName), ChatSetupStrategy.SetupWithEnterpriseProvider, { - styleButton: button => button.element.classList.add('link-button') - }] + enterpriseProviderButton, + googleProviderButton, + variant === 'apple' ? appleProviderButton : undefined, + defaultProviderLink ]); } - - if (alternateProvider === 'first') { - [buttons[0], buttons[1]] = [buttons[1], buttons[0]]; - } } else { buttons = [[localize('setupCopilotButton', "Set up Copilot"), ChatSetupStrategy.DefaultSetup, undefined]]; } - buttons.push([localize('skipForNow', "Skip for now"), ChatSetupStrategy.Canceled, { styleButton: button => button.element.classList.add('link-button', 'skip-button') }]); + buttons.push([localize('skipForNow', "Skip for now"), ChatSetupStrategy.Canceled, styleButton('link-button', 'skip-button')]); return buttons; } @@ -791,7 +776,7 @@ class ChatSetup { const markdown = this.instantiationService.createInstance(MarkdownRenderer, {}); // SKU Settings - const settings = localize({ key: 'settings', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "{0} Copilot Free, Pro and Pro+ may show [public code]({1}) suggestions and we may use your data for product improvement. You can change these [settings]({2}) at any time.", defaultChat.providerName, defaultChat.publicCodeMatchesUrl, defaultChat.manageSettingsUrl); + const settings = localize({ key: 'settings', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "{0} Copilot Free, Pro and Pro+ may show [public code]({1}) suggestions and we may use your data for product improvement. You can change these [settings]({2}) at any time.", defaultChat.provider?.default.name, defaultChat.publicCodeMatchesUrl, defaultChat.manageSettingsUrl); element.appendChild($('p', undefined, disposables.add(markdown.render(new MarkdownString(settings, { isTrusted: true }))).element)); return element; @@ -1154,11 +1139,13 @@ 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.' }; + provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider used for the chat installation.' }; }; type InstallChatEvent = { installResult: 'installed' | 'alreadyInstalled' | 'cancelled' | 'failedInstall' | 'failedNotSignedIn' | 'failedSignUp' | 'failedNotTrusted' | 'failedNoSession' | 'failedMaybeLater'; installDuration: number; signUpErrorCode: number | undefined; + provider: string | undefined; }; enum ChatSetupStep { @@ -1186,7 +1173,6 @@ class ChatSetupController extends Disposable { @IProgressService private readonly progressService: IProgressService, @IActivityService private readonly activityService: IActivityService, @ICommandService private readonly commandService: ICommandService, - @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IDialogService private readonly dialogService: IDialogService, @IConfigurationService private readonly configurationService: IConfigurationService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @@ -1210,7 +1196,7 @@ class ChatSetupController extends Disposable { this._onDidChange.fire(); } - async setup(options?: { forceSignIn?: boolean; useAlternateProvider?: boolean }): Promise { + async setup(options?: { forceSignIn?: boolean; useSocialProvider?: string; useEnterpriseProvider?: boolean }): Promise { const watch = new StopWatch(false); const title = localize('setupChatProgress', "Getting Copilot ready..."); const badge = this.activityService.showViewContainerActivity(CHAT_SIDEBAR_PANEL_ID, { @@ -1228,7 +1214,7 @@ class ChatSetupController extends Disposable { } } - private async doSetup(options: { forceSignIn?: boolean; useAlternateProvider?: boolean }, watch: StopWatch): Promise { + private async doSetup(options: { forceSignIn?: boolean; useSocialProvider?: string; useEnterpriseProvider?: boolean }, watch: StopWatch): Promise { this.context.suspend(); // reduces flicker let success: ChatSetupResultValue = false; @@ -1237,14 +1223,15 @@ class ChatSetupController extends Disposable { let session: AuthenticationSession | undefined; let entitlement: ChatEntitlement | undefined; - const installation = this.doInstall(); - // Entitlement Unknown or `forceSignIn`: we need to sign-in user if (this.context.state.entitlement === ChatEntitlement.Unknown || options.forceSignIn) { this.setStep(ChatSetupStep.SigningIn); - const result = await this.signIn({ useAlternateProvider: options.useAlternateProvider }); + const result = await this.signIn(options); if (!result.session) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', installDuration: watch.elapsed(), signUpErrorCode: undefined }); + this.doInstall(); // still install the extension in the background to remind the user to sign-in eventually + + const provider = options.useSocialProvider ?? options.useEnterpriseProvider ? defaultChat.provider?.enterprise.id : defaultChat.provider?.default.id; + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); return undefined; // treat as cancelled because signing in already triggers an error dialog } @@ -1252,17 +1239,9 @@ class ChatSetupController extends Disposable { entitlement = result.entitlement; } - const trusted = await this.workspaceTrustRequestService.requestWorkspaceTrust({ - 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 }); - return false; - } - - // Install + // Await Install this.setStep(ChatSetupStep.Installing); - success = await this.install(session, entitlement ?? this.context.state.entitlement, providerId, watch, installation); + success = await this.install(session, entitlement ?? this.context.state.entitlement, providerId, watch, options); } finally { this.setStep(ChatSetupStep.Initial); this.context.resume(); @@ -1271,7 +1250,7 @@ class ChatSetupController extends Disposable { return success; } - private async signIn(options: { useAlternateProvider?: boolean }): Promise<{ session: AuthenticationSession | undefined; entitlement: ChatEntitlement | undefined }> { + private async signIn(options: { useSocialProvider?: string }): Promise<{ session: AuthenticationSession | undefined; entitlement: ChatEntitlement | undefined }> { let session: AuthenticationSession | undefined; let entitlements; try { @@ -1283,7 +1262,7 @@ class ChatSetupController extends Disposable { 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?", options?.useAlternateProvider ? defaultChat.alternativeProviderName : ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.enterpriseProviderId ? defaultChat.enterpriseProviderName : defaultChat.providerName), + message: localize('unknownSignInError', "Failed to sign in to {0}. Would you like to try again?", ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider?.enterprise.id ? defaultChat.provider?.enterprise.name : defaultChat.provider?.default.name), detail: localize('unknownSignInErrorDetail', "You must be signed in to use Copilot."), primaryButton: localize('retry', "Retry") }); @@ -1296,10 +1275,12 @@ class ChatSetupController extends Disposable { return { session, entitlement: entitlements?.entitlement }; } - private async install(session: AuthenticationSession | undefined, entitlement: ChatEntitlement, providerId: string, watch: StopWatch, installation: Promise): Promise { + private async install(session: AuthenticationSession | undefined, entitlement: ChatEntitlement, providerId: string, watch: StopWatch, options: { useSocialProvider?: string; useEnterpriseProvider?: boolean }): Promise { const wasRunning = this.context.state.installed && !this.context.state.disabled; let signUpResult: boolean | { errorCode: number } | undefined = undefined; + const provider = options.useSocialProvider ?? options.useEnterpriseProvider ? defaultChat.provider?.enterprise.id : defaultChat.provider?.default.id; + try { if ( @@ -1315,7 +1296,7 @@ class ChatSetupController extends Disposable { } if (!session) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNoSession', installDuration: watch.elapsed(), signUpErrorCode: undefined }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNoSession', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); return false; // unexpected } } @@ -1323,32 +1304,36 @@ class ChatSetupController extends Disposable { signUpResult = await this.requests.signUpFree(session); if (typeof signUpResult !== 'boolean' /* error */) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedSignUp', installDuration: watch.elapsed(), signUpErrorCode: signUpResult.errorCode }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedSignUp', installDuration: watch.elapsed(), signUpErrorCode: signUpResult.errorCode, provider }); } } - await this.doInstallWithRetry(installation); + await this.doInstallWithRetry(); } 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 }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: isCancellationError(error) ? 'cancelled' : 'failedInstall', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); return false; } if (typeof signUpResult === 'boolean') { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: wasRunning && !signUpResult ? 'alreadyInstalled' : 'installed', installDuration: watch.elapsed(), signUpErrorCode: undefined }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: wasRunning && !signUpResult ? 'alreadyInstalled' : 'installed', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); } - if (wasRunning && signUpResult === true) { + if (wasRunning) { + // We always trigger refresh of tokens to help the user + // get out of authentication issues that can happen when + // for example the sign-up ran after the extension tried + // to use the authentication information to mint a token refreshTokens(this.commandService); } return true; } - private async doInstallWithRetry(installation: Promise): Promise { + private async doInstallWithRetry(): Promise { let error: Error | undefined; try { - await installation; + await this.doInstall(); } catch (e) { this.logService.error(`[chat setup] install: error ${error}`); error = e; @@ -1364,7 +1349,7 @@ class ChatSetupController extends Disposable { }); if (confirmed) { - return this.doInstallWithRetry(this.doInstall()); + return this.doInstallWithRetry(); } } @@ -1382,7 +1367,7 @@ class ChatSetupController extends Disposable { }, ChatViewId); } - async setupWithProvider(options: { useEnterpriseProvider: boolean; useAlternateProvider: boolean }): Promise { + async setupWithProvider(options: { useEnterpriseProvider: boolean; useSocialProvider: string | undefined }): Promise { const registry = Registry.as(ConfigurationExtensions.Configuration); registry.registerConfiguration({ 'id': 'copilot.setup', @@ -1417,7 +1402,7 @@ class ChatSetupController extends Disposable { if (options.useEnterpriseProvider) { await this.configurationService.updateValue(`${defaultChat.completionsAdvancedSetting}`, { ...existingAdvancedSetting, - 'authProvider': defaultChat.enterpriseProviderId + 'authProvider': defaultChat.provider?.enterprise.id }, ConfigurationTarget.USER); } else { await this.configurationService.updateValue(`${defaultChat.completionsAdvancedSetting}`, Object.keys(existingAdvancedSetting).length > 0 ? { @@ -1440,7 +1425,7 @@ class ChatSetupController extends Disposable { let isSingleWord = false; const result = await this.quickInputService.input({ - prompt: localize('enterpriseInstance', "What is your {0} instance?", defaultChat.enterpriseProviderName), + prompt: localize('enterpriseInstance', "What is your {0} instance?", defaultChat.provider?.enterprise.name), placeHolder: localize('enterpriseInstancePlaceholder', 'i.e. "octocat" or "https://octocat.ghe.com"...'), ignoreFocusLost: true, value: uri, @@ -1458,7 +1443,7 @@ class ChatSetupController extends Disposable { }; } if (!fullUriRegEx.test(value)) { return { - content: localize('invalidEnterpriseInstance', 'You must 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.provider?.enterprise.name), severity: Severity.Error }; } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus.ts b/src/vs/workbench/contrib/chat/browser/chatStatus.ts index 62d45271e89..b6c34cf3b77 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus.ts @@ -119,6 +119,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu @IStatusbarService private readonly statusbarService: IStatusbarService, @IEditorService private readonly editorService: IEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IInlineCompletionsService private readonly completionsService: IInlineCompletionsService, ) { super(); @@ -143,6 +144,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.update())); this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.update())); this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.update())); + this._register(this.completionsService.onDidChangeIsSnoozing(() => this.update())); this._register(this.editorService.onDidActiveEditorChange(() => this.onDidActiveEditorChange())); @@ -228,6 +230,12 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu text = `$(copilot-unavailable)`; ariaLabel = localize('completionsDisabledStatus', "Code completions disabled"); } + + // Completions Snoozed + else if (this.completionsService.isSnoozing()) { + text = `$(copilot-snooze)`; + ariaLabel = localize('completionsSnoozedStatus', "Code completions snoozed"); + } } return { @@ -412,7 +420,7 @@ class ChatStatusDashboard extends Disposable { // Settings { const chatSentiment = this.chatEntitlementService.sentiment; - addSeparator(localize('completionsAndNES', "Completions / NES"), chatSentiment.installed && !chatSentiment.disabled && !chatSentiment.untrusted ? toAction({ + addSeparator(localize('codeCompletions', "Code Completions"), chatSentiment.installed && !chatSentiment.disabled && !chatSentiment.untrusted ? toAction({ id: 'workbench.action.openChatSettings', label: localize('settingsLabel', "Settings"), tooltip: localize('settingsTooltip', "Open Settings"), @@ -599,11 +607,11 @@ class ChatStatusDashboard extends Disposable { // --- Code completions { const globalSetting = append(settings, $('div.setting')); - this.createCodeCompletionsSetting(globalSetting, localize('settings.codeCompletions', "Code completions (all files)"), '*', disposables); + this.createCodeCompletionsSetting(globalSetting, localize('settings.codeCompletions.allFiles', "All files"), '*', disposables); if (modeId) { const languageSetting = append(settings, $('div.setting')); - this.createCodeCompletionsSetting(languageSetting, localize('settings.codeCompletionsLanguage', "Code completions ({0})", this.languageService.getLanguageName(modeId) ?? modeId), modeId, disposables); + this.createCodeCompletionsSetting(languageSetting, localize('settings.codeCompletions.language', "{0}", this.languageService.getLanguageName(modeId) ?? modeId), modeId, disposables); } } @@ -732,7 +740,7 @@ class ChatStatusDashboard extends Disposable { const toolbar = disposables.add(new ActionBar(actionBar, { hoverDelegate: nativeHoverDelegate })); const cancelAction = toAction({ id: 'workbench.action.cancelSnoozeStatusBarLink', - label: 'Cancel Snooze', + label: localize('cancelSnooze', "Cancel Snooze"), run: () => this.inlineCompletionsService.cancelSnooze(), class: ThemeIcon.asClassName(Codicon.stopCircle) }); @@ -743,9 +751,10 @@ class ChatStatusDashboard extends Disposable { const timeLeftMs = this.inlineCompletionsService.snoozeTimeLeft; if (!isEnabled || timeLeftMs <= 0) { - timerDisplay.textContent = localize('settings.mute5minutes', "Mute for 5 mins"); + timerDisplay.textContent = localize('completions.snooze5minutesTitle', "Hide completions for 5 min"); + timerDisplay.title = ''; button.label = label; - button.setTitle(localize('settings.snooze5minutes', "Snooze completions and NES for 5 mins")); + button.setTitle(localize('completions.snooze5minutes', "Hide completions and NES for 5 min")); return true; } @@ -753,9 +762,10 @@ class ChatStatusDashboard extends Disposable { const minutes = Math.floor(timeLeftSeconds / 60); const seconds = timeLeftSeconds % 60; - timerDisplay.textContent = `${minutes}:${seconds < 10 ? '0' : ''}${seconds} remaining`; - button.label = localize('settings.plus5mins', "+5 mins"); - button.setTitle(localize('settings.snoozeAdditional5minutes', "Snooze additional 5 mins")); + timerDisplay.textContent = `${minutes}:${seconds < 10 ? '0' : ''}${seconds} ${localize('completions.remainingTime', "remaining")}`; + timerDisplay.title = localize('completions.snoozeTimeDescription', "Completions are hidden for the remaining duration"); + button.label = localize('completions.plus5min', "+5 min"); + button.setTitle(localize('completions.snoozeAdditional5minutes', "Snooze additional 5 min")); toolbar.push([cancelAction], { icon: true, label: false }); return false; diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index 483b1f05a51..b49bc87252d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -11,7 +11,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../base/common/errorMessage.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { FuzzyScore } from '../../../../base/common/filters.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { combinedDisposable, Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../base/common/map.js'; @@ -25,7 +25,7 @@ import { ICodeEditorService } from '../../../../editor/browser/services/codeEdit import { localize } from '../../../../nls.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { ITextResourceEditorInput } from '../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -71,6 +71,7 @@ import { PromptsConfig } from '../common/promptSyntax/config/config.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ComputeAutomaticInstructions } from '../common/promptSyntax/computeAutomaticInstructions.js'; import { startupExpContext, StartupExperimentGroup } from '../../../services/coreExperimentation/common/coreExperimentationService.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; const $ = dom.$; @@ -185,6 +186,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private isRequestPaused: IContextKey; private canRequestBePaused: IContextKey; private agentInInput: IContextKey; + private currentRequest: Promise | undefined; private _visible = false; @@ -290,6 +292,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @ITelemetryService private readonly telemetryService: ITelemetryService, @IPromptsService private readonly promptsService: IPromptsService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService ) { super(); @@ -458,6 +461,21 @@ export class ChatWidget extends Disposable implements IChatWidget { })); this._register(this.onDidChangeParsedInput(() => this.updateChatInputContext())); + + this._register(this.contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(new Set([ + ChatContextKeys.Setup.installed.key, + ChatContextKeys.Entitlement.canSignUp.key + ]))) { + // reset the input in welcome view if it was rendered in experimental mode + if (this.container.classList.contains('experimental-welcome-view')) { + this.container.classList.remove('experimental-welcome-view'); + const renderFollowups = this.viewOptions.renderFollowups ?? false; + const renderStyle = this.viewOptions.renderStyle; + this.createInput(this.container, { renderFollowups, renderStyle }); + } + } + })); } private _lastSelectedAgent: IChatAgentData | undefined; @@ -669,6 +687,15 @@ export class ChatWidget extends Disposable implements IChatWidget { }; }); + + // reset the input in welcome view if it was rendered in experimental mode + if (this.container.classList.contains('experimental-welcome-view')) { + this.container.classList.remove('experimental-welcome-view'); + const renderFollowups = this.viewOptions.renderFollowups ?? false; + const renderStyle = this.viewOptions.renderStyle; + this.createInput(this.container, { renderFollowups, renderStyle }); + } + this.renderWelcomeViewContentIfNeeded(); this._onWillMaybeChangeHeight.fire(); @@ -712,14 +739,6 @@ export class ChatWidget extends Disposable implements IChatWidget { private renderWelcomeViewContentIfNeeded() { - // reset the input in welcome view if it was rendered in experimental mode - if (this.container.classList.contains('experimental-welcome-view')) { - this.container.classList.remove('experimental-welcome-view'); - const renderFollowups = this.viewOptions.renderFollowups ?? false; - const renderStyle = this.viewOptions.renderStyle; - this.createInput(this.container, { renderFollowups, renderStyle }); - } - if (this.viewOptions.renderStyle === 'compact' || this.viewOptions.renderStyle === 'minimal') { return; } @@ -727,27 +746,41 @@ export class ChatWidget extends Disposable implements IChatWidget { const numItems = this.viewModel?.getItems().length ?? 0; if (!numItems) { dom.clearNode(this.welcomeMessageContainer); + // TODO@bhavyaus remove this startup experiment once settled + const startupExpValue = startupExpContext.getValue(this.contextKeyService); + const configuration = this.configurationService.inspect('workbench.secondarySideBar.defaultVisibility'); + const expIsActive = configuration.defaultValue !== 'hidden'; + + const expEmptyState = this.configurationService.getValue('chat.emptyChatState.enabled'); + + const chatSetupTriggerContext = ContextKeyExpr.or( + ChatContextKeys.Setup.installed.negate(), + ChatContextKeys.Entitlement.canSignUp + ); + + let welcomeContent: IChatViewWelcomeContent; const defaultAgent = this.chatAgentService.getDefaultAgent(this.location, this.input.currentModeKind); const additionalMessage = defaultAgent?.metadata.additionalWelcomeMessage; - - const startupExpValue = startupExpContext.getValue(this.contextKeyService); - let welcomeContent: IChatViewWelcomeContent; - if (startupExpValue === StartupExperimentGroup.MaximizedChat + if ((startupExpValue === StartupExperimentGroup.MaximizedChat || startupExpValue === StartupExperimentGroup.SplitEmptyEditorChat - || startupExpValue === StartupExperimentGroup.SplitWelcomeChat) { + || startupExpValue === StartupExperimentGroup.SplitWelcomeChat + || expIsActive) && this.contextKeyService.contextMatchesRules(chatSetupTriggerContext)) { welcomeContent = this.getExpWelcomeViewContent(); this.container.classList.add('experimental-welcome-view'); } + else if (expEmptyState) { + welcomeContent = this.getWelcomeViewContent(additionalMessage, expEmptyState); + } else { const tips = this.input.currentModeKind === ChatModeKind.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 }); - welcomeContent = this.getWelcomeViewContent(); + welcomeContent = this.getWelcomeViewContent(additionalMessage); welcomeContent.tips = tips; } this.welcomePart.value = this.instantiationService.createInstance( ChatViewWelcomePart, - { ...welcomeContent, additionalMessage }, + welcomeContent, { location: this.location, isWidgetAgentWelcomeViewContent: this.input?.currentModeKind === ChatModeKind.Agent @@ -762,58 +795,87 @@ export class ChatWidget extends Disposable implements IChatWidget { } } - private getWelcomeViewContent(): IChatViewWelcomeContent { - const baseMessage = localize('chatMessage', "Copilot is powered by AI, so mistakes are possible. Review output carefully before use."); + private getWelcomeViewContent(additionalMessage: string | IMarkdownString | undefined, expEmptyState?: boolean): IChatViewWelcomeContent { + const disclaimerMessage = expEmptyState + ? localize('chatDisclaimer', "AI responses may be inaccurate.") + : localize('chatMessage', "Copilot is powered by AI, so mistakes are possible. Review output carefully before use."); + const icon = expEmptyState ? Codicon.chatSparkle : Codicon.copilotLarge; + if (this.input.currentModeKind === ChatModeKind.Ask) { return { - title: localize('chatDescription', "Ask Copilot"), - message: new MarkdownString(baseMessage), - icon: Codicon.copilotLarge + title: localize('chatDescription', "Ask about your code."), + message: new MarkdownString(disclaimerMessage), + icon, + additionalMessage, }; } else if (this.input.currentModeKind === ChatModeKind.Edit) { + const editsHelpMessage = localize('editsHelp', "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."); + const message = expEmptyState ? disclaimerMessage : `${editsHelpMessage}\n\n${disclaimerMessage}`; + 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 + title: localize('editsTitle', "Edit in context."), + message: new MarkdownString(message), + icon, + additionalMessage }; } else { + const agentHelpMessage = 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'); + const message = expEmptyState ? disclaimerMessage : `${agentHelpMessage}\n\n${disclaimerMessage}`; + 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 + title: localize('agentTitle', "Build with agent mode."), + message: new MarkdownString(message), + icon, + additionalMessage }; } } private getExpWelcomeViewContent(): IChatViewWelcomeContent { - const baseMessage = localize('chatMessage', "Copilot is powered by AI, so mistakes are possible. Review output carefully before use."); - const welcomeContent = { - title: localize('expChatTitle', 'Get Started with VS Code'), - message: new MarkdownString(baseMessage), + const welcomeContent: IChatViewWelcomeContent = { + title: localize('expChatTitle', 'Welcome to Copilot'), + message: new MarkdownString(localize('expchatMessage', "Let's get started")), icon: Codicon.copilotLarge, - suggestedPrompts: this.getExpSuggestedPrompts(), inputPart: this.inputPart.element, + additionalMessage: localize('expChatAdditionalMessage', "Review AI output carefully before use."), + isExperimental: true, + suggestedPrompts: this.getExpSuggestedPrompts(), }; return welcomeContent; } private getExpSuggestedPrompts(): IChatSuggestedPrompts[] { - - return [ - { - icon: Codicon.vscode, - label: localize('chatWidget.suggestedPrompts.gettingStarted', "Ask @vscode"), - prompt: '@vscode Help me get started with VS Code?', - }, - { - icon: Codicon.newFolder, - label: localize('chatWidget.suggestedPrompts.newProject', "Create a #new Project"), - prompt: '#new Create a new project for me', - } - ]; + // Check if the workbench is empty + const isEmpty = this.contextService.getWorkbenchState() === WorkbenchState.EMPTY; + if (isEmpty) { + return [ + { + icon: Codicon.vscode, + label: localize('chatWidget.suggestedPrompts.gettingStarted', "Ask @vscode"), + prompt: localize('chatWidget.suggestedPrompts.gettingStartedPrompt', "@vscode How do I change the theme to light mode?"), + }, + { + icon: Codicon.newFolder, + label: localize('chatWidget.suggestedPrompts.newProject', "Create project"), + prompt: localize('chatWidget.suggestedPrompts.newProjectPrompt', "Create a #new Hello World project in TypeScript"), + } + ]; + } else { + return [ + { + icon: Codicon.debugAlt, + label: localize('chatWidget.suggestedPrompts.buildWorkspace', "Build workspace"), + prompt: localize('chatWidget.suggestedPrompts.buildWorkspacePrompt', "How do I build this workspace?"), + }, + { + icon: Codicon.gear, + label: localize('chatWidget.suggestedPrompts.findConfig', "Show project config"), + prompt: localize('chatWidget.suggestedPrompts.findConfigPrompt', "Where is the configuration for this project defined?"), + } + ]; + } } - private async renderChatEditingSessionState() { if (!this.input) { return; @@ -1086,6 +1148,12 @@ export class ChatWidget extends Disposable implements IChatWidget { const isInput = this.configurationService.getValue('chat.editRequests') === 'input'; if (!isInput) { + this.inputPart.setChatMode(this.input.currentModeKind); + const currentModel = this.input.selectedLanguageModel; + if (currentModel) { + this.inputPart.switchModel(currentModel.metadata); + } + this.inputPart?.toggleChatInputOverlay(false); try { if (editedRequest?.rowContainer && editedRequest.rowContainer.contains(this.inputContainer)) { @@ -1519,7 +1587,7 @@ export class ChatWidget extends Disposable implements IChatWidget { parseResult = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.slashPromptCommand, CancellationToken.None); if (parseResult) { // add the prompt file to the context, but not sticky - requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, PromptFileVariableKind.PromptFile)); + requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, PromptFileVariableKind.PromptFile, undefined, true)); // remove the slash command from the input requestInput.input = this.parsedInput.parts.filter(part => !(part instanceof ChatRequestSlashPromptPart)).map(part => part.text).join('').trim(); @@ -1620,6 +1688,11 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionId); + if (this.currentRequest) { + // We have to wait the current request to be properly cancelled so that it has a chance to update the model with its result metadata. + // This is awkward, it's basically a limitation of the chat provider-based agent. + await Promise.race([this.currentRequest, timeout(1000)]); + } this.input.validateAgentMode(); @@ -1634,21 +1707,20 @@ export class ChatWidget extends Disposable implements IChatWidget { } const result = await this.chatService.sendRequest(this.viewModel.sessionId, requestInputs.input, { - mode: this.input.currentModeKind, userSelectedModelId: this.input.currentLanguageModel, location: this.location, locationData: this._location.resolveData?.(), parserContext: { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind }, attachedContext: requestInputs.attachedContext.asArray(), noCommandDetection: options?.noCommandDetection, - userSelectedTools: this.getUserSelectedTools(), + ...this.getModeRequestOptions(), modeInstructions: this.input.currentModeObs.get().body?.get() }); if (result) { this.input.acceptInput(isUserQuery); this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand }); - result.responseCompletePromise.then(() => { + this.currentRequest = result.responseCompletePromise.then(() => { const responses = this.viewModel?.getItems().filter(isResponseVM); const lastResponse = responses?.[responses.length - 1]; this.chatAccessibilityService.acceptResponse(lastResponse, requestId, options?.isVoiceInput); @@ -1659,6 +1731,8 @@ export class ChatWidget extends Disposable implements IChatWidget { this.input.setValue(question, false); } } + + this.currentRequest = undefined; }); if (this.viewModel?.editing) { @@ -1727,7 +1801,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } if (this.container.classList.contains('experimental-welcome-view')) { - this.inputPart.layout(layoutHeight, Math.min(width, 700)); + this.inputPart.layout(layoutHeight, Math.min(width, 650)); } else { this.inputPart.layout(layoutHeight, width); @@ -1903,12 +1977,12 @@ export class ChatWidget extends Disposable implements IChatWidget { // if not tools to enable are present, we are done if (tools !== undefined && this.input.currentModeKind === ChatModeKind.Agent) { - const enablementMap = this.toolsService.toToolAndToolSetEnablementMap(new Set(tools)); + const enablementMap = this.toolsService.toToolAndToolSetEnablementMap(tools); this.input.selectedToolsModel.set(enablementMap, true); } if (model !== undefined) { - this.input.switchModelByName(model); + this.input.switchModelByQualifiedName(model); } } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts index 79d057dc429..7ec2881066c 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts @@ -161,17 +161,21 @@ export class ChatImplicitContextContribution extends Disposable implements IWork newValue = { uri: model.uri, range: selection } satisfies Location; isSelection = true; } else { - const visibleRanges = codeEditor?.getVisibleRanges(); - if (visibleRanges && 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: model.uri, range } satisfies Location; - } else { + if (this.configurationService.getValue('chat.implicitContext.suggestedContext')) { newValue = model.uri; + } else { + const visibleRanges = codeEditor?.getVisibleRanges(); + if (visibleRanges && 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: model.uri, range } satisfies Location; + } else { + newValue = model.uri; + } } } } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts index cf529794f3a..f61e7d6b3e9 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts @@ -11,6 +11,8 @@ import { ICodeEditorService } from '../../../../../editor/browser/services/codeE import { Range } from '../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../editor/common/editorCommon.js'; import { TrackedRangeStickiness } from '../../../../../editor/common/model.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.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'; @@ -18,6 +20,7 @@ import { IChatAgentCommand, IChatAgentData, IChatAgentService } from '../../comm import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../common/chatColors.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../common/chatParserTypes.js'; import { ChatRequestParser } from '../../common/chatRequestParser.js'; +import { ChatModeKind } from '../../common/constants.js'; import { IChatWidget } from '../chat.js'; import { ChatWidget } from '../chatWidget.js'; import { dynamicVariableDecorationType } from './chatDynamicVariables.js'; @@ -44,6 +47,7 @@ class InputEditorDecorations extends Disposable { @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IThemeService private readonly themeService: IThemeService, @IChatAgentService private readonly chatAgentService: IChatAgentService, + @IConfigurationService private readonly configurationService: IConfigurationService ) { super(); @@ -126,7 +130,16 @@ class InputEditorDecorations extends Disposable { } if (!inputValue) { - const description = this.widget.input.currentModeObs.get().description.get(); + const mode = this.widget.input.currentModeObs.get(); + let description = mode.description.get(); + if (this.configurationService.getValue('chat.emptyChatState.enabled')) { + if (mode.kind === ChatModeKind.Ask) { + description += ` ${localize('askPlaceholderHint', "# context, @ extensions, / commands")}`; + } else if (mode.kind === ChatModeKind.Edit || mode.kind === ChatModeKind.Agent) { + description += ` ${localize('editPlaceholderHint', "# context")}`; + } + } + const decoration: IDecorationOptions[] = [ { range: { diff --git a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts index 965f71dd31d..a13152e5427 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts @@ -484,13 +484,19 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return result; } - toToolAndToolSetEnablementMap(toolOrToolSetNames: Set): Map { + /** + * Create a map that contains all tools and toolsets with their enablement state. + * @param toolOrToolSetNames A list of tool or toolset names to check for enablement. If undefined, all tools and toolsets are enabled. + * @returns A map of tool or toolset instances to their enablement state. + */ + toToolAndToolSetEnablementMap(enabledToolOrToolSetNames: readonly string[] | undefined): Map { + const toolOrToolSetNames = enabledToolOrToolSetNames ? new Set(enabledToolOrToolSetNames) : undefined; const result = new Map(); for (const tool of this._tools.values()) { - result.set(tool.data, tool.data.toolReferenceName !== undefined && toolOrToolSetNames.has(tool.data.toolReferenceName)); + result.set(tool.data, tool.data.toolReferenceName !== undefined && (toolOrToolSetNames === undefined || toolOrToolSetNames.has(tool.data.toolReferenceName))); } for (const toolSet of this._toolSets) { - result.set(toolSet, toolOrToolSetNames.has(toolSet.referenceName)); + result.set(toolSet, (toolOrToolSetNames === undefined || toolOrToolSetNames.has(toolSet.referenceName))); } return result; } diff --git a/src/vs/workbench/contrib/chat/browser/media/apple-dark.svg b/src/vs/workbench/contrib/chat/browser/media/apple-dark.svg new file mode 100644 index 00000000000..be725db24f3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/media/apple-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/chat/browser/media/apple-light.svg b/src/vs/workbench/contrib/chat/browser/media/apple-light.svg new file mode 100644 index 00000000000..f51d23005c3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/media/apple-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index 2f657d8aea3..ed2b27a18a0 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -1255,6 +1255,10 @@ have to be updated for changes to the rules above, or to support more deeply nes outline-offset: -4px; } +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-button.codicon.codicon-plus { + font-size: 12px; +} + .chat-related-files .monaco-button.codicon.codicon-add:hover, .action-item.chat-attached-context-attachment.chat-add-files:hover, .interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-button:hover { @@ -1975,7 +1979,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } div[data-index="0"] .monaco-tl-contents { - .interactive-item-container.interactive-request { + .interactive-item-container.interactive-request:not(.editing) { padding-top: 19px; } @@ -2061,3 +2065,10 @@ have to be updated for changes to the rules above, or to support more deeply nes width: 1px; height: 1px; } + + +.interactive-session .chat-attached-context .chat-attached-context-attachment.implicit.disabled:hover { + cursor: pointer; + border-style: solid; + background-color: var(--vscode-toolbar-hoverBackground); +} diff --git a/src/vs/workbench/contrib/chat/browser/media/chatSetup.css b/src/vs/workbench/contrib/chat/browser/media/chatSetup.css index 055e3a8f9f2..7bd6ce82a2f 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatSetup.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatSetup.css @@ -23,7 +23,7 @@ background-image: url('./github.svg'); } - .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.alternate::before { + .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.google::before { background-image: url('./google.svg'); } @@ -51,12 +51,12 @@ } } -.monaco-workbench.hc-black .chat-setup-dialog .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.alternate.monochrome::before, -.monaco-workbench.vs-dark .chat-setup-dialog .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.alternate.monochrome::before { - background-image: url('./google-mono-dark.svg'); +.monaco-workbench.hc-black .chat-setup-dialog .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.apple::before, +.monaco-workbench.vs-dark .chat-setup-dialog .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.apple::before { + background-image: url('./apple-dark.svg'); } -.monaco-workbench.hc-light .chat-setup-dialog .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.alternate.monochrome::before, -.monaco-workbench.vs .chat-setup-dialog .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.alternate.monochrome::before { - background-image: url('./google-mono-light.svg'); +.monaco-workbench.hc-light .chat-setup-dialog .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.apple::before, +.monaco-workbench.vs .chat-setup-dialog .dialog-buttons-row > .dialog-buttons > .monaco-button.continue-button.apple::before { + background-image: url('./apple-light.svg'); } diff --git a/src/vs/workbench/contrib/chat/browser/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/media/chatStatus.css index 3cdad3c5980..d47c9b79565 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatStatus.css @@ -138,6 +138,8 @@ text-overflow: ellipsis; text-wrap: nowrap; padding: 2px 8px; + user-select: none; + -webkit-user-select: none; } .chat-status-bar-entry-tooltip .snooze-completions .snooze-label { @@ -147,6 +149,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-variant-numeric: tabular-nums; } .chat-status-bar-entry-tooltip .snooze-completions.disabled .snooze-label { diff --git a/src/vs/workbench/contrib/chat/browser/media/chatViewWelcome.css b/src/vs/workbench/contrib/chat/browser/media/chatViewWelcome.css index d9d69aa9615..6523d2807b4 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatViewWelcome.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatViewWelcome.css @@ -22,7 +22,12 @@ } .interactive-session .experimental-welcome-view & > .chat-welcome-view-input-part { - max-width: 700px; + max-width: 650px; + margin-bottom: 48px; +} + +.interactive-session.experimental-welcome-view .chat-input-toolbars > .chat-input-toolbar > div { + display: none; } /* Container for ChatViewPane welcome view */ @@ -53,6 +58,7 @@ div.chat-welcome-view { font-weight: 500; text-align: center; line-height: normal; + padding: 0 8px; } & > .chat-welcome-view-indicator-container { @@ -74,6 +80,22 @@ div.chat-welcome-view { } } + & > .chat-welcome-view-message.experimental-empty-state { + position: relative; + text-align: center; + max-width: 100%; + margin: 0 auto; + color: var(--vscode-input-placeholderForeground); + + a { + color: var(--vscode-textLink-foreground); + } + p{ + margin-top: 8px; + margin-bottom: 8px; + } + } + .monaco-button { display: inline-block; width: initial; @@ -102,38 +124,58 @@ div.chat-welcome-view { } } + & > .chat-welcome-experimental-view-message { + text-align: center; + max-width: 350px; + padding: 0 20px 32px; + font-size: 16px; + + a { + color: var(--vscode-descriptionForeground); + } + } + + & > .chat-welcome-view-experimental-additional-message { + font-size: 12px; + color: var(--vscode-disabledForeground); + text-align: center; + max-width: 400px; + margin-top: 8px; + } + & > .chat-welcome-view-suggested-prompts { display: flex; flex-wrap: wrap; - gap: 10px; justify-content: center; - margin-top: 15px; + row-gap: 8px; + margin-top: 4px; > .chat-welcome-view-suggested-prompt { display: flex; align-items: center; - padding: 0 4px; + padding: 2px; border-radius: 8px; background-color: var(--vscode-editorWidget-background); cursor: pointer; border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); border-radius: 4px; - gap: 2px; max-width: 100%; width: fit-content; + margin: 0 4px; > .chat-welcome-view-suggested-prompt-icon { display: flex; align-items: center; - margin-right: 8px; font-size: 4px; color: var(--vscode-icon-foreground) !important; align-items: center; + padding: 4px; } > .chat-welcome-view-suggested-prompt-label { font-size: 14px; color: var(--vscode-editorWidget-foreground); + padding: 4px 4px 4px 0; } } diff --git a/src/vs/workbench/contrib/chat/browser/media/google-mono-dark.svg b/src/vs/workbench/contrib/chat/browser/media/google-mono-dark.svg deleted file mode 100644 index 7c91281c36a..00000000000 --- a/src/vs/workbench/contrib/chat/browser/media/google-mono-dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/vs/workbench/contrib/chat/browser/media/google-mono-light.svg b/src/vs/workbench/contrib/chat/browser/media/google-mono-light.svg deleted file mode 100644 index 264c6ce8c5c..00000000000 --- a/src/vs/workbench/contrib/chat/browser/media/google-mono-light.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts index bccee94cbf4..2dce470eacf 100644 --- a/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts @@ -19,6 +19,7 @@ import { getFlatActionBarActions } from '../../../../../platform/actions/browser import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ChatEntitlement, IChatEntitlementService } from '../../common/chatEntitlementService.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../common/modelPicker/modelPickerWidget.js'; export interface IModelPickerDelegate { readonly onDidChangeModel: Event; @@ -35,7 +36,7 @@ function modelDelegateToWidgetActionsProvider(delegate: IModelPickerDelegate): I id: model.metadata.id, enabled: true, checked: model.metadata.id === delegate.getCurrentModel()?.metadata.id, - category: model.metadata.modelPickerCategory, + category: model.metadata.modelPickerCategory || DEFAULT_MODEL_PICKER_CATEGORY, class: undefined, description: model.metadata.cost, tooltip: model.metadata.description ?? model.metadata.name, diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index eac5bbf7c32..e3ce0b0b8d1 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -146,21 +146,22 @@ function getDefaultContentSnippet(promptType: PromptsType): string { `---`, `mode: \${1|ask,edit,agent|}`, `---`, - `\${2:Expected output and any relevant constraints for this task.}`, + `\${2:Define the task to achieve, including specific requirements, constraints, and success criteria.}`, ].join('\n'); case PromptsType.instructions: return [ `---`, `applyTo: '\${1|**,**/*.ts|}'`, `---`, - `\${2:Coding standards, domain knowledge, and preferences that AI should follow.}`, + `\${2:Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.}`, ].join('\n'); case PromptsType.mode: return [ `---`, `description: '\${1:Description of the custom chat mode.}'`, - `tools: [ '\${2:tool1}', '\${3:tool2}' ]`, + `tools: []`, `---`, + `\${2:Define the purpose of this chat mode and how AI should behave: response style, available tools, focus areas, and any mode-specific instructions or constraints.}`, ].join('\n'); default: throw new Error(`Unknown prompt type: ${promptType}`); diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptCodingAgentActionOverlay.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptCodingAgentActionOverlay.ts index a848944936e..9ef18bf66fb 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptCodingAgentActionOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptCodingAgentActionOverlay.ts @@ -40,7 +40,7 @@ export class PromptCodingAgentActionOverlayWidget extends Disposable implements this._button.element.style.background = 'var(--vscode-button-background)'; this._button.element.style.color = 'var(--vscode-button-foreground)'; - this._button.label = localize('runWithCodingAgent.label', "{0} Push to Copilot coding agent", '$(cloud-upload)'); + this._button.label = localize('runWithCodingAgent.label', "{0} Delegate to Copilot coding agent", '$(cloud-upload)'); this._register(this._button.onDidClick(async () => { await this._execute(); @@ -80,10 +80,11 @@ export class PromptCodingAgentActionOverlayWidget extends Disposable implements } private _updateVisibility(): void { + const enableRemoteCodingAgentPromptFileOverlay = ChatContextKeys.enableRemoteCodingAgentPromptFileOverlay.getValue(this._contextKeyService); const hasRemoteCodingAgent = ChatContextKeys.hasRemoteCodingAgent.getValue(this._contextKeyService); const model = this._editor.getModel(); const isPromptFile = model?.getLanguageId() === PROMPT_LANGUAGE_ID; - const shouldBeVisible = !!(hasRemoteCodingAgent && isPromptFile); + const shouldBeVisible = !!(isPromptFile && enableRemoteCodingAgentPromptFileOverlay && hasRemoteCodingAgent); if (shouldBeVisible !== this._isVisible) { this._isVisible = shouldBeVisible; diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts index 9b186f987b5..695b89f1cbd 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts @@ -27,12 +27,17 @@ export class PromptFileRewriter { const model = editor.getModel(); const parser = this._promptsService.getSyntaxParserFor(model); - const { header } = await parser.start(token).settled(); - - if ((header === undefined) || token.isCancellationRequested) { + await parser.start(token).settled(); + const { header } = parser; + if (header === undefined) { return undefined; } + const completed = await header.settled; + if (!completed || token.isCancellationRequested) { + return; + } + if (('tools' in header.metadataUtility) === false) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index 018237936be..904ff2dff6f 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -48,11 +48,14 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider const parser = this.promptsService.getSyntaxParserFor(model); - const { header } = await parser - .start(token) - .settled(); + await parser.start(token).settled(); + const { header } = parser; + if (!header) { + return undefined; + } - if ((header === undefined) || token.isCancellationRequested) { + const completed = await header.settled; + if (!completed || token.isCancellationRequested) { return undefined; } @@ -78,7 +81,7 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider private async updateTools(model: ITextModel, tools: PromptToolsMetadata) { - const selectedToolsNow = tools.value ? this.languageModelToolsService.toToolAndToolSetEnablementMap(new Set(tools.value)) : new Map(); + const selectedToolsNow = tools.value ? this.languageModelToolsService.toToolAndToolSetEnablementMap(tools.value) : new Map(); const newSelectedAfter = await this.instantiationService.invokeFunction(showToolsPicker, localize('placeholder', "Select tools"), undefined, selectedToolsNow); if (!newSelectedAfter) { return; diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptUrlHandler.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptUrlHandler.ts index 9c40e9f90a0..2875e5bcdb7 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptUrlHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptUrlHandler.ts @@ -21,9 +21,10 @@ import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { localize } from '../../../../../nls.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { Schemas } from '../../../../../base/common/network.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; // example URL: code-oss:chat-prompt/install?url=https://gist.githubusercontent.com/aeschli/43fe78babd5635f062aef0195a476aad/raw/dfd71f60058a4dd25f584b55de3e20f5fd580e63/filterEvenNumbers.prompt.md @@ -31,8 +32,6 @@ export class PromptUrlHandler extends Disposable implements IWorkbenchContributi static readonly ID = 'workbench.contrib.promptUrlHandler'; - static readonly CONFIRM_INSTALL_STORAGE_KEY = 'security.promptForPromptProtocolHandling'; - constructor( @IURLService urlService: IURLService, @INotificationService private readonly notificationService: INotificationService, @@ -42,7 +41,8 @@ export class PromptUrlHandler extends Disposable implements IWorkbenchContributi @IOpenerService private readonly openerService: IOpenerService, @ILogService private readonly logService: ILogService, @IDialogService private readonly dialogService: IDialogService, - @IStorageService private readonly storageService: IStorageService, + + @IHostService private readonly hostService: IHostService, ) { super(); this._register(urlService.registerHandler(this)); @@ -67,37 +67,39 @@ export class PromptUrlHandler extends Disposable implements IWorkbenchContributi try { const query = decodeURIComponent(uri.query); if (!query || !query.startsWith('url=')) { - return false; + return true; } const urlString = query.substring(4); const url = URI.parse(urlString); if (url.scheme !== Schemas.https && url.scheme !== Schemas.http) { this.logService.error(`[PromptUrlHandler] Invalid URL: ${urlString}`); - return false; + return true; } + await this.hostService.focus(mainWindow); + if (await this.shouldBlockInstall(promptType, url)) { - return false; + return true; } const result = await this.requestService.request({ type: 'GET', url: urlString }, CancellationToken.None); if (result.res.statusCode !== 200) { this.logService.error(`[PromptUrlHandler] Failed to fetch URL: ${urlString}`); this.notificationService.error(localize('failed', 'Failed to fetch URL: {0}', urlString)); - return false; + return true; } const responseData = (await streamToBuffer(result.stream)).toString(); const newFolder = await this.instantiationService.invokeFunction(askForPromptSourceFolder, promptType); if (!newFolder) { - return false; + return true; } const newName = await this.instantiationService.invokeFunction(askForPromptFileName, promptType, newFolder.uri, getCleanPromptName(url)); if (!newName) { - return false; + return true; } const promptUri = URI.joinPath(newFolder.uri, newName); @@ -110,18 +112,11 @@ export class PromptUrlHandler extends Disposable implements IWorkbenchContributi } catch (error) { this.logService.error(`Error handling prompt URL ${uri.toString()}`, error); - return false; + return true; } } private async shouldBlockInstall(promptType: PromptsType, url: URI): Promise { - const location = url.with({ path: url.path.substring(0, url.path.indexOf('/', 1) + 1), query: undefined, fragment: undefined }).toString(); - const key = PromptUrlHandler.CONFIRM_INSTALL_STORAGE_KEY + '-' + location; - - if (this.storageService.getBoolean(key, StorageScope.APPLICATION, false)) { - return false; - } - let uriLabel = url.toString(); if (uriLabel.length > 50) { uriLabel = `${uriLabel.substring(0, 35)}...${uriLabel.substring(uriLabel.length - 15)}`; @@ -129,27 +124,26 @@ export class PromptUrlHandler extends Disposable implements IWorkbenchContributi const detail = new MarkdownString('', { supportHtml: true }); detail.appendMarkdown(localize('confirmOpenDetail2', "This will access {0}.\n\n", `[${uriLabel}](${url.toString()})`)); - detail.appendMarkdown(localize('confirmOpenDetail1', "Do you want to continue by selecting a destination folder and name?\n\n")); - detail.appendMarkdown(localize('confirmOpenDetail3', "If you did not initiate this request, it may represent an attempted attack on your system. Unless you took an explicit action to initiate this request, you should press 'Cancel'")); + detail.appendMarkdown(localize('confirmOpenDetail3', "If you did not initiate this request, it may represent an attempted attack on your system. Unless you took an explicit action to initiate this request, you should press 'No'")); let message: string; switch (promptType) { case PromptsType.prompt: - message = localize('confirmInstallPrompt', "An external application wants to create a prompt file with content from a URL."); + message = localize('confirmInstallPrompt', "An external application wants to create a prompt file with content from a URL. Do you want to continue by selecting a destination folder and name?"); break; case PromptsType.instructions: - message = localize('confirmInstallInstructions', "An external application wants to create an instructions file with content from a URL."); + message = localize('confirmInstallInstructions', "An external application wants to create an instructions file with content from a URL. Do you want to continue by selecting a destination folder and name?"); break; default: - message = localize('confirmInstallMode', "An external application wants to create a chat mode with content from a URL."); + message = localize('confirmInstallMode', "An external application wants to create a chat mode with content from a URL. Do you want to continue by selecting a destination folder and name?"); break; } - const { confirmed, checkboxChecked } = await this.dialogService.confirm({ + const { confirmed } = await this.dialogService.confirm({ type: 'warning', - primaryButton: localize({ key: 'confirmOpenButton', comment: ['&& denotes a mnemonic'] }, "&&Continue"), + primaryButton: localize({ key: 'yesButton', comment: ['&& denotes a mnemonic'] }, "&&Yes"), + cancelButton: localize('noButton', "No"), message, - checkbox: { label: localize('confirmOpenDoNotAskAgain', "Do not show this message again for files from '{0}'", location) }, custom: { markdownDetails: [{ markdown: detail @@ -157,9 +151,6 @@ export class PromptUrlHandler extends Disposable implements IWorkbenchContributi } }); - if (checkboxChecked) { - this.storageService.store(key, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } return !confirmed; } diff --git a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts index 7b63aae3a9f..46d8ccadeea 100644 --- a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts +++ b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts @@ -16,11 +16,12 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ChatAgentLocation } from '../../common/constants.js'; -import { chatViewsWelcomeRegistry, IChatViewsWelcomeDescriptor } from './chatViewsWelcome.js'; import { IChatWidgetService } from '../chat.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { chatViewsWelcomeRegistry, IChatViewsWelcomeDescriptor } from './chatViewsWelcome.js'; const $ = dom.$; @@ -115,6 +116,7 @@ export interface IChatViewWelcomeContent { additionalMessage?: string | IMarkdownString; tips?: IMarkdownString; inputPart?: HTMLElement; + isExperimental?: boolean; suggestedPrompts?: IChatSuggestedPrompts[]; } @@ -141,6 +143,7 @@ export class ChatViewWelcomePart extends Disposable { @ILogService private logService: ILogService, @IChatWidgetService private chatWidgetService: IChatWidgetService, @ITelemetryService private telemetryService: ITelemetryService, + @IConfigurationService private configurationService: IConfigurationService, ) { super(); this.element = dom.$('.chat-welcome-view'); @@ -159,63 +162,71 @@ export class ChatViewWelcomePart extends Disposable { title.textContent = content.title; // Preview indicator - if (typeof content.message !== 'function' && options?.isWidgetAgentWelcomeViewContent) { + const expEmptyState = this.configurationService.getValue('chat.emptyChatState.enabled'); + if (typeof content.message !== 'function' && options?.isWidgetAgentWelcomeViewContent && !expEmptyState) { const container = dom.append(this.element, $('.chat-welcome-view-indicator-container')); dom.append(container, $('.chat-welcome-view-subtitle', undefined, localize('agentModeSubtitle', "Agent Mode"))); } // Message - const message = dom.append(this.element, $('.chat-welcome-view-message')); + const message = dom.append(this.element, content.isExperimental ? $('.chat-welcome-experimental-view-message') : $('.chat-welcome-view-message')); + message.classList.toggle('experimental-empty-state', expEmptyState); if (typeof content.message === 'function') { dom.append(message, content.message(this._register(new DisposableStore()))); } else { 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); - } - - if (content.inputPart) { + if (content.isExperimental && content.inputPart) { + content.inputPart.querySelector('.chat-attachments-container')?.remove(); dom.append(this.element, content.inputPart); - } - if (content.suggestedPrompts && content.suggestedPrompts.length) { + if (content.suggestedPrompts && content.suggestedPrompts.length) { + // create a tile with icon and label for each suggested promot + const suggestedPromptsContainer = dom.append(this.element, $('.chat-welcome-view-suggested-prompts')); + for (const prompt of content.suggestedPrompts) { + const promptElement = dom.append(suggestedPromptsContainer, $('.chat-welcome-view-suggested-prompt')); + if (prompt.icon) { + const iconElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-icon')); + iconElement.appendChild(renderIcon(prompt.icon)); + } + const labelElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-label')); + labelElement.textContent = prompt.label; + this._register(dom.addDisposableListener(promptElement, dom.EventType.CLICK, () => { - // create a tile with icon and label for each suggested promot - const suggestedPromptsContainer = dom.append(this.element, $('.chat-welcome-view-suggested-prompts')); - for (const prompt of content.suggestedPrompts) { - const promptElement = dom.append(suggestedPromptsContainer, $('.chat-welcome-view-suggested-prompt')); - if (prompt.icon) { - const iconElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-icon')); - iconElement.appendChild(renderIcon(prompt.icon)); + type SuggestedPromptClickEvent = { suggestedPrompt: string }; + + type SuggestedPromptClickData = { + owner: 'bhavyaus'; + comment: 'Event used to gain insights into when suggested prompts are clicked.'; + suggestedPrompt: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The suggested prompt clicked.' }; + }; + + this.telemetryService.publicLog2('chat.clickedSuggestedPrompt', { + suggestedPrompt: prompt.prompt, + }); + + this.chatWidgetService.lastFocusedWidget?.focusInput(); + this.chatWidgetService.lastFocusedWidget?.setInput(prompt.prompt); + })); } - const labelElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-label')); - labelElement.textContent = prompt.label; - this._register(dom.addDisposableListener(promptElement, dom.EventType.CLICK, () => { + } - type SuggestedPromptClickEvent = { suggestedPrompt: string }; - - type SuggestedPromptClickData = { - owner: 'bhavyaus'; - comment: 'Event used to gain insights into when suggested prompts are clicked.'; - suggestedPrompt: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The suggested prompt clicked.' }; - }; - - this.telemetryService.publicLog2('chat.clickedSuggestedPrompt', { - suggestedPrompt: prompt.prompt, - }); - - this.chatWidgetService.lastFocusedWidget?.setInput(prompt.prompt); - })); + if (typeof content.additionalMessage === 'string') { + const additionalMsg = $('.chat-welcome-view-experimental-additional-message'); + additionalMsg.textContent = content.additionalMessage; + dom.append(this.element, additionalMsg); + } + } else { + // 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); } } diff --git a/src/vs/workbench/contrib/chat/common/chatAgents.ts b/src/vs/workbench/contrib/chat/common/chatAgents.ts index 9336fbe6ce5..dc74c196260 100644 --- a/src/vs/workbench/contrib/chat/common/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/chatAgents.ts @@ -16,6 +16,7 @@ import { equalsIgnoreCase } from '../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { Command } from '../../../../editor/common/languages.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -27,7 +28,7 @@ import { ChatContextKeys } from './chatContextKeys.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, ChatModeKind } from './constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from './constants.js'; //#region agent service, commands etc @@ -238,6 +239,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService { constructor( @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this._hasDefaultAgent = ChatContextKeys.enabled.bindTo(this.contextKeyService); @@ -392,7 +394,8 @@ export class ChatAgentService extends Disposable implements IChatAgentService { } public get hasToolsAgent(): boolean { - return !!this._hasToolsAgent; + // The chat participant enablement is just based on this setting. Don't wait for the extension to be loaded. + return !!this.configurationService.getValue(ChatConfiguration.AgentEnabled); } getContributedDefaultAgent(location: ChatAgentLocation): IChatAgentData | undefined { diff --git a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts index 86f5327ae6d..764ad70e395 100644 --- a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts @@ -56,6 +56,7 @@ export namespace ChatContextKeys { export const remoteJobCreating = new RawContextKey('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") }); export const hasRemoteCodingAgent = new RawContextKey('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available")); + export const enableRemoteCodingAgentPromptFileOverlay = new RawContextKey('enableRemoteCodingAgentPromptFileOverlay', false, localize('enableRemoteCodingAgentPromptFileOverlay', "Whether the remote coding agent prompt file overlay feature is enabled")); export const Setup = { hidden: new RawContextKey('chatSetupHidden', false, true), // True when chat setup is explicitly hidden. diff --git a/src/vs/workbench/contrib/chat/common/chatEntitlementService.ts b/src/vs/workbench/contrib/chat/common/chatEntitlementService.ts index f8e2b2f4e17..2850f656171 100644 --- a/src/vs/workbench/contrib/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/contrib/chat/common/chatEntitlementService.ts @@ -134,9 +134,7 @@ const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '', upgradePlanUrl: product.defaultChatAgent?.upgradePlanUrl ?? '', - providerId: product.defaultChatAgent?.providerId ?? '', - enterpriseProviderId: product.defaultChatAgent?.enterpriseProviderId ?? '', - alternativeProviderId: product.defaultChatAgent?.alternativeProviderId ?? '', + provider: product.defaultChatAgent?.provider ?? { default: { id: '' }, enterprise: { id: '' } }, providerScopes: product.defaultChatAgent?.providerScopes ?? [[]], entitlementUrl: product.defaultChatAgent?.entitlementUrl ?? '', entitlementSignupLimitedUrl: product.defaultChatAgent?.entitlementSignupLimitedUrl ?? '', @@ -421,11 +419,11 @@ interface IQuotas { export class ChatEntitlementRequests extends Disposable { static providerId(configurationService: IConfigurationService): string { - if (configurationService.getValue(`${defaultChat.completionsAdvancedSetting}.authProvider`) === defaultChat.enterpriseProviderId) { - return defaultChat.enterpriseProviderId; + if (configurationService.getValue(`${defaultChat.completionsAdvancedSetting}.authProvider`) === defaultChat.provider?.enterprise.id) { + return defaultChat.provider!.enterprise.id; } - return defaultChat.providerId; + return defaultChat.provider!.default.id; } private state: IEntitlements; @@ -568,7 +566,7 @@ export class ChatEntitlementRequests extends Disposable { } private async doResolveEntitlement(session: AuthenticationSession, token: CancellationToken): Promise { - if (ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.enterpriseProviderId) { + if (ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider?.enterprise.id) { this.logService.trace('[chat entitlement]: enterprise provider, assuming Enterprise plan'); return { entitlement: ChatEntitlement.Enterprise }; } @@ -857,9 +855,9 @@ export class ChatEntitlementRequests extends Disposable { } } - async signIn(options?: { useAlternateProvider?: boolean }) { + async signIn(options?: { useSocialProvider?: string }) { const providerId = ChatEntitlementRequests.providerId(this.configurationService); - const session = await this.authenticationService.createSession(providerId, defaultChat.providerScopes[0], options?.useAlternateProvider ? { provider: defaultChat.alternativeProviderId } : undefined); + const session = await this.authenticationService.createSession(providerId, defaultChat.providerScopes[0], options?.useSocialProvider ? { provider: options.useSocialProvider } : undefined); this.authenticationExtensionsService.updateAccountPreference(defaultChat.extensionId, providerId, session.account); this.authenticationExtensionsService.updateAccountPreference(defaultChat.chatExtensionId, providerId, session.account); diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 5556dfdc80e..7f6ae8d59a8 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -901,7 +901,7 @@ export interface IChatRequestDisablement { afterUndoStop?: string; } -export interface IChatModel { +export interface IChatModel extends IDisposable { readonly onDidDispose: Event; readonly onDidChange: Event; readonly sessionId: string; diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index 6cabc697dcd..85d0bbb9750 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -185,7 +185,7 @@ export class ChatModeService extends Disposable implements IChatModeService { ]; if (this.chatAgentService.hasToolsAgent) { - builtinModes.push(ChatMode.Agent); + builtinModes.unshift(ChatMode.Agent); } builtinModes.push(ChatMode.Edit); return builtinModes; @@ -333,9 +333,9 @@ export class BuiltinChatMode implements IChatMode { } export namespace ChatMode { - export const Ask = new BuiltinChatMode(ChatModeKind.Ask, 'Ask', localize('chatDescription', "Ask Copilot")); - export const Edit = new BuiltinChatMode(ChatModeKind.Edit, 'Edit', localize('editsDescription', "Edit files in your workspace")); - export const Agent = new BuiltinChatMode(ChatModeKind.Agent, 'Agent', localize('agentDescription', "Edit files in your workspace in agent mode")); + export const Ask = new BuiltinChatMode(ChatModeKind.Ask, 'Ask', localize('chatDescription', "Ask a question.")); + export const Edit = new BuiltinChatMode(ChatModeKind.Edit, 'Edit', localize('editsDescription', "Edit files.")); + export const Agent = new BuiltinChatMode(ChatModeKind.Agent, 'Agent', localize('agentDescription', "Build autonomously.")); } export function isBuiltinChatMode(mode: IChatMode): boolean { diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index f537edeed16..fbaaeae7670 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -574,6 +574,8 @@ export interface IChatService { activateDefaultAgent(location: ChatAgentLocation): Promise; readonly edits2Enabled: boolean; + + readonly requestInProgressObs: IObservable; } 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 7e937103259..a9f81d447c6 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -13,6 +13,7 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { Disposable, DisposableMap, IDisposable } from '../../../../base/common/lifecycle.js'; import { revive } from '../../../../base/common/marshalling.js'; +import { derived, IObservable, ObservableMap } from '../../../../base/common/observable.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { URI } from '../../../../base/common/uri.js'; import { isLocation } from '../../../../editor/common/languages.js'; @@ -24,11 +25,10 @@ import { Progress } from '../../../../platform/progress/common/progress.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 { 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 { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, IParsedChatRequest, chatAgentLeader, chatSubcommandLeader, getPromptText } from './chatParserTypes.js'; +import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } 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'; @@ -109,7 +109,7 @@ class CancellableRequest implements IDisposable { export class ChatService extends Disposable implements IChatService { declare _serviceBrand: undefined; - private readonly _sessionModels = this._register(new DisposableMap()); + private readonly _sessionModels = new ObservableMap(); private readonly _pendingRequests = this._register(new DisposableMap()); private _persistedSessions: ISerializableChatsData; @@ -134,6 +134,8 @@ export class ChatService extends Disposable implements IChatService { private readonly _chatServiceTelemetry: ChatServiceTelemetry; private readonly _chatSessionStore: ChatSessionStore; + readonly requestInProgressObs: IObservable; + @memoize private get useFileStorage(): boolean { return this.configurationService.getValue(ChatConfiguration.UseFileStorage); @@ -158,7 +160,6 @@ export class ChatService extends Disposable implements IChatService { @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, @IChatTransferService private readonly chatTransferService: IChatTransferService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, ) { @@ -196,6 +197,11 @@ export class ChatService extends Disposable implements IChatService { } this._register(storageService.onWillSaveState(() => this.saveState())); + + this.requestInProgressObs = derived(reader => { + const models = this._sessionModels.observable.read(reader).values(); + return Array.from(models).some(model => model.requestInProgressObs.read(reader)); + }); } isEnabled(location: ChatAgentLocation): boolean { @@ -614,7 +620,7 @@ export class ChatService extends Disposable implements IChatService { if (request.shouldBeRemovedOnSend.afterUndoStop) { request.response?.finalizeUndoState(); } else { - this.removeRequest(sessionId, request.id); + await this.removeRequest(sessionId, request.id); } } } @@ -796,7 +802,6 @@ export class ChatService extends Disposable implements IChatService { const agent = (detectedAgent ?? agentPart?.agent ?? defaultAgent)!; const command = detectedCommand ?? agentSlashCommandPart?.command; await this.extensionService.activateByEvent(`onChatParticipant:${agent.id}`); - await this.checkAgentAllowed(agent); // Recompute history in case the agent or command changed const history = this.getHistoryEntriesFromModel(requests, model.sessionId, location, agent.id); @@ -951,15 +956,6 @@ export class ChatService extends Disposable implements IChatService { return attachedContextVariables; } - private async checkAgentAllowed(agent: IChatAgentData): Promise { - if (agent.modes.includes(ChatModeKind.Agent)) { - const enabled = await this.experimentService.getTreatment('chatAgentEnabled'); - if (enabled === false) { - throw new Error('Agent is currently disabled'); - } - } - } - private attachmentKindsForTelemetry(variableData: IChatRequestVariableData): string[] { // TODO this shows why attachments still have to be cleaned up somewhat return variableData.variables.map(v => { @@ -1125,7 +1121,8 @@ export class ChatService extends Disposable implements IChatService { } } - this._sessionModels.deleteAndDispose(sessionId); + this._sessionModels.delete(sessionId); + model.dispose(); this._pendingRequests.get(sessionId)?.cancel(); this._pendingRequests.deleteAndDispose(sessionId); this._onDidDisposeSession.fire({ sessionId, reason: 'cleared' }); diff --git a/src/vs/workbench/contrib/chat/common/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/chatVariableEntries.ts index cd074ad4d8f..90d5f479c92 100644 --- a/src/vs/workbench/contrib/chat/common/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/chatVariableEntries.ts @@ -187,7 +187,7 @@ export interface IPromptFileVariableEntry extends IBaseChatRequestVariableEntry readonly isRoot: boolean; readonly originLabel?: string; readonly modelDescription: string; - readonly isHidden: boolean; + readonly automaticallyAdded: boolean; } export interface IPromptTextVariableEntry extends IBaseChatRequestVariableEntry { @@ -195,7 +195,7 @@ export interface IPromptTextVariableEntry extends IBaseChatRequestVariableEntry readonly value: string; readonly settingId?: string; readonly modelDescription: string; - readonly isHidden: boolean; + readonly automaticallyAdded: boolean; } export interface ISCMHistoryItemVariableEntry extends IBaseChatRequestVariableEntry { @@ -291,7 +291,7 @@ export enum PromptFileVariableKind { * @param uri A resource URI that points to a prompt instructions file. * @param kind The kind of the prompt file variable entry. */ -export function toPromptFileVariableEntry(uri: URI, kind: PromptFileVariableKind, originLabel?: string): IPromptFileVariableEntry { +export function toPromptFileVariableEntry(uri: URI, kind: PromptFileVariableKind, originLabel?: string, automaticallyAdded = false): IPromptFileVariableEntry { // `id` for all `prompt files` starts with the well-defined part that the copilot extension(or other chatbot) can rely on return { id: `${kind}__${uri.toString()}`, @@ -301,11 +301,11 @@ export function toPromptFileVariableEntry(uri: URI, kind: PromptFileVariableKind modelDescription: 'Prompt instructions file', isRoot: kind !== PromptFileVariableKind.InstructionReference, originLabel, - isHidden: kind === PromptFileVariableKind.PromptFile + automaticallyAdded }; } -export function toPromptTextVariableEntry(content: string, settingId?: string): IPromptTextVariableEntry { +export function toPromptTextVariableEntry(content: string, settingId?: string, automaticallyAdded = false): IPromptTextVariableEntry { return { id: `vscode.prompt.instructions.text${settingId ? `.${settingId}` : ''}`, name: `prompt:text`, @@ -313,7 +313,7 @@ export function toPromptTextVariableEntry(content: string, settingId?: string): settingId, kind: 'promptText', modelDescription: 'Prompt instructions text', - isHidden: true, // do not show in the UI + automaticallyAdded }; } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index ab617994c94..a27f4e23678 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -9,6 +9,7 @@ export enum ChatConfiguration { Edits2Enabled = 'chat.edits2.enabled', ExtensionToolsEnabled = 'chat.extensionTools.enabled', EditRequests = 'chat.editRequests', + EnableMath = 'chat.math.enabled', } /** diff --git a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts index be257861899..f1df32784ce 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -275,7 +275,7 @@ export interface ILanguageModelToolsService { resetToolAutoConfirmation(): void; cancelToolCallsForRequest(requestId: string): void; toToolEnablementMap(toolOrToolSetNames: Set): Record; - toToolAndToolSetEnablementMap(toolOrToolSetNames: Set): IToolAndToolSetEnablementMap; + toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[] | undefined): IToolAndToolSetEnablementMap; readonly toolSets: IObservable>; getToolSet(id: string): ToolSet | undefined; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 99051b82c75..3cbf23c7263 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -134,7 +134,7 @@ export interface ILanguageModelChatMetadata { readonly isDefault?: boolean; readonly isUserSelectable?: boolean; - readonly modelPickerCategory: { label: string; order: number }; + readonly modelPickerCategory: { label: string; order: number } | undefined; readonly auth?: { readonly providerLabel: string; readonly accountLabel?: string; @@ -151,6 +151,14 @@ export namespace ILanguageModelChatMetadata { const supportsToolsAgent = typeof metadata.capabilities?.agentMode === 'undefined' || metadata.capabilities.agentMode; return supportsToolsAgent && !!metadata.capabilities?.toolCalling; } + + export function asQualifiedName(metadata: ILanguageModelChatMetadata): string { + if (metadata.modelPickerCategory === undefined) { + // in the others category + return `${metadata.name} (${metadata.family})`; + } + return metadata.name; + } } export interface ILanguageModelChatResponse { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/base/baseDecoder.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/base/baseDecoder.ts index 2d0780e7d7c..76d1745d3c6 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/base/baseDecoder.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/codecs/base/baseDecoder.ts @@ -67,18 +67,22 @@ export abstract class BaseDecoder< * Promise that resolves when the stream has ended, either by * receiving the `end` event or by a disposal, but not when * the `error` event is received alone. + * The promise is true if the stream has ended, and false + * if the stream has been disposed without ending. */ - private settledPromise = new DeferredPromise(); + private settledPromise = new DeferredPromise(); /** * Promise that resolves when the stream has ended, either by * receiving the `end` event or by a disposal, but not when * the `error` event is received alone. + * The promise is true if the stream has ended, and false + * if the stream has been disposed without ending. * * @throws If the stream was not yet started to prevent this * promise to block the consumer calls indefinitely. */ - public get settled(): Promise { + public get settled(): Promise { // if the stream has not started yet, the promise might // block the consumer calls indefinitely if they forget // to call the `start()` method, or if the call happens @@ -296,7 +300,7 @@ export abstract class BaseDecoder< this._ended = true; this._onEnd.fire(); - this.settledPromise.complete(); + this.settledPromise.complete(this._ended); } /** @@ -347,7 +351,7 @@ export abstract class BaseDecoder< } public override dispose(): void { - this.settledPromise.complete(); + this.settledPromise.complete(this.ended); this._listeners.clearAndDisposeAll(); this.stream.destroy(); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 22dab49c4b2..97b28420f83 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -74,7 +74,7 @@ export class ComputeAutomaticInstructions { const instructionsWithPatternsList = await this._getInstructionsWithPatternsList(instructionFiles, variables, token); if (instructionsWithPatternsList.length > 0) { const text = instructionsWithPatternsList.join('\n'); - variables.add(toPromptTextVariableEntry(text, PromptsConfig.COPILOT_INSTRUCTIONS)); + variables.add(toPromptTextVariableEntry(text, PromptsConfig.COPILOT_INSTRUCTIONS, true)); } // add all instructions for all instruction files that are in the context await this._addReferencedInstructions(variables, token); @@ -114,7 +114,7 @@ export class ComputeAutomaticInstructions { localize('instruction.file.reason.specificFile', 'Automatically attached as pattern {0} matches {1}', applyTo, this._labelService.getUriLabel(match.file, { relative: true })); - autoAddedInstructions.push(toPromptFileVariableEntry(uri, PromptFileVariableKind.Instruction, reason)); + autoAddedInstructions.push(toPromptFileVariableEntry(uri, PromptFileVariableKind.Instruction, reason, true)); } else { this._logService.trace(`[InstructionsContextComputer] No match for ${uri} with ${applyTo}`); } @@ -153,7 +153,7 @@ export class ComputeAutomaticInstructions { for (const instructionFilePath of instructionFiles) { const file = joinPath(folder.uri, instructionFilePath); if (await this._fileService.exists(file)) { - entries.push(toPromptFileVariableEntry(file, PromptFileVariableKind.Instruction, localize('instruction.file.reason.copilot', 'Automatically attached as setting {0} is enabled', PromptsConfig.USE_COPILOT_INSTRUCTION_FILES))); + entries.push(toPromptFileVariableEntry(file, PromptFileVariableKind.Instruction, localize('instruction.file.reason.copilot', 'Automatically attached as setting {0} is enabled', PromptsConfig.USE_COPILOT_INSTRUCTION_FILES), true)); } } } @@ -247,7 +247,8 @@ export class ComputeAutomaticInstructions { const result = await this._parseInstructionsFile(next, token); const refsToCheck: { resource: URI }[] = []; for (const ref of result.references) { - if (!seen.has(ref) && isPromptOrInstructionsFile(ref)) { + if (!seen.has(ref) && (isPromptOrInstructionsFile(ref) || this._workspaceService.getWorkspaceFolder(ref) !== undefined)) { + // only add references that are either prompt or instruction files or are part of the workspace refsToCheck.push({ resource: ref }); seen.add(ref); } @@ -258,9 +259,12 @@ export class ComputeAutomaticInstructions { const stat = stats[i]; const uri = refsToCheck[i].resource; if (stat.success && stat.stat?.isFile) { - todo.push(uri); + if (isPromptOrInstructionsFile(uri)) { + // only recursivly parse instruction files + todo.push(uri); + } const reason = localize('instruction.file.reason.referenced', 'Referenced by {0}', basename(next)); - attachedContext.add(toPromptFileVariableEntry(uri, PromptFileVariableKind.InstructionReference, reason)); + attachedContext.add(toPromptFileVariableEntry(uri, PromptFileVariableKind.InstructionReference, reason, true)); } } } 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 ea70d0137d1..47705213282 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/filePromptContentsProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/filePromptContentsProvider.ts @@ -57,26 +57,28 @@ export class FilePromptContentProvider extends PromptContentsProviderBase { - // if file was added or updated, forward the event to - // the `getContentsStream()` produce a new stream for file contents - if (event.contains(this.uri, FileChangeType.ADDED, FileChangeType.UPDATED)) { - // we support only full file parsing right now because - // the event doesn't contain a list of changed lines - this.onChangeEmitter.fire('full'); - return; - } + if (options.updateOnChange) { + // make sure the object is updated on file changes + this._register( + this.fileService.onDidFilesChange((event) => { + // if file was added or updated, forward the event to + // the `getContentsStream()` produce a new stream for file contents + if (event.contains(this.uri, FileChangeType.ADDED, FileChangeType.UPDATED)) { + // we support only full file parsing right now because + // the event doesn't contain a list of changed lines + this.onChangeEmitter.fire('full'); + return; + } - // if file was deleted, forward the event to - // the `getContentsStream()` produce an error - if (event.contains(this.uri, FileChangeType.DELETED)) { - this.onChangeEmitter.fire(event); - return; - } - }), - ); + // if file was deleted, forward the event to + // the `getContentsStream()` produce an error + if (event.contains(this.uri, FileChangeType.DELETED)) { + this.onChangeEmitter.fire(event); + return; + } + }), + ); + } } /** 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 73eaaae6a1f..7c9e58dc3d3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/promptContentsProviderBase.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/promptContentsProviderBase.ts @@ -29,6 +29,11 @@ export interface IPromptContentsProviderOptions { * Language ID to use for the prompt contents. If not set, the language ID will be inferred from the file. */ readonly languageId: string | undefined; + + /** + * If set to `true`, the contents provider will listen for updates and retrigger a parse. + */ + readonly updateOnChange: boolean; } 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 fc9b0714814..e083b773206 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/textModelContentsProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/contentProviders/textModelContentsProvider.ts @@ -42,9 +42,9 @@ export class TextModelContentsProvider extends PromptContentsProviderBase { - // clean up all previously added markers - this.markerService.remove(MARKERS_OWNER_ID, [this.model.uri]); const { header } = this.parser; if (header === undefined) { + this.markerService.remove(MARKERS_OWNER_ID, [this.model.uri]); return; } // header parsing process is separate from the prompt parsing one, hence // apply markers only after the header is settled and so has diagnostics - await header.settled; - // by the time the promise finishes, the token might have been cancelled - // already due to a new 'onSettle' event, hence don't apply outdated markers - if (token.isCancellationRequested) { + const completed = await header.settled; + if (!completed || token.isCancellationRequested) { return; } @@ -84,6 +81,11 @@ class PromptHeaderDiagnosticsProvider extends ProviderInstanceBase { } + if (markers.length === 0) { + this.markerService.remove(MARKERS_OWNER_ID, [this.model.uri]); + return; + } + this.markerService.changeOne( MARKERS_OWNER_ID, this.model.uri, @@ -119,7 +121,7 @@ class PromptHeaderDiagnosticsProvider extends ProviderInstanceBase { findModelByName(languageModes: string[], modelName: string): ILanguageModelChatMetadata | undefined { for (const model of languageModes) { const metadata = this.languageModelsService.lookupLanguageModel(model); - if (metadata && metadata.isUserSelectable !== false && metadata.name === modelName) { + if (metadata && metadata.isUserSelectable !== false && ILanguageModelChatMetadata.asQualifiedName(metadata) === modelName) { return metadata; } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts index 85aac5ad625..a8505faeb8e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts @@ -12,7 +12,7 @@ import { Hover, HoverContext, HoverProvider } from '../../../../../../editor/com import { ITextModel } from '../../../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../../../editor/common/services/languageFeatures.js'; import { localize } from '../../../../../../nls.js'; -import { ILanguageModelsService } from '../../languageModels.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, ToolSet } from '../../languageModelToolsService.js'; import { InstructionsHeader } from '../parsers/promptHeader/instructionsHeader.js'; import { PromptModelMetadata } from '../parsers/promptHeader/metadata/model.js'; @@ -70,7 +70,10 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid return undefined; } - await header.settled; + const completed = await header.settled; + if (!completed || token.isCancellationRequested) { + return undefined; + } if (header instanceof InstructionsHeader) { const descriptionRange = header.metadataUtility.description?.range; @@ -124,7 +127,7 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid if (toolRange?.containsPosition(position)) { const tool = this.languageModelToolsService.getToolByName(toolName); if (tool) { - return this.createHover(tool.displayName, toolRange); + return this.createHover(tool.modelDescription, toolRange); } const toolSet = this.languageModelToolsService.getToolSetByName(toolName); if (toolSet) { @@ -143,22 +146,25 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid lines.push(toolSet.description); } for (const tool of toolSet.getTools()) { - lines.push(`- ${tool.toolReferenceName ?? tool.displayName} (${tool.displayName})`); + lines.push(`- ${tool.toolReferenceName ?? tool.displayName}`); } return this.createHover(lines.join('\n'), range); } private getModelHover(node: PromptModelMetadata, range: Range, baseMessage: string): Hover | undefined { - if (node.value) { - + const modelName = node.value; + if (modelName) { for (const id of this.languageModelsService.getLanguageModelIds()) { const meta = this.languageModelsService.lookupLanguageModel(id); - if (meta) { + if (meta && ILanguageModelChatMetadata.asQualifiedName(meta) === modelName) { const lines: string[] = []; lines.push(baseMessage + '\n'); - lines.push(localize('modelName', '{0}', meta.description ?? meta.name)); + lines.push(localize('modelName', '- Name: {0}', meta.name)); lines.push(localize('modelFamily', '- Family: {0}', meta.family)); lines.push(localize('modelVendor', '- Vendor: {0}', meta.vendor)); + if (meta.description) { + lines.push('', '', meta.description); + } return this.createHover(lines.join('\n'), range); } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts index 766af1f2383..22b498d8b3a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptLinkProvider.ts @@ -26,7 +26,7 @@ export class PromptLinkProvider implements LinkProvider { public async provideLinks( model: ITextModel, token: CancellationToken, - ): Promise { + ): Promise { assert( !token.isCancellationRequested, new CancellationError(), @@ -40,15 +40,11 @@ export class PromptLinkProvider implements LinkProvider { // start the parser in case it was not started yet, // and wait for it to settle to a final result - const { references } = await parser - .start(token) - .settled(); - - // validate that the cancellation was not yet requested - assert( - !token.isCancellationRequested, - new CancellationError(), - ); + const completed = await parser.start(token).settled(); + if (!completed || token.isCancellationRequested) { + return undefined; + } + const { references } = parser; // filter out references that are not valid links const links: ILink[] = references diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptPathAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptPathAutocompletion.ts index 8470df895c9..b3e9f429cb8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptPathAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptPathAutocompletion.ts @@ -17,7 +17,7 @@ import { IPromptsService } from '../service/promptsService.js'; import { URI } from '../../../../../../base/common/uri.js'; import { isOneOf } from '../../../../../../base/common/types.js'; -import { extUri } from '../../../../../../base/common/resources.js'; +import { dirname, extUri } from '../../../../../../base/common/resources.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; @@ -137,28 +137,18 @@ export class PromptPathAutocompletion extends Disposable implements CompletionIt // start the parser in case it was not started yet, // and wait for it to settle to a final result - const { references } = await parser - .start(token) - .settled(); - - // validate that the cancellation was not yet requested - assert( - !token.isCancellationRequested, - new CancellationError(), - ); + const completed = await parser.start(token).settled(); + if (!completed || token.isCancellationRequested) { + return undefined; + } + const { references } = parser; const fileReference = findFileReference(references, position); if (!fileReference) { return undefined; } - 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; - } + const parentFolder = dirname(parser.uri); // 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 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 e900e1ce2b8..156859802ef 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts @@ -23,7 +23,7 @@ import type { IPromptContentsProvider } from '../contentProviders/types.js'; import type { TPromptReference, ITopError } from './types.js'; import { type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { assert, assertNever } from '../../../../../../base/common/assert.js'; -import { basename, dirname } from '../../../../../../base/common/resources.js'; +import { basename, dirname, joinPath } from '../../../../../../base/common/resources.js'; import { BaseToken } from '../codecs/base/baseToken.js'; import { VSBufferReadableStream } from '../../../../../../base/common/buffer.js'; import { type IRange, Range } from '../../../../../../editor/common/core/range.js'; @@ -31,7 +31,6 @@ import { PromptHeader, type TPromptMetadata } from './promptHeader/promptHeader. import { ObservableDisposable } from '../utils/observableDisposable.js'; import { INSTRUCTIONS_LANGUAGE_ID, MODE_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../promptTypes.js'; import { LinesDecoder } from '../codecs/base/linesCodec/linesDecoder.js'; -import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { MarkdownLink } from '../codecs/base/markdownCodec/tokens/markdownLink.js'; import { MarkdownToken } from '../codecs/base/markdownCodec/tokens/markdownToken.js'; @@ -39,17 +38,13 @@ import { FrontMatterHeader } from '../codecs/base/markdownExtensionsCodec/tokens import { OpenFailed, NotPromptFile, RecursiveReference, FolderReference, ResolveError } from '../../promptFileReferenceErrors.js'; import { type IPromptContentsProviderOptions } from '../contentProviders/promptContentsProviderBase.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { Schemas } from '../../../../../../base/common/network.js'; /** * Options of the {@link BasePromptParser} class. */ export interface IBasePromptParserOptions { - /** - * 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[]; } export type IPromptParserOptions = IBasePromptParserOptions & IPromptContentsProviderOptions; @@ -66,8 +61,7 @@ export type TErrorCondition = OpenFailed | RecursiveReference | FolderReference */ export class BasePromptParser extends ObservableDisposable { /** - * Options passed to the constructor, extended with - * value defaults from {@link DEFAULT_OPTIONS}. + * Options passed to the constructor. */ protected readonly options: IBasePromptParserOptions; @@ -197,7 +191,7 @@ export class BasePromptParser * block until the latest prompt contents parsing logic is settled * (e.g., for every `onContentChanged` event of the prompt source). */ - public async settled(): Promise { + public async settled(): Promise { assert( this.started, 'Cannot wait on the parser that did not start yet.', @@ -206,13 +200,13 @@ export class BasePromptParser await this.firstParseResult.promise; if (this.errorCondition) { - return this; + return false; } // by the time when the `firstParseResult` promise is resolved, // this object may have been already disposed, hence noop if (this.isDisposed) { - return this; + return false; } assertDefined( @@ -220,64 +214,34 @@ export class BasePromptParser 'No stream reference found.', ); - await this.stream.settled; + const completed = await this.stream.settled; // if prompt header exists, also wait for it to be settled if (this.promptHeader) { - await this.promptHeader.settled; + const headerCompleted = await this.promptHeader.settled; + if (!headerCompleted) { + return false; + } } - return this; - } - - /** - * Same as {@link settled} but also waits for all possible - * nested child prompt references and their children to be settled. - */ - public async allSettled(): Promise { - await this.settled(); - - return this; + return completed; } constructor( private readonly promptContentsProvider: TContentsProvider, options: IBasePromptParserOptions, @IInstantiationService protected readonly instantiationService: IInstantiationService, - @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, + @IWorkbenchEnvironmentService private readonly envService: IWorkbenchEnvironmentService, @ILogService protected readonly logService: ILogService, ) { super(); this.options = options; - 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 - // file reference has been already seen before - if (seenReferences.includes(this.uri.path)) { - seenReferences.push(this.uri.path); - - this._errorCondition = new RecursiveReference( - this.uri, - seenReferences, - ); - this._onUpdate.fire(); - this.firstParseResult.end(); - - return this; - } - - // we don't care if reading the file fails below, hence can add the path - // of the current reference to the `seenReferences` set immediately, - - // even if the file doesn't exist, we would never end up in the recursion - seenReferences.push(this.uri.path); - this._register( this.promptContentsProvider.onContentChanged((streamOrError) => { // process the received message - this.onContentsChanged(streamOrError, seenReferences); + this.onContentsChanged(streamOrError); // indicate that we've received at least one `onContentChanged` event this.firstParseResult.end(); @@ -306,8 +270,7 @@ export class BasePromptParser * references recursion. */ private onContentsChanged( - streamOrError: VSBufferReadableStream | ResolveError, - seenReferences: string[], + streamOrError: VSBufferReadableStream | ResolveError ): void { // dispose and cleanup the previously received stream // object or an error condition, if any received yet @@ -363,7 +326,7 @@ export class BasePromptParser // try to convert a prompt variable with data token into a file reference if (token instanceof PromptVariableWithData) { try { - this.handleLinkToken(FileReference.from(token), [...seenReferences]); + this.handleLinkToken(FileReference.from(token)); } catch (error) { // the `FileReference.from` call might throw if the `PromptVariableWithData` token // can not be converted into a valid `#file` reference, hence we ignore the error @@ -373,7 +336,7 @@ export class BasePromptParser // note! the `isURL` is a simple check and needs to be improved to truly // handle only file references, ignoring broken URLs or references if (token instanceof MarkdownLink && !token.isURL) { - this.handleLinkToken(token, [...seenReferences]); + this.handleLinkToken(token); } }); @@ -417,16 +380,20 @@ export class BasePromptParser /** * Handle a new reference token inside prompt contents. */ - private handleLinkToken( - token: FileReference | MarkdownLink, - seenReferences: string[], - ): this { - const { parentFolder } = this; - - const referenceUri = ((parentFolder !== null) && (path.isAbsolute(token.path) === false)) - ? URI.joinPath(parentFolder, token.path) - : URI.file(token.path); + private handleLinkToken(token: FileReference | MarkdownLink): this { + let referenceUri: URI; + if (path.isAbsolute(token.path)) { + referenceUri = URI.file(token.path); + if (this.envService.remoteAuthority) { + referenceUri = referenceUri.with({ + scheme: Schemas.vscodeRemote, + authority: this.envService.remoteAuthority, + }); + } + } else { + referenceUri = joinPath(dirname(this.uri), token.path); + } this._references.push(new PromptReference(referenceUri, token)); this._onUpdate.fire(); @@ -504,29 +471,6 @@ export class BasePromptParser return this.promptContentsProvider.uri; } - /** - * 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 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; - } - /** * Get a list of immediate child references of the prompt. */ 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 a79ef3b6ec2..c73efe10884 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/filePromptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/filePromptParser.ts @@ -7,8 +7,8 @@ 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'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; /** * Class capable of parsing prompt syntax out of a provided file, @@ -19,11 +19,11 @@ export class FilePromptParser extends BasePromptParser { + public get settled(): Promise { return this.stream.settled; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/promptHeader.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/promptHeader.ts index 5ec6de8e5c4..19185ee8961 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/promptHeader.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/promptHeader.ts @@ -133,7 +133,7 @@ export class PromptHeader extends HeaderBase { this.issues.push( new PromptMetadataWarning( - mode.range, + tools.range, localize( 'prompt.header.metadata.mode.diagnostics.incompatible-with-tools', "Tools can only be used when in 'agent' mode, but the mode is set to '{0}'. The tools will be ignored.", diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptParser.ts index 255b38413a3..99df26fd5e9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptParser.ts @@ -10,9 +10,9 @@ import { BasePromptParser, IPromptParserOptions } from './basePromptParser.js'; import { IModelService } from '../../../../../../editor/common/services/model.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'; import { IPromptContentsProviderOptions } from '../contentProviders/promptContentsProviderBase.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; /** * Get prompt contents provider object based on the prompt type. @@ -46,7 +46,7 @@ export class PromptParser extends BasePromptParser { @ILogService logService: ILogService, @IModelService modelService: IModelService, @IInstantiationService instaService: IInstantiationService, - @IWorkspaceContextService workspaceService: IWorkspaceContextService, + @IWorkbenchEnvironmentService envService: IWorkbenchEnvironmentService, ) { const contentsProvider = getContentsProvider(uri, options, modelService, instaService); @@ -54,7 +54,7 @@ export class PromptParser extends BasePromptParser { contentsProvider, options, instaService, - workspaceService, + envService, logService, ); 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 17f4716d628..bb2b7060abb 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/textModelPromptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/textModelPromptParser.ts @@ -7,8 +7,8 @@ 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'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; /** * Class capable of parsing prompt syntax out of a provided text model, @@ -19,7 +19,7 @@ export class TextModelPromptParser extends BasePromptParser(ChatContextKeys.panelLocation.key); + if (location === ViewContainerLocation.AuxiliaryBar) { + this.layoutService.setAuxiliaryBarMaximized(true); + } else if (location === ViewContainerLocation.Panel && !this.layoutService.isPanelMaximized()) { + this.layoutService.toggleMaximizedPanel(); + } + } + await chatWidget?.waitForReady(); await this.commandService.executeCommand(ACTION_ID_NEW_CHAT); await this.commandService.executeCommand(CHAT_OPEN_ACTION_ID, opts); } } +class ChatSuspendThrottlingHandler extends Disposable { + + static readonly ID = 'workbench.contrib.chatSuspendThrottlingHandler'; + + constructor( + @INativeHostService nativeHostService: INativeHostService, + @IChatService chatService: IChatService + ) { + super(); + + this._register(autorun(reader => { + const running = chatService.requestInProgressObs.read(reader); + + // When a chat request is in progress, we must ensure that background + // throttling is not applied so that the chat session can continue + // even when the window is not in focus. + nativeHostService.setBackgroundThrottling(!running); + })); + } +} + registerAction2(StartVoiceChatAction); registerAction2(InstallSpeechProviderForVoiceChatAction); @@ -110,3 +150,4 @@ registerChatDeveloperActions(); registerWorkbenchContribution2(KeywordActivationContribution.ID, KeywordActivationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(NativeBuiltinToolsContribution.ID, NativeBuiltinToolsContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatCommandLineHandler.ID, ChatCommandLineHandler, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatSuspendThrottlingHandler.ID, ChatSuspendThrottlingHandler, WorkbenchPhase.AfterRestored); 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 86903354543..42270fb0be7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditingModifiedNotebookEntry.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditingModifiedNotebookEntry.test.ts @@ -680,7 +680,7 @@ suite('ChatEditingModifiedNotebookEntry', function () { ]); }); - test.skip('Revert first deleted with multiple cells', async function () { + test('Revert first deleted with multiple cells', async function () { const cellsDiffInfo: ICellDiffInfo[] = [ { diff, keep, undo, type: 'insert', originalModel: createOriginalModel('null'), originalCellIndex: undefined, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditingSessionStorage.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditingSessionStorage.test.ts index 7777341548b..50d651b7bce 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditingSessionStorage.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditingSessionStorage.test.ts @@ -65,8 +65,8 @@ suite('ChatEditingSessionStorage', () => { 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 }, + { startIndex: 0, requestId: r1, stops: [makeStop(r1, 'a', 'b')] }, + { startIndex: 1, requestId: r2, stops: [makeStop(r2, 'c', 'd'), makeStop(r2, 'd', 'd')] }, ] }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditingTimeline.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditingTimeline.test.ts new file mode 100644 index 00000000000..00e0b5c759e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditingTimeline.test.ts @@ -0,0 +1,507 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { ChatEditingTimeline } from '../../browser/chatEditing/chatEditingTimeline.js'; +import { IChatEditingSessionStop } from '../../browser/chatEditing/chatEditingSessionStorage.js'; +import { transaction } from '../../../../../base/common/observable.js'; +import { IChatRequestDisablement } from '../../common/chatModel.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ISnapshotEntry } from '../../common/chatEditingService.js'; + +suite('ChatEditingTimeline', () => { + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + let timeline: ChatEditingTimeline; + + setup(() => { + const instaService = workbenchInstantiationService(undefined, ds); + timeline = instaService.createInstance(ChatEditingTimeline); + }); + + suite('undo/redo', () => { + test('undo/redo with empty history', () => { + assert.strictEqual(timeline.getUndoSnapshot(), undefined); + assert.strictEqual(timeline.getRedoSnapshot(), undefined); + assert.strictEqual(timeline.canRedo.get(), false); + assert.strictEqual(timeline.canUndo.get(), false); + }); + }); + + function createSnapshot(stopId: string | undefined, requestId = 'req1'): IChatEditingSessionStop { + return { + stopId, + entries: stopId === undefined ? new ResourceMap() : new ResourceMap([[ + URI.file(`file:///path/to/${stopId}`), + { requestId, current: `Content for ${stopId}` } as Partial as ISnapshotEntry + ]]), + }; + } + + suite('Basic functionality', () => { + test('pushSnapshot and undo/redo navigation', () => { + // Push two snapshots + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + + // After two pushes, canUndo should be true, canRedo false + assert.strictEqual(timeline.canUndo.get(), true); + assert.strictEqual(timeline.canRedo.get(), false); + + // Undo should move back to stop1 + const undoSnap = timeline.getUndoSnapshot(); + assert.ok(undoSnap); + assert.strictEqual(undoSnap.stop.stopId, 'stop1'); + undoSnap.apply(); + assert.strictEqual(timeline.canUndo.get(), false); + assert.strictEqual(timeline.canRedo.get(), true); + + // Redo should move forward to stop2 + const redoSnap = timeline.getRedoSnapshot(); + assert.ok(redoSnap); + assert.strictEqual(redoSnap.stop.stopId, 'stop2'); + redoSnap.apply(); + assert.strictEqual(timeline.canUndo.get(), true); + assert.strictEqual(timeline.canRedo.get(), false); + }); + + test('restoreFromState restores history and index', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + const state = timeline.getStateForPersistence(); + + // Move back + timeline.getUndoSnapshot()?.apply(); + + // Restore state + transaction(tx => timeline.restoreFromState(state, tx)); + assert.strictEqual(timeline.canUndo.get(), true); + assert.strictEqual(timeline.canRedo.get(), false); + }); + + test('getSnapshotForRestore returns correct snapshot', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + + const snap = timeline.getSnapshotForRestore('req1', 'stop1'); + assert.ok(snap); + assert.strictEqual(snap.stop.stopId, 'stop1'); + snap.apply(); + + assert.strictEqual(timeline.canRedo.get(), true); + assert.strictEqual(timeline.canUndo.get(), false); + + const snap2 = timeline.getSnapshotForRestore('req1', 'stop2'); + assert.ok(snap2); + assert.strictEqual(snap2.stop.stopId, 'stop2'); + snap2.apply(); + + assert.strictEqual(timeline.canRedo.get(), false); + assert.strictEqual(timeline.canUndo.get(), true); + }); + + test('getRequestDisablement returns correct requests', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2')); + + // Move back to first + timeline.getUndoSnapshot()?.apply(); + + const disables = timeline.requestDisablement.get(); + assert.ok(Array.isArray(disables)); + assert.ok(disables.some(d => d.requestId === 'req2')); + }); + }); + + suite('Multiple requests', () => { + test('handles multiple requests with separate snapshots', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2')); + timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3')); + + assert.strictEqual(timeline.canUndo.get(), true); + assert.strictEqual(timeline.canRedo.get(), false); + + // Undo should go back through requests + let undoSnap = timeline.getUndoSnapshot(); + assert.ok(undoSnap); + assert.strictEqual(undoSnap.stop.stopId, 'stop2'); + undoSnap.apply(); + + undoSnap = timeline.getUndoSnapshot(); + assert.ok(undoSnap); + assert.strictEqual(undoSnap.stop.stopId, 'stop1'); + }); + + test('handles same request with multiple stops', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + timeline.pushSnapshot('req1', 'stop3', createSnapshot('stop3')); + + const state = timeline.getStateForPersistence(); + assert.strictEqual(state.history.length, 1); + assert.strictEqual(state.history[0].stops.length, 3); + assert.strictEqual(state.history[0].requestId, 'req1'); + }); + + test('mixed requests and stops', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + timeline.pushSnapshot('req2', 'stop3', createSnapshot('stop3', 'req2')); + timeline.pushSnapshot('req2', 'stop4', createSnapshot('stop4', 'req2')); + + const state = timeline.getStateForPersistence(); + assert.strictEqual(state.history.length, 2); + assert.strictEqual(state.history[0].stops.length, 2); + assert.strictEqual(state.history[1].stops.length, 2); + }); + }); + + suite('Edge cases', () => { + test('getSnapshotForRestore with non-existent request', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + + const snap = timeline.getSnapshotForRestore('nonexistent', 'stop1'); + assert.strictEqual(snap, undefined); + }); + + test('getSnapshotForRestore with non-existent stop', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + + const snap = timeline.getSnapshotForRestore('req1', 'nonexistent'); + assert.strictEqual(snap, undefined); + }); + }); + + suite('History manipulation', () => { + test('pushing snapshots after undo truncates future history', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + timeline.pushSnapshot('req1', 'stop3', createSnapshot('stop3')); + + // Undo twice + timeline.getUndoSnapshot()?.apply(); + timeline.getUndoSnapshot()?.apply(); + + // Push new snapshot - should truncate stop3 + timeline.pushSnapshot('req1', 'new_stop', createSnapshot('new_stop')); + + const state = timeline.getStateForPersistence(); + assert.strictEqual(state.history[0].stops.length, 2); // stop1 + new_stop + assert.strictEqual(state.history[0].stops[1].stopId, 'new_stop'); + }); + + test('branching from middle of history creates new branch', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2')); + timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3')); + + // Undo to middle + timeline.getUndoSnapshot()?.apply(); + + // Push new request + timeline.pushSnapshot('req4', 'stop4', createSnapshot('stop4')); + + const state = timeline.getStateForPersistence(); + assert.strictEqual(state.history.length, 3); // req1, req2, req4 + assert.strictEqual(state.history[2].requestId, 'req4'); + }); + }); + + suite('State persistence', () => { + test('getStateForPersistence returns complete state', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2')); + + const state = timeline.getStateForPersistence(); + assert.ok(state.history); + assert.ok(typeof state.index === 'number'); + assert.strictEqual(state.history.length, 2); + assert.strictEqual(state.index, 2); + }); + + test('restoreFromState handles empty history', () => { + const emptyState = { history: [], index: 0 }; + + transaction(tx => timeline.restoreFromState(emptyState, tx)); + + assert.strictEqual(timeline.canUndo.get(), false); + assert.strictEqual(timeline.canRedo.get(), false); + }); + + test('restoreFromState with complex history', () => { + // Create complex state + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + timeline.pushSnapshot('req2', 'stop3', createSnapshot('stop3', 'req2')); + + const originalState = timeline.getStateForPersistence(); + + // Create new timeline and restore + const instaService = workbenchInstantiationService(undefined, ds); + const newTimeline = instaService.createInstance(ChatEditingTimeline); + transaction(tx => newTimeline.restoreFromState(originalState, tx)); + + const restoredState = newTimeline.getStateForPersistence(); + assert.deepStrictEqual(restoredState.index, originalState.index); + assert.strictEqual(restoredState.history.length, originalState.history.length); + }); + }); + + suite('Request disablement', () => { + test('getRequestDisablement at various positions', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2')); + timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3')); + + // At end - no disabled requests + let disables = timeline.requestDisablement.get(); + assert.strictEqual(disables.length, 0); + + // Move back one + timeline.getUndoSnapshot()?.apply(); + disables = timeline.requestDisablement.get(); + assert.strictEqual(disables.length, 1); + assert.strictEqual(disables[0].requestId, 'req3'); + + // Move back to beginning + timeline.getUndoSnapshot()?.apply(); + timeline.getUndoSnapshot()?.apply(); + disables = timeline.requestDisablement.get(); + assert.strictEqual(disables.length, 2); + }); + + test('getRequestDisablement with mixed request/stop structure', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + timeline.pushSnapshot('req2', 'stop3', createSnapshot('stop3', 'req2')); + + // Move to middle of req1 + timeline.getUndoSnapshot()?.apply(); + timeline.getUndoSnapshot()?.apply(); + + const disables = timeline.requestDisablement.get(); + assert.strictEqual(disables.length, 2); + + // Should have partial disable for req1 and full disable for req2 + const req1Disable = disables.find(d => d.requestId === 'req1'); + const req2Disable = disables.find(d => d.requestId === 'req2'); + + assert.ok(req1Disable); + assert.ok(req2Disable); + assert.ok(req1Disable.afterUndoStop); + assert.strictEqual(req2Disable.afterUndoStop, undefined); + }); + }); + + suite('Boundary conditions', () => { + test('undo/redo at boundaries', () => { + // Empty timeline + assert.strictEqual(timeline.getUndoSnapshot(), undefined); + assert.strictEqual(timeline.getRedoSnapshot(), undefined); + + // Single snapshot + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + assert.ok(timeline.getUndoSnapshot()); + assert.strictEqual(timeline.getRedoSnapshot(), undefined); + + // At beginning after undo + timeline.getUndoSnapshot()?.apply(); + assert.strictEqual(timeline.getUndoSnapshot(), undefined); + assert.ok(timeline.getRedoSnapshot()); + }); + + test('multiple undos and redos', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2')); + timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3')); + + // Undo all + const stops: string[] = []; + let undoSnap = timeline.getUndoSnapshot(); + while (undoSnap) { + stops.push(undoSnap.stop.stopId!); + undoSnap.apply(); + undoSnap = timeline.getUndoSnapshot(); + } + assert.deepStrictEqual(stops, ['stop2', 'stop1']); + + // Redo all + const redoStops: string[] = []; + let redoSnap = timeline.getRedoSnapshot(); + while (redoSnap) { + redoStops.push(redoSnap.stop.stopId!); + redoSnap.apply(); + redoSnap = timeline.getRedoSnapshot(); + } + assert.deepStrictEqual(redoStops, ['stop2', 'stop3']); + }); + + test('getRequestDisablement with root request ID', () => { + timeline.pushSnapshot('req1', undefined, createSnapshot(undefined)); + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + + timeline.pushSnapshot('req2', undefined, createSnapshot(undefined, 'req2')); + timeline.pushSnapshot('req2', 'stop1-2', createSnapshot('stop1-2', 'req2')); + timeline.pushSnapshot('req2', 'stop2-2', createSnapshot('stop2-2', 'req2')); + + const expected: IChatRequestDisablement[][] = [ + [{ requestId: 'req2', afterUndoStop: 'stop1-2' }], + [{ requestId: 'req2' }], + // stop2 is not in this because we're at stop2 when undoing req2 + [{ requestId: 'req1', afterUndoStop: 'stop1' }, { requestId: 'req2' }], + [{ requestId: 'req1', afterUndoStop: undefined }, { requestId: 'req2' }], + ]; + + let ei = 0; + while (timeline.canUndo.get()) { + timeline.getUndoSnapshot()!.apply(); + const actual = timeline.requestDisablement.get(); + + assert.deepStrictEqual(actual, expected[ei++]); + } + + expected.unshift([]); + + while (timeline.canRedo.get()) { + timeline.getRedoSnapshot()!.apply(); + const actual = timeline.requestDisablement.get(); + assert.deepStrictEqual(actual, expected[--ei]); + } + }); + }); + + suite('Static methods', () => { + test('createEmptySnapshot creates valid snapshot', () => { + const snapshot = ChatEditingTimeline.createEmptySnapshot('test-stop'); + assert.strictEqual(snapshot.stopId, 'test-stop'); + assert.ok(snapshot.entries); + assert.strictEqual(snapshot.entries.size, 0); + }); + + test('createEmptySnapshot with undefined stopId', () => { + const snapshot = ChatEditingTimeline.createEmptySnapshot(undefined); + assert.strictEqual(snapshot.stopId, undefined); + assert.ok(snapshot.entries); + }); + + test('POST_EDIT_STOP_ID is consistent', () => { + assert.strictEqual(typeof ChatEditingTimeline.POST_EDIT_STOP_ID, 'string'); + assert.ok(ChatEditingTimeline.POST_EDIT_STOP_ID.length > 0); + }); + }); + + suite('Observable behavior', () => { + test('canUndo observable updates correctly', () => { + assert.strictEqual(timeline.canUndo.get(), false); + + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + assert.strictEqual(timeline.canUndo.get(), true); + + timeline.getUndoSnapshot()?.apply(); + assert.strictEqual(timeline.canUndo.get(), false); + }); + + test('canRedo observable updates correctly', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + assert.strictEqual(timeline.canRedo.get(), false); + + timeline.getUndoSnapshot()?.apply(); + assert.strictEqual(timeline.canRedo.get(), true); + + timeline.getRedoSnapshot()?.apply(); + assert.strictEqual(timeline.canRedo.get(), false); + }); + }); + + suite('Complex scenarios', () => { + test('interleaved requests and undos', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2')); + + // Undo req2 + timeline.getUndoSnapshot()?.apply(); + + // Add req3 (should branch from req1) + timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3')); + + const state = timeline.getStateForPersistence(); + assert.strictEqual(state.history.length, 2); // req1, req3 + assert.strictEqual(state.history[1].requestId, 'req3'); + }); + + test('large number of snapshots', () => { + // Push 100 snapshots + for (let i = 1; i <= 100; i++) { + timeline.pushSnapshot(`req${i}`, `stop${i}`, createSnapshot(`stop${i}`)); + } + + assert.strictEqual(timeline.canUndo.get(), true); + assert.strictEqual(timeline.canRedo.get(), false); + + const state = timeline.getStateForPersistence(); + assert.strictEqual(state.history.length, 100); + assert.strictEqual(state.index, 100); + }); + + test('alternating single and multi-stop requests', () => { + // Single stop request + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + + // Multi-stop request + timeline.pushSnapshot('req2', 'stop2a', createSnapshot('stop2a', 'req2')); + timeline.pushSnapshot('req2', 'stop2b', createSnapshot('stop2b', 'req2')); + timeline.pushSnapshot('req2', 'stop2c', createSnapshot('stop2c', 'req2')); + + // Single stop request + timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3')); + + const state = timeline.getStateForPersistence(); + assert.strictEqual(state.history.length, 3); + assert.strictEqual(state.history[0].stops.length, 1); + assert.strictEqual(state.history[1].stops.length, 3); + assert.strictEqual(state.history[2].stops.length, 1); + }); + }); + + suite('Error resilience', () => { + test('handles invalid apply calls gracefully', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2')); + + const undoSnap = timeline.getUndoSnapshot(); + assert.ok(undoSnap); + + // Apply twice - second should be safe + undoSnap.apply(); + undoSnap.apply(); // Should not throw + + assert.strictEqual(timeline.canUndo.get(), false); + }); + + test('getSnapshotForRestore with malformed stopId', () => { + timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1')); + + const snap = timeline.getSnapshotForRestore('req1', ''); + assert.strictEqual(snap, undefined); + }); + + test('handles restoration edge cases', () => { + const emptyState = { history: [], index: 0 }; + transaction(tx => timeline.restoreFromState(emptyState, tx)); + + // Should be safe to call methods on empty timeline + assert.strictEqual(timeline.getUndoSnapshot(), undefined); + assert.strictEqual(timeline.getRedoSnapshot(), undefined); + assert.deepStrictEqual(timeline.requestDisablement.get(), []); + }); + }); +}); 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 41c00776359..5a2fa987cd7 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts @@ -9,6 +9,7 @@ import { ContextKeyExpression } from '../../../../../platform/contextkey/common/ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ChatAgentService, IChatAgentData, IChatAgentImplementation } from '../../common/chatAgents.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; const testAgentId = 'testAgent'; const testAgentData: IChatAgentData = { @@ -42,7 +43,7 @@ suite('ChatAgents', function () { let contextKeyService: TestingContextKeyService; setup(() => { contextKeyService = new TestingContextKeyService(); - chatAgentService = store.add(new ChatAgentService(contextKeyService)); + chatAgentService = store.add(new ChatAgentService(contextKeyService, new TestConfigurationService())); }); test('registerAgent', async () => { 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 21ef20cdc00..0d011f6e027 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService.test.ts @@ -287,6 +287,7 @@ suite('ChatService', () => { assert(chatModel2); await assertSnapshot(toSnapshotExportData(chatModel2)); + chatModel2.dispose(); }); test('can deserialize with response', async () => { @@ -315,6 +316,7 @@ suite('ChatService', () => { assert(chatModel2); await assertSnapshot(toSnapshotExportData(chatModel2)); + chatModel2.dispose(); }); }); 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 138d176f95b..8178d3e9274 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -51,8 +51,8 @@ suite('LanguageModels', function () { name: 'Pretty Name', vendor: 'test-vendor', family: 'test-family', - modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, version: 'test-version', + modelPickerCategory: undefined, id: 'test-id', maxInputTokens: 100, maxOutputTokens: 100, @@ -72,7 +72,7 @@ suite('LanguageModels', function () { vendor: 'test-vendor', family: 'test2-family', version: 'test2-version', - modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + modelPickerCategory: undefined, id: 'test-id', maxInputTokens: 100, maxOutputTokens: 100, diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts index b3dbb032861..99e32b76c0f 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts @@ -5,6 +5,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ChatModel, IChatModel, IChatRequestModel, IChatRequestVariableData, ISerializableChatData } from '../../common/chatModel.js'; import { IParsedChatRequest } from '../../common/chatParserTypes.js'; @@ -12,6 +13,7 @@ import { IChatCompleteResponse, IChatDetail, IChatProviderInfo, IChatSendRequest import { ChatAgentLocation } from '../../common/constants.js'; export class MockChatService implements IChatService { + requestInProgressObs = observableValue('name', false); edits2Enabled: boolean = false; _serviceBrand: undefined; transferredSessionData: IChatTransferredSessionData | undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts index 511ff781e42..69bdd684bfd 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts @@ -76,7 +76,7 @@ export class MockLanguageModelToolsService implements ILanguageModelToolsService throw new Error('Method not implemented.'); } - toToolAndToolSetEnablementMap(toolOrToolSetNames: Set): Map { + toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[] | undefined): Map { throw new Error('Method not implemented.'); } } 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 ec9b55e2f6b..fdbab2e2d2f 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 @@ -70,7 +70,7 @@ suite('FilePromptContentsProvider', () => { const contentsProvider = testDisposables.add(instantiationService.createInstance( FilePromptContentProvider, fileUri, - { allowNonPromptFiles: true, languageId: undefined }, + { allowNonPromptFiles: true, languageId: undefined, updateOnChange: true }, )); let streamOrError: ReadableStream | Error | undefined; @@ -128,7 +128,7 @@ suite('FilePromptContentsProvider', () => { const contentsProvider = testDisposables.add(instantiationService.createInstance( FilePromptContentProvider, fileUri, - { allowNonPromptFiles: true, languageId: undefined }, + { allowNonPromptFiles: true, languageId: undefined, updateOnChange: true }, )); let streamOrError: ReadableStream | Error | undefined; @@ -184,7 +184,7 @@ suite('FilePromptContentsProvider', () => { const contentsProvider = testDisposables.add(instantiationService.createInstance( FilePromptContentProvider, fileUri, - { allowNonPromptFiles: false, languageId: undefined }, + { allowNonPromptFiles: false, languageId: undefined, updateOnChange: true }, )); let streamOrError: ReadableStream | Error | undefined; 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 c61629a1b2b..35f77dfd78e 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 @@ -25,6 +25,8 @@ import { IInstantiationService } from '../../../../../../../platform/instantiati 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'; +import { IWorkbenchEnvironmentService } from '../../../../../../services/environment/common/environmentService.js'; + /** * Test helper to run unit tests for the {@link TextModelPromptParser} @@ -69,15 +71,16 @@ class TextModelPromptParserTest extends Disposable { // create the parser instance this.parser = this._register( - instantiationService.createInstance(TextModelPromptParser, this.model, { seenReferences: [], allowNonPromptFiles: true, languageId: undefined }), + instantiationService.createInstance(TextModelPromptParser, this.model, { allowNonPromptFiles: true, languageId: undefined, updateOnChange: true }), ).start(); } /** * Wait for the prompt parsing/resolve process to finish. */ - public allSettled(): Promise { - return this.parser.allSettled(); + public async allSettled(): Promise { + await this.parser.settled(); + return this.parser; } /** @@ -86,7 +89,7 @@ class TextModelPromptParserTest extends Disposable { public async validateReferences( expectedReferences: readonly ExpectedReference[], ) { - await this.parser.allSettled(); + await this.parser.settled(); const { references } = this.parser; for (let i = 0; i < expectedReferences.length; i++) { @@ -113,7 +116,7 @@ class TextModelPromptParserTest extends Disposable { public async validateHeaderDiagnostics( expectedDiagnostics: readonly TExpectedDiagnostic[], ) { - await this.parser.allSettled(); + await this.parser.settled(); const { header } = this.parser; assertDefined( @@ -156,6 +159,7 @@ suite('TextModelPromptParser', () => { instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IFileService, disposables.add(instantiationService.createInstance(FileService))); + instantiationService.stub(IWorkbenchEnvironmentService, {}); }); /** @@ -688,7 +692,7 @@ suite('TextModelPromptParser', () => { 'Duplicate tool name \'tool_name2\'.', ), new ExpectedDiagnosticWarning( - new Range(3, 2, 3, 2 + 11), + new Range(5, 1, 5, 84), `Tools can only be used when in 'agent' mode, but the mode is set to 'ask'. The tools will be ignored.`, ), new ExpectedDiagnosticWarning( @@ -1170,7 +1174,7 @@ suite('TextModelPromptParser', () => { await test.validateHeaderDiagnostics([ new ExpectedDiagnosticWarning( - new Range(3, 1, 3, 1 + 11), + new Range(2, 1, 2, 38), 'Tools can only be used when in \'agent\' mode, but the mode is set to \'ask\'. The tools will be ignored.', ), ]); @@ -1217,7 +1221,7 @@ suite('TextModelPromptParser', () => { await test.validateHeaderDiagnostics([ new ExpectedDiagnosticWarning( - new Range(3, 1, 3, 1 + 12), + new Range(2, 1, 2, 38), 'Tools can only be used when in \'agent\' mode, but the mode is set to \'edit\'. The tools will be ignored.', ), ]); 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 ab4b302e8d8..c1cfdd00f0b 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 @@ -104,12 +104,12 @@ class TestPromptFileReference extends Disposable { this.instantiationService.createInstance( FilePromptParser, this.rootFileUri, - { seenReferences: [], allowNonPromptFiles: true, languageId: undefined }, + { allowNonPromptFiles: true, languageId: undefined, updateOnChange: true }, ), ).start(); // wait until entire prompts tree is resolved - await rootReference.allSettled(); + await rootReference.settled(); // resolve the root file reference including all nested references const resolvedReferences: readonly (TPromptReference | undefined)[] = rootReference.references; 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 b7d38ee3c51..eff89d6cca3 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 @@ -34,6 +34,7 @@ import { ILabelService } from '../../../../../../../platform/label/common/label. import { ComputeAutomaticInstructions } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; +import { IWorkbenchEnvironmentService } from '../../../../../../services/environment/common/environmentService.js'; /** * Helper class to assert the properties of a link. @@ -113,6 +114,7 @@ suite('PromptsService', () => { instaService.stub(ILogService, new NullLogService()); instaService.stub(IWorkspacesService, {}); instaService.stub(IConfigurationService, new TestConfigurationService()); + instaService.stub(IWorkbenchEnvironmentService, {}); const fileService = disposables.add(instaService.createInstance(FileService)); instaService.stub(IFileService, fileService); diff --git a/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts b/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts index aa18d550d2a..58487b1242e 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts @@ -18,9 +18,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ConfigurationChangedEvent, EditorOption } from '../../../../../editor/common/config/editorOptions.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../../editor/browser/editorExtensions.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; -import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IContentActionHandler, renderFormattedText } from '../../../../../base/browser/formattedTextRenderer.js'; -import { ApplyFileSnippetAction } from '../../../snippets/browser/commands/fileTemplateSnippets.js'; import { IInlineChatSessionService } from '../../../inlineChat/browser/inlineChatSessionService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; @@ -144,7 +142,6 @@ class EmptyTextEditorHintContentWidget extends Disposable implements IContentWid constructor( private readonly editor: ICodeEditor, - @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, @IKeybindingService private readonly keybindingService: IKeybindingService, @@ -213,12 +210,9 @@ class EmptyTextEditorHintContentWidget extends Disposable implements IContentWid hasInlineChatProvider ? askSomething(event.browserEvent) : languageOnClickOrTap(event.browserEvent); break; case '1': - hasInlineChatProvider ? languageOnClickOrTap(event.browserEvent) : snippetOnClickOrTap(event.browserEvent); + hasInlineChatProvider ? languageOnClickOrTap(event.browserEvent) : this.disableHint(); break; case '2': - hasInlineChatProvider ? snippetOnClickOrTap(event.browserEvent) : chooseEditorOnClickOrTap(event.browserEvent); - break; - case '3': this.disableHint(); break; } @@ -247,33 +241,7 @@ class EmptyTextEditorHintContentWidget extends Disposable implements IContentWid this.editor.focus(); }; - const snippetOnClickOrTap = async (e: UIEvent) => { - e.stopPropagation(); - - this.telemetryService.publicLog2('workbenchActionExecuted', { - id: ApplyFileSnippetAction.Id, - from: 'hint' - }); - await this.commandService.executeCommand(ApplyFileSnippetAction.Id); - }; - - const chooseEditorOnClickOrTap = async (e: UIEvent) => { - e.stopPropagation(); - - const activeEditorInput = this.editorGroupsService.activeGroup.activeEditor; - this.telemetryService.publicLog2('workbenchActionExecuted', { - id: 'welcome.showNewFileEntries', - from: 'hint' - }); - const newEditorSelected = await this.commandService.executeCommand('welcome.showNewFileEntries', { from: 'hint' }); - - // Close the active editor as long as it is untitled (swap the editors out) - if (newEditorSelected && activeEditorInput !== null && activeEditorInput.resource?.scheme === Schemas.untitled) { - this.editorGroupsService.activeGroup.closeEditor(activeEditorInput, { preserveFocus: true }); - } - }; - - const keybindingsLookup = hasInlineChatProvider ? [askSomethingCommandId, ChangeLanguageAction.ID, ApplyFileSnippetAction.Id] : [ChangeLanguageAction.ID, ApplyFileSnippetAction.Id, 'welcome.showNewFileEntries']; + const keybindingsLookup = [askSomethingCommandId, ChangeLanguageAction.ID]; const keybindingLabels = keybindingsLookup.map(id => this.keybindingService.lookupKeybinding(id)?.getLabel()); const hintMsg = (hasInlineChatProvider ? localize({ @@ -282,13 +250,13 @@ class EmptyTextEditorHintContentWidget extends Disposable implements IContentWid 'Preserve double-square brackets and their order', 'language refers to a programming language' ] - }, '[[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({ + }, '[[Generate code]] ({0}), or [[select a language]] ({1}). Start typing to dismiss or [[don\'t show]] this again.', keybindingLabels.at(0) ?? '', keybindingLabels.at(1) ?? '') : 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(' ()', ''); + }, '[[Select a language]] ({0}) to get started. Start typing to dismiss or [[don\'t show]] this again.', keybindingLabels.at(1) ?? '')).replaceAll(' ()', ''); const hintElement = renderFormattedText(hintMsg, { actionHandler: hintHandler, renderCodeSegments: false, @@ -296,8 +264,8 @@ class EmptyTextEditorHintContentWidget extends Disposable implements IContentWid hintElement.style.fontStyle = 'italic'; 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); + localize('defaultHintAriaLabelWithInlineChat', 'Execute {0} to ask a question, execute {1} to select a language and get started. Start typing to dismiss.', ...keybindingLabels) : + localize('defaultHintAriaLabelWithoutInlineChat', 'Execute {0} to select a language and get started. Start typing to dismiss.', ...keybindingLabels); for (const anchor of hintElement.querySelectorAll('a')) { anchor.style.cursor = 'pointer'; } diff --git a/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts b/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts index d366a6c17b2..07af7312d21 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts @@ -49,6 +49,9 @@ export function getSimpleEditorOptions(configurationService: IConfigurationServi cursorBlinking: configurationService.getValue<'blink' | 'smooth' | 'phase' | 'expand' | 'solid'>('editor.cursorBlinking'), editContext: configurationService.getValue('editor.editContext'), defaultColorDecorators: 'never', + allowVariableLineHeights: false, + allowVariableFonts: false, + allowVariableFontsInAccessibilityMode: false, }; } diff --git a/src/vs/workbench/contrib/debug/browser/breakpointWidget.ts b/src/vs/workbench/contrib/debug/browser/breakpointWidget.ts index 84524c76602..07b92710f72 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointWidget.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointWidget.ts @@ -421,7 +421,6 @@ export class BreakpointWidget extends ZoneWidget implements IPrivateBreakpointWi options.lineHeight = editorConfig.lineHeight; options.fontLigatures = editorConfig.fontLigatures; options.ariaLabel = this.placeholder; - options.allowVariableLineHeights = false; return options; } diff --git a/src/vs/workbench/contrib/debug/browser/breakpointsView.ts b/src/vs/workbench/contrib/debug/browser/breakpointsView.ts index 19f796fd9ee..9f52557a0ae 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointsView.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointsView.ts @@ -913,7 +913,7 @@ class InstructionBreakpointsRenderer implements IListRenderer('debug'); options.acceptSuggestionOnEnter = config.console.acceptSuggestionOnEnter === 'on' ? 'on' : 'off'; options.ariaLabel = this.getAriaLabel(); - options.allowVariableLineHeights = false; this.replInput = this.scopedInstantiationService.createInstance(CodeEditorWidget, this.replInputContainer, options, getSimpleCodeEditorWidgetOptions()); diff --git a/src/vs/workbench/contrib/debug/common/debugModel.ts b/src/vs/workbench/contrib/debug/common/debugModel.ts index 75062d56a1d..59aa5a5ff43 100644 --- a/src/vs/workbench/contrib/debug/common/debugModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugModel.ts @@ -542,10 +542,10 @@ export class StackFrame implements IStackFrame { async openInEditor(editorService: IEditorService, preserveFocus?: boolean, sideBySide?: boolean, pinned?: boolean): Promise { const threadStopReason = this.thread.stoppedDetails?.reason; if (this.instructionPointerReference && - (threadStopReason === 'instruction breakpoint' || - (threadStopReason === 'step' && this.thread.lastSteppingGranularity === 'instruction') || + ((threadStopReason === 'instruction breakpoint' && !preserveFocus) || + (threadStopReason === 'step' && this.thread.lastSteppingGranularity === 'instruction' && !preserveFocus) || editorService.activeEditor instanceof DisassemblyViewInput)) { - return editorService.openEditor(DisassemblyViewInput.instance, { pinned: true, revealIfOpened: true }); + return editorService.openEditor(DisassemblyViewInput.instance, { pinned: true, revealIfOpened: true, preserveFocus }); } if (this.source.available) { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/arcTracker.ts b/src/vs/workbench/contrib/editTelemetry/browser/arcTracker.ts new file mode 100644 index 00000000000..2b1dcb7bc0a --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/arcTracker.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 { sumBy } from '../../../../base/common/arrays.js'; +import { AnnotatedStringEdit, BaseStringEdit, IEditData } from '../../../../editor/common/core/edits/stringEdit.js'; + +/** + * The ARC (accepted and retained characters) counts how many characters inserted by the initial suggestion (trackedEdit) + * stay unmodified after a certain amount of time after acceptance. +*/ +export class ArcTracker { + private _updatedTrackedEdit: AnnotatedStringEdit; + + constructor( + public readonly originalText: string, + private readonly _trackedEdit: BaseStringEdit, + ) { + const eNormalized = _trackedEdit.removeCommonSuffixPrefix(originalText); + this._updatedTrackedEdit = eNormalized.mapData(() => new IsTrackedEditData(true)); + } + + handleEdits(edit: BaseStringEdit): void { + const e = edit.mapData(_d => new IsTrackedEditData(false)); + const composedEdit = this._updatedTrackedEdit.compose(e); + const onlyTrackedEdit = composedEdit.decomposeSplit(e => !e.data.isTrackedEdit).e2; + this._updatedTrackedEdit = onlyTrackedEdit; + } + + getAcceptedRestrainedCharactersCount(): number { + const s = sumBy(this._updatedTrackedEdit.replacements, e => e.getNewLength()); + return s; + } + + getOriginalCharacterCount(): number { + return sumBy(this._trackedEdit.replacements, e => e.getNewLength()); + } + + getDebugState(): unknown { + return { + edits: this._updatedTrackedEdit.replacements.map(e => ({ + range: e.replaceRange.toString(), + newText: e.newText, + isTrackedEdit: e.data.isTrackedEdit, + })) + }; + } +} + +export class IsTrackedEditData implements IEditData { + constructor( + public readonly isTrackedEdit: boolean + ) { } + + join(data: IsTrackedEditData): IsTrackedEditData | undefined { + if (this.isTrackedEdit !== data.isTrackedEdit) { + return undefined; + } + return this; + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/documentWithAnnotatedEdits.ts b/src/vs/workbench/contrib/editTelemetry/browser/documentWithAnnotatedEdits.ts new file mode 100644 index 00000000000..88e1f926e1c --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/documentWithAnnotatedEdits.ts @@ -0,0 +1,426 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AsyncIterableObject, raceTimeout } from '../../../../base/common/async.js'; +import { CachedFunction } from '../../../../base/common/cache.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservableWithChange, ISettableObservable, observableValue, RemoveUndefined, runOnChange } from '../../../../base/common/observable.js'; +import { AnnotatedStringEdit, IEditData } from '../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../editor/common/core/text/abstractText.js'; +import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js'; +import { TextModelEditReason } from '../../../../editor/common/textModelEditReason.js'; +import { IObservableDocument } from './observableWorkspace.js'; + +export interface IDocumentWithAnnotatedEdits = EditKeySourceData> { + readonly value: IObservableWithChange }>; + waitForQueue(): Promise; +} + +/** + * Creates a document that is a delayed copy of the original document, + * but with edits annotated with the source of the edit. +*/ +export class DocumentWithSourceAnnotatedEdits extends Disposable implements IDocumentWithAnnotatedEdits { + public readonly value: IObservableWithChange }>; + + constructor(private readonly _originalDoc: IObservableDocument) { + super(); + + const v = this.value = observableValue(this, _originalDoc.value.get()); + + this._register(runOnChange(this._originalDoc.value, (val, _prevVal, edits) => { + const eComposed = AnnotatedStringEdit.compose(edits.map(e => { + const editSourceData = new EditSourceData(e.reason); + return e.mapData(() => editSourceData); + })); + + v.set(val, undefined, { edit: eComposed }); + })); + } + + public waitForQueue(): Promise { + return Promise.resolve(); + } +} + +/** + * Only joins touching edits if the source and the metadata is the same (e.g. requestUuids must be equal). +*/ +export class EditSourceData implements IEditData { + public readonly source; + public readonly key; + + constructor( + public readonly editReason: TextModelEditReason + ) { + this.key = this.editReason.toKey(1); + this.source = EditSourceBase.create(this.editReason); + } + + join(data: EditSourceData): EditSourceData | undefined { + if (this.editReason !== data.editReason) { + return undefined; + } + return this; + } + + toEditSourceData(): EditKeySourceData { + return new EditKeySourceData(this.key, this.source, this.editReason); + } +} + +export class EditKeySourceData implements IEditData { + constructor( + public readonly key: string, + public readonly source: EditSource, + public readonly representative: TextModelEditReason, + ) { } + + join(data: EditKeySourceData): EditKeySourceData | undefined { + if (this.key !== data.key) { + return undefined; + } + if (this.source !== data.source) { + return undefined; + } + // The representatives could be different! (But equal modulo key) + return this; + } +} + +export abstract class EditSourceBase { + private static _cache = new CachedFunction({ getCacheKey: v => v.toString() }, (arg: EditSource) => arg); + + public static create(reason: TextModelEditReason): EditSource { + const data = reason.metadata; + switch (data.source) { + case 'reloadFromDisk': + return this._cache.get(new ExternalEditSource()); + case 'inlineCompletionPartialAccept': + case 'inlineCompletionAccept': { + const type = 'type' in data ? data.type : undefined; + if ('$nes' in data && data.$nes) { + return this._cache.get(new InlineSuggestEditSource('nes', data.$extensionId ?? '', type)); + } + return this._cache.get(new InlineSuggestEditSource('completion', data.$extensionId ?? '', type)); + } + case 'snippet': + return this._cache.get(new IdeEditSource('suggest')); + case 'unknown': + if (!data.name) { + return this._cache.get(new UnknownEditSource()); + } + switch (data.name) { + case 'formatEditsCommand': + return this._cache.get(new IdeEditSource('format')); + } + return this._cache.get(new UnknownEditSource()); + + case 'Chat.applyEdits': + return this._cache.get(new ChatEditSource('sidebar')); + case 'inlineChat.applyEdits': + return this._cache.get(new ChatEditSource('inline')); + case 'cursor': + return this._cache.get(new UserEditSource()); + default: + return this._cache.get(new UnknownEditSource()); + } + } + + public abstract getColor(): string; +} + +export type EditSource = InlineSuggestEditSource | ChatEditSource | IdeEditSource | UserEditSource | UnknownEditSource | ExternalEditSource; + +export class InlineSuggestEditSource extends EditSourceBase { + public readonly category = 'ai'; + public readonly feature = 'inlineSuggest'; + constructor( + public readonly kind: 'completion' | 'nes', + public readonly extensionId: string, + public readonly type: 'word' | 'line' | undefined, + ) { super(); } + + override toString() { return `${this.category}/${this.feature}/${this.kind}/${this.extensionId}/${this.type}`; } + + public getColor(): string { return '#00ff0033'; } +} + +class ChatEditSource extends EditSourceBase { + public readonly category = 'ai'; + public readonly feature = 'chat'; + constructor( + public readonly kind: 'sidebar' | 'inline', + ) { super(); } + + override toString() { return `${this.category}/${this.feature}/${this.kind}`; } + + public getColor(): string { return '#00ff0066'; } +} + +class IdeEditSource extends EditSourceBase { + public readonly category = 'ide'; + constructor( + public readonly feature: 'suggest' | 'format' | string, + ) { super(); } + + override toString() { return `${this.category}/${this.feature}`; } + + public getColor(): string { return this.feature === 'format' ? '#0000ff33' : '#80808033'; } +} + +class UserEditSource extends EditSourceBase { + public readonly category = 'user'; + constructor() { super(); } + + override toString() { return this.category; } + + public getColor(): string { return '#d3d3d333'; } +} + +/** Caused by external tools that trigger a reload from disk */ +class ExternalEditSource extends EditSourceBase { + public readonly category = 'external'; + constructor() { super(); } + + override toString() { return this.category; } + + public getColor(): string { return '#009ab254'; } +} + +class UnknownEditSource extends EditSourceBase { + public readonly category = 'unknown'; + constructor() { super(); } + + override toString() { return this.category; } + + public getColor(): string { return '#ff000033'; } +} + +export class CombineStreamedChanges> extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + private readonly _runStore = this._register(new DisposableStore()); + private _runQueue: Promise = Promise.resolve(); + + constructor( + private readonly _originalDoc: IDocumentWithAnnotatedEdits, + @IEditorWorkerService private readonly _diffService: IEditorWorkerService, + ) { + super(); + + this.value = this._value = observableValue(this, _originalDoc.value.get()); + this._restart(); + + this._diffService.computeStringEditFromDiff('foo', 'last.value.value', { maxComputationTimeMs: 500 }, 'advanced'); + } + + async _restart(): Promise { + this._runStore.clear(); + const iterator = iterateChangesFromObservable(this._originalDoc.value, this._runStore)[Symbol.asyncIterator](); + const p = this._runQueue; + this._runQueue = this._runQueue.then(() => this._run(iterator)); + await p; + } + + private async _run(iterator: AsyncIterator<{ value: StringText; prevValue: StringText; change: { edit: AnnotatedStringEdit }[] }, any, any>) { + const reader = new AsyncReader(iterator); + while (true) { + let peeked = await reader.peek(); + if (peeked === AsyncReaderEndOfStream) { + return; + } else if (isChatEdit(peeked)) { + const first = peeked; + + let last = first; + let chatEdit = AnnotatedStringEdit.empty as AnnotatedStringEdit; + + do { + reader.readSyncOrThrow(); + last = peeked; + chatEdit = chatEdit.compose(AnnotatedStringEdit.compose(peeked.change.map(c => c.edit))); + if (!await reader.waitForBufferTimeout(1000)) { + break; + } + peeked = reader.peekSyncOrThrow(); + } while (peeked !== AsyncReaderEndOfStream && isChatEdit(peeked)); + + if (!chatEdit.isEmpty()) { + const data = chatEdit.replacements[0].data; + const diffEdit = await this._diffService.computeStringEditFromDiff(first.prevValue.value, last.value.value, { maxComputationTimeMs: 500 }, 'advanced'); + const edit = diffEdit.mapData(_e => data); + this._value.set(last.value, undefined, { edit }); + } + } else { + reader.readSyncOrThrow(); + const e = AnnotatedStringEdit.compose(peeked.change.map(c => c.edit)); + this._value.set(peeked.value, undefined, { edit: e }); + } + } + } + + async waitForQueue(): Promise { + await this._originalDoc.waitForQueue(); + await this._restart(); + } +} + +function isChatEdit(next: { value: StringText; change: { edit: AnnotatedStringEdit }[] }) { + return next.change.every(c => c.edit.replacements.every(e => { + if (e.data.source.category === 'ai' && e.data.source.feature === 'chat') { + return true; + } + return false; + })); +} + +function iterateChangesFromObservable(obs: IObservableWithChange, store: DisposableStore): AsyncIterable<{ value: T; prevValue: T; change: RemoveUndefined[] }> { + return new AsyncIterableObject<{ value: T; prevValue: T; change: RemoveUndefined[] }>((e) => { + store.add(runOnChange(obs, (value, prevValue, change) => { + e.emitOne({ value, prevValue, change: change }); + })); + + return new Promise((res) => { + store.add(toDisposable(() => { + res(undefined); + })); + }); + }); +} + +export class MinimizeEditsProcessor> extends Disposable implements IDocumentWithAnnotatedEdits { + readonly value: IObservableWithChange }>; + + constructor( + private readonly _originalDoc: IDocumentWithAnnotatedEdits, + ) { + super(); + + const v = this.value = observableValue(this, _originalDoc.value.get()); + + let prevValue: string = this._originalDoc.value.get().value; + this._register(runOnChange(this._originalDoc.value, (val, _prevVal, edits) => { + const eComposed = AnnotatedStringEdit.compose(edits.map(e => e.edit)); + + const e = eComposed.removeCommonSuffixAndPrefix(prevValue); + prevValue = val.value; + + v.set(val, undefined, { edit: e }); + })); + } + + async waitForQueue(): Promise { + await this._originalDoc.waitForQueue(); + } +} + +export const AsyncReaderEndOfStream = Symbol('AsyncReaderEndOfStream'); + +export class AsyncReader { + private _buffer: T[] = []; + private _atEnd = false; + + public get endOfStream(): boolean { return this._buffer.length === 0 && this._atEnd; } + + constructor( + private readonly _source: AsyncIterator + ) { + } + + private async _extendBuffer(): Promise { + if (this._atEnd) { + return; + } + const { value, done } = await this._source.next(); + if (done) { + this._atEnd = true; + } else { + this._buffer.push(value); + } + } + + public async peek(): Promise { + if (this._buffer.length === 0 && !this._atEnd) { + await this._extendBuffer(); + } + if (this._buffer.length === 0) { + return AsyncReaderEndOfStream; + } + return this._buffer[0]; + } + + public peekSyncOrThrow(): T | typeof AsyncReaderEndOfStream { + if (this._buffer.length === 0) { + if (this._atEnd) { + return AsyncReaderEndOfStream; + } + throw new Error('No more elements'); + } + + return this._buffer[0]; + } + + public readSyncOrThrow(): T | typeof AsyncReaderEndOfStream { + if (this._buffer.length === 0) { + if (this._atEnd) { + return AsyncReaderEndOfStream; + } + throw new Error('No more elements'); + } + + return this._buffer.shift()!; + } + + public async peekNextTimeout(timeoutMs: number): Promise { + if (this._buffer.length === 0 && !this._atEnd) { + await raceTimeout(this._extendBuffer(), timeoutMs); + } + if (this._atEnd) { + return AsyncReaderEndOfStream; + } + if (this._buffer.length === 0) { + return undefined; + } + return this._buffer[0]; + } + + public async waitForBufferTimeout(timeoutMs: number): Promise { + if (this._buffer.length > 0 || this._atEnd) { + return true; + } + const result = await raceTimeout(this._extendBuffer().then(() => true), timeoutMs); + return result !== undefined; + } + + public async read(): Promise { + if (this._buffer.length === 0 && !this._atEnd) { + await this._extendBuffer(); + } + if (this._buffer.length === 0) { + return AsyncReaderEndOfStream; + } + return this._buffer.shift()!; + } + + public async readWhile(predicate: (value: T) => boolean, callback: (element: T) => unknown): Promise { + do { + const piece = await this.peek(); + if (piece === AsyncReaderEndOfStream) { + break; + } + if (!predicate(piece)) { + break; + } + await this.read(); // consume + await callback(piece); + } while (true); + } + + public async consumeToEnd(): Promise { + while (!this.endOfStream) { + await this.read(); + } + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/editSourceTrackingFeature.ts b/src/vs/workbench/contrib/editTelemetry/browser/editSourceTrackingFeature.ts new file mode 100644 index 00000000000..59e8d2245e8 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/editSourceTrackingFeature.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { CachedFunction } from '../../../../base/common/cache.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, mapObservableArrayCached, derived, IObservable, ISettableObservable, observableValue, derivedWithSetter, observableSignalFromEvent, observableFromEvent } from '../../../../base/common/observable.js'; +import { isDefined } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; +import { DynamicCssRules } from '../../../../editor/browser/editorDom.js'; +import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; +import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IModelDeltaDecoration } from '../../../../editor/common/model.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.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 { EditorResourceAccessor } from '../../../common/editor.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IStatusbarService, StatusbarAlignment } from '../../../services/statusbar/browser/statusbar.js'; +import { EditSource } from './documentWithAnnotatedEdits.js'; +import { EditSourceTrackingImpl } from './editSourceTrackingImpl.js'; +import { EDIT_TELEMETRY_DETAILS_SETTING_ID, EDIT_TELEMETRY_SHOW_DECORATIONS, EDIT_TELEMETRY_SHOW_STATUS_BAR } from './settings.js'; +import { VSCodeWorkspace } from './vscodeObservableWorkspace.js'; + +export class EditTrackingFeature extends Disposable { + + private readonly _editSourceTrackingShowDecorations; + private readonly _editSourceTrackingShowStatusBar; + private readonly _editSourceDetailsEnabled; + private readonly _showStateInMarkdownDoc = 'editTelemetry.showDebugDetails'; + private readonly _toggleDecorations = 'editTelemetry.toggleDebugDecorations'; + + constructor( + private readonly _workspace: VSCodeWorkspace, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IStatusbarService private readonly _statusbarService: IStatusbarService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IEditorService private readonly _editorService: IEditorService, + ) { + super(); + + this._editSourceTrackingShowDecorations = makeSettable(observableConfigValue(EDIT_TELEMETRY_SHOW_DECORATIONS, false, this._configurationService)); + this._editSourceTrackingShowStatusBar = observableConfigValue(EDIT_TELEMETRY_SHOW_STATUS_BAR, false, this._configurationService); + this._editSourceDetailsEnabled = observableConfigValue(EDIT_TELEMETRY_DETAILS_SETTING_ID, false, this._configurationService); + + const onDidAddGroupSignal = observableSignalFromEvent(this, this._editorGroupsService.onDidAddGroup); + const onDidRemoveGroupSignal = observableSignalFromEvent(this, this._editorGroupsService.onDidRemoveGroup); + const groups = derived(this, reader => { + onDidAddGroupSignal.read(reader); + onDidRemoveGroupSignal.read(reader); + return this._editorGroupsService.groups; + }); + const visibleUris: IObservable> = mapObservableArrayCached(this, groups, g => { + const editors = observableFromEvent(this, g.onDidModelChange, () => g.editors); + return editors.map(e => e.map(editor => EditorResourceAccessor.getCanonicalUri(editor))); + }).map((editors, reader) => { + const map = new Map(); + for (const urisObs of editors) { + for (const uri of urisObs.read(reader)) { + if (isDefined(uri)) { + map.set(uri.toString(), uri); + } + } + } + return map; + }); + + const impl = this._register(this._instantiationService.createInstance(EditSourceTrackingImpl, this._workspace, (doc, reader) => { + const map = visibleUris.read(reader); + return map.get(doc.uri.toString()) !== undefined; + }, this._editSourceDetailsEnabled)); + + this._register(autorun((reader) => { + if (!this._editSourceTrackingShowDecorations.read(reader)) { + return; + } + + const visibleEditors = observableFromEvent(this, this._editorService.onDidVisibleEditorsChange, () => this._editorService.visibleTextEditorControls); + + mapObservableArrayCached(this, visibleEditors, (editor, store) => { + if (editor instanceof CodeEditorWidget) { + const obsEditor = observableCodeEditor(editor); + + const cssStyles = new DynamicCssRules(editor); + const decorations = new CachedFunction((source: EditSource) => { + const r = store.add(cssStyles.createClassNameRef({ + backgroundColor: source.getColor(), + })); + return r.className; + }); + + store.add(obsEditor.setDecorations(derived(reader => { + const uri = obsEditor.model.read(reader)?.uri; + if (!uri) { return []; } + const doc = this._workspace.getDocument(uri); + if (!doc) { return []; } + const docsState = impl.docsState.read(reader).get(doc); + if (!docsState) { return []; } + + const ranges = (docsState.longtermTracker.read(reader)?.getTrackedRanges(reader)) ?? []; + + return ranges.map(r => ({ + range: doc.value.get().getTransformer().getRange(r.range), + options: { + description: 'editSourceTracking', + inlineClassName: decorations.get(r.source), + } + })); + }))); + } + }).recomputeInitiallyAndOnChange(reader.store); + })); + + this._register(autorun(reader => { + if (!this._editSourceTrackingShowStatusBar.read(reader)) { + return; + } + + const statusBarItem = reader.store.add(this._statusbarService.addEntry( + { + name: '', + text: '', + command: this._showStateInMarkdownDoc, + tooltip: 'Edit Source Tracking', + ariaLabel: '', + }, + 'editTelemetry', + StatusbarAlignment.RIGHT, + 100 + )); + + const sumChangedCharacters = derived(reader => { + const docs = impl.docsState.read(reader); + let sum = 0; + for (const state of docs.values()) { + const t = state.longtermTracker.read(reader); + if (!t) { continue; } + const d = state.getTelemetryData(t.getTrackedRanges(reader)); + sum += d.totalModifiedCharactersInFinalState; + } + return sum; + }); + + const tooltipMarkdownString = derived(reader => { + const docs = impl.docsState.read(reader); + const docsDataInTooltip: string[] = []; + const editSources: EditSource[] = []; + for (const [doc, state] of docs) { + const tracker = state.longtermTracker.read(reader); + if (!tracker) { + continue; + } + const trackedRanges = tracker.getTrackedRanges(reader); + const data = state.getTelemetryData(trackedRanges); + if (data.totalModifiedCharactersInFinalState === 0) { + continue; // Don't include unmodified documents in tooltip + } + + editSources.push(...trackedRanges.map(r => r.source)); + + // Filter out unmodified properties as these are not interesting to see in the hover + const filteredData = Object.fromEntries( + Object.entries(data).filter(([_, value]) => !(typeof value === 'number') || value !== 0) + ); + + docsDataInTooltip.push([ + `### ${doc.uri.fsPath}`, + '```json', + JSON.stringify(filteredData, undefined, '\t'), + '```', + '\n' + ].join('\n')); + } + + let tooltipContent: string; + if (docsDataInTooltip.length === 0) { + tooltipContent = 'No modified documents'; + } else if (docsDataInTooltip.length <= 3) { + tooltipContent = docsDataInTooltip.join('\n\n'); + } else { + const lastThree = docsDataInTooltip.slice(-3); + tooltipContent = '...\n\n' + lastThree.join('\n\n'); + } + + const agenda = this._createEditSourceAgenda(editSources); + + const tooltipWithCommand = new MarkdownString(tooltipContent + '\n\n[View Details](command:' + this._showStateInMarkdownDoc + ')'); + tooltipWithCommand.appendMarkdown('\n\n' + agenda + '\n\nToggle decorations: [Click here](command:' + this._toggleDecorations + ')'); + tooltipWithCommand.isTrusted = { enabledCommands: [this._toggleDecorations] }; + tooltipWithCommand.supportHtml = true; + + return tooltipWithCommand; + }); + + reader.store.add(autorun(reader => { + statusBarItem.update({ + name: 'editTelemetry', + text: `$(edit) ${sumChangedCharacters.read(reader)} chars inserted`, + ariaLabel: `Edit Source Tracking: ${sumChangedCharacters.read(reader)} modified characters`, + tooltip: tooltipMarkdownString.read(reader), + command: this._showStateInMarkdownDoc, + }); + })); + + reader.store.add(CommandsRegistry.registerCommand(this._toggleDecorations, () => { + this._editSourceTrackingShowDecorations.set(!this._editSourceTrackingShowDecorations.get(), undefined); + })); + })); + } + + private _createEditSourceAgenda(editSources: EditSource[]): string { + // Collect all edit sources from the tracked documents + const editSourcesSeen = new Set(); + const editSourceInfo = []; + for (const editSource of editSources) { + if (!editSourcesSeen.has(editSource.toString())) { + editSourcesSeen.add(editSource.toString()); + editSourceInfo.push({ name: editSource.toString(), color: editSource.getColor() }); + } + } + + const agendaItems = editSourceInfo.map(info => + `${info.name}` + ); + + return agendaItems.join(' '); + } +} + +export function makeSettable(obs: IObservable): ISettableObservable { + const overrideObs = observableValue('overrideObs', undefined); + return derivedWithSetter(overrideObs, (reader) => { + return overrideObs.read(reader) ?? obs.read(reader); + }, (value, tx) => { + overrideObs.set(value, tx); + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/editSourceTrackingImpl.ts new file mode 100644 index 00000000000..6849a9f7d47 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/editSourceTrackingImpl.ts @@ -0,0 +1,509 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { reverseOrder, compareBy, numberComparator, sumBy } from '../../../../base/common/arrays.js'; +import { IntervalTimer, TimeoutTimer } from '../../../../base/common/async.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { toDisposable, DisposableStore, Disposable } from '../../../../base/common/lifecycle.js'; +import { mapObservableArrayCached, derived, IReader, IObservable, observableSignal, runOnChange, IObservableWithChange, observableValue, transaction, derivedObservableWithCache } from '../../../../base/common/observable.js'; +import { isDefined } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { AnnotatedStringEdit, BaseStringEdit } from '../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../editor/common/core/text/abstractText.js'; +import { TextModelEditReason } from '../../../../editor/common/textModelEditReason.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ISCMRepository, ISCMService } from '../../scm/common/scm.js'; +import { ArcTracker } from './arcTracker.js'; +import { CombineStreamedChanges, DocumentWithSourceAnnotatedEdits, EditKeySourceData, EditSource, EditSourceData, IDocumentWithAnnotatedEdits, MinimizeEditsProcessor } from './documentWithAnnotatedEdits.js'; +import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js'; +import { ObservableWorkspace, IObservableDocument } from './observableWorkspace.js'; + +export class EditSourceTrackingImpl extends Disposable { + public readonly docsState; + + constructor( + private readonly _workspace: ObservableWorkspace, + private readonly _docIsVisible: (doc: IObservableDocument, reader: IReader) => boolean, + private readonly _statsEnabled: IObservable, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + const scmBridge = this._instantiationService.createInstance(ScmBridge); + + const states = mapObservableArrayCached(this, this._workspace.documents, (doc, store) => { + const docIsVisible = derived(reader => this._docIsVisible(doc, reader)); + const wasEverVisible = derivedObservableWithCache(this, (reader, lastVal) => lastVal || docIsVisible.read(reader)); + return wasEverVisible.map(v => v ? [doc, store.add(this._instantiationService.createInstance(TrackedDocumentInfo, doc, docIsVisible, scmBridge, this._statsEnabled))] as const : undefined); + }); + + this.docsState = states.map((entries, reader) => new Map(entries.map(e => e.read(reader)).filter(isDefined))) + .recomputeInitiallyAndOnChange(this._store); + } +} + +class ScmBridge { + constructor( + @ISCMService private readonly _scmService: ISCMService + ) { } + + public async getRepo(uri: URI): Promise { + const repo = this._scmService.getRepository(uri); + if (!repo) { + return undefined; + } + return new ScmRepoBridge(repo); + } +} + +class ScmRepoBridge { + public readonly headBranchNameObs: IObservable = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.name); + public readonly headCommitHashObs: IObservable = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.revision); + + constructor( + private readonly _repo: ISCMRepository, + ) { + } + + async isIgnored(uri: URI): Promise { + return false; + } +} + +class TrackedDocumentInfo extends Disposable { + public readonly longtermTracker: IObservable | undefined>; + public readonly windowedTracker: IObservable | undefined>; + + private readonly _repo: Promise; + + constructor( + private readonly _doc: IObservableDocument, + docIsVisible: IObservable, + private readonly _scm: ScmBridge, + private readonly _statsEnabled: IObservable, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService + ) { + super(); + + // Use the listener service and special events from core to annotate where an edit came from (is async) + let processedDoc: IDocumentWithAnnotatedEdits = this._store.add(new DocumentWithSourceAnnotatedEdits(_doc)); + // Combine streaming edits into one and make edit smaller + processedDoc = this._store.add(this._instantiationService.createInstance((CombineStreamedChanges), processedDoc)); + // Remove common suffix and prefix from edits + processedDoc = this._store.add(new MinimizeEditsProcessor(processedDoc)); + + const docWithJustReason = createDocWithJustReason(processedDoc, this._store); + + const longtermResetSignal = observableSignal('resetSignal'); + + let longtermReason: '10hours' | 'hashChange' | 'branchChange' | 'closed' = 'closed'; + this.longtermTracker = derived((reader) => { + if (!this._statsEnabled.read(reader)) { return undefined; } + longtermResetSignal.read(reader); + + const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined)); + reader.store.add(toDisposable(() => { + // send long term document telemetry + if (!t.isEmpty()) { + this.sendTelemetry('longterm', longtermReason, t); + } + t.dispose(); + })); + return t; + }).recomputeInitiallyAndOnChange(this._store); + + this._store.add(new IntervalTimer()).cancelAndSet(() => { + // Reset after 10 hours + longtermReason = '10hours'; + longtermResetSignal.trigger(undefined); + longtermReason = 'closed'; + }, 10 * 60 * 60 * 1000); + + (async () => { + const repo = await this._scm.getRepo(_doc.uri); + if (this._store.isDisposed) { + return; + } + // Reset on branch change or commit + if (repo) { + this._store.add(runOnChange(repo.headCommitHashObs, () => { + longtermReason = 'hashChange'; + longtermResetSignal.trigger(undefined); + longtermReason = 'closed'; + })); + this._store.add(runOnChange(repo.headBranchNameObs, () => { + longtermReason = 'branchChange'; + longtermResetSignal.trigger(undefined); + longtermReason = 'closed'; + })); + } + + this._store.add(this._instantiationService.createInstance(ArcTelemetrySender, processedDoc, repo)); + })(); + + const resetSignal = observableSignal('resetSignal'); + + this.windowedTracker = derived((reader) => { + if (!this._statsEnabled.read(reader)) { return undefined; } + + if (!docIsVisible.read(reader)) { + return undefined; + } + resetSignal.read(reader); + + reader.store.add(new TimeoutTimer(() => { + // Reset after 5 minutes + resetSignal.trigger(undefined); + }, 5 * 60 * 1000)); + + const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined)); + reader.store.add(toDisposable(async () => { + // send long term document telemetry + this.sendTelemetry('5minWindow', 'time', t); + t.dispose(); + })); + + return t; + }).recomputeInitiallyAndOnChange(this._store); + + this._repo = this._scm.getRepo(_doc.uri); + } + + async sendTelemetry(mode: 'longterm' | '5minWindow', trigger: string, t: DocumentEditSourceTracker) { + const ranges = t.getTrackedRanges(); + if (ranges.length === 0) { + return; + } + + const data = this.getTelemetryData(ranges); + + + const statsUuid = generateUuid(); + + const sourceKeyToRepresentative = new Map(); + for (const r of ranges) { + sourceKeyToRepresentative.set(r.sourceKey, r.sourceRepresentative); + } + + const sums = sumByCategory(ranges, r => r.range.length, r => r.sourceKey); + const entries = Object.entries(sums).filter(([key, value]) => value !== undefined); + entries.sort(reverseOrder(compareBy(([key, value]) => value!, numberComparator))); + entries.length = mode === 'longterm' ? 30 : 10; + + for (const [key, value] of Object.entries(sums)) { + if (value === undefined) { + continue; + } + + + const repr = sourceKeyToRepresentative.get(key); + const cleanedKey = repr?.toKey(1, { $extensionId: false, $extensionVersion: false }); + + const metadata = repr?.metadata; + const extensionId = metadata && '$extensionId' in metadata ? metadata.$extensionId : undefined; + const extensionVersion = metadata && '$extensionVersion' in metadata ? metadata.$extensionVersion : undefined; + + const m = t.getChangedCharactersCount(key); + + this._telemetryService.publicLog2<{ + mode: string; + sourceKey: string; + extensionId: string; + extensionVersion: string; + sourceKeyWithoutExtId: string; + trigger: string; + languageId: string; + statsUuid: string; + modifiedCount: number; + deltaModifiedCount: number; + totalModifiedCount: number; + }, { + owner: 'hediet'; + comment: 'Reports distribution of various edit kinds.'; + + sourceKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit.' }; + mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'longterm or 5minWindow' }; + languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; + statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier for the telemetry event.' }; + extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id which provided this inline completion.' }; + extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' }; + sourceKeyWithoutExtId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit.' }; + trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The trigger for the telemetry event.' }; + + modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of nes modified characters'; isMeasurement: true }; + deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Delta of modified characters'; isMeasurement: true }; + totalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Total number of characters'; isMeasurement: true }; + + }>('editTelemetry.editSources.details', { + mode, + sourceKey: key, + extensionId: extensionId ?? '', + extensionVersion: extensionVersion ?? '', + sourceKeyWithoutExtId: cleanedKey ?? '', + trigger, + languageId: this._doc.languageId.get(), + statsUuid: statsUuid, + modifiedCount: value, + deltaModifiedCount: m, + totalModifiedCount: data.totalModifiedCharactersInFinalState, + }); + } + + + const isTrackedByGit = await data.isTrackedByGit; + this._telemetryService.publicLog2<{ + mode: string; + languageId: string; + statsUuid: string; + nesModifiedCount: number; + inlineCompletionsCopilotModifiedCount: number; + inlineCompletionsNESModifiedCount: number; + otherAIModifiedCount: number; + unknownModifiedCount: number; + userModifiedCount: number; + ideModifiedCount: number; + totalModifiedCharacters: number; + externalModifiedCount: number; + isTrackedByGit: number; + }, { + owner: 'hediet'; + comment: 'Reports distribution of AI vs user edited characters.'; + + mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'longterm or 5minWindow' }; + languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; + statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier for the telemetry event.' }; + + nesModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of nes modified characters'; isMeasurement: true }; + inlineCompletionsCopilotModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of inline completions copilot modified characters'; isMeasurement: true }; + inlineCompletionsNESModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of inline completions nes modified characters'; isMeasurement: true }; + otherAIModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of other AI modified characters'; isMeasurement: true }; + unknownModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of unknown modified characters'; isMeasurement: true }; + userModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of user modified characters'; isMeasurement: true }; + ideModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of IDE modified characters'; isMeasurement: true }; + totalModifiedCharacters: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Total modified characters'; isMeasurement: true }; + externalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of external modified characters'; isMeasurement: true }; + isTrackedByGit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates if the document is tracked by git.' }; + }>('editTelemetry.editSources.stats', { + mode, + languageId: this._doc.languageId.get(), + statsUuid: statsUuid, + nesModifiedCount: data.nesModifiedCount, + inlineCompletionsCopilotModifiedCount: data.inlineCompletionsCopilotModifiedCount, + inlineCompletionsNESModifiedCount: data.inlineCompletionsNESModifiedCount, + otherAIModifiedCount: data.otherAIModifiedCount, + unknownModifiedCount: data.unknownModifiedCount, + userModifiedCount: data.userModifiedCount, + ideModifiedCount: data.ideModifiedCount, + totalModifiedCharacters: data.totalModifiedCharactersInFinalState, + externalModifiedCount: data.externalModifiedCount, + isTrackedByGit: isTrackedByGit ? 1 : 0, + }); + } + + getTelemetryData(ranges: readonly TrackedEdit[]) { + const getEditCategory = (source: EditSource) => { + if (source.category === 'ai' && source.kind === 'nes') { return 'nes'; } + if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot') { return 'inlineCompletionsCopilot'; } + if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat') { return 'inlineCompletionsNES'; } + if (source.category === 'ai' && source.kind === 'completion') { return 'inlineCompletionsOther'; } + if (source.category === 'ai') { return 'otherAI'; } + if (source.category === 'user') { return 'user'; } + if (source.category === 'ide') { return 'ide'; } + if (source.category === 'external') { return 'external'; } + if (source.category === 'unknown') { return 'unknown'; } + + return 'unknown'; + }; + + const sums = sumByCategory(ranges, r => r.range.length, r => getEditCategory(r.source)); + const totalModifiedCharactersInFinalState = sumBy(ranges, r => r.range.length); + + return { + nesModifiedCount: sums.nes ?? 0, + inlineCompletionsCopilotModifiedCount: sums.inlineCompletionsCopilot ?? 0, + inlineCompletionsNESModifiedCount: sums.inlineCompletionsNES ?? 0, + otherAIModifiedCount: sums.otherAI ?? 0, + userModifiedCount: sums.user ?? 0, + ideModifiedCount: sums.ide ?? 0, + unknownModifiedCount: sums.unknown ?? 0, + externalModifiedCount: sums.external ?? 0, + totalModifiedCharactersInFinalState, + languageId: this._doc.languageId.get(), + isTrackedByGit: this._repo.then(async (repo) => !!repo && !await repo.isIgnored(this._doc.uri)), + }; + } +} + + +function mapObservableDelta(obs: IObservableWithChange, mapFn: (value: TDelta) => TDeltaNew, store: DisposableStore): IObservableWithChange { + const obsResult = observableValue('mapped', obs.get()); + store.add(runOnChange(obs, (value, _prevValue, changes) => { + transaction(tx => { + for (const c of changes) { + obsResult.set(value, tx, mapFn(c)); + } + }); + })); + return obsResult; +} + +/** + * Removing the metadata allows touching edits from the same source to merged, even if they were caused by different actions (e.g. two user edits). + */ +function createDocWithJustReason(docWithAnnotatedEdits: IDocumentWithAnnotatedEdits, store: DisposableStore): IDocumentWithAnnotatedEdits { + const docWithJustReason: IDocumentWithAnnotatedEdits = { + value: mapObservableDelta(docWithAnnotatedEdits.value, edit => ({ edit: edit.edit.mapData(d => d.data.toEditSourceData()) }), store), + waitForQueue: () => docWithAnnotatedEdits.waitForQueue(), + }; + return docWithJustReason; +} + +class ArcTelemetrySender extends Disposable { + constructor( + docWithAnnotatedEdits: IDocumentWithAnnotatedEdits, + scmRepoBridge: ScmRepoBridge | undefined, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + this._register(runOnChange(docWithAnnotatedEdits.value, (_val, _prev, changes) => { + const edit = AnnotatedStringEdit.compose(changes.map(c => c.edit)); + + if (!edit.replacements.some(r => r.data.editReason.metadata.source === 'inlineCompletionAccept')) { + return; + } + if (!edit.replacements.every(r => r.data.editReason.metadata.source === 'inlineCompletionAccept')) { + onUnexpectedError(new Error('ArcTelemetrySender: Not all edits are inline completion accept edits!')); + return; + } + if (edit.replacements[0].data.editReason.metadata.source !== 'inlineCompletionAccept') { + return; + } + const data = edit.replacements[0].data.editReason.metadata; + + const docWithJustReason = createDocWithJustReason(docWithAnnotatedEdits, this._store); + const reporter = this._instantiationService.createInstance(ArcTelemetryReporter, docWithJustReason, scmRepoBridge, edit, res => { + res.telemetryService.publicLog2<{ + extensionId: string; + extensionVersion: string; + opportunityId: string; + didBranchChange: number; + timeDelayMs: number; + arc: number; + originalCharCount: number; + }, { + owner: 'hediet'; + comment: 'Reports the accepted and retained character count for an inline completion/edit.'; + + extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id (copilot or copilot-chat); which provided this inline completion.' }; + extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' }; + opportunityId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Unique identifier for an opportunity to show an inline completion or NES.' }; + + didBranchChange: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates if the branch changed in the meantime. If the branch changed (value is 1); this event should probably be ignored.' }; + timeDelayMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The time delay between the user accepting the edit and measuring the survival rate.' }; + arc: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The accepted and restrained character count.' }; + originalCharCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The original character count before any edits.' }; + }>('editTelemetry.reportInlineEditArc', { + extensionId: data.$extensionId ?? '', + extensionVersion: data.$extensionVersion ?? '', + opportunityId: data.$$requestUuid ?? 'unknown', + didBranchChange: res.didBranchChange ? 1 : 0, + timeDelayMs: res.timeDelayMs, + arc: res.arc, + originalCharCount: res.originalCharCount, + }); + }); + + this._register(toDisposable(() => { + reporter.cancel(); + })); + })); + } +} + +export interface EditTelemetryData { + telemetryService: ITelemetryService; + timeDelayMs: number; + didBranchChange: boolean; + arc: number; + originalCharCount: number; +} + +export class ArcTelemetryReporter { + private readonly _store = new DisposableStore(); + private readonly _arcTracker; + private readonly _initialBranchName: string | undefined; + + constructor( + private readonly _document: { value: IObservableWithChange }, + // _markedEdits -> document.value + private readonly _gitRepo: ScmRepoBridge | undefined, + private readonly _trackedEdit: BaseStringEdit, + private readonly _sendTelemetryEvent: (res: EditTelemetryData) => void, + + @ITelemetryService private readonly _telemetryService: ITelemetryService + ) { + this._arcTracker = new ArcTracker(this._document.value.get().value, this._trackedEdit); + + this._store.add(runOnChange(this._document.value, (_val, _prevVal, changes) => { + const edit = BaseStringEdit.composeOrUndefined(changes.map(c => c.edit)); + if (edit) { + this._arcTracker.handleEdits(edit); + } + })); + + this._initialBranchName = this._gitRepo?.headBranchNameObs.get(); + + // This aligns with github inline completions + this._report(0); // for debugging + this._reportAfter(30 * 1000); + this._reportAfter(120 * 1000); + this._reportAfter(300 * 1000); + this._reportAfter(600 * 1000); + // track up to 15min to allow for slower edit responses from legacy SD endpoint + this._reportAfter(900 * 1000, () => { + this._store.dispose(); + }); + } + + private _reportAfter(timeoutMs: number, cb?: () => void) { + const timer = new TimeoutTimer(() => { + this._report(timeoutMs); + timer.dispose(); + if (cb) { + cb(); + } + }, timeoutMs); + this._store.add(timer); + } + + private _report(timeMs: number): void { + const currentBranch = this._gitRepo?.headBranchNameObs.get(); + const didBranchChange = currentBranch !== this._initialBranchName; + + this._sendTelemetryEvent({ + telemetryService: this._telemetryService, + timeDelayMs: timeMs, + didBranchChange, + arc: this._arcTracker.getAcceptedRestrainedCharactersCount(), + originalCharCount: this._arcTracker.getOriginalCharacterCount(), + }); + } + + public cancel(): void { + this._store.dispose(); + } +} + +function sumByCategory(items: readonly T[], getValue: (item: T) => number, getCategory: (item: T) => TCategory): Record { + return items.reduce((acc, item) => { + const category = getCategory(item); + acc[category] = (acc[category] || 0) + getValue(item); + return acc; + }, {} as any as Record); +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/editTelemetry.contribution.ts b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetry.contribution.ts new file mode 100644 index 00000000000..74f6d3865fe --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetry.contribution.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 { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditTelemetryService } from './editTelemetryService.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { localize } from '../../../../nls.js'; +import { EDIT_TELEMETRY_DETAILS_SETTING_ID, EDIT_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_SHOW_DECORATIONS, EDIT_TELEMETRY_SHOW_STATUS_BAR } from './settings.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; + +registerWorkbenchContribution2('EditTelemetryService', EditTelemetryService, WorkbenchPhase.AfterRestored); + +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +configurationRegistry.registerConfiguration({ + id: 'task', + order: 100, + title: localize('editTelemetry', "Edit Telemetry"), + type: 'object', + properties: { + [EDIT_TELEMETRY_SETTING_ID]: { + markdownDescription: localize('telemetry.editStats.enabled', "Controls whether to enable telemetry for edit statistics (only sends statistics if general telemetry is enabled)."), + type: 'boolean', + default: true, + tags: ['experimental'], + }, + [EDIT_TELEMETRY_DETAILS_SETTING_ID]: { + markdownDescription: localize('telemetry.editStats.detailed.enabled', "Controls whether to enable telemetry for detailed edit statistics (only sends statistics if general telemetry is enabled)."), + type: 'boolean', + default: false, + tags: ['experimental'], + }, + [EDIT_TELEMETRY_SHOW_STATUS_BAR]: { + markdownDescription: localize('telemetry.editStats.showStatusBar', "Controls whether to show the status bar for edit telemetry."), + type: 'boolean', + default: false, + tags: ['experimental'], + }, + [EDIT_TELEMETRY_SHOW_DECORATIONS]: { + markdownDescription: localize('telemetry.editStats.showDecorations', "Controls whether to show decorations for edit telemetry."), + type: 'boolean', + default: false, + tags: ['experimental'], + }, + } +}); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryService.ts b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryService.ts new file mode 100644 index 00000000000..e77b9a3cb78 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryService.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { ITelemetryService, TelemetryLevel, telemetryLevelEnabled } from '../../../../platform/telemetry/common/telemetry.js'; +import { EditTrackingFeature } from './editSourceTrackingFeature.js'; +import { EDIT_TELEMETRY_SETTING_ID } from './settings.js'; +import { VSCodeWorkspace } from './vscodeObservableWorkspace.js'; + +export class EditTelemetryService extends Disposable { + private readonly _editSourceTrackingEnabled; + + constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { + super(); + + this._editSourceTrackingEnabled = observableConfigValue(EDIT_TELEMETRY_SETTING_ID, true, this._configurationService); + + this._register(autorun(r => { + const enabled = this._editSourceTrackingEnabled.read(r); + if (!enabled || !telemetryLevelEnabled(this._telemetryService, TelemetryLevel.USAGE)) { + return; + } + + const workspace = this._instantiationService.createInstance(VSCodeWorkspace); + + r.store.add(this._instantiationService.createInstance(EditTrackingFeature, workspace)); + })); + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/editTracker.ts b/src/vs/workbench/contrib/editTelemetry/browser/editTracker.ts new file mode 100644 index 00000000000..7346efae6a0 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/editTracker.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { observableSignal, runOnChange, IReader } from '../../../../base/common/observable.js'; +import { AnnotatedStringEdit } from '../../../../editor/common/core/edits/stringEdit.js'; +import { OffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js'; +import { TextModelEditReason } from '../../../../editor/common/textModelEditReason.js'; +import { IDocumentWithAnnotatedEdits, EditKeySourceData, EditSource } from './documentWithAnnotatedEdits.js'; + +/** + * Tracks a single document. +*/ +export class DocumentEditSourceTracker extends Disposable { + private _edits: AnnotatedStringEdit = AnnotatedStringEdit.empty; + private _pendingExternalEdits: AnnotatedStringEdit = AnnotatedStringEdit.empty; + + private readonly _update = observableSignal(this); + private readonly _sumAddedCharactersPerKey: Map = new Map(); + + constructor( + private readonly _doc: IDocumentWithAnnotatedEdits, + public readonly data: T, + ) { + super(); + + this._register(runOnChange(this._doc.value, (_val, _prevVal, edits) => { + const eComposed = AnnotatedStringEdit.compose(edits.map(e => e.edit)); + if (eComposed.replacements.every(e => e.data.source.category === 'external')) { + if (this._edits.isEmpty()) { + // Ignore initial external edits + } else { + // queue pending external edits + this._pendingExternalEdits = this._pendingExternalEdits.compose(eComposed); + } + } else { + if (!this._pendingExternalEdits.isEmpty()) { + this._applyEdit(this._pendingExternalEdits); + this._pendingExternalEdits = AnnotatedStringEdit.empty; + } + this._applyEdit(eComposed); + } + + this._update.trigger(undefined); + })); + } + + private _applyEdit(e: AnnotatedStringEdit): void { + for (const r of e.replacements) { + const existing = this._sumAddedCharactersPerKey.get(r.data.key) ?? 0; + const newCount = existing + r.getNewLength(); + this._sumAddedCharactersPerKey.set(r.data.key, newCount); + } + + this._edits = this._edits.compose(e); + } + + async waitForQueue(): Promise { + await this._doc.waitForQueue(); + } + + public getChangedCharactersCount(key: string): number { + const val = this._sumAddedCharactersPerKey.get(key); + return val ?? 0; + } + + getTrackedRanges(reader?: IReader): TrackedEdit[] { + this._update.read(reader); + const ranges = this._edits.getNewRanges(); + return ranges.map((r, idx) => { + const e = this._edits.replacements[idx]; + const te = new TrackedEdit(e.replaceRange, r, e.data.key, e.data.source, e.data.representative); + return te; + }); + } + + isEmpty(): boolean { + return this._edits.isEmpty(); + } + + public reset(): void { + this._edits = AnnotatedStringEdit.empty; + } + + public _getDebugVisualization() { + const ranges = this.getTrackedRanges(); + const txt = this._doc.value.get().value; + + return { + ...{ $fileExtension: 'text.w' }, + 'value': txt, + 'decorations': ranges.map(r => { + return { + range: [r.range.start, r.range.endExclusive], + color: r.source.getColor(), + }; + }) + }; + } +} + +export class TrackedEdit { + constructor( + public readonly originalRange: OffsetRange, + public readonly range: OffsetRange, + public readonly sourceKey: string, + public readonly source: EditSource, + public readonly sourceRepresentative: TextModelEditReason, + ) { } +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/observableWorkspace.ts b/src/vs/workbench/contrib/editTelemetry/browser/observableWorkspace.ts new file mode 100644 index 00000000000..537b9e00ccf --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/observableWorkspace.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservableWithChange, derivedHandleChanges, derivedWithStore, observableValue, autorunWithStore, runOnChange, IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { StringEdit } from '../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../editor/common/core/text/abstractText.js'; +import { TextModelEditReason } from '../../../../editor/common/textModelEditReason.js'; + +export abstract class ObservableWorkspace { + abstract get documents(): IObservableWithChange; + + + getFirstOpenDocument(): IObservableDocument | undefined { + return this.documents.get()[0]; + } + + getDocument(documentId: URI): IObservableDocument | undefined { + return this.documents.get().find(d => d.uri.toString() === documentId.toString()); + } + + private _version = 0; + + /** + * Is fired when any open document changes. + */ + public readonly onDidOpenDocumentChange = derivedHandleChanges({ + owner: this, + changeTracker: { + createChangeSummary: () => ({ didChange: false }), + handleChange: (ctx, changeSummary) => { + if (!ctx.didChange(this.documents)) { + changeSummary.didChange = true; // A document changed + } + return true; + } + } + }, (reader, changeSummary) => { + const docs = this.documents.read(reader); + for (const d of docs) { + d.value.read(reader); // add dependency + } + if (changeSummary.didChange) { + this._version++; // to force a change + } + return this._version; + + // TODO@hediet make this work: + /* + const docs = this.openDocuments.read(reader); + for (const d of docs) { + if (reader.readChangesSinceLastRun(d.value).length > 0) { + reader.reportChange(d); + } + } + return undefined; + */ + }); + + public readonly lastActiveDocument = derivedWithStore((_reader, store) => { + const obs = observableValue('lastActiveDocument', undefined as IObservableDocument | undefined); + store.add(autorunWithStore((reader, store) => { + const docs = this.documents.read(reader); + for (const d of docs) { + store.add(runOnChange(d.value, () => { + obs.set(d, undefined); + })); + } + })); + return obs; + }).flatten(); +} + +export interface IObservableDocument { + readonly uri: URI; + readonly value: IObservableWithChange; + + /** + * Increases whenever the value changes. Is also used to reference document states from the past. + */ + readonly version: IObservable; + readonly languageId: IObservable; +} + +export class StringEditWithReason extends StringEdit { + constructor( + replacements: StringEdit['replacements'], + public readonly reason: TextModelEditReason, + ) { + super(replacements); + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/settings.ts b/src/vs/workbench/contrib/editTelemetry/browser/settings.ts new file mode 100644 index 00000000000..b87eb3e7ca4 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/settings.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. + *--------------------------------------------------------------------------------------------*/ + +export const EDIT_TELEMETRY_SETTING_ID = 'telemetry.editStats.enabled'; +export const EDIT_TELEMETRY_DETAILS_SETTING_ID = 'telemetry.editStats.details.enabled'; +export const EDIT_TELEMETRY_SHOW_DECORATIONS = 'telemetry.editStats.showDecorations'; +export const EDIT_TELEMETRY_SHOW_STATUS_BAR = 'telemetry.editStats.showStatusBar'; diff --git a/src/vs/workbench/contrib/editTelemetry/browser/vscodeObservableWorkspace.ts b/src/vs/workbench/contrib/editTelemetry/browser/vscodeObservableWorkspace.ts new file mode 100644 index 00000000000..ea0afcac958 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/vscodeObservableWorkspace.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 { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable, IObservableWithChange, mapObservableArrayCached, observableSignalFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { isDefined } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; +import { StringText } from '../../../../editor/common/core/text/abstractText.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { offsetEditFromContentChanges } from '../../../../editor/common/model/textModelStringEdit.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { IObservableDocument, ObservableWorkspace, StringEditWithReason } from './observableWorkspace.js'; + +export class VSCodeWorkspace extends ObservableWorkspace implements IDisposable { + private readonly _documents; + public get documents() { return this._documents; } + + private readonly _store = new DisposableStore(); + + constructor( + @IModelService private readonly _textModelService: IModelService, + ) { + super(); + + const onModelAdded = observableSignalFromEvent(this, this._textModelService.onModelAdded); + const onModelRemoved = observableSignalFromEvent(this, this._textModelService.onModelRemoved); + + const models = derived(this, reader => { + onModelAdded.read(reader); + onModelRemoved.read(reader); + const models = this._textModelService.getModels(); + return models; + }); + + const documents = mapObservableArrayCached(this, models, (m, store) => { + if (m.isTooLargeForSyncing()) { + return undefined; + } + return store.add(new VSCodeDocument(m)); + }).recomputeInitiallyAndOnChange(this._store).map(d => d.filter(isDefined)); + + this._documents = documents; + } + + dispose(): void { + this._store.dispose(); + } +} + +export class VSCodeDocument extends Disposable implements IObservableDocument { + get uri(): URI { return this.textModel.uri; } + private readonly _value; + private readonly _version; + private readonly _languageId; + get value(): IObservableWithChange { return this._value; } + get version(): IObservable { return this._version; } + get languageId(): IObservable { return this._languageId; } + + constructor( + public readonly textModel: ITextModel, + ) { + super(); + + this._value = observableValue(this, new StringText(this.textModel.getValue())); + this._version = observableValue(this, this.textModel.getVersionId()); + this._languageId = observableValue(this, this.textModel.getLanguageId()); + + this._register(this.textModel.onDidChangeContent((e) => { + transaction(tx => { + const edit = offsetEditFromContentChanges(e.changes); + if (e.detailedReasons.length !== 1) { + onUnexpectedError(new Error(`Unexpected number of detailed reasons: ${e.detailedReasons.length}`)); + } + + const change = new StringEditWithReason(edit.replacements, e.detailedReasons[0]); + + this._value.set(new StringText(this.textModel.getValue()), tx, change); + this._version.set(this.textModel.getVersionId(), tx); + }); + })); + + this._register(this.textModel.onDidChangeLanguage(e => { + transaction(tx => { + this._languageId.set(this.textModel.getLanguageId(), tx); + }); + })); + } +} diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts index b2a54e1f873..282f5e496dd 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewer.ts @@ -265,8 +265,7 @@ class ExtensionRenderer implements IListRenderer, IExt public renderTemplate(container: HTMLElement): IExtensionTemplateData { container.classList.add('extension'); - const icon = dom.append(container, dom.$('img.icon')); - const iconWidget = this.instantiationService.createInstance(ExtensionIconWidget, icon); + const iconWidget = this.instantiationService.createInstance(ExtensionIconWidget, container); const details = dom.append(container, dom.$('.details')); const header = dom.append(details, dom.$('.header')); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 752dcc3196f..ffe36f413bb 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -1935,7 +1935,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension 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(), updateCheck: true }, CancellationToken.None); + const galleryExtensions = await this.galleryService.getExtensions(infos, { targetPlatform, compatible: true, productVersion: this.getProductVersion() }, CancellationToken.None); if (galleryExtensions.length) { await this.syncInstalledExtensionsWithGallery(galleryExtensions, infos); } diff --git a/src/vs/workbench/contrib/extensions/common/extensionQuery.ts b/src/vs/workbench/contrib/extensions/common/extensionQuery.ts index cd74a11bd4d..fab81196b99 100644 --- a/src/vs/workbench/contrib/extensions/common/extensionQuery.ts +++ b/src/vs/workbench/contrib/extensions/common/extensionQuery.ts @@ -20,7 +20,7 @@ export class Query { commands.push('featured'); } - commands.push(...['popular', 'recommended', 'recentlyPublished', 'workspaceUnsupported', 'deprecated', 'sort']); + commands.push(...['mcp', 'popular', 'recommended', 'recentlyPublished', 'workspaceUnsupported', 'deprecated', 'sort']); const isCategoriesEnabled = galleryManifest?.capabilities.extensionQuery?.filtering?.some(c => c.name === FilterType.Category); if (isCategoriesEnabled) { commands.push('category'); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts index a2bcfa14a33..551435f3040 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts @@ -19,7 +19,6 @@ import { IEditorDecorationsCollection } from '../../../../editor/common/editorCo import { IModelDecorationsChangeAccessor, IModelDeltaDecoration, IValidEditOperation, MinimapPosition, OverviewRulerLane, TrackedRangeStickiness } from '../../../../editor/common/model.js'; import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js'; -import { InlineDecoration, InlineDecorationType } from '../../../../editor/common/viewModel.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { Progress } from '../../../../platform/progress/common/progress.js'; import { SaveReason } from '../../../common/editor.js'; @@ -41,6 +40,7 @@ import { Iterable } from '../../../../base/common/iterator.js'; import { ConflictActionsFactory, IContentWidgetAction } from '../../mergeEditor/browser/view/conflictActions.js'; import { observableValue } from '../../../../base/common/observable.js'; import { IMenuService, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { InlineDecoration, InlineDecorationType } from '../../../../editor/common/viewModel/inlineDecorations.js'; export interface IEditObserver { start(): void; diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css index 01c83a45b7b..74d57ab1ce2 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css @@ -359,6 +359,10 @@ opacity: 1; } +.monaco-workbench .inline-chat .chat-attached-context { + padding: 3px 0px; +} + /* HINT */ diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index 4b9b193ab5c..fa946fff60e 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -30,7 +30,7 @@ import { McpSamplingService } from '../common/mcpSamplingService.js'; import { McpService } from '../common/mcpService.js'; import { IMcpElicitationService, IMcpSamplingService, IMcpService, IMcpWorkbenchService } from '../common/mcpTypes.js'; import { McpAddContextContribution } from './mcpAddContextContribution.js'; -import { AddConfigurationAction, EditStoredInput, ListMcpServerCommand, McpBrowseCommand, McpBrowseResourcesCommand, McpConfigureSamplingModels, MCPServerActionRendering, McpServerOptionsCommand, McpStartPromptingServerCommand, OpenRemoteUserMcpResourceCommand, OpenUserMcpResourceCommand, OpenWorkspaceFolderMcpResourceCommand, OpenWorkspaceMcpResourceCommand, RemoveStoredInput, ResetMcpCachedTools, ResetMcpTrustCommand, RestartServer, ShowConfiguration, ShowInstalledMcpServersCommand, ShowOutput, StartServer, StopServer } from './mcpCommands.js'; +import { AddConfigurationAction, BrowseMcpServersPageCommand, EditStoredInput, ListMcpServerCommand, McpBrowseCommand, McpBrowseResourcesCommand, McpConfigureSamplingModels, MCPServerActionRendering, McpServerOptionsCommand, McpStartPromptingServerCommand, OpenRemoteUserMcpResourceCommand, OpenUserMcpResourceCommand, OpenWorkspaceFolderMcpResourceCommand, OpenWorkspaceMcpResourceCommand, RemoveStoredInput, ResetMcpCachedTools, ResetMcpTrustCommand, RestartServer, ShowConfiguration, ShowInstalledMcpServersCommand, ShowOutput, StartServer, StopServer } from './mcpCommands.js'; import { McpDiscovery } from './mcpDiscovery.js'; import { McpElicitationService } from './mcpElicitationService.js'; import { McpLanguageFeatures } from './mcpLanguageFeatures.js'; @@ -71,6 +71,7 @@ registerAction2(ShowOutput); registerAction2(RestartServer); registerAction2(ShowConfiguration); registerAction2(McpBrowseCommand); +registerAction2(BrowseMcpServersPageCommand); registerAction2(OpenUserMcpResourceCommand); registerAction2(OpenRemoteUserMcpResourceCommand); registerAction2(OpenWorkspaceMcpResourceCommand); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index aa54c5deb60..27b1e707779 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -33,7 +33,7 @@ import { IAccountQuery, IAuthenticationQueryService } from '../../../services/au import { IAuthenticationService } from '../../../services/authentication/common/authentication.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; -import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { ChatViewId, IChatWidgetService } from '../../chat/browser/chat.js'; import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; import { ChatModeKind } from '../../chat/common/constants.js'; import { ILanguageModelsService } from '../../chat/common/languageModels.js'; @@ -52,6 +52,10 @@ import { PICK_WORKSPACE_FOLDER_COMMAND_ID } from '../../../browser/actions/works import { MCP_CONFIGURATION_KEY, WORKSPACE_STANDALONE_CONFIGURATIONS } from '../../../services/configuration/common/configuration.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { CHAT_CONFIG_MENU_ID } from '../../chat/browser/actions/chatActions.js'; +import { VIEW_CONTAINER } from '../../extensions/browser/extensions.contribution.js'; // acroynms do not get localized const category: ILocalizedString = { @@ -698,6 +702,27 @@ MenuRegistry.appendMenuItem(MenuId.CommandPalette, { }, }); +export class BrowseMcpServersPageCommand extends Action2 { + constructor() { + super({ + id: McpCommandIds.BrowsePage, + title: localize2('mcp.command.open', "Browse MCP Servers"), + icon: Codicon.globe, + menu: [{ + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', InstalledMcpServersViewId), + group: 'navigation', + }], + }); + } + + async run(accessor: ServicesAccessor) { + const productService = accessor.get(IProductService); + const openerService = accessor.get(IOpenerService); + return openerService.open(productService.quality === 'insider' ? 'https://code.visualstudio.com/insider/mcp' : 'https://code.visualstudio.com/mcp'); + } +} + export class ShowInstalledMcpServersCommand extends Action2 { constructor() { super({ @@ -710,10 +735,25 @@ export class ShowInstalledMcpServersCommand extends Action2 { } async run(accessor: ServicesAccessor) { - accessor.get(IViewsService).openView(InstalledMcpServersViewId, true); + const viewsService = accessor.get(IViewsService); + const view = await viewsService.openView(InstalledMcpServersViewId, true); + if (!view) { + await viewsService.openViewContainer(VIEW_CONTAINER.id); + await viewsService.openView(InstalledMcpServersViewId, true); + } } } +MenuRegistry.appendMenuItem(CHAT_CONFIG_MENU_ID, { + command: { + id: McpCommandIds.ShowInstalled, + title: localize2('mcp.servers', "MCP Servers") + }, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), + order: 14, + group: '0_level' +}); + abstract class OpenMcpResourceCommand extends Action2 { protected abstract getURI(accessor: ServicesAccessor): Promise; diff --git a/src/vs/workbench/contrib/mcp/browser/mcpPromptArgumentPick.ts b/src/vs/workbench/contrib/mcp/browser/mcpPromptArgumentPick.ts index f261f122024..05044e81fa5 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpPromptArgumentPick.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpPromptArgumentPick.ts @@ -168,10 +168,13 @@ export class McpPromptArgumentPick extends Disposable { })); store.add(quickPick.onDidAccept(() => { const item = quickPick.selectedItems[0]; - if (!quickPick.value && arg.required && (item.action === 'text' || item.action === 'command')) { + if (!quickPick.value && arg.required && (!item || item.action === 'text' || item.action === 'command')) { quickPick.validationMessage = localize('mcp.arg.required', "This argument is required"); + } else if (!item) { + // For optional arguments when no item is selected, return empty text action + resolve({ id: 'insert-text', label: '', action: 'text' }); } else { - resolve(quickPick.selectedItems[0]); + resolve(item); } })); store.add(quickPick.onDidTriggerButton(() => { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpResourceQuickAccess.ts b/src/vs/workbench/contrib/mcp/browser/mcpResourceQuickAccess.ts index d084e5064ca..5af938ba637 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpResourceQuickAccess.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpResourceQuickAccess.ts @@ -130,7 +130,7 @@ export class McpResourcePickHelper { return uri; } - this._notificationService.warn(localize('mcp.resource.template.notFound', "The resource {0} was not found.", McpResourceURI.toServer(uri).resourceURI.toString())); + this._notificationService.warn(localize('mcp.resource.template.notFound', "The resource {0} was not found.", McpResourceURI.toServer(uri).resourceURL.toString())); return undefined; } diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts b/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts index c924e97ba0f..928d25c8372 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts @@ -12,15 +12,14 @@ import { IContextMenuService } from '../../../../platform/contextview/browser/co import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { manageExtensionIcon } from '../../extensions/browser/extensionsIcons.js'; import { getDomNodePagePosition } from '../../../../base/browser/dom.js'; -import { IMcpSamplingService, IMcpServer, IMcpServerContainer, IMcpService, IMcpWorkbenchService, IWorkbenchMcpServer, McpCapability, McpConnectionState } from '../common/mcpTypes.js'; -import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; -import { URI } from '../../../../base/common/uri.js'; -import { Location } from '../../../../editor/common/languages.js'; +import { IMcpSamplingService, IMcpServer, IMcpServerContainer, IMcpService, IMcpWorkbenchService, IWorkbenchMcpServer, McpCapability, McpConnectionState, McpServerEditorTab, McpServerInstallState } from '../common/mcpTypes.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; import { IAccountQuery, IAuthenticationQueryService } from '../../../services/authentication/common/authenticationQuery.js'; import { IAuthenticationService } from '../../../services/authentication/common/authentication.js'; +import { alert } from '../../../../base/browser/ui/aria/aria.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; export abstract class McpServerAction extends Action implements IMcpServerContainer { @@ -100,7 +99,9 @@ export class InstallAction extends McpServerAction { private static readonly HIDE = `${this.CLASS} hide`; constructor( + private readonly editor: boolean, @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super('extensions.install', localize('install', "Install"), InstallAction.CLASS, false); this.update(); @@ -115,6 +116,9 @@ export class InstallAction extends McpServerAction { if (!this.mcpServer?.gallery && !this.mcpServer?.installable) { return; } + if (this.mcpServer.installState !== McpServerInstallState.Uninstalled) { + return; + } this.class = InstallAction.CLASS; this.enabled = true; this.label = localize('install', "Install"); @@ -124,10 +128,40 @@ export class InstallAction extends McpServerAction { if (!this.mcpServer) { return; } + + if (!this.editor) { + this.mcpWorkbenchService.open(this.mcpServer); + alert(localize('mcpServerInstallation', "Installing MCP Server {0} started. An editor is now open with more details on this MCP Server", this.mcpServer.label)); + } + + type McpServerInstallClassification = { + owner: 'sandy081'; + comment: 'Used to understand if the action to install the MCP server is used.'; + name?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The gallery name of the MCP server being installed' }; + }; + type McpServerInstall = { + name?: string; + }; + this.telemetryService.publicLog2('mcp:action:install', { name: this.mcpServer.gallery?.name }); + await this.mcpWorkbenchService.install(this.mcpServer); } } +export class InstallingLabelAction extends McpServerAction { + + private static readonly LABEL = localize('installing', "Installing"); + private static readonly CLASS = `${McpServerAction.LABEL_ACTION_CLASS} install installing`; + + constructor() { + super('extension.installing', InstallingLabelAction.LABEL, InstallingLabelAction.CLASS, false); + } + + update(): void { + this.class = `${InstallingLabelAction.CLASS}${this.mcpServer && this.mcpServer.installState === McpServerInstallState.Installing ? '' : ' hide'}`; + } +} + export class UninstallAction extends McpServerAction { static readonly CLASS = `${this.LABEL_ACTION_CLASS} prominent uninstall`; @@ -149,6 +183,10 @@ export class UninstallAction extends McpServerAction { if (!this.mcpServer.local) { return; } + if (this.mcpServer.installState !== McpServerInstallState.Installed) { + this.enabled = false; + return; + } this.class = UninstallAction.CLASS; this.enabled = true; this.label = localize('uninstall', "Uninstall"); @@ -520,9 +558,7 @@ export class ShowServerConfigurationAction extends McpServerAction { private static readonly HIDE = `${this.CLASS} hide`; constructor( - @IMcpService private readonly mcpService: IMcpService, - @IMcpRegistry private readonly mcpRegistry: IMcpRegistry, - @IEditorService private readonly editorService: IEditorService, + @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService ) { super('extensions.config', localize('config', "Show Configuration"), ShowServerConfigurationAction.CLASS, false); this.update(); @@ -531,8 +567,7 @@ export class ShowServerConfigurationAction extends McpServerAction { update(): void { this.enabled = false; this.class = ShowServerConfigurationAction.HIDE; - const configurationTarget = this.getConfigurationTarget(); - if (!configurationTarget) { + if (!this.mcpServer?.local) { return; } this.class = ShowServerConfigurationAction.CLASS; @@ -541,31 +576,12 @@ export class ShowServerConfigurationAction extends McpServerAction { } override async run(): Promise { - const configurationTarget = this.getConfigurationTarget(); - if (!configurationTarget) { + if (!this.mcpServer?.local) { return; } - this.editorService.openEditor({ - resource: URI.isUri(configurationTarget) ? configurationTarget : configurationTarget!.uri, - options: { selection: URI.isUri(configurationTarget) ? undefined : configurationTarget!.range } - }); + this.mcpWorkbenchService.open(this.mcpServer, { tab: McpServerEditorTab.Configuration }); } - private getConfigurationTarget(): Location | URI | undefined { - if (!this.mcpServer) { - return; - } - if (!this.mcpServer.local) { - return; - } - const server = this.mcpService.servers.get().find(s => s.definition.label === this.mcpServer?.name); - if (!server) { - return; - } - const collection = this.mcpRegistry.collections.get().find(c => c.id === server.collection.id); - const serverDefinition = collection?.serverDefinitions.get().find(s => s.id === server.definition.id); - return serverDefinition?.presentation?.origin || collection?.presentation?.origin; - } } export class ConfigureModelAccessAction extends McpServerAction { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts b/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts index a9eb0474c15..77626709d7e 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts @@ -37,11 +37,10 @@ import { IWebview, IWebviewService } from '../../webview/browser/webview.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IWorkbenchMcpServer, McpServerContainers } from '../common/mcpTypes.js'; +import { IMcpServerEditorOptions, IMcpWorkbenchService, IWorkbenchMcpServer, McpServerContainers } from '../common/mcpTypes.js'; import { InstallCountWidget, McpServerIconWidget, McpServerWidget, onClick, PublisherWidget, RatingsWidget } from './mcpServerWidgets.js'; -import { DropDownAction, InstallAction, ManageMcpServerAction, UninstallAction } from './mcpServerActions.js'; +import { DropDownAction, InstallAction, InstallingLabelAction, ManageMcpServerAction, UninstallAction } from './mcpServerActions.js'; import { McpServerEditorInput } from './mcpServerEditorInput.js'; -import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { ILocalMcpServer, IMcpServerManifest, IMcpServerPackage, PackageType } from '../../../../platform/mcp/common/mcpManagement.js'; import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; @@ -74,19 +73,34 @@ class NavBar extends Disposable { this.actionbar = this._register(new ActionBar(element)); } - push(id: string, label: string, tooltip: string): void { + push(id: string, label: string, tooltip: string, index?: number): void { const action = new Action(id, label, undefined, true, () => this.update(id, true)); action.tooltip = tooltip; - this.actions.push(action); - this.actionbar.push(action); + if (typeof index === 'number') { + this.actions.splice(index, 0, action); + } else { + this.actions.push(action); + } + this.actionbar.push(action, { index }); if (this.actions.length === 1) { this.update(id); } } + remove(id: string): void { + const index = this.actions.findIndex(action => action.id === id); + if (index !== -1) { + this.actions.splice(index, 1); + this.actionbar.pull(index); + if (this._currentId === id) { + this.switch(this.actions[0]?.id); + } + } + } + clear(): void { this.actions = dispose(this.actions); this.actionbar.clear(); @@ -101,6 +115,10 @@ class NavBar extends Disposable { return false; } + has(id: string): boolean { + return this.actions.some(action => action.id === id); + } + private update(id: string, focus?: boolean): void { this._currentId = id; this._onChange.fire({ id, focus: !!focus }); @@ -166,6 +184,7 @@ export class McpServerEditor extends EditorPane { @IWebviewService private readonly webviewService: IWebviewService, @ILanguageService private readonly languageService: ILanguageService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, @IHoverService private readonly hoverService: IHoverService, ) { super(McpServerEditor.ID, group, telemetryService, themeService, storageService); @@ -220,7 +239,8 @@ export class McpServerEditor extends EditorPane { const description = append(details, $('.description')); const actions = [ - this.instantiationService.createInstance(InstallAction), + this.instantiationService.createInstance(InstallAction, true), + this.instantiationService.createInstance(InstallingLabelAction), this.instantiationService.createInstance(UninstallAction), this.instantiationService.createInstance(ManageMcpServerAction, true), ]; @@ -286,7 +306,7 @@ export class McpServerEditor extends EditorPane { }; } - override async setInput(input: McpServerEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + override async setInput(input: McpServerEditorInput, options: IMcpServerEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { await super.setInput(input, options, context, token); if (this.template) { await this.render(input.mcpServer, this.template, !!options?.preserveFocus); @@ -313,6 +333,13 @@ export class McpServerEditor extends EditorPane { this.renderNavbar(mcpServer, template, preserveFocus); } + override setOptions(options: IMcpServerEditorOptions | undefined): void { + super.setOptions(options); + if (options?.tab) { + this.template?.navbar.switch(options.tab); + } + } + private renderNavbar(extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, preserveFocus: boolean): void { template.content.innerText = ''; template.navbar.clear(); @@ -322,7 +349,7 @@ export class McpServerEditor extends EditorPane { this.currentIdentifier = extension.id; } - if (extension.hasReadme()) { + if (extension.readmeUrl) { template.navbar.push(McpServerEditorTab.Readme, localize('details', "Details"), localize('detailstooltip', "Extension details, rendered from the extension's 'README.md' file")); } @@ -334,6 +361,21 @@ export class McpServerEditor extends EditorPane { template.navbar.push(McpServerEditorTab.Manifest, localize('manifest', "Manifest"), localize('manifesttooltip', "Server manifest details")); } + this.transientDisposables.add(this.mcpWorkbenchService.onChange(e => { + if (e === extension) { + if (e.config && !template.navbar.has(McpServerEditorTab.Configuration)) { + template.navbar.push(McpServerEditorTab.Configuration, localize('configuration', "Configuration"), localize('configurationtooltip', "Server configuration details"), extension.readmeUrl ? 1 : 0); + } + if (!e.config && template.navbar.has(McpServerEditorTab.Configuration)) { + template.navbar.remove(McpServerEditorTab.Configuration); + } + } + })); + + if ((this.options)?.tab) { + template.navbar.switch((this.options).tab!); + } + if (template.navbar.currentId) { this.onNavbarChange(extension, { id: template.navbar.currentId, focus: !preserveFocus }, template); } @@ -558,7 +600,7 @@ export class McpServerEditor extends EditorPane { private async openConfiguration(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise { const configContainer = append(template.content, $('.configuration')); - const content = $('div', { class: 'configuration-content', tabindex: '0' }); + const content = $('div', { class: 'configuration-content' }); this.renderConfigurationDetails(content, mcpServer); @@ -573,7 +615,7 @@ export class McpServerEditor extends EditorPane { private async openManifest(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise { const manifestContainer = append(template.content, $('.manifest')); - const content = $('div', { class: 'manifest-content', tabindex: '0' }); + const content = $('div', { class: 'manifest-content' }); try { const manifest = await this.loadContents(() => this.mcpServerManifest!.get(), content); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServerEditorInput.ts b/src/vs/workbench/contrib/mcp/browser/mcpServerEditorInput.ts index 18a9416a0bc..f82a38ff41e 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServerEditorInput.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServerEditorInput.ts @@ -14,7 +14,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { IWorkbenchMcpServer } from '../common/mcpTypes.js'; -const ExtensionEditorIcon = registerIcon('extensions-editor-label-icon', Codicon.extensions, localize('extensionsEditorLabelIcon', 'Icon of the extensions editor label.')); +const MCPServerEditorIcon = registerIcon('mcp-server-editor-icon', Codicon.mcp, localize('mcpServerEditorLabelIcon', 'Icon of the MCP Server editor.')); export class McpServerEditorInput extends EditorInput { @@ -42,11 +42,11 @@ export class McpServerEditorInput extends EditorInput { get mcpServer(): IWorkbenchMcpServer { return this._mcpServer; } override getName(): string { - return localize('extensionsInputName', "Extension: {0}", this._mcpServer.label); + return localize('extensionsInputName', "MCP Server: {0}", this._mcpServer.label); } override getIcon(): ThemeIcon | undefined { - return ExtensionEditorIcon; + return MCPServerEditorIcon; } override matches(other: EditorInput | IUntypedEditorInput): boolean { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts index 23d90aec64d..ad5a7ec18b7 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts @@ -7,7 +7,7 @@ import './media/mcpServersView.css'; import * as dom from '../../../../base/browser/dom.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IListContextMenuEvent, IListRenderer } from '../../../../base/browser/ui/list/list.js'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { combinedDisposable, Disposable, DisposableStore, dispose, IDisposable, isDisposable } from '../../../../base/common/lifecycle.js'; import { DelayedPagedModel, IPagedModel, PagedModel } from '../../../../base/common/paging.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -24,8 +24,8 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { getLocationBasedViewColors, ViewPane } from '../../../browser/parts/views/viewPane.js'; import { IViewletViewOptions } from '../../../browser/parts/views/viewsViewlet.js'; import { IViewDescriptorService, IViewsRegistry, Extensions as ViewExtensions } from '../../../common/views.js'; -import { HasInstalledMcpServersContext, IMcpWorkbenchService, InstalledMcpServersViewId, IWorkbenchMcpServer, McpServerContainers, mcpServerIcon } from '../common/mcpTypes.js'; -import { DropDownAction, InstallAction, ManageMcpServerAction } from './mcpServerActions.js'; +import { HasInstalledMcpServersContext, IMcpWorkbenchService, InstalledMcpServersViewId, IWorkbenchMcpServer, McpServerContainers, mcpServerIcon, McpServerInstallState } from '../common/mcpTypes.js'; +import { DropDownAction, InstallAction, InstallingLabelAction, ManageMcpServerAction } from './mcpServerActions.js'; import { PublisherWidget, InstallCountWidget, RatingsWidget, McpServerIconWidget } from './mcpServerWidgets.js'; import { ActionRunner, IAction, Separator } from '../../../../base/common/actions.js'; import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -41,14 +41,18 @@ import { VIEW_CONTAINER } from '../../extensions/browser/extensions.contribution import { renderMarkdown } from '../../../../base/browser/markdownRenderer.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; export interface McpServerListViewOptions { showWelcomeOnEmpty?: boolean; } interface IQueryResult { - showWelcomeContent: boolean; model: IPagedModel; + disposables: DisposableStore; + showWelcomeContent?: boolean; + onDidChangeModel?: Event>; } export class McpServersListView extends ViewPane { @@ -157,18 +161,26 @@ export class McpServersListView extends ViewPane { async show(query: string): Promise> { if (this.input) { + this.input.disposables.dispose(); this.input = undefined; } - query = query.trim(); - const servers = query ? await this.mcpWorkbenchService.queryGallery({ text: query.replace('@mcp', '') }) : await this.mcpWorkbenchService.queryLocal(); - const showWelcomeContent = !this.mcpGalleryService.isEnabled() && servers.length === 0 && !!this.mpcViewOptions.showWelcomeOnEmpty; - - const model = new PagedModel(servers); - this.input = { model, showWelcomeContent }; + this.input = await this.query(query.trim()); + this.input.showWelcomeContent = !this.mcpGalleryService.isEnabled() && this.input.model.length === 0 && !!this.mpcViewOptions.showWelcomeOnEmpty; this.renderInput(); - return model; + if (this.input.onDidChangeModel) { + this.input.disposables.add(this.input.onDidChangeModel(model => { + if (!this.input) { + return; + } + this.input.model = model; + this.input.showWelcomeContent = !this.mcpGalleryService.isEnabled() && this.input.model.length === 0 && !!this.mpcViewOptions.showWelcomeOnEmpty; + this.renderInput(); + })); + } + + return this.input.model; } private renderInput() { @@ -178,7 +190,7 @@ export class McpServersListView extends ViewPane { if (this.list) { this.list.model = new DelayedPagedModel(this.input.model); } - this.showWelcomeContent(this.input.showWelcomeContent); + this.showWelcomeContent(!!this.input.showWelcomeContent); } private showWelcomeContent(show: boolean): void { @@ -197,9 +209,8 @@ export class McpServersListView extends ViewPane { title.textContent = localize('mcp.welcome.title', "MCP Servers"); const description = dom.append(welcomeContent, dom.$('.mcp-welcome-description')); - const browseUrl = this.productService.quality === 'insider' ? 'https://code.visualstudio.com/insider/mcp' : 'https://code.visualstudio.com/mcp'; const markdownResult = this._register(renderMarkdown(new MarkdownString( - localize('mcp.welcome.descriptionWithLink', "Extend agent mode by installing [MCP servers]({0}) to bring extra tools for connecting to databases, invoking APIs and performing specialized tasks.", browseUrl), + localize('mcp.welcome.descriptionWithLink', "Extend agent mode by installing MCP servers to bring extra tools for connecting to databases, invoking APIs and performing specialized tasks."), { isTrusted: true } ), { actionHandler: { @@ -210,6 +221,62 @@ export class McpServersListView extends ViewPane { } })); description.appendChild(markdownResult.element); + + // Browse button + const buttonContainer = dom.append(welcomeContent, dom.$('.mcp-welcome-button-container')); + const button = this._register(new Button(buttonContainer, { + title: localize('mcp.welcome.browseButton', "Browse MCP Servers"), + ...defaultButtonStyles + })); + button.label = localize('mcp.welcome.browseButton', "Browse MCP Servers"); + + this._register(button.onDidClick(() => this.openerService.open(URI.parse(this.productService.quality === 'insider' ? 'https://code.visualstudio.com/insider/mcp' : 'https://code.visualstudio.com/mcp')))); + } + + private async query(query: string): Promise { + const disposables = new DisposableStore(); + if (query) { + const servers = await this.mcpWorkbenchService.queryGallery({ text: query.replace('@mcp', '') }); + return { model: new PagedModel(servers), disposables }; + } + + const onDidChangeModel = disposables.add(new Emitter>()); + let servers = await this.mcpWorkbenchService.queryLocal(); + disposables.add(Event.debounce(Event.filter(this.mcpWorkbenchService.onChange, e => e?.installState === McpServerInstallState.Installed), () => undefined)(() => { + const mergedMcpServers = this.mergeAddedMcpServers(servers, [...this.mcpWorkbenchService.local]); + if (mergedMcpServers) { + servers = mergedMcpServers; + onDidChangeModel.fire(new PagedModel(servers)); + } + })); + disposables.add(this.mcpWorkbenchService.onReset(() => onDidChangeModel.fire(new PagedModel([...this.mcpWorkbenchService.local])))); + return { model: new PagedModel(servers), onDidChangeModel: onDidChangeModel.event, disposables }; + } + + private mergeAddedMcpServers(mcpServers: IWorkbenchMcpServer[], newMcpServers: IWorkbenchMcpServer[]): IWorkbenchMcpServer[] | undefined { + const oldMcpServers = [...mcpServers]; + const findPreviousMcpServerIndex = (from: number): number => { + let index = -1; + const previousMcpServerInNew = newMcpServers[from]; + if (previousMcpServerInNew) { + index = oldMcpServers.findIndex(e => e.name === previousMcpServerInNew.name); + if (index === -1) { + return findPreviousMcpServerIndex(from - 1); + } + } + return index; + }; + + let hasChanged: boolean = false; + for (let index = 0; index < newMcpServers.length; index++) { + const mcpServer = newMcpServers[index]; + if (mcpServers.every(r => r.name !== mcpServer.name)) { + hasChanged = true; + mcpServers.splice(findPreviousMcpServerIndex(index - 1) + 1, 0, mcpServer); + } + } + + return hasChanged ? mcpServers : undefined; } } @@ -264,7 +331,8 @@ class McpServerRenderer implements IListRenderer error && this.notificationService.error(error)); const actions = [ - this.instantiationService.createInstance(InstallAction), + this.instantiationService.createInstance(InstallAction, false), + this.instantiationService.createInstance(InstallingLabelAction), this.instantiationService.createInstance(ManageMcpServerAction, false), ]; diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index 5a050170b70..46ad72e1724 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Emitter } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { basename } from '../../../../base/common/resources.js'; @@ -17,7 +17,7 @@ import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; -import { DidUninstallMcpServerEvent, IGalleryMcpServer, IMcpGalleryService, InstallMcpServerResult, IQueryOptions, IInstallableMcpServer, IMcpServerManifest } from '../../../../platform/mcp/common/mcpManagement.js'; +import { DidUninstallMcpServerEvent, IGalleryMcpServer, IMcpGalleryService, InstallMcpServerResult, IQueryOptions, IInstallableMcpServer, IMcpServerManifest, ILocalMcpServer } from '../../../../platform/mcp/common/mcpManagement.js'; import { IMcpServerConfiguration, IMcpServerVariable, IMcpStdioServerConfiguration, McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; @@ -32,12 +32,17 @@ import { IWorkbenchEnvironmentService } from '../../../services/environment/comm import { IWorkbenchLocalMcpServer, IWorkbenchMcpManagementService, LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { mcpConfigurationSection } from '../common/mcpConfiguration.js'; -import { HasInstalledMcpServersContext, IMcpConfigPath, IMcpWorkbenchService, IWorkbenchMcpServer, McpCollectionSortOrder, McpServersGalleryEnabledContext } from '../common/mcpTypes.js'; +import { HasInstalledMcpServersContext, IMcpConfigPath, IMcpWorkbenchService, IWorkbenchMcpServer, McpCollectionSortOrder, McpServerInstallState, McpServersGalleryEnabledContext } from '../common/mcpTypes.js'; import { McpServerEditorInput } from './mcpServerEditorInput.js'; +interface IMcpServerStateProvider { + (mcpWorkbenchServer: McpWorkbenchServer): T; +} + class McpWorkbenchServer implements IWorkbenchMcpServer { constructor( + private installStateProvider: IMcpServerStateProvider, public local: IWorkbenchLocalMcpServer | undefined, public gallery: IGalleryMcpServer | undefined, public readonly installable: IInstallableMcpServer | undefined, @@ -66,6 +71,10 @@ class McpWorkbenchServer implements IWorkbenchMcpServer { return this.gallery?.icon ?? this.local?.icon; } + get installState(): McpServerInstallState { + return this.installStateProvider(this); + } + get codicon(): string | undefined { return this.gallery?.codicon ?? this.local?.codicon; } @@ -98,8 +107,8 @@ class McpWorkbenchServer implements IWorkbenchMcpServer { return this.local?.config ?? this.installable?.config; } - hasReadme(): boolean { - return !!(this.local?.readmeUrl || this.gallery?.readmeUrl); + get readmeUrl(): URI | undefined { + return this.local?.readmeUrl ?? (this.gallery?.readmeUrl ? URI.parse(this.gallery.readmeUrl) : undefined); } async getReadme(token: CancellationToken): Promise { @@ -133,12 +142,18 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ _serviceBrand: undefined; + private installing: McpWorkbenchServer[] = []; + private uninstalling: McpWorkbenchServer[] = []; + private _local: McpWorkbenchServer[] = []; - get local(): readonly McpWorkbenchServer[] { return this._local; } + get local(): readonly McpWorkbenchServer[] { return [...this._local]; } private readonly _onChange = this._register(new Emitter()); readonly onChange = this._onChange.event; + private readonly _onReset = this._register(new Emitter()); + readonly onReset = this._onReset.event; + constructor( @IMcpGalleryService private readonly mcpGalleryService: IMcpGalleryService, @IWorkbenchMcpManagementService private readonly mcpManagementService: IWorkbenchMcpManagementService, @@ -157,13 +172,17 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ this._register(this.mcpManagementService.onDidInstallMcpServersInCurrentProfile(e => this.onDidInstallMcpServers(e))); this._register(this.mcpManagementService.onDidUpdateMcpServersInCurrentProfile(e => this.onDidUpdateMcpServers(e))); this._register(this.mcpManagementService.onDidUninstallMcpServerInCurrentProfile(e => this.onDidUninstallMcpServer(e))); - this.queryLocal().then(async () => { - await this.queryGallery(); - this._onChange.fire(undefined); - }); + this._register(this.mcpManagementService.onDidChangeProfile(e => this.onDidChangeProfile())); + this.queryLocal().then(() => this.syncInstalledMcpServers()); urlService.registerHandler(this); } + private async onDidChangeProfile() { + await this.queryLocal(); + this._onChange.fire(undefined); + this._onReset.fire(); + } + private onDidUninstallMcpServer(e: DidUninstallMcpServerEvent) { if (e.error) { return; @@ -177,19 +196,30 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } private onDidInstallMcpServers(e: readonly InstallMcpServerResult[]) { + const servers: IWorkbenchMcpServer[] = []; for (const result of e) { if (!result.local) { continue; } - let server = this._local.find(server => server.local?.name === result.name); - if (server) { - server.local = result.local; - } else { - server = this.instantiationService.createInstance(McpWorkbenchServer, result.local, result.source, undefined); - this._local.push(server); - } - this._onChange.fire(server); + servers.push(this.onDidInstallMcpServer(result.local, result.source)); } + if (servers.some(server => server.local?.source === 'gallery' && !server.gallery)) { + this.syncInstalledMcpServers(); + } + } + + private onDidInstallMcpServer(local: IWorkbenchLocalMcpServer, gallery?: IGalleryMcpServer): IWorkbenchMcpServer { + let server = this.installing.find(server => server.name === local.name); + this.installing = server ? this.installing.filter(e => e !== server) : this.installing; + if (server) { + server.local = local; + } else { + server = this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), local, gallery, undefined); + } + this._local = this._local.filter(e => e.name !== local.name); + this._local.push(server); + this._onChange.fire(server); + return server; } private onDidUpdateMcpServers(e: readonly InstallMcpServerResult[]) { @@ -203,7 +233,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ this._local[serverIndex].local = result.local; server = this._local[serverIndex]; } else { - server = this.instantiationService.createInstance(McpWorkbenchServer, result.local, result.source, undefined); + server = this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), result.local, result.source, undefined); this._local.push(server); } this._onChange.fire(server); @@ -220,33 +250,77 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ return undefined; } + private async syncInstalledMcpServers(): Promise { + const installedGalleryServers: ILocalMcpServer[] = []; + for (const installed of this.local) { + if (installed.local?.source !== 'gallery') { + continue; + } + installedGalleryServers.push(installed.local); + } + if (installedGalleryServers.length) { + const galleryServers = await this.mcpGalleryService.getMcpServers(installedGalleryServers.map(server => server.name)); + if (galleryServers.length) { + this.syncInstalledMcpServersWithGallery(galleryServers); + } + } + } + + private async syncInstalledMcpServersWithGallery(gallery: IGalleryMcpServer[]): Promise { + const galleryMap = new Map(gallery.map(server => [server.name, server])); + for (const mcpServer of this.local) { + if (!mcpServer.gallery) { + if (!mcpServer.local) { + continue; + } + if (mcpServer.gallery) { + continue; + } + const galleryServer = galleryMap.get(mcpServer.name); + if (!galleryServer) { + continue; + } + mcpServer.gallery = galleryServer; + if (!mcpServer.id) { + mcpServer.local = await this.mcpManagementService.updateMetadata(mcpServer.local, galleryServer); + } + this._onChange.fire(mcpServer); + } + } + } + async queryGallery(options?: IQueryOptions, token?: CancellationToken): Promise { if (!this.mcpGalleryService.isEnabled()) { return []; } const result = await this.mcpGalleryService.query(options, token); - return result.map(gallery => this.fromGallery(gallery) ?? this.instantiationService.createInstance(McpWorkbenchServer, undefined, gallery, undefined)); + return result.map(gallery => this.fromGallery(gallery) ?? this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), undefined, gallery, undefined)); } async queryLocal(): Promise { const installed = await this.mcpManagementService.getInstalled(); this._local = installed.map(i => { - const local = this._local.find(server => server.name === i.name) ?? this.instantiationService.createInstance(McpWorkbenchServer, undefined, undefined, undefined); + const local = this._local.find(server => server.name === i.name) ?? this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), undefined, undefined, undefined); local.local = i; return local; }); - return this._local; + this._onChange.fire(undefined); + return [...this.local]; } - async install(server: IWorkbenchMcpServer): Promise { + async install(server: IWorkbenchMcpServer): Promise { + if (!(server instanceof McpWorkbenchServer)) { + throw new Error('Invalid server instance'); + } + if (server.installable) { - await this.mcpManagementService.install(server.installable); - return; + const installable = server.installable; + return this.doInstall(server, () => this.mcpManagementService.install(installable)); } if (server.gallery) { - await this.mcpManagementService.installFromGallery(server.gallery, { packageType: server.gallery.packageTypes[0] }); - return; + const gallery = server.gallery; + return this.doInstall(server, () => this.mcpManagementService.installFromGallery(gallery, { packageType: gallery.packageTypes[0] })); } throw new Error('No installable server found'); @@ -259,6 +333,26 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ await this.mcpManagementService.uninstall(server.local); } + private async doInstall(server: McpWorkbenchServer, installTask: () => Promise): Promise { + this.installing.push(server); + this._onChange.fire(server); + await installTask(); + return this.waitAndGetInstalledMcpServer(server); + } + + private async waitAndGetInstalledMcpServer(server: McpWorkbenchServer): Promise { + let installed = this.local.find(local => local.name === server.name); + if (!installed) { + await Event.toPromise(Event.filter(this.onChange, e => !!e && this.local.some(local => local.name === server.name))); + } + installed = this.local.find(local => local.name === server.name); + if (!installed) { + // This should not happen + throw new Error('Extension should have been installed'); + } + return installed; + } + getMcpConfigPath(localMcpServer: IWorkbenchLocalMcpServer): IMcpConfigPath | undefined; getMcpConfigPath(mcpResource: URI): Promise; getMcpConfigPath(arg: URI | IWorkbenchLocalMcpServer): Promise | IMcpConfigPath | undefined { @@ -373,16 +467,16 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ const { name, inputs, gallery, ...config } = parsed; if (gallery || !config || Object.keys(config).length === 0) { - const galleryServer = await this.mcpGalleryService.getMcpServer(name); + const [galleryServer] = await this.mcpGalleryService.getMcpServers([name]); if (!galleryServer) { throw new Error(`MCP server '${name}' not found in gallery`); } - this.open(this.instantiationService.createInstance(McpWorkbenchServer, undefined, galleryServer, undefined)); + this.open(this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), undefined, galleryServer, undefined)); } else { if (config.type === undefined) { (>config).type = (parsed).command ? McpServerType.LOCAL : McpServerType.REMOTE; } - this.open(this.instantiationService.createInstance(McpWorkbenchServer, undefined, undefined, { name, config, inputs })); + this.open(this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), undefined, undefined, { name, config, inputs })); } } catch (e) { // ignore @@ -394,6 +488,17 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, ACTIVE_GROUP); } + private getInstallState(extension: McpWorkbenchServer): McpServerInstallState { + if (this.installing.some(i => i.name === extension.name)) { + return McpServerInstallState.Installing; + } + if (this.uninstalling.some(e => e.name === extension.name)) { + return McpServerInstallState.Uninstalling; + } + const local = this.local.find(e => e === extension); + return local ? McpServerInstallState.Installed : McpServerInstallState.Uninstalled; + } + } export class MCPContextsInitialisation extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/contrib/mcp/browser/media/mcpServersView.css b/src/vs/workbench/contrib/mcp/browser/media/mcpServersView.css index bab1e75db0e..547e8f20582 100644 --- a/src/vs/workbench/contrib/mcp/browser/media/mcpServersView.css +++ b/src/vs/workbench/contrib/mcp/browser/media/mcpServersView.css @@ -36,12 +36,18 @@ .mcp-welcome-description { max-width: 350px; padding: 0 20px; - margin-top: 26px; + margin-top: 16px; a { color: var(--vscode-textLink-foreground); } } + .mcp-welcome-button-container { + margin-top: 16px; + max-width: 320px; + width: 100%; + } + } } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts index 74cd6b26cf0..9232a0a2e11 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts @@ -92,7 +92,8 @@ export class ExtensionMcpDiscovery extends Disposable implements IMcpDiscovery { isCached: !!serverDefs, load: () => this._activateExtensionServers(coll.id), removed: () => extensionCollections.deleteAndDispose(id), - } + }, + source: collections.description.identifier }); extensionCollections.set(id, dispo); diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index ccfca206ce2..24089082107 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; import { StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { McpServerDefinition, McpServerTransportType, IMcpWorkbenchService, IMcpConfigPath } from '../mcpTypes.js'; +import { McpServerDefinition, McpServerTransportType, IMcpWorkbenchService, IMcpConfigPath, IWorkbenchMcpServer } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; import { mcpConfigurationSection } from '../mcpConfiguration.js'; import { posix as pathPosix, win32 as pathWin32, sep as pathSep } from '../../../../../base/common/path.js'; @@ -58,7 +58,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc private async sync(): Promise { try { const remoteEnv = await this.remoteAgentService.getEnvironment(); - const collections = new Map(); + const collections = new Map(); const mcpConfigPathInfos = new ResourceMap } | undefined>>(); for (const server of this.mcpWorkbenchService.local) { if (!server.local) { @@ -81,7 +81,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc let definitions = collections.get(collectionId); if (!definitions) { - definitions = [mcpConfigPath, []]; + definitions = [mcpConfigPath, [], server]; collections.set(collectionId, definitions); } @@ -135,7 +135,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc for (const [id, [mcpConfigPath, serverDefinitions]] of collections) { this.collectionDisposables.deleteAndDispose(id); this.collectionDisposables.set(id, this.mcpRegistry.registerCollection({ - id: id, + id, label: mcpConfigPath?.label ?? '', presentation: { order: serverDefinitions[0]?.presentation?.order, diff --git a/src/vs/workbench/contrib/mcp/common/mcpCommandIds.ts b/src/vs/workbench/contrib/mcp/common/mcpCommandIds.ts index cf6a27064b0..9c8ae9608f0 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpCommandIds.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpCommandIds.ts @@ -9,6 +9,7 @@ export const enum McpCommandIds { AddConfiguration = 'workbench.mcp.addConfiguration', Browse = 'workbench.mcp.browseServers', + BrowsePage = 'workbench.mcp.browseServersPage', ShowInstalled = 'workbench.mcp.showInstalledServers', OpenUserMcp = 'workbench.mcp.openUserMcpJson', OpenRemoteUserMcp = 'workbench.mcp.openRemoteUserMcpJson', diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index 97b463f6b7d..48c3e2eecf7 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -3,11 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../base/common/jsonSchema.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; -import { IMcpCollectionContribution } from '../../../../platform/extensions/common/extensions.js'; +import { IExtensionManifest, IMcpCollectionContribution } from '../../../../platform/extensions/common/extensions.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; import { mcpSchemaId } from '../../../services/configuration/common/configuration.js'; import { inputsSchema } from '../../../services/configurationResolver/common/configurationResolverSchema.js'; +import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../services/extensionManagement/common/extensionFeatures.js'; import { IExtensionPointDescriptor } from '../../../services/extensions/common/extensionsRegistry.js'; const mcpActivationEventPrefix = 'onMcpCollection:'; @@ -239,3 +244,42 @@ export const mcpContributionPoint: IExtensionPointDescriptor 0; + } + + render(manifest: IExtensionManifest): IRenderedData { + const mcpServerDefinitionProviders = manifest.contributes?.mcpServerDefinitionProviders ?? []; + const headers = [localize('id', "ID"), localize('name', "Name")]; + const rows: IRowData[][] = mcpServerDefinitionProviders + .map(mcpServerDefinitionProvider => { + return [ + new MarkdownString().appendMarkdown(`\`${mcpServerDefinitionProvider.id}\``), + mcpServerDefinitionProvider.label + ]; + }); + + return { + data: { + headers, + rows + }, + dispose: () => { } + }; + } +} + +Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: mcpConfigurationSection, + label: localize('mcpServerDefinitionProviders', "MCP Servers"), + access: { + canToggle: false + }, + renderer: new SyncDescriptor(McpServerDefinitionsProviderRenderer), +}); + diff --git a/src/vs/workbench/contrib/mcp/common/mcpResourceFilesystem.ts b/src/vs/workbench/contrib/mcp/common/mcpResourceFilesystem.ts index 19ca63170cb..19392dfdd2f 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpResourceFilesystem.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpResourceFilesystem.ts @@ -108,11 +108,11 @@ export class McpResourceFilesystem extends Disposable implements IWorkbenchContr watchedOnHandler = handler; const token = callCts.value.token; - handler.subscribe({ uri: resourceURI.toString(true) }, token).then( + handler.subscribe({ uri: resourceURI.toString() }, token).then( () => { if (!token.isCancellationRequested) { watchListener.value = handler.onDidUpdateResource(e => { - if (equalsUriPath(e.params.uri, resourceURI)) { + if (equalsUrlPath(e.params.uri, resourceURI)) { this._onDidChangeFile.fire([{ resource: uri, type: FileChangeType.UPDATED }]); } }); @@ -147,7 +147,7 @@ export class McpResourceFilesystem extends Disposable implements IWorkbenchContr throw createFileSystemProviderError(`File is not a directory`, FileSystemProviderErrorCode.FileNotADirectory); } - const resourcePathParts = resourceURI.path.split('/'); + const resourcePathParts = resourceURI.pathname.split('/'); const output = new Map(); for (const content of contents) { @@ -207,15 +207,15 @@ export class McpResourceFilesystem extends Disposable implements IWorkbenchContr private _decodeURI(uri: URI) { let definitionId: string; - let resourceURI: URI; + let resourceURL: URL; try { - ({ definitionId, resourceURI } = McpResourceURI.toServer(uri)); + ({ definitionId, resourceURL } = McpResourceURI.toServer(uri)); } catch (e) { throw createFileSystemProviderError(String(e), FileSystemProviderErrorCode.FileNotFound); } - if (resourceURI.path.endsWith('/')) { - resourceURI = resourceURI.with({ path: resourceURI.path.slice(0, -1) }); + if (resourceURL.pathname.endsWith('/')) { + resourceURL.pathname = resourceURL.pathname.slice(0, -1); } const server = this._mcpService.servers.get().find(s => s.definition.id === definitionId); @@ -228,25 +228,25 @@ export class McpResourceFilesystem extends Disposable implements IWorkbenchContr throw createFileSystemProviderError(`MCP server ${definitionId} does not support resources`, FileSystemProviderErrorCode.FileNotFound); } - return { definitionId, resourceURI, server }; + return { definitionId, resourceURI: resourceURL, server }; } private async _readURI(uri: URI, token?: CancellationToken) { const { resourceURI, server } = this._decodeURI(uri); - const res = await McpServer.callOn(server, r => r.readResource({ uri: resourceURI.toString(true) }, token), token); + const res = await McpServer.callOn(server, r => r.readResource({ uri: resourceURI.toString() }, token), token); return { contents: res.contents, resourceURI, - forSameURI: res.contents.filter(c => equalsUriPath(c.uri, resourceURI)), + forSameURI: res.contents.filter(c => equalsUrlPath(c.uri, resourceURI)), }; } } -function equalsUriPath(a: string, b: URI): boolean { +function equalsUrlPath(a: string, b: URL): boolean { // MCP doesn't specify either way, but underlying systems may can be case-sensitive. // It's better to treat case-sensitive paths as case-insensitive than vise-versa. - return equalsIgnoreCase(URI.parse(a).path, b.path); + return equalsIgnoreCase(new URL(a).pathname, b.pathname); } function contentToBuffer(content: MCP.TextResourceContents | MCP.BlobResourceContents): Uint8Array { diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 9dcfd3504ba..eb7c88dd470 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -71,6 +71,8 @@ export interface McpCollectionDefinition { removed?(): void; }; + readonly source?: IWorkbenchMcpServer | ExtensionIdentifier; + readonly presentation?: { /** Sort order of the collection. */ readonly order?: number; @@ -569,10 +571,29 @@ export interface IMcpServerContainer extends IDisposable { update(): void; } +export interface IMcpServerEditorOptions extends IEditorOptions { + tab?: McpServerEditorTab; + sideByside?: boolean; +} + +export const enum McpServerInstallState { + Installing, + Installed, + Uninstalling, + Uninstalled +} + +export const enum McpServerEditorTab { + Readme = 'readme', + Manifest = 'manifest', + Configuration = 'configuration', +} + export interface IWorkbenchMcpServer { readonly gallery: IGalleryMcpServer | undefined; readonly local: IWorkbenchLocalMcpServer | undefined; readonly installable: IInstallableMcpServer | undefined; + readonly installState: McpServerInstallState; readonly id: string; readonly name: string; readonly label: string; @@ -590,7 +611,7 @@ export interface IWorkbenchMcpServer { readonly url?: string; readonly repository?: string; readonly config?: IMcpServerConfiguration | undefined; - hasReadme(): boolean; + readonly readmeUrl?: URI; getReadme(token: CancellationToken): Promise; getManifest(token: CancellationToken): Promise; } @@ -599,14 +620,15 @@ export const IMcpWorkbenchService = createDecorator('IMcpW export interface IMcpWorkbenchService { readonly _serviceBrand: undefined; readonly onChange: Event; + readonly onReset: Event; readonly local: readonly IWorkbenchMcpServer[]; queryLocal(): Promise; queryGallery(options?: IQueryOptions, token?: CancellationToken): Promise; - install(server: IWorkbenchMcpServer, installOptions?: IWorkbencMcpServerInstallOptions): Promise; + install(server: IWorkbenchMcpServer, installOptions?: IWorkbencMcpServerInstallOptions): Promise; uninstall(mcpServer: IWorkbenchMcpServer): Promise; getMcpConfigPath(arg: IWorkbenchLocalMcpServer): IMcpConfigPath | undefined; getMcpConfigPath(arg: URI): Promise; - open(extension: IWorkbenchMcpServer | string, options?: IEditorOptions): Promise; + open(extension: IWorkbenchMcpServer | string, options?: IMcpServerEditorOptions): Promise; } export class McpServerContainers extends Disposable { @@ -658,7 +680,7 @@ export namespace McpResourceURI { }); } - export function toServer(uri: URI | string): { definitionId: string; resourceURI: URI } { + export function toServer(uri: URI | string): { definitionId: string; resourceURL: URL } { if (typeof uri === 'string') { uri = URI.parse(uri); } @@ -670,13 +692,16 @@ export namespace McpResourceURI { throw new Error(`Invalid MCP resource URI: ${uri.toString()}`); } const [, serverScheme, authority, ...path] = parts; + + // URI cannot correctly stringify empty authorities (#250905) so we use URL instead to construct + const url = new URL(`${serverScheme}://${authority.toLowerCase() === emptyAuthorityPlaceholder ? '' : authority}`); + url.pathname = path.length ? ('/' + path.join('/')) : ''; + url.search = uri.query; + url.hash = uri.fragment; + return { definitionId: decodeHex(uri.authority).toString(), - resourceURI: uri.with({ - scheme: serverScheme, - authority: authority.toLowerCase() === emptyAuthorityPlaceholder ? '' : authority, - path: path.length ? ('/' + path.join('/')) : '', - }), + resourceURL: url, }; } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts index ca832e81c44..b308d02c3c1 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts @@ -15,7 +15,7 @@ suite('MCP Types', () => { const from = McpResourceURI.fromServer({ label: '', id: 'my-id' }, uri); const to = McpResourceURI.toServer(from); assert.strictEqual(to.definitionId, 'my-id'); - assert.strictEqual(to.resourceURI.toString(true), uri, `expected to round trip ${uri}`); + assert.strictEqual(to.resourceURL.toString(), uri, `expected to round trip ${uri}`); }; roundTrip('file:///path/to/file.txt'); @@ -23,5 +23,8 @@ suite('MCP Types', () => { roundTrip('custom-scheme://my-path'); roundTrip('custom-scheme://my-path/'); roundTrip('custom-scheme://my-path/?with=query¶ms=here'); + + roundTrip('custom-scheme:///my-path'); + roundTrip('custom-scheme:///my-path/foo/?with=query¶ms=here'); }); }); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookMulticursor.ts b/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookMulticursor.ts index 571248d12e4..237326159ff 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookMulticursor.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookMulticursor.ts @@ -29,7 +29,6 @@ import { ILanguageConfigurationService } from '../../../../../../editor/common/l import { IModelDeltaDecoration, ITextModel, PositionAffinity } from '../../../../../../editor/common/model.js'; import { indentOfLine } from '../../../../../../editor/common/model/textModel.js'; import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; -import { ICoordinatesConverter } from '../../../../../../editor/common/viewModel.js'; import { ViewModelEventsCollector } from '../../../../../../editor/common/viewModelEventDispatcher.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; @@ -48,6 +47,7 @@ import { CellEditorOptions } from '../../view/cellParts/cellEditorOptions.js'; import { NotebookFindContrib } from '../find/notebookFindWidget.js'; import { NotebookTextModel } from '../../../common/model/notebookTextModel.js'; import { NotebookCellTextModel } from '../../../common/model/notebookCellTextModel.js'; +import { ICoordinatesConverter } from '../../../../../../editor/common/coordinatesConverter.js'; const NOTEBOOK_ADD_FIND_MATCH_TO_SELECTION_ID = 'notebook.addFindMatchToSelection'; const NOTEBOOK_SELECT_ALL_FIND_MATCHES_ID = 'notebook.selectAllFindMatches'; diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/troubleshoot/layout.ts b/src/vs/workbench/contrib/notebook/browser/contrib/troubleshoot/layout.ts index 2748815fe1a..3c0b9f6941c 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/troubleshoot/layout.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/troubleshoot/layout.ts @@ -182,10 +182,13 @@ export class TroubleshootController extends Disposable implements INotebookEdito topLine.style.backgroundColor = 'rgba(255, 0, 0, 0.7)'; overlayContainer.appendChild(topLine); - const cellTop = this._notebookEditor.getAbsoluteTopOfElement(cell); - + const getLayoutInfo = () => { + const eol = cell.textBuffer.getEOL() === '\n' ? 'LF' : 'CRLF'; + const scrollTop = this._notebookEditor.getAbsoluteTopOfElement(cell); + return `cell #${index} (handle: ${cell.handle}) | AbsoluteTopOfElement: ${scrollTop}px | EOL: ${eol}`; + }; const label = document.createElement('div'); - label.textContent = `cell #${index} (handle: ${cell.handle}) | AbsoluteTopOfElement: ${cellTop}px`; + label.textContent = getLayoutInfo(); label.style.position = 'absolute'; label.style.top = '0px'; label.style.right = '10px'; @@ -213,10 +216,8 @@ export class TroubleshootController extends Disposable implements INotebookEdito // Update overlay when layout changes const updateLayout = () => { - const scrollTop = this._notebookEditor.getAbsoluteTopOfElement(cell); - // Update label text - label.textContent = `cell #${index} (handle: ${cell.handle}) | AbsoluteTopOfElement: ${scrollTop}px`; + label.textContent = getLayoutInfo(); // Refresh the overlay position if (overlayId) { @@ -229,7 +230,14 @@ export class TroubleshootController extends Disposable implements INotebookEdito this._localStore.add(cell.onDidChangeLayout((e) => { updateLayout(); })); - + this._localStore.add(cell.textBuffer.onDidChangeContent(() => { + updateLayout(); + })); + if (cell.textModel) { + this._localStore.add(cell.textModel.onDidChangeContent(() => { + updateLayout(); + })); + } this._localStore.add(this._notebookEditor.onDidChangeLayout(() => { updateLayout(); })); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookCellDiffDecorator.ts b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookCellDiffDecorator.ts index 8da4d2c230d..5f3ae5f09cd 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookCellDiffDecorator.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookCellDiffDecorator.ts @@ -16,12 +16,12 @@ import { diffAddDecoration, diffWholeLineAddDecoration, diffDeleteDecoration } f import { IDocumentDiff } from '../../../../../../editor/common/diff/documentDiffProvider.js'; import { ITextModel, TrackedRangeStickiness, MinimapPosition, IModelDeltaDecoration, OverviewRulerLane } from '../../../../../../editor/common/model.js'; import { ModelDecorationOptions } from '../../../../../../editor/common/model/textModel.js'; -import { InlineDecoration, InlineDecorationType } from '../../../../../../editor/common/viewModel.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { NotebookCellTextModel } from '../../../common/model/notebookCellTextModel.js'; import { DetailedLineRangeMapping } from '../../../../../../editor/common/diff/rangeMapping.js'; import { minimapGutterAddedBackground, minimapGutterDeletedBackground, minimapGutterModifiedBackground, overviewRulerAddedForeground, overviewRulerDeletedForeground, overviewRulerModifiedForeground } from '../../../../scm/common/quickDiff.js'; import { INotebookOriginalCellModelFactory } from './notebookOriginalCellModelFactory.js'; +import { InlineDecoration, InlineDecorationType } from '../../../../../../editor/common/viewModel/inlineDecorations.js'; //TODO: allow client to set read-only - chateditsession should set read-only while making changes export class NotebookCellDiffDecorator extends DisposableStore { diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index d1f2af83275..fbe016aa70d 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -10,7 +10,7 @@ import { extname, isEqual } from '../../../../base/common/resources.js'; import { assertType } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { toFormattedString } from '../../../../base/common/jsonFormatter.js'; -import { ITextModel, ITextBufferFactory, DefaultEndOfLine, ITextBuffer } from '../../../../editor/common/model.js'; +import { ITextModel, ITextBufferFactory, ITextBuffer } from '../../../../editor/common/model.js'; import { IModelService } from '../../../../editor/common/services/model.js'; import { ILanguageSelection, ILanguageService } from '../../../../editor/common/languages/language.js'; import { ITextModelContentProvider, ITextModelService } from '../../../../editor/common/services/resolverService.js'; @@ -398,8 +398,6 @@ class CellContentProvider implements ITextModelContentProvider { if (cell.uri.toString() === resource.toString()) { const bufferFactory: ITextBufferFactory = { create: (defaultEOL) => { - const newEOL = (defaultEOL === DefaultEndOfLine.CRLF ? '\r\n' : '\n'); - (cell.textBuffer as ITextBuffer).setEOL(newEOL); return { textBuffer: cell.textBuffer as ITextBuffer, disposable: Disposable.None }; }, getFirstLineText: (limit: number) => { diff --git a/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts b/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts index 20518d6be9b..3f45789a32f 100644 --- a/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts +++ b/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts @@ -211,7 +211,8 @@ class PerfModelContentProvider implements ITextModelContentProvider { table.push(['init keybindings, snippets & extensions from settings sync service', metrics.timers.ellapsedOtherUserDataInit, '[renderer]', undefined]); } table.push(['register extensions & spawn extension host', metrics.timers.ellapsedExtensions, '[renderer]', undefined]); - table.push(['restore viewlet', metrics.timers.ellapsedViewletRestore, '[renderer]', metrics.viewletId]); + table.push(['restore primary viewlet', metrics.timers.ellapsedViewletRestore, '[renderer]', metrics.viewletId]); + table.push(['restore secondary viewlet', metrics.timers.ellapsedAuxiliaryViewletRestore, '[renderer]', metrics.auxiliaryViewletId]); table.push(['restore panel', metrics.timers.ellapsedPanelRestore, '[renderer]', metrics.panelId]); table.push(['restore & resolve visible editors', metrics.timers.ellapsedEditorRestore, '[renderer]', `${metrics.editorIds.length}: ${metrics.editorIds.join(', ')}`]); table.push(['create workbench contributions', metrics.timers.ellapsedWorkbenchContributions, '[renderer]', `${(contribTimings.get(LifecyclePhase.Starting)?.length ?? 0) + (contribTimings.get(LifecyclePhase.Starting)?.length ?? 0)} blocking startup`]); diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css index 5aae7a8df36..b34ede68376 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css @@ -40,13 +40,13 @@ .settings-editor > .settings-header > .search-container > .settings-count-widget { position: absolute; - right: 48px; + right: 49px; top: 0px; margin: 2.5px 0px; } .settings-editor > .settings-header > .search-container.with-ai-toggle > .settings-count-widget { - right: 69px; + right: 70px; } .settings-editor > .settings-header > .search-container > .settings-count-widget:empty { @@ -60,6 +60,7 @@ top: 0; right: 0; height: 100%; + margin-right: 1px; } .settings-editor > .settings-header > .search-container > .settings-clear-widget .action-label { diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts index e1b7dda3e85..4a38b9bb227 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts @@ -20,7 +20,7 @@ import { IAiSettingsSearchService } from '../../../services/aiSettingsSearch/com import { IWorkbenchExtensionEnablementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IGroupFilter, ISearchResult, ISetting, ISettingMatch, ISettingMatcher, ISettingsEditorModel, ISettingsGroup, SettingKeyMatchTypes, SettingMatchType } from '../../../services/preferences/common/preferences.js'; import { nullRange } from '../../../services/preferences/common/preferencesModels.js'; -import { IAiSearchProvider, IPreferencesSearchService, IRemoteSearchProvider, ISearchProvider, IWorkbenchSettingsConfiguration } from '../common/preferences.js'; +import { EMBEDDINGS_ONLY_SEARCH_PROVIDER_NAME, EMBEDDINGS_SEARCH_PROVIDER_NAME, IAiSearchProvider, IPreferencesSearchService, IRemoteSearchProvider, ISearchProvider, IWorkbenchSettingsConfiguration, LLM_RANKED_SEARCH_PROVIDER_NAME, STRING_MATCH_SEARCH_PROVIDER_NAME, TF_IDF_SEARCH_PROVIDER_NAME } from '../common/preferences.js'; export interface IEndpointDetails { urlBase?: string; @@ -134,7 +134,7 @@ export class LocalSearchProvider implements ISearchProvider { const alwaysAllowedMatchTypes = SettingMatchType.DescriptionOrValueMatch | SettingMatchType.LanguageTagSettingMatch; const filteredMatches = filterMatches .filter(m => (m.matchType & topKeyMatchType) || (m.matchType & alwaysAllowedMatchTypes) || m.matchType === SettingMatchType.ExactMatch) - .map(m => ({ ...m, providerName: 'local' })); + .map(m => ({ ...m, providerName: STRING_MATCH_SEARCH_PROVIDER_NAME })); return Promise.resolve({ filterMatches: filteredMatches, exactMatch: filteredMatches.some(m => m.matchType === SettingMatchType.ExactMatch) @@ -441,7 +441,7 @@ class EmbeddingsSearchProvider implements IRemoteSearchProvider { return []; } - const providerName = this._excludeSelectionStep ? 'embeddingsOnly' : 'embeddingsFull'; + const providerName = this._excludeSelectionStep ? EMBEDDINGS_ONLY_SEARCH_PROVIDER_NAME : EMBEDDINGS_SEARCH_PROVIDER_NAME; for (const settingKey of settings) { if (filterMatches.length === EmbeddingsSearchProvider.EMBEDDINGS_SETTINGS_SEARCH_MAX_PICKS) { break; @@ -550,7 +550,7 @@ class TfIdfSearchProvider implements IRemoteSearchProvider { matchType: SettingMatchType.RemoteMatch, keyMatchScore: 0, score: info.score, - providerName: 'tfIdf' + providerName: TF_IDF_SEARCH_PROVIDER_NAME }); } @@ -639,7 +639,7 @@ class AiSearchProvider implements IAiSearchProvider { matchType: SettingMatchType.RemoteMatch, keyMatchScore: 0, score: 0, // the results are sorted upstream. - providerName: 'llmRanked' + providerName: LLM_RANKED_SEARCH_PROVIDER_NAME }); } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 1662f480b83..55b8304732a 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -23,6 +23,7 @@ import { Iterable } from '../../../../base/common/iterator.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, dispose, type IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import * as platform from '../../../../base/common/platform.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; @@ -58,7 +59,7 @@ import { nullRange, Settings2EditorModel } from '../../../services/preferences/c import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import { IUserDataSyncWorkbenchService } from '../../../services/userDataSync/common/userDataSync.js'; import { SuggestEnabledInput } from '../../codeEditor/browser/suggestEnabledInput/suggestEnabledInput.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, getExperimentalExtensionToggleData, 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_SHOW_AI_RESULTS, SETTINGS_EDITOR_COMMAND_SUGGEST_FILTERS, WorkbenchSettingsEditorSettings, WORKSPACE_TRUST_SETTING_TAG } from '../common/preferences.js'; +import { CONTEXT_SETTINGS_EDITOR, CONTEXT_SETTINGS_ROW_FOCUS, CONTEXT_SETTINGS_SEARCH_FOCUS, CONTEXT_TOC_ROW_FOCUS, EMBEDDINGS_SEARCH_PROVIDER_NAME, ENABLE_LANGUAGE_FILTER, EXTENSION_FETCH_TIMEOUT_MS, EXTENSION_SETTING_TAG, FEATURE_SETTING_TAG, FILTER_MODEL_SEARCH_PROVIDER_NAME, getExperimentalExtensionToggleData, ID_SETTING_TAG, IPreferencesSearchService, ISearchProvider, LANGUAGE_SETTING_TAG, LLM_RANKED_SEARCH_PROVIDER_NAME, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, SETTINGS_EDITOR_COMMAND_SHOW_AI_RESULTS, SETTINGS_EDITOR_COMMAND_SUGGEST_FILTERS, STRING_MATCH_SEARCH_PROVIDER_NAME, TF_IDF_SEARCH_PROVIDER_NAME, WorkbenchSettingsEditorSettings, WORKSPACE_TRUST_SETTING_TAG } from '../common/preferences.js'; import { settingsHeaderBorder, settingsSashBorder, settingsTextInputBorder } from '../common/settingsEditorColorRegistry.js'; import './media/settingsEditor2.css'; import { preferencesAiResultsIcon, preferencesClearInputIcon, preferencesFilterIcon } from './preferencesIcons.js'; @@ -191,6 +192,8 @@ export class SettingsEditor2 extends EditorPane { private searchInProgress: CancellationTokenSource | null = null; private aiSearchPromise: CancelablePromise | null = null; + private stopWatch: StopWatch; + private showAiResultsAction: Action | null = null; private searchInputDelayer: Delayer; @@ -276,6 +279,7 @@ export class SettingsEditor2 extends EditorPane { this.settingRowFocused = CONTEXT_SETTINGS_ROW_FOCUS.bindTo(contextKeyService); this.scheduledRefreshes = new Map(); + this.stopWatch = new StopWatch(false); this.editorMemento = this.getEditorMemento(editorGroupService, textResourceConfigurationService, SETTINGS_EDITOR_STATE_KEY); @@ -1778,7 +1782,7 @@ export class SettingsEditor2 extends EditorPane { matchType: SettingMatchType.None, keyMatchScore: 0, score: 0, - providerName: 'filterModel' + providerName: FILTER_MODEL_SEARCH_PROVIDER_NAME }); } } @@ -1857,7 +1861,7 @@ export class SettingsEditor2 extends EditorPane { private doLocalSearch(query: string, token: CancellationToken): Promise { const localSearchProvider = this.preferencesSearchService.getLocalSearchProvider(query); - return this.searchWithProvider(SearchResultIdx.Local, localSearchProvider, token); + return this.searchWithProvider(SearchResultIdx.Local, localSearchProvider, STRING_MATCH_SEARCH_PROVIDER_NAME, token); } private doRemoteSearch(query: string, token: CancellationToken): Promise { @@ -1865,7 +1869,7 @@ export class SettingsEditor2 extends EditorPane { if (!remoteSearchProvider) { return Promise.resolve(null); } - return this.searchWithProvider(SearchResultIdx.Remote, remoteSearchProvider, token); + return this.searchWithProvider(SearchResultIdx.Remote, remoteSearchProvider, TF_IDF_SEARCH_PROVIDER_NAME, token); } private async doAiSearch(query: string, token: CancellationToken): Promise { @@ -1874,7 +1878,7 @@ export class SettingsEditor2 extends EditorPane { return null; } - const embeddingsResults = await this.searchWithProvider(SearchResultIdx.Embeddings, aiSearchProvider, token); + const embeddingsResults = await this.searchWithProvider(SearchResultIdx.Embeddings, aiSearchProvider, EMBEDDINGS_SEARCH_PROVIDER_NAME, token); if (!embeddingsResults || token.isCancellationRequested) { return null; } @@ -1896,26 +1900,62 @@ export class SettingsEditor2 extends EditorPane { return null; } + this.stopWatch.reset(); const result = await aiSearchProvider.getLLMRankedResults(token); - if (!result || token.isCancellationRequested) { + this.stopWatch.stop(); + + if (token.isCancellationRequested) { return null; } + // Only log the elapsed time if there are actual results. + if (result && result.filterMatches.length > 0) { + const elapsed = this.stopWatch.elapsed(); + this.logSearchPerformance(LLM_RANKED_SEARCH_PROVIDER_NAME, elapsed); + } + this.searchResultModel!.setResult(SearchResultIdx.AiSelected, result); return result; } - private async searchWithProvider(type: SearchResultIdx, searchProvider: ISearchProvider, token: CancellationToken): Promise { + private async searchWithProvider(type: SearchResultIdx, searchProvider: ISearchProvider, providerName: string, token: CancellationToken): Promise { + this.stopWatch.reset(); const result = await this._searchPreferencesModel(this.defaultSettingsEditorModel, searchProvider, token); + this.stopWatch.stop(); + if (token.isCancellationRequested) { // Handle cancellation like this because cancellation is lost inside the search provider due to async/await return null; } + + // Only log the elapsed time if there are actual results. + if (result && result.filterMatches.length > 0) { + const elapsed = this.stopWatch.elapsed(); + this.logSearchPerformance(providerName, elapsed); + } + this.searchResultModel ??= this.instantiationService.createInstance(SearchResultModel, this.viewState, this.settingsOrderByTocIndex, this.workspaceTrustManagementService.isWorkspaceTrusted()); this.searchResultModel.setResult(type, result); return result; } + private logSearchPerformance(providerName: string, elapsed: number): void { + type SettingsEditorSearchPerformanceEvent = { + providerName: string | undefined; + elapsedMs: number; + }; + type SettingsEditorSearchPerformanceClassification = { + providerName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the search provider, if applicable.' }; + elapsedMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The time taken to perform the search, in milliseconds.' }; + owner: 'rzhao271'; + comment: 'Event emitted when the Settings editor calls a search provider to search for a setting'; + }; + this.telemetryService.publicLog2('settingsEditor.searchPerformance', { + providerName, + elapsedMs: elapsed, + }); + } + private renderResultCountMessages(showAiResultsMessage: boolean) { if (!this.currentSettingsModel) { return; diff --git a/src/vs/workbench/contrib/preferences/common/preferences.ts b/src/vs/workbench/contrib/preferences/common/preferences.ts index a71b33eba08..7c0dd724f00 100644 --- a/src/vs/workbench/contrib/preferences/common/preferences.ts +++ b/src/vs/workbench/contrib/preferences/common/preferences.ts @@ -110,6 +110,13 @@ export const ENABLE_LANGUAGE_FILTER = true; export const ENABLE_EXTENSION_TOGGLE_SETTINGS = true; export const EXTENSION_FETCH_TIMEOUT_MS = 1000; +export const STRING_MATCH_SEARCH_PROVIDER_NAME = 'local'; +export const TF_IDF_SEARCH_PROVIDER_NAME = 'tfIdf'; +export const FILTER_MODEL_SEARCH_PROVIDER_NAME = 'filterModel'; +export const EMBEDDINGS_ONLY_SEARCH_PROVIDER_NAME = 'embeddingsOnly'; +export const EMBEDDINGS_SEARCH_PROVIDER_NAME = 'embeddingsFull'; +export const LLM_RANKED_SEARCH_PROVIDER_NAME = 'llmRanked'; + export enum WorkbenchSettingsEditorSettings { ShowAISearchToggle = 'workbench.settings.showAISearchToggle', EnableNaturalLanguageSearch = 'workbench.settings.enableNaturalLanguageSearch', @@ -224,6 +231,10 @@ knownTermMappings.set('powershell', 'PowerShell'); knownTermMappings.set('javascript', 'JavaScript'); knownTermMappings.set('typescript', 'TypeScript'); knownTermMappings.set('github', 'GitHub'); +knownTermMappings.set('jet brains', 'JetBrains'); +knownTermMappings.set('jetbrains', 'JetBrains'); +knownTermMappings.set('re sharper', 'ReSharper'); +knownTermMappings.set('resharper', 'ReSharper'); export function wordifyKey(key: string): string { key = key diff --git a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts index ebb20fe2bc7..95118b980b1 100644 --- a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts @@ -759,7 +759,9 @@ class HistoryItemHoverDelegate extends WorkbenchHoverDelegate { @IHoverService hoverService: IHoverService, ) { - super('element', { instantHover: true }, () => this.getHoverOptions(), configurationService, hoverService); + super(_viewContainerLocation === ViewContainerLocation.Panel ? 'mouse' : 'element', { + instantHover: _viewContainerLocation !== ViewContainerLocation.Panel + }, () => this.getHoverOptions(), configurationService, hoverService); } private getHoverOptions(): Partial { diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 56a5397e985..ef1b10e3eef 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -93,7 +93,7 @@ import { EditorOption, EditorOptions, IEditorOptions } from '../../../../editor/ import { IAsyncDataTreeViewState, ITreeCompressionDelegate } from '../../../../base/browser/ui/tree/asyncDataTree.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { EditOperation } from '../../../../editor/common/core/editOperation.js'; -import { IMenuWorkbenchToolBarOptions, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { HiddenItemStrategy, IMenuWorkbenchToolBarOptions, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { DropdownWithPrimaryActionViewItem } from '../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; import { clamp, rot } from '../../../../base/common/numbers.js'; @@ -1335,7 +1335,7 @@ registerAction2(class extends Action2 { constructor() { super({ id: SCMInputWidgetCommandId.SetupAction, - title: localize('scmInputGenerateCommitMessage', "Generate Commit Message with Copilot"), + title: localize('scmInputGenerateCommitMessage', "Generate commit message"), icon: Codicon.sparkle, f1: false, menu: { @@ -1449,7 +1449,7 @@ class SCMInputWidgetToolbar extends WorkbenchToolBar { @IStorageService private readonly storageService: IStorageService, @ITelemetryService telemetryService: ITelemetryService, ) { - super(container, { resetMenu: MenuId.SCMInputBox, ...options }, menuService, contextKeyService, contextMenuService, keybindingService, commandService, telemetryService); + super(container, options, menuService, contextKeyService, contextMenuService, keybindingService, commandService, telemetryService); this._dropdownAction = new Action( 'scmInputMoreActions', @@ -1561,7 +1561,6 @@ class SCMInputWidgetEditorOptions { return { ...getSimpleEditorOptions(this.configurationService), ...this.getEditorOptions(), - allowVariableLineHeights: false, dragAndDrop: true, dropIntoEditor: { enabled: true }, formatOnType: true, @@ -1947,6 +1946,7 @@ class SCMInputWidget { return createActionViewItem(instantiationService, action, options); }, + hiddenItemStrategy: HiddenItemStrategy.NoHide, menuOptions: { shouldForwardArgs: true } diff --git a/src/vs/workbench/contrib/search/browser/searchResultsView.ts b/src/vs/workbench/contrib/search/browser/searchResultsView.ts index b66f6bae7c7..fb2ec1fc959 100644 --- a/src/vs/workbench/contrib/search/browser/searchResultsView.ts +++ b/src/vs/workbench/contrib/search/browser/searchResultsView.ts @@ -141,20 +141,19 @@ export class TextSearchResultRenderer extends Disposable implements ICompressibl SearchContext.FileFocusKey.bindTo(templateData.contextKeyService).set(false); SearchContext.FolderFocusKey.bindTo(templateData.contextKeyService).set(false); } else { - let aiName = 'Copilot'; try { - aiName = (await node.element.parent().searchModel.getAITextResultProviderName()) || 'Copilot'; + await node.element.parent().searchModel.getAITextResultProviderName(); } 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)'] - }, '{0} Results', aiName); + comment: ['This is displayed before the AI text search results, now always "AI-assisted results".'] + }, 'AI-assisted results'); // todo: make icon extension-contributed. - templateData.label.setLabel(`$(${Codicon.copilot.id}) ${localizedLabel}`); + templateData.label.setLabel(`$(${Codicon.searchSparkle.id}) ${localizedLabel}`); SearchContext.AIResultsTitle.bindTo(templateData.contextKeyService).set(true); SearchContext.MatchFocusKey.bindTo(templateData.contextKeyService).set(false); diff --git a/src/vs/workbench/contrib/search/browser/searchView.ts b/src/vs/workbench/contrib/search/browser/searchView.ts index 668140877c8..2e13aeb6557 100644 --- a/src/vs/workbench/contrib/search/browser/searchView.ts +++ b/src/vs/workbench/contrib/search/browser/searchView.ts @@ -543,9 +543,9 @@ export class SearchView extends ViewPane { // Subscribe to AI search result changes and update the tree when new AI results are reported this._onAIResultChangedDisposable?.dispose(); this._onAIResultChangedDisposable = this._register( - this.viewModel.searchResult.aiTextSearchResult.onChange(() => { + this.viewModel.searchResult.aiTextSearchResult.onChange((e) => { // Only refresh the AI node, not the whole tree - if (this.tree && this.tree.hasNode(this.searchResult.aiTextSearchResult)) { + if (this.tree && this.tree.hasNode(this.searchResult.aiTextSearchResult) && !e.removed) { this.tree.updateChildren(this.searchResult.aiTextSearchResult); } }) @@ -1349,6 +1349,7 @@ export class SearchView extends ViewPane { this.searchWidget.clear(); } this.viewModel.cancelSearch(); + this.viewModel.cancelAISearch(); this.tree.ariaLabel = nls.localize('emptySearch', "Empty Search"); this.accessibilitySignalService.playSignal(AccessibilitySignal.clear); @@ -1746,10 +1747,10 @@ export class SearchView extends ViewPane { if (aiName) { const searchWithAIButtonTooltip = appendKeyBindingLabel( - nls.localize('triggerAISearch.tooltip', "Search with {0}", aiName), + nls.localize('triggerAISearch.tooltip', "Search with AI."), this.keybindingService.lookupKeybinding(Constants.SearchCommandIds.SearchWithAIActionId) ); - const searchWithAIButtonText = nls.localize('searchWithAIButtonTooltip', "Search with {0}.", aiName); + const searchWithAIButtonText = nls.localize('searchWithAIButtonTooltip', "Search with AI."); const searchWithAIButton = this.messageDisposables.add(new SearchLinkButton( searchWithAIButtonText, () => { @@ -1858,17 +1859,17 @@ export class SearchView extends ViewPane { public clearAIResults() { this.model.searchResult.aiTextSearchResult.hidden = true; - if (!this._pendingSemanticSearchPromise) { - this._cachedResults = undefined; - this._cachedKeywords = []; - this.model.cancelAISearch(true); - this.model.clearAiSearchResults(); - } + this.refreshTreeController.clearAllPending(); + this._pendingSemanticSearchPromise = undefined; + this._cachedResults = undefined; + this._cachedKeywords = []; + this.model.cancelAISearch(true); + this.model.clearAiSearchResults(); } public async requestAIResults() { this.logService.info(`SearchView: Requesting semantic results from keybinding. Cached: ${!!this.cachedResults}`); - if (!this.cachedResults || this.cachedResults.results.length === 0) { + if ((!this.cachedResults || this.cachedResults.results.length === 0) && !this._pendingSemanticSearchPromise) { this.clearAIResults(); } this.model.searchResult.aiTextSearchResult.hidden = false; @@ -2646,6 +2647,10 @@ class RefreshTreeController extends Disposable { private queuedIChangeEvents: IChangeEvent[] = []; + public clearAllPending(): void { + this.searchView.getControl().cancelAllRefreshPromises(); + } + public async queue(e?: IChangeEvent): Promise { if (e) { this.queuedIChangeEvents.push(e); diff --git a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts index 3a66a88b82c..9330fbbb24c 100644 --- a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +++ b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts @@ -88,6 +88,7 @@ import { IChatService } from '../../chat/common/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../chat/common/constants.js'; import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; import { IChatAgentService } from '../../chat/common/chatAgents.js'; +import { getActiveElement } from '../../../../base/browser/dom.js'; const QUICKOPEN_HISTORY_LIMIT_CONFIG = 'task.quickOpen.history'; @@ -2933,7 +2934,8 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer } private async _trust(): Promise { - if (ServerlessWebContext && !TaskExecutionSupportedContext) { + const context = this._contextKeyService.getContext(getActiveElement()); + if (ServerlessWebContext.getValue(this._contextKeyService) && !TaskExecutionSupportedContext?.evaluate(context)) { return false; } await this._workspaceTrustManagementService.workspaceTrustInitialized; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 7fb424f6eab..be55daf2992 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -489,6 +489,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } } })); + this._register(this.onDidChangeShellType(() => refreshShellIntegrationInfoStatus(this))); this._register(this.capabilities.onDidRemoveCapabilityType(capability => { capabilityListeners.get(capability)?.dispose(); })); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 798bae0b1b1..f29abfee746 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -846,6 +846,12 @@ export class TerminalService extends Disposable implements ITerminalService { })); instanceDisposables.add(instance.onDidFocus(this._onDidChangeActiveInstance.fire, this._onDidChangeActiveInstance)); instanceDisposables.add(instance.onRequestAddInstanceToGroup(async e => await this._addInstanceToGroup(instance, e))); + instanceDisposables.add(instance.onDidChangeShellType(() => this._extensionService.activateByEvent(`onTerminal:${instance.shellType}`))); + instanceDisposables.add(Event.runAndSubscribe(instance.capabilities.onDidAddCapability, (() => { + if (instance.capabilities.has(TerminalCapability.CommandDetection)) { + this._extensionService.activateByEvent(`onTerminalShellIntegration:${instance.shellType}`); + } + }))); const disposeListener = this._register(instance.onDisposed(() => { instanceDisposables.dispose(); this._store.delete(disposeListener); @@ -1017,10 +1023,17 @@ export class TerminalService extends Disposable implements ITerminalService { const location = await this.resolveLocation(options?.location) || this.defaultLocation; const parent = await this._getSplitParent(options?.location); this._terminalHasBeenCreated.set(true); + this._extensionService.activateByEvent('onTerminal:*'); + let instance; if (parent) { - return this._splitTerminal(shellLaunchConfig, location, parent); + instance = this._splitTerminal(shellLaunchConfig, location, parent); + } else { + instance = this._createTerminal(shellLaunchConfig, location, options); } - return this._createTerminal(shellLaunchConfig, location, options); + if (instance.shellType) { + this._extensionService.activateByEvent(`onTerminal:${instance.shellType}`); + } + return instance; } private async _getContributedProfile(shellLaunchConfig: IShellLaunchConfig, options?: ICreateTerminalOptions): Promise { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts b/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts index 9874708eb42..ebd16411d7a 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts @@ -92,6 +92,13 @@ export function refreshShellIntegrationInfoStatus(instance: ITerminalInstance) { ); const detailedAdditions: string[] = []; + if (instance.shellType) { + detailedAdditions.push(`Shell type: \`${instance.shellType}\``); + } + const cwd = instance.cwd; + if (cwd) { + detailedAdditions.push(`Current working directory: \`${cwd}\``); + } const seenSequences = Array.from(instance.xterm.shellIntegration.seenSequences); if (seenSequences.length > 0) { detailedAdditions.push(`Seen sequences: ${seenSequences.map(e => `\`${e}\``).join(', ')}`); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.initialHint.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.initialHint.contribution.ts index 4bef1ee66e5..996b5c18ccb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.initialHint.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.initialHint.contribution.ts @@ -18,19 +18,18 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ITerminalCapabilityStore, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { IChatAgent, IChatAgentService } from '../../../chat/common/chatAgents.js'; +import { ChatAgentLocation } from '../../../chat/common/constants.js'; import { IDetachedTerminalInstance, ITerminalContribution, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService, IXtermTerminal } from '../../../terminal/browser/terminal.js'; import { registerTerminalContribution, type IDetachedCompatibleTerminalContributionContext, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; import { TerminalInstance } from '../../../terminal/browser/terminalInstance.js'; import { TerminalInitialHintSettingId } from '../common/terminalInitialHintConfiguration.js'; import './media/terminalInitialHint.css'; import { TerminalChatCommandId } from './terminalChat.js'; -import { ChatAgentLocation } from '../../../chat/common/constants.js'; const $ = dom.$; @@ -215,12 +214,10 @@ class TerminalInitialHintWidget extends Disposable { constructor( private readonly _instance: ITerminalInstance, - @IChatAgentService private readonly _chatAgentService: IChatAgentService, @ICommandService private readonly _commandService: ICommandService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IKeybindingService private readonly _keybindingService: IKeybindingService, - @IProductService private readonly _productService: IProductService, @IStorageService private readonly _storageService: IStorageService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @ITerminalService private readonly _terminalService: ITerminalService, @@ -244,13 +241,7 @@ class TerminalInitialHintWidget extends Disposable { } private _getHintInlineChat(agents: IChatAgent[]) { - let providerName = (agents.length === 1 ? agents[0].fullName : undefined) ?? this._productService.nameShort; - const defaultAgent = this._chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); - if (defaultAgent?.extensionId.value === agents[0].extensionId.value) { - providerName = defaultAgent.fullName ?? providerName; - } - - let ariaLabel = `Ask ${providerName} something or start typing to dismiss.`; + let ariaLabel = `Open chat.`; const handleClick = () => { this._storageService.store(Constants.InitialHintHideStorageKey, true, StorageScope.APPLICATION, StorageTarget.USER); @@ -285,7 +276,7 @@ class TerminalInitialHintWidget extends Disposable { const keybindingHintLabel = keybindingHint?.getLabel(); if (keybindingHint && keybindingHintLabel) { - const actionPart = localize('emptyHintText', 'Press {0} to ask {1} to do something. ', keybindingHintLabel, providerName); + const actionPart = localize('emptyHintText', 'Open chat {0}. ', keybindingHintLabel); const [before, after] = actionPart.split(keybindingHintLabel).map((fragment) => { const hintPart = $('a', undefined, fragment); @@ -316,7 +307,7 @@ class TerminalInitialHintWidget extends Disposable { comment: [ 'Preserve double-square brackets and their order', ] - }, '[[Ask {0} to do something]] or start typing to dismiss.', providerName); + }, '[[Open chat]] or start typing to dismiss.'); const rendered = renderFormattedText(hintMsg, { actionHandler: hintHandler }); hintElement.appendChild(rendered); } 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 cc6faaada5b..1616d3317c2 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 @@ -94,14 +94,4 @@ suite('Terminal Initial Hint Addon', () => { strictEqual(eventCount, 1); }); }); - suite('Input', () => { - test('hint is not shown when there has been input', () => { - onDidChangeAgentsEmitter.fire(agent); - xterm.writeln('data'); - setTimeout(() => { - xterm.focus(); - strictEqual(eventCount, 0); - }, 50); - }); - }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts index 3cfea79b814..e7b27e43556 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts @@ -52,8 +52,8 @@ const fallbackMatchers: RegExp[] = [ // C:\foo/bar baz:339: error ... // C:\foo/bar baz:339:12: error ... [#178584, Clang] /^(?(?.+):(?\d+)(?::(?\d+))?) ?:/, - // Cmd prompt - /^(?(?.+))>/, + // PowerShell and cmd prompt + /^(?:PS\s+)?(?(?[^>]+))>/, // The whole line is the path /^ *(?(?.+))/ ]; 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 04d0dfa7415..32d6d384bb8 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 @@ -158,6 +158,8 @@ const supportedFallbackLinkFormats: LinkFormatInfo[] = [ { urlFormat: '{0}:{1}:{2} :', line: '5', column: '3', linkCellEndOffset: -2 }, { urlFormat: '{0}:{1}:', line: '5', linkCellEndOffset: -1 }, { urlFormat: '{0}:{1}:{2}:', line: '5', column: '3', linkCellEndOffset: -1 }, + // PowerShell prompt + { urlFormat: 'PS {0}>', linkCellStartOffset: 3, linkCellEndOffset: -1 }, // Cmd prompt { urlFormat: '{0}>', linkCellEndOffset: -1 }, // The whole line is the path diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts index 0da38ee5994..3e94022f700 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts @@ -337,8 +337,9 @@ export class TerminalStickyScrollOverlay extends Disposable { // following command. let endMarkerOffset = 0; if (!isPartialCommand && command.endMarker && command.endMarker.line !== -1) { - if (buffer.viewportY + stickyScrollLineCount > command.endMarker.line) { - const diff = buffer.viewportY + stickyScrollLineCount - command.endMarker.line; + const lastLine = Math.min(command.endMarker.line, buffer.baseY + buffer.cursorY); + if (buffer.viewportY + stickyScrollLineCount > lastLine) { + const diff = buffer.viewportY + stickyScrollLineCount - lastLine; endMarkerOffset = diff * rowHeight; } } 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 f7727e42202..0f693c1f73b 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 @@ -297,29 +297,15 @@ registerTerminalAction({ order: 1 }, keybinding: { - primary: KeyMod.CtrlCmd | KeyCode.Slash, - mac: { primary: KeyMod.WinCtrl | KeyCode.KeyK }, - weight: KeybindingWeight.WorkbenchContrib + 1 + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyL, + weight: KeybindingWeight.WorkbenchContrib + 1, + when: TerminalContextKeys.suggestWidgetVisible }, run: (c, accessor) => { (accessor.get(IOpenerService)).open('https://aka.ms/vscode-terminal-intellisense'); } }); -registerActiveInstanceAction({ - id: TerminalSuggestCommandId.ResetDiscoverability, - title: localize2('workbench.action.terminal.resetDiscoverability', 'Reset Suggest Discoverability'), - f1: true, - precondition: ContextKeyExpr.and( - ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), - TerminalContextKeys.isOpen, - ContextKeyExpr.equals(`config.${TerminalSuggestSettingId.Enabled}`, true) - ), - run: (activeInstance) => { - TerminalSuggestContribution.get(activeInstance)?.addon?.resetDiscoverability(); - } -}); - registerActiveInstanceAction({ id: TerminalSuggestCommandId.RequestCompletions, title: localize2('workbench.action.terminal.requestCompletions', 'Request Completions'), @@ -441,11 +427,12 @@ registerActiveInstanceAction({ keybinding: [{ primary: KeyCode.Tab, // Tab is bound to other workbench keybindings that this needs to beat - weight: KeybindingWeight.WorkbenchContrib + 1 + weight: KeybindingWeight.WorkbenchContrib + 2, + when: ContextKeyExpr.and(SimpleSuggestContext.HasFocusedSuggestion) }, { primary: KeyCode.Enter, - when: ContextKeyExpr.or(ContextKeyExpr.notEquals(`config.${TerminalSuggestSettingId.SelectionMode}`, 'partial'), ContextKeyExpr.or(SimpleSuggestContext.FirstSuggestionFocused.toNegated(), SimpleSuggestContext.HasNavigated)), + when: ContextKeyExpr.and(SimpleSuggestContext.HasFocusedSuggestion, ContextKeyExpr.or(ContextKeyExpr.notEquals(`config.${TerminalSuggestSettingId.SelectionMode}`, 'partial'), ContextKeyExpr.or(SimpleSuggestContext.FirstSuggestionFocused.toNegated(), SimpleSuggestContext.HasNavigated))), weight: KeybindingWeight.WorkbenchContrib + 1 }], menu: { diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts index ba7186834a7..06531857053 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts @@ -19,6 +19,7 @@ import type { IProcessEnvironment } from '../../../../../base/common/platform.js import { timeout } from '../../../../../base/common/async.js'; import { gitBashToWindowsPath } from './terminalGitBashHelpers.js'; import { isEqual } from '../../../../../base/common/resources.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; export const ITerminalCompletionService = createDecorator('terminalCompletionService'); @@ -72,7 +73,7 @@ export interface ITerminalCompletionService { _serviceBrand: undefined; readonly providers: IterableIterator; registerTerminalCompletionProvider(extensionIdentifier: string, id: string, provider: ITerminalCompletionProvider, ...triggerCharacters: string[]): IDisposable; - provideCompletions(promptValue: string, cursorPosition: number, allowFallbackCompletions: boolean, shellType: TerminalShellType, capabilities: ITerminalCapabilityStore, token: CancellationToken, triggerCharacter?: boolean, skipExtensionCompletions?: boolean, explicitlyInvoked?: boolean): Promise; + provideCompletions(promptValue: string, cursorPosition: number, allowFallbackCompletions: boolean, shellType: TerminalShellType | undefined, capabilities: ITerminalCapabilityStore, token: CancellationToken, triggerCharacter?: boolean, skipExtensionCompletions?: boolean, explicitlyInvoked?: boolean): Promise; } export class TerminalCompletionService extends Disposable implements ITerminalCompletionService { @@ -98,6 +99,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService ) { super(); } @@ -122,7 +124,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo }); } - async provideCompletions(promptValue: string, cursorPosition: number, allowFallbackCompletions: boolean, shellType: TerminalShellType, capabilities: ITerminalCapabilityStore, token: CancellationToken, triggerCharacter?: boolean, skipExtensionCompletions?: boolean, explicitlyInvoked?: boolean): Promise { + async provideCompletions(promptValue: string, cursorPosition: number, allowFallbackCompletions: boolean, shellType: TerminalShellType | undefined, capabilities: ITerminalCapabilityStore, token: CancellationToken, triggerCharacter?: boolean, skipExtensionCompletions?: boolean, explicitlyInvoked?: boolean): Promise { if (!this._providers || !this._providers.values || cursorPosition < 0) { return undefined; } @@ -151,11 +153,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo return this._collectCompletions(providers, shellType, promptValue, cursorPosition, allowFallbackCompletions, capabilities, token, explicitlyInvoked); } - const providerConfig: { [key: string]: boolean } = this._configurationService.getValue(TerminalSuggestSettingId.Providers); - providers = providers.filter(p => { - const providerId = p.id; - return providerId && providerId in providerConfig && providerConfig[providerId] !== false; - }); + providers = this._getEnabledProviders(providers); if (!providers.length) { return; @@ -164,16 +162,35 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo return this._collectCompletions(providers, shellType, promptValue, cursorPosition, allowFallbackCompletions, capabilities, token, explicitlyInvoked); } - private async _collectCompletions(providers: ITerminalCompletionProvider[], shellType: TerminalShellType, promptValue: string, cursorPosition: number, allowFallbackCompletions: boolean, capabilities: ITerminalCapabilityStore, token: CancellationToken, explicitlyInvoked?: boolean): Promise { + protected _getEnabledProviders(providers: ITerminalCompletionProvider[]): ITerminalCompletionProvider[] { + const providerConfig: { [key: string]: boolean } = this._configurationService.getValue(TerminalSuggestSettingId.Providers); + return providers.filter(p => { + const providerId = p.id; + return providerId && (!(providerId in providerConfig) || providerConfig[providerId] !== false); + }); + } + + private async _collectCompletions(providers: ITerminalCompletionProvider[], shellType: TerminalShellType | undefined, promptValue: string, cursorPosition: number, allowFallbackCompletions: boolean, capabilities: ITerminalCapabilityStore, token: CancellationToken, explicitlyInvoked?: boolean): Promise { const completionPromises = providers.map(async provider => { - if (provider.shellTypes && !provider.shellTypes.includes(shellType)) { + if (provider.shellTypes && shellType && !provider.shellTypes.includes(shellType)) { return undefined; } const timeoutMs = explicitlyInvoked ? 30000 : 5000; - const completions = await Promise.race([ - provider.provideCompletions(promptValue, cursorPosition, allowFallbackCompletions, token), - timeout(timeoutMs) - ]); + let timedOut = false; + let completions; + try { + completions = await Promise.race([ + provider.provideCompletions(promptValue, cursorPosition, allowFallbackCompletions, token), + (async () => { await timeout(timeoutMs); timedOut = true; return undefined; })() + ]); + } catch (e) { + this._logService.trace(`[TerminalCompletionService] Exception from provider '${provider.id}':`, e); + return undefined; + } + if (timedOut) { + this._logService.trace(`[TerminalCompletionService] Provider '${provider.id}' timed out after ${timeoutMs}ms. promptValue='${promptValue}', cursorPosition=${cursorPosition}, explicitlyInvoked=${explicitlyInvoked}`); + return undefined; + } if (!completions) { return undefined; } diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index ac0444c8f78..a132f371522 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -253,13 +253,11 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest return; } - // Require a shell type for completions. This will wait a short period after launching to - // wait for the shell type to initialize. This prevents user requests sometimes getting lost - // if requested shortly after the terminal is created. + // Wait for the shell type to initialize. This will wait a short period after launching to + // allow the shell type to be set if possible. This prevents user requests sometimes getting + // lost if requested shortly after the terminal is created. Completion providers can still + // work with undefined shell types such as Pseudoterminal-based extension terminals. await this._shellTypeInit; - if (!this.shellType) { - return; - } let doNotRequestExtensionCompletions = false; // Ensure that a key has been pressed since the last accepted completion in order to prevent @@ -342,11 +340,13 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest } const lineContext = new LineContext(normalizedLeadingLineContent, this._cursorIndexDelta); + const items = completions.filter(c => !!c.label).map(c => new TerminalCompletionItem(c)); + if (isInlineCompletionSupported(this.shellType)) { + items.push(this._inlineCompletionItem); + } + const model = new TerminalCompletionModel( - [ - ...completions.filter(c => !!c.label).map(c => new TerminalCompletionItem(c)), - this._inlineCompletionItem, - ], + items, lineContext ); if (token.isCancellationRequested) { @@ -718,7 +718,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest // Track the time when completions are shown for the first time if (this._completionRequestTimestamp !== undefined) { const completionLatency = Date.now() - this._completionRequestTimestamp; - if (this._suggestTelemetry && this.shellType && this._discoverability) { + if (this._suggestTelemetry && this._discoverability) { const firstShown = this._discoverability.getFirstShown(this.shellType); this._discoverability.updateShown(); this._suggestTelemetry.logCompletionLatency(this._sessionId, completionLatency, firstShown); @@ -814,8 +814,9 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest if (!suggestion) { suggestion = this._suggestWidget?.getFocusedItem(); } + const initialPromptInputState = this._mostRecentPromptInputState; - if (!suggestion || !initialPromptInputState || this._leadingLineContent === undefined || !this._model) { + if (!suggestion?.item || !initialPromptInputState || this._leadingLineContent === undefined || !this._model) { this._suggestTelemetry?.acceptCompletion(this._sessionId, undefined, this._mostRecentPromptInputState?.value); return; } @@ -907,6 +908,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest } hideSuggestWidget(cancelAnyRequest: boolean): void { + this._discoverability?.resetTimer(); if (cancelAnyRequest) { this._cancellationTokenSource?.cancel(); this._cancellationTokenSource = undefined; @@ -915,7 +917,6 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._leadingLineContent = undefined; this._suggestWidget?.hide(); } - } class PersistedWidgetSize { diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestShownTracker.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestShownTracker.ts index 6fb1932dd14..3185fcc783e 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestShownTracker.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestShownTracker.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { TimeoutTimer } from '../../../../../base/common/async.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { TerminalShellType } from '../../../../../platform/terminal/common/terminal.js'; @@ -23,10 +24,10 @@ interface ITerminalSuggestShownTracker extends IDisposable { export class TerminalSuggestShownTracker extends Disposable implements ITerminalSuggestShownTracker { private _done: boolean; private _count: number; - private _timeout: Timeout | undefined; + private _timeout: TimeoutTimer | undefined; private _start: number | undefined; - private _firstShownTracker: { shell: Set; window: boolean } | undefined = undefined; + private _firstShownTracker: { shell: Set; window: boolean } | undefined = undefined; constructor( private readonly _shellType: TerminalShellType | undefined, @@ -51,6 +52,14 @@ export class TerminalSuggestShownTracker extends Disposable implements ITerminal this._firstShownTracker = undefined; } + resetTimer(): void { + if (this._timeout) { + this._timeout.cancel(); + this._timeout = undefined; + } + this._start = undefined; + } + update(widgetElt: HTMLElement | undefined): void { if (this._done) { return; @@ -63,10 +72,11 @@ export class TerminalSuggestShownTracker extends Disposable implements ITerminal if (this._count >= TERMINAL_SUGGEST_DISCOVERABILITY_MAX_COUNT) { this._setDone(widgetElt); } else if (!this._start) { + this.resetTimer(); this._start = Date.now(); - this._timeout = setTimeout(() => { + this._timeout = this._register(new TimeoutTimer(() => { this._setDone(widgetElt); - }, TERMINAL_SUGGEST_DISCOVERABILITY_MIN_MS); + }, TERMINAL_SUGGEST_DISCOVERABILITY_MIN_MS)); } } @@ -77,13 +87,13 @@ export class TerminalSuggestShownTracker extends Disposable implements ITerminal widgetElt.classList.remove('increased-discoverability'); } if (this._timeout) { - clearTimeout(this._timeout); + this._timeout.cancel(); this._timeout = undefined; } this._start = undefined; } - getFirstShown(shellType: TerminalShellType): { window: boolean; shell: boolean } { + getFirstShown(shellType: TerminalShellType | undefined): { window: boolean; shell: boolean } { if (!this._firstShownTracker) { this._firstShownTracker = { window: true, diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/common/terminalSuggestConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/suggest/common/terminalSuggestConfiguration.ts index 3434d973693..1f2b14e3f60 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/common/terminalSuggestConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/common/terminalSuggestConfiguration.ts @@ -57,6 +57,7 @@ export interface ITerminalSuggestConfiguration { providers: { 'terminal-suggest': boolean; 'pwsh-shell-integration': boolean; + [key: string]: boolean; }; showStatusBar: boolean; cdPath: 'off' | 'relative' | 'absolute'; @@ -78,7 +79,7 @@ export const terminalSuggestConfiguration: IStringDictionary { setup(() => { instantiationService = store.add(new TestInstantiationService()); configurationService = new TestConfigurationService(); + instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, { async stat(resource) { @@ -768,4 +771,106 @@ suite('TerminalCompletionService', () => { }); }); + + suite('Provider Configuration', () => { + // Test class that extends TerminalCompletionService to access protected methods + class TestTerminalCompletionService extends TerminalCompletionService { + public getEnabledProviders(providers: ITerminalCompletionProvider[]): ITerminalCompletionProvider[] { + return super._getEnabledProviders(providers); + } + } + + let testTerminalCompletionService: TestTerminalCompletionService; + + setup(() => { + testTerminalCompletionService = store.add(instantiationService.createInstance(TestTerminalCompletionService)); + }); + + // Mock provider for testing + function createMockProvider(id: string): ITerminalCompletionProvider { + return { + id, + provideCompletions: async () => [{ + label: `completion-from-${id}`, + kind: TerminalCompletionItemKind.Method, + replacementIndex: 0, + replacementLength: 0, + provider: id + }] + }; + } + + test('should enable providers by default when no configuration exists', () => { + const defaultProvider = createMockProvider('terminal-suggest'); + const newProvider = createMockProvider('new-extension-provider'); + const providers = [defaultProvider, newProvider]; + + // Set empty configuration (no provider keys) + configurationService.setUserConfiguration(TerminalSuggestSettingId.Providers, {}); + + const result = testTerminalCompletionService.getEnabledProviders(providers); + + // Both providers should be enabled since they're not explicitly disabled + assert.strictEqual(result.length, 2, 'Should enable both providers by default'); + assert.ok(result.includes(defaultProvider), 'Should include default provider'); + assert.ok(result.includes(newProvider), 'Should include new provider'); + }); + + test('should disable providers when explicitly set to false', () => { + const provider1 = createMockProvider('provider1'); + const provider2 = createMockProvider('provider2'); + const providers = [provider1, provider2]; + + // Disable provider1, leave provider2 unconfigured + configurationService.setUserConfiguration(TerminalSuggestSettingId.Providers, { + 'provider1': false + }); + + const result = testTerminalCompletionService.getEnabledProviders(providers); + + // Only provider2 should be enabled + assert.strictEqual(result.length, 1, 'Should enable only one provider'); + assert.ok(result.includes(provider2), 'Should include unconfigured provider'); + assert.ok(!result.includes(provider1), 'Should not include disabled provider'); + }); + + test('should enable providers when explicitly set to true', () => { + const provider1 = createMockProvider('provider1'); + const provider2 = createMockProvider('provider2'); + const providers = [provider1, provider2]; + + // Explicitly enable provider1, leave provider2 unconfigured + configurationService.setUserConfiguration(TerminalSuggestSettingId.Providers, { + 'provider1': true + }); + + const result = testTerminalCompletionService.getEnabledProviders(providers); + + // Both providers should be enabled + assert.strictEqual(result.length, 2, 'Should enable both providers'); + assert.ok(result.includes(provider1), 'Should include explicitly enabled provider'); + assert.ok(result.includes(provider2), 'Should include unconfigured provider'); + }); + + test('should handle mixed configuration correctly', () => { + const provider1 = createMockProvider('provider1'); + const provider2 = createMockProvider('provider2'); + const provider3 = createMockProvider('provider3'); + const providers = [provider1, provider2, provider3]; + + // Mixed configuration: enable provider1, disable provider2, leave provider3 unconfigured + configurationService.setUserConfiguration(TerminalSuggestSettingId.Providers, { + 'provider1': true, + 'provider2': false + }); + + const result = testTerminalCompletionService.getEnabledProviders(providers); + + // provider1 and provider3 should be enabled, provider2 should be disabled + assert.strictEqual(result.length, 2, 'Should enable two providers'); + assert.ok(result.includes(provider1), 'Should include explicitly enabled provider'); + assert.ok(result.includes(provider3), 'Should include unconfigured provider'); + assert.ok(!result.includes(provider2), 'Should not include disabled provider'); + }); + }); }); diff --git a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts index 8081ab47d8d..0fc7adb151f 100644 --- a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts +++ b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts @@ -35,7 +35,7 @@ import { SideBySideEditor, EditorResourceAccessor } from '../../../common/editor import { ICommandService, CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IViewDescriptorService } from '../../../common/views.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../common/views.js'; import { IProgressService } from '../../../../platform/progress/common/progress.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { ActionBar, IActionViewItemProvider } from '../../../../base/browser/ui/actionbar/actionbar.js'; @@ -926,7 +926,7 @@ export class TimelinePane extends ViewPane { // this.treeElement.classList.add('show-file-icons'); container.appendChild(this.$tree); - this.treeRenderer = this.instantiationService.createInstance(TimelineTreeRenderer, this.commands); + this.treeRenderer = this.instantiationService.createInstance(TimelineTreeRenderer, this.commands, this.viewDescriptorService.getViewLocationById(this.id)); this._register(this.treeRenderer.onDidScrollToEnd(item => { if (this.pageOnScroll) { this.loadMore(item); @@ -1165,11 +1165,18 @@ class TimelineTreeRenderer implements ITreeRenderer + content="default-src 'none'; script-src 'sha256-l9e7E37hgQqoIAqcFRgQ5/0NqMMW93mQmYAsUcDUyZ4=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> -const sw: ServiceWorkerGlobalScope = self as any as ServiceWorkerGlobalScope; +/** @type {ServiceWorkerGlobalScope} */ +const sw = /** @type {any} */ (self); const VERSION = 4; @@ -16,38 +18,54 @@ const searchParams = new URL(location.toString()).searchParams; const remoteAuthority = searchParams.get('remoteAuthority'); -let outerIframeMessagePort: MessagePort | undefined; +/** @type {MessagePort|undefined} */ +let outerIframeMessagePort; /** * Origin used for resources */ const resourceBaseAuthority = searchParams.get('vscode-resource-base-authority'); + +/** @type {number} */ const resolveTimeout = 30_000; -type RequestStoreResult = { - status: 'ok'; - value: T; -} | { - status: 'timeout'; -}; -interface RequestStoreEntry { - resolve: (x: RequestStoreResult) => void; - promise: Promise>; -} +/** + * @template T + * @typedef {{ status: 'ok', value: T } | { status: 'timeout' }} RequestStoreResult + */ -class RequestStore { - private map: Map> = new Map(); - private requestPool: number = 0; - create(): { requestId: number; promise: Promise> } { +/** + * @template T + * @typedef {{ resolve: (x: RequestStoreResult) => void, promise: Promise> }} RequestStoreEntry + */ + + +/** + * @template T + */ +class RequestStore { + constructor() { + /** @type {Map>} */ + this.map = new Map(); + /** @type {number} */ + this.requestPool = 0; + } + + /** + * @returns {{ requestId: number, promise: Promise> }} + */ + create() { const requestId = ++this.requestPool; - let resolve: (x: RequestStoreResult) => void; - const promise = new Promise>(r => resolve = r); + /** @type {(x: RequestStoreResult) => void} */ + let resolve; + const promise = new Promise(r => resolve = r); - const entry: RequestStoreEntry = { resolve: resolve!, promise }; + /** @type {RequestStoreEntry} */ + const entry = { resolve, promise }; this.map.set(requestId, entry); const dispose = () => { @@ -62,7 +80,12 @@ class RequestStore { return { requestId, promise }; } - resolve(requestId: number, result: T): boolean { + /** + * @param {number} requestId + * @param {T} result + * @returns {boolean} + */ + resolve(requestId, result) { const entry = this.map.get(requestId); if (!entry) { return false; @@ -76,12 +99,14 @@ class RequestStore { /** * Map of requested paths to responses. */ -const resourceRequestStore = new RequestStore(); +/** @type {RequestStore} */ +const resourceRequestStore = new RequestStore(); /** * Map of requested localhost origins to optional redirects. */ -const localhostRequestStore = new RequestStore(); +/** @type {RequestStore} */ +const localhostRequestStore = new RequestStore(); const unauthorized = () => new Response('Unauthorized', { status: 401, }); @@ -95,12 +120,13 @@ const methodNotAllowed = () => const requestTimeout = () => new Response('Request Timeout', { status: 408, }); -sw.addEventListener('message', async (event: ExtendableMessageEvent) => { +sw.addEventListener('message', async (event) => { if (!event.source) { return; } - const source = event.source as Client; + /** @type {Client} */ + const source = event.source; switch (event.data.channel) { case 'version': { outerIframeMessagePort = event.ports[0]; @@ -115,7 +141,8 @@ sw.addEventListener('message', async (event: ExtendableMessageEvent) => { return; } case 'did-load-resource': { - const response = event.data.data as ResourceResponse; + /** @type {ResourceResponse} */ + const response = event.data.data; if (!resourceRequestStore.resolve(response.id, response)) { console.log('Could not resolve unknown resource', response.path); } @@ -135,7 +162,7 @@ sw.addEventListener('message', async (event: ExtendableMessageEvent) => { } }); -sw.addEventListener('fetch', (event: FetchEvent) => { +sw.addEventListener('fetch', (event) => { const requestUrl = new URL(event.request.url); if (typeof resourceBaseAuthority === 'string' && requestUrl.protocol === 'https:' && requestUrl.hostname.endsWith('.' + resourceBaseAuthority)) { switch (event.request.method) { @@ -184,25 +211,32 @@ sw.addEventListener('fetch', (event: FetchEvent) => { } }); -sw.addEventListener('install', (event: ExtendableEvent) => { +sw.addEventListener('install', (event) => { event.waitUntil(sw.skipWaiting()); // Activate worker immediately }); -sw.addEventListener('activate', (event: ExtendableEvent) => { +sw.addEventListener('activate', (event) => { event.waitUntil(sw.clients.claim()); // Become available to all pages }); -interface ResourceRequestUrlComponents { - scheme: string; - authority: string; - path: string; - query: string; -} +/** + * @typedef {Object} ResourceRequestUrlComponents + * @property {string} scheme + * @property {string} authority + * @property {string} path + * @property {string} query + */ + +/** + * @param {FetchEvent} event + * @param {ResourceRequestUrlComponents} requestUrlComponents + * @returns {Promise} + */ async function processResourceRequest( - event: FetchEvent, - requestUrlComponents: ResourceRequestUrlComponents -): Promise { + event, + requestUrlComponents +) { let client = await sw.clients.get(event.clientId); if (!client) { client = await getWorkerClientForId(event.clientId); @@ -227,10 +261,12 @@ async function processResourceRequest( const shouldTryCaching = (event.request.method === 'GET'); - const resolveResourceEntry = ( - result: RequestStoreResult, - cachedResponse: Response | undefined - ): Response => { + /** + * @param {RequestStoreResult} result + * @param {Response|undefined} cachedResponse + * @returns {Response} + */ + const resolveResourceEntry = (result, cachedResponse) => { if (result.status === 'timeout') { return requestTimeout(); } @@ -252,7 +288,8 @@ async function processResourceRequest( return notFound(); } - const commonHeaders: Record = { + /** @type {Record} */ + const commonHeaders = { 'Access-Control-Allow-Origin': '*', }; @@ -287,7 +324,8 @@ async function processResourceRequest( } } - const headers: Record = { + /** @type {Record} */ + const headers = { ...commonHeaders, 'Content-Type': entry.mime, 'Content-Length': byteLength.toString(), @@ -312,7 +350,7 @@ async function processResourceRequest( headers['Cross-Origin-Opener-Policy'] = 'same-origin'; } - const response = new Response(entry.data as Uint8Array, { + const response = new Response(entry.data, { status: 200, headers }); @@ -325,7 +363,8 @@ async function processResourceRequest( return response.clone(); }; - let cached: Response | undefined; + /** @type {Response|undefined} */ + let cached; if (shouldTryCaching) { const cache = await caches.open(resourceCacheName); cached = await cache.match(event.request); @@ -366,10 +405,15 @@ async function processResourceRequest( return promise.then(entry => resolveResourceEntry(entry, cached)); } +/** + * @param {FetchEvent} event + * @param {URL} requestUrl + * @returns {Promise} + */ async function processLocalhostRequest( - event: FetchEvent, - requestUrl: URL -): Promise { + event, + requestUrl +) { const client = await sw.clients.get(event.clientId); if (!client) { // This is expected when requesting resources on other localhost ports @@ -390,9 +434,11 @@ async function processLocalhostRequest( const origin = requestUrl.origin; - const resolveRedirect = async ( - result: RequestStoreResult - ): Promise => { + /** + * @param {RequestStoreResult} result + * @returns {Promise} + */ + const resolveRedirect = async function (result) { if (result.status !== 'ok' || !result.value) { return fetch(event.request); } @@ -432,12 +478,20 @@ async function processLocalhostRequest( return promise.then(resolveRedirect); } -function getWebviewIdForClient(client: Client): string | null { +/** + * @param {Client} client + * @returns {string|null} + */ +function getWebviewIdForClient(client) { const requesterClientUrl = new URL(client.url); return requesterClientUrl.searchParams.get('id'); } -async function getOuterIframeClient(webviewId: string): Promise { +/** + * @param {string} webviewId + * @returns {Promise} + */ +async function getOuterIframeClient(webviewId) { const allClients = await sw.clients.matchAll({ includeUncontrolled: true }); return allClients.filter(client => { const clientUrl = new URL(client.url); @@ -446,7 +500,11 @@ async function getOuterIframeClient(webviewId: string): Promise { }); } -async function getWorkerClientForId(clientId: string): Promise { +/** + * @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]; @@ -455,9 +513,12 @@ async function getWorkerClientForId(clientId: string): Promise { - (editor as GettingStartedPage)?.makeCategoryVisibleWhenAvailable(selectedCategory, selectedStep); - }); + // Otherwise open the walkthrough editor with the selected category and step + const options: GettingStartedEditorOptions = { selectedCategory: selectedCategory, selectedStep: selectedStep, showWelcome: false, preserveFocus: toSide ?? false, inactive }; + editorService.openEditor({ + resource: GettingStartedInput.RESOURCE, + options + }, toSide ? SIDE_GROUP : undefined); - } } else { editorService.openEditor({ resource: GettingStartedInput.RESOURCE, diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts index cc5c3bd5727..77a3a36c528 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts @@ -961,8 +961,7 @@ export class GettingStartedPage extends EditorPane { const fistContentBehaviour = daysSinceFirstSession < 1 ? 'openToFirstCategory' : 'index'; const startupExpValue = startupExpContext.getValue(this.contextService); - if (fistContentBehaviour === 'openToFirstCategory' && ((startupExpValue === '' || startupExpValue === StartupExperimentGroup.Control))) { - startupExpContext.bindTo(this.contextService).reset(); + if (fistContentBehaviour === 'openToFirstCategory' && ((!startupExpValue || startupExpValue === '' || startupExpValue === StartupExperimentGroup.Control))) { const first = this.gettingStartedCategories.filter(c => !c.when || this.contextService.contextMatchesRules(c.when))[0]; if (first) { this.hasScrolledToFirstCategory = true; @@ -1265,7 +1264,7 @@ export class GettingStartedPage extends EditorPane { private focusSideEditorGroup() { const fullSize = this.groupsService.getPart(this.group).contentDimension; - if (!fullSize || fullSize.width <= 700) { return; } + if (!fullSize || fullSize.width <= 700 || this.container.classList.contains('width-constrained') || this.container.classList.contains('width-semi-constrained')) { return; } if (this.groupsService.count === 1) { const sideGroup = this.groupsService.addGroup(this.groupsService.groups[0], GroupDirection.RIGHT); this.groupsService.activateGroup(sideGroup); @@ -1669,6 +1668,7 @@ export class GettingStartedPage extends EditorPane { // Add next button this.nextButton = $('button.button-link.navigation.next', { + 'aria-label': localize('nextStep', "Next"), }, localize('next', "Next"), $('span.codicon.codicon-arrow-right')); diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts index cc37ab45cad..183288a5a10 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService.ts @@ -37,6 +37,8 @@ import { IProductService } from '../../../../platform/product/common/productServ import { asWebviewUri } from '../../webview/common/webview.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; import { extensionDefaultIcon } from '../../../services/extensionManagement/common/extensionsIcons.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { GettingStartedInput } from './gettingStartedInput.js'; export const HasMultipleNewFileEntries = new RawContextKey('hasMultipleNewFileEntries', false); @@ -161,6 +163,7 @@ export class WalkthroughsService extends Disposable implements IWalkthroughsServ @IWorkbenchAssignmentService private readonly tasExperimentService: IWorkbenchAssignmentService, @IProductService private readonly productService: IProductService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IEditorService private readonly editorService: IEditorService ) { super(); @@ -458,6 +461,10 @@ export class WalkthroughsService extends Disposable implements IWalkthroughsServ id: string; }; this.telemetryService.publicLog2('gettingStarted.didAutoOpenWalkthrough', { id: sectionToOpen }); + const activeEditor = this.editorService.activeEditor; + if (activeEditor instanceof GettingStartedInput) { + this.commandService.executeCommand('workbench.action.keepEditor'); + } this.commandService.executeCommand('workbench.action.openWalkthrough', sectionToOpen, { inactive: this.layoutService.hasFocus(Parts.EDITOR_PART) // do not steal the active editor away }); diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts index 8a974a0dff0..9b5282c0f7d 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts @@ -12,7 +12,6 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { onUnexpectedError } from '../../../../base/common/errors.js'; import { IWorkspaceContextService, UNKNOWN_EMPTY_WINDOW_WORKSPACE, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; 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, DisposableStore } from '../../../../base/common/lifecycle.js'; import { IFileService } from '../../../../platform/files/common/files.js'; @@ -85,7 +84,6 @@ export class StartupPageRunnerContribution extends Disposable implements IWorkbe constructor( @IConfigurationService private readonly configurationService: IConfigurationService, @IEditorService private readonly editorService: IEditorService, - @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, @IFileService private readonly fileService: IFileService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @@ -135,8 +133,6 @@ export class StartupPageRunnerContribution extends Disposable implements IWorkbe const enabled = isStartupPageEnabled(this.configurationService, this.contextService, this.environmentService, this.logService); if (enabled && this.lifecycleService.startupKind !== StartupKind.ReloadedWindow) { - const hasBackups = await this.workingCopyBackupService.hasBackups(); - if (hasBackups) { return; } // Open the welcome even if we opened a set of default editors if (!this.editorService.activeEditor || this.layoutService.openedDefaultEditors) { @@ -148,7 +144,6 @@ export class StartupPageRunnerContribution extends Disposable implements IWorkbe if (this.storageService.isNew(StorageScope.APPLICATION)) { const startupExpValue = startupExpContext.getValue(this.contextKeyService); if (startupExpValue === StartupExperimentGroup.MaximizedChat || startupExpValue === StartupExperimentGroup.SplitEmptyEditorChat) { - startupExpContext.bindTo(this.contextKeyService).reset(); return; } } diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts index fad3c94a4c5..a7a2eb28265 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts @@ -19,7 +19,14 @@ interface IGettingStartedContentProvider { (): string; } -export const copilotSettingsMessage = localize({ key: 'settings', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "{0} Copilot Free, Pro and Pro+ may show [public code]({1}) suggestions and we may use your data for product improvement. You can change these [settings]({2}) at any time.", product.defaultChatAgent?.providerName, product.defaultChatAgent?.publicCodeMatchesUrl, product.defaultChatAgent?.manageSettingsUrl); +const defaultChat = { + documentationUrl: product.defaultChatAgent?.documentationUrl ?? '', + manageSettingsUrl: product.defaultChatAgent?.manageSettingsUrl ?? '', + provider: product.defaultChatAgent?.provider ?? { default: { name: '' } }, + publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', +}; + +export const copilotSettingsMessage = localize({ key: 'settings', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "{0} Copilot Free, Pro and Pro+ may show [public code]({1}) suggestions and we may use your data for product improvement. You can change these [settings]({2}) at any time.", defaultChat.provider.default.name, defaultChat.publicCodeMatchesUrl, defaultChat.manageSettingsUrl); class GettingStartedContentProviderRegistry { @@ -217,7 +224,7 @@ export const startEntries: GettingStartedStartEntryContent = [ const Button = (title: string, href: string) => `[${title}](${href})`; 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 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.", defaultChat.documentationUrl ?? ''); 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'); @@ -700,7 +707,7 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ { id: 'copilotSetup.customize', title: localize('gettingStarted.customize.title', "Personalized to how you work"), - description: localize('gettingStarted.customize.description', "Swap models, add agent mode tools, and create personalized instructions.\n{0}", Button(localize('signUp', "Set up AI"), 'command:workbench.action.chat.triggerSetupWithoutDialog')), + description: localize('gettingStarted.customize.description', "Swap models, add agent mode tools, and create personalized instructions.\n{0}", Button(localize('signUp', "Enable AI features"), 'command:workbench.action.chat.triggerSetupWithoutDialog')), media: { type: 'svg', altText: 'Personalize', path: 'customize-ai.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 index bcb0653de01..03363851219 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/media/ai-powered-suggestions.svg +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/media/ai-powered-suggestions.svg @@ -21,32 +21,32 @@ - - + + - - + - + @@ -56,9 +56,9 @@ - - + @@ -74,13 +74,13 @@ - + - + @@ -99,8 +99,8 @@ - + @@ -114,8 +114,8 @@ - + @@ -126,14 +126,14 @@ - + + - diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/media/customize-ai.svg b/src/vs/workbench/contrib/welcomeGettingStarted/common/media/customize-ai.svg index 3594f6f9c6b..978e2594e8c 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/media/customize-ai.svg +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/media/customize-ai.svg @@ -3,10 +3,8 @@ - - + + - + 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 583a2c3607d..146196fdfb7 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 @@ -323,8 +323,8 @@ - - + + diff --git a/src/vs/workbench/electron-browser/desktop.contribution.ts b/src/vs/workbench/electron-browser/desktop.contribution.ts index 987a02a2c60..3d94f59a785 100644 --- a/src/vs/workbench/electron-browser/desktop.contribution.ts +++ b/src/vs/workbench/electron-browser/desktop.contribution.ts @@ -314,7 +314,7 @@ import { registerWorkbenchContribution2, WorkbenchPhase } from '../common/contri 'type': 'string', 'scope': ConfigurationScope.APPLICATION, 'default': 'default', - 'markdownDescription': localize('window.border', "Controls the border color of the window. Set to `off` to disable or to a specific color in Hex, RGB, RGBA, HSL, HSLA format. This requires Windows to have the 'Show accent color on title bars and window borders' enabled and is ignored when {0} is set to {1}.", '`#window.titleBarStyle#`', '`native`'), + 'markdownDescription': localize('window.border', "Controls the border color of the window. Set to `default` to respect Windows settings, `off` to disable or to a specific color in Hex, RGB, RGBA, HSL, HSLA format. This requires Windows to have the 'Show accent color on title bars and window borders' enabled and is ignored when {0} is set to {1}.", '`#window.titleBarStyle#`', '`native`'), 'included': isWindows } } diff --git a/src/vs/workbench/services/assignment/common/assignmentService.ts b/src/vs/workbench/services/assignment/common/assignmentService.ts index 8e7aa27c14c..addfe055c6c 100644 --- a/src/vs/workbench/services/assignment/common/assignmentService.ts +++ b/src/vs/workbench/services/assignment/common/assignmentService.ts @@ -5,35 +5,40 @@ import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import type { IKeyValueStorage, IExperimentationTelemetry } from 'tas-client-umd'; +import type { IKeyValueStorage, IExperimentationTelemetry, ExperimentationService as TASClient } from 'tas-client-umd'; import { MementoObject, Memento } from '../../../common/memento.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ITelemetryService, TelemetryLevel } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryData } from '../../../../base/common/actions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { IAssignmentService } from '../../../../platform/assignment/common/assignment.js'; +import { ASSIGNMENT_REFETCH_INTERVAL, ASSIGNMENT_STORAGE_KEY, AssignmentFilterProvider, IAssignmentService, TargetPopulation } from '../../../../platform/assignment/common/assignment.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { BaseAssignmentService } from '../../../../platform/assignment/common/assignmentService.js'; import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; +import { getTelemetryLevel } from '../../../../platform/telemetry/common/telemetryUtils.js'; +import { importAMDNodeModule } from '../../../../amdX.js'; +import { timeout } from '../../../../base/common/async.js'; -export const IWorkbenchAssignmentService = createDecorator('WorkbenchAssignmentService'); +export const IWorkbenchAssignmentService = createDecorator('assignmentService'); export interface IWorkbenchAssignmentService extends IAssignmentService { getCurrentExperiments(): Promise; } class MementoKeyValueStorage implements IKeyValueStorage { - private mementoObj: MementoObject; - constructor(private memento: Memento) { + + private readonly mementoObj: MementoObject; + + constructor(private readonly memento: Memento) { this.mementoObj = memento.getMemento(StorageScope.APPLICATION, StorageTarget.MACHINE); } async getValue(key: string, defaultValue?: T | undefined): Promise { const value = await this.mementoObj[key]; + return value || defaultValue; } @@ -44,16 +49,17 @@ class MementoKeyValueStorage implements IKeyValueStorage { } class WorkbenchAssignmentServiceTelemetry implements IExperimentationTelemetry { - private _lastAssignmentContext: string | undefined; - constructor( - private telemetryService: ITelemetryService, - private productService: IProductService - ) { } + private _lastAssignmentContext: string | undefined; get assignmentContext(): string[] | undefined { return this._lastAssignmentContext?.split(';'); } + constructor( + private readonly telemetryService: ITelemetryService, + private readonly productService: IProductService + ) { } + // __GDPR__COMMON__ "abexp.assignmentcontext" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } setSharedProperty(name: string, value: string): void { if (name === this.productService.tasConfig?.assignmentContextTelemetryPropertyName) { @@ -80,31 +86,49 @@ class WorkbenchAssignmentServiceTelemetry implements IExperimentationTelemetry { } } -export class WorkbenchAssignmentService extends BaseAssignmentService { +export class WorkbenchAssignmentService implements IAssignmentService { + + declare readonly _serviceBrand: undefined; + + private readonly tasClient: Promise | undefined; + + private networkInitialized = false; + private readonly overrideInitDelay: Promise; + + private readonly telemetry: WorkbenchAssignmentServiceTelemetry; + private readonly keyValueStorage: IKeyValueStorage; + + private readonly experimentsEnabled: boolean; + constructor( - @ITelemetryService private telemetryService: ITelemetryService, + @ITelemetryService private readonly telemetryService: ITelemetryService, @IStorageService storageService: IStorageService, - @IConfigurationService configurationService: IConfigurationService, - @IProductService productService: IProductService, - @IEnvironmentService environmentService: IEnvironmentService + @IConfigurationService private readonly configurationService: IConfigurationService, + @IProductService private readonly productService: IProductService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService ) { + this.experimentsEnabled = getTelemetryLevel(configurationService) === TelemetryLevel.USAGE && + !environmentService.disableExperiments && + !environmentService.extensionTestsLocationURI && + !environmentService.enableSmokeTestDriver && + configurationService.getValue('workbench.enableExperiments') === true; - super( - telemetryService.machineId, - configurationService, - productService, - environmentService, - new WorkbenchAssignmentServiceTelemetry(telemetryService, productService), - new MementoKeyValueStorage(new Memento('experiment.service.memento', storageService)) - ); + if (productService.tasConfig && this.experimentsEnabled) { + this.tasClient = this.setupTASClient(); + } + + this.telemetry = new WorkbenchAssignmentServiceTelemetry(telemetryService, productService); + this.keyValueStorage = new MementoKeyValueStorage(new Memento('experiment.service.memento', storageService)); + + // For development purposes, configure the delay until tas local tas treatment ovverrides are available + const overrideDelaySetting = configurationService.getValue('experiments.overrideDelay'); + const overrideDelay = typeof overrideDelaySetting === 'number' ? overrideDelaySetting : 0; + this.overrideInitDelay = timeout(overrideDelay); } - protected override get experimentsEnabled(): boolean { - return this.configurationService.getValue('workbench.enableExperiments') === true; - } + async getTreatment(name: string): Promise { + const result = await this.doGetTreatment(name); - override async getTreatment(name: string): Promise { - const result = await super.getTreatment(name); type TASClientReadTreatmentData = { treatmentName: string; treatmentValue: string; @@ -117,12 +141,77 @@ export class WorkbenchAssignmentService extends BaseAssignmentService { treatmentName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The name of the treatment that was read' }; }; - this.telemetryService.publicLog2('tasClientReadTreatmentComplete', - { treatmentName: name, treatmentValue: JSON.stringify(result) }); + this.telemetryService.publicLog2('tasClientReadTreatmentComplete', { + treatmentName: name, + treatmentValue: JSON.stringify(result) + }); return result; } + private async doGetTreatment(name: string): Promise { + await this.overrideInitDelay; // For development purposes, allow overriding tas assignments to test variants locally. + + const override = this.configurationService.getValue(`experiments.override.${name}`); + if (override !== undefined) { + return override; + } + + if (!this.tasClient) { + return undefined; + } + + if (!this.experimentsEnabled) { + return undefined; + } + + let result: T | undefined; + const client = await this.tasClient; + + // The TAS client is initialized but we need to check if the initial fetch has completed yet + // If it is complete, return a cached value for the treatment + // If not, use the async call with `checkCache: true`. This will allow the module to return a cached value if it is present. + // Otherwise it will await the initial fetch to return the most up to date value. + if (this.networkInitialized) { + result = client.getTreatmentVariable('vscode', name); + } else { + result = await client.getTreatmentVariableAsync('vscode', name, true); + } + + result = client.getTreatmentVariable('vscode', name); + return result; + } + + private async setupTASClient(): Promise { + const targetPopulation = this.productService.quality === 'stable' ? + TargetPopulation.Public : (this.productService.quality === 'exploration' ? + TargetPopulation.Exploration : TargetPopulation.Insiders); + + const filterProvider = new AssignmentFilterProvider( + this.productService.version, + this.productService.nameLong, + this.telemetryService.machineId, + targetPopulation + ); + + const tasConfig = this.productService.tasConfig!; + const tasClient = new (await importAMDNodeModule('tas-client-umd', 'lib/tas-client-umd.js')).ExperimentationService({ + filterProviders: [filterProvider], + telemetry: this.telemetry, + storageKey: ASSIGNMENT_STORAGE_KEY, + keyValueStorage: this.keyValueStorage, + assignmentContextTelemetryPropertyName: tasConfig.assignmentContextTelemetryPropertyName, + telemetryEventName: tasConfig.telemetryEventName, + endpoint: tasConfig.endpoint, + refetchInterval: ASSIGNMENT_REFETCH_INTERVAL, + }); + + await tasClient.initializePromise; + tasClient.initialFetch.then(() => this.networkInitialized = true); + + return tasClient; + } + async getCurrentExperiments(): Promise { if (!this.tasClient) { return undefined; @@ -134,11 +223,12 @@ export class WorkbenchAssignmentService extends BaseAssignmentService { await this.tasClient; - return (this.telemetry as WorkbenchAssignmentServiceTelemetry)?.assignmentContext; + return this.telemetry.assignmentContext; } } registerSingleton(IWorkbenchAssignmentService, WorkbenchAssignmentService, InstantiationType.Delayed); + const registry = Registry.as(ConfigurationExtensions.Configuration); registry.registerConfiguration({ ...workbenchConfigurationNodeBase, diff --git a/src/vs/workbench/services/coreExperimentation/common/coreExperimentationService.ts b/src/vs/workbench/services/coreExperimentation/common/coreExperimentationService.ts index 5db22d7ae7a..5ab4a26c6b3 100644 --- a/src/vs/workbench/services/coreExperimentation/common/coreExperimentationService.ts +++ b/src/vs/workbench/services/coreExperimentation/common/coreExperimentationService.ts @@ -10,6 +10,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { firstSessionDateStorageKey, ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; export const ICoreExperimentationService = createDecorator('coreExperimentationService'); export const startupExpContext = new RawContextKey('coreExperimentation.startupExpGroup', ''); @@ -83,9 +84,19 @@ export class CoreExperimentationService extends Disposable implements ICoreExper @IStorageService private readonly storageService: IStorageService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IProductService private readonly productService: IProductService, - @IContextKeyService private readonly contextKeyService: IContextKeyService + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { super(); + + if ( + environmentService.disableExperiments || + environmentService.enableSmokeTestDriver || + environmentService.extensionTestsLocationURI + ) { + return; //not applicable in this environment + } + this.initializeExperiments(); } @@ -107,7 +118,15 @@ export class CoreExperimentationService extends Disposable implements ICoreExper const storageKey = `coreExperimentation.${experimentConfig.experimentName}`; const storedExperiment = this.storageService.get(storageKey, StorageScope.APPLICATION); if (storedExperiment) { - return; + try { + const parsedExperiment: IExperiment = JSON.parse(storedExperiment); + this.experiments.set(experimentConfig.experimentName, parsedExperiment); + startupExpContext.bindTo(this.contextKeyService).set(parsedExperiment.experimentGroup); + return; + } catch (e) { + this.storageService.remove(storageKey, StorageScope.APPLICATION); + return; + } } const experiment = this.createStartupExperiment(experimentConfig.experimentName, experimentConfig); @@ -133,6 +152,22 @@ export class CoreExperimentationService extends Disposable implements ICoreExper } private createStartupExperiment(experimentName: string, experimentConfig: ExperimentConfiguration): IExperiment | undefined { + const startupExpGroupOverride = this.environmentService.startupExperimentGroup; + if (startupExpGroupOverride) { + // If the user has an override, we use that directly + const group = experimentConfig.groups.find(g => g.name === startupExpGroupOverride); + if (group) { + return { + cohort: 1, + subCohort: 1, + experimentGroup: group.name, + iteration: group.iteration, + isInExperiment: true + }; + } + return undefined; + } + const cohort = Math.random(); if (cohort >= experimentConfig.targetPercentage / 100) { @@ -196,4 +231,4 @@ export class CoreExperimentationService extends Disposable implements ICoreExper } } -registerSingleton(ICoreExperimentationService, CoreExperimentationService, InstantiationType.Delayed); +registerSingleton(ICoreExperimentationService, CoreExperimentationService, InstantiationType.Eager); diff --git a/src/vs/workbench/services/coreExperimentation/test/browser/coreExperimentationService.test.ts b/src/vs/workbench/services/coreExperimentation/test/browser/coreExperimentationService.test.ts index c35ebae816d..77e44c5668d 100644 --- a/src/vs/workbench/services/coreExperimentation/test/browser/coreExperimentationService.test.ts +++ b/src/vs/workbench/services/coreExperimentation/test/browser/coreExperimentationService.test.ts @@ -11,6 +11,7 @@ import { firstSessionDateStorageKey, ITelemetryService, ITelemetryData, Telemetr import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; interface ITelemetryEvent { eventName: string; @@ -72,15 +73,17 @@ suite('CoreExperimentationService', () => { let telemetryService: MockTelemetryService; let productService: MockProductService; let contextKeyService: MockContextKeyService; + let environmentService: IWorkbenchEnvironmentService; setup(() => { storageService = disposables.add(new TestStorageService()); telemetryService = new MockTelemetryService(); productService = new MockProductService(); contextKeyService = new MockContextKeyService(); + environmentService = {} as IWorkbenchEnvironmentService; }); - test('should not initialize experiment if user has already seen startup experience (found in storage)', () => { + test('should return experiment from storage if it exists', () => { storageService.store(firstSessionDateStorageKey, new Date().toUTCString(), StorageScope.APPLICATION, StorageTarget.MACHINE); // Set that user has already seen the experiment @@ -97,11 +100,12 @@ suite('CoreExperimentationService', () => { storageService, telemetryService, productService, - contextKeyService + contextKeyService, + environmentService )); // Should not return experiment again - assert.strictEqual(service.getExperiment(), undefined); + assert.deepStrictEqual(service.getExperiment(), existingExperiment); // No telemetry should be sent for new experiment assert.strictEqual(telemetryService.events.length, 0); @@ -120,7 +124,8 @@ suite('CoreExperimentationService', () => { storageService, telemetryService, productService, - contextKeyService + contextKeyService, + environmentService )); // Should create experiment @@ -154,7 +159,8 @@ suite('CoreExperimentationService', () => { storageService, telemetryService, productService, - contextKeyService + contextKeyService, + environmentService )); const experiment = service.getExperiment(); @@ -191,7 +197,8 @@ suite('CoreExperimentationService', () => { storageService, telemetryService, productService, - contextKeyService + contextKeyService, + environmentService )); // Should not create experiment @@ -229,7 +236,8 @@ suite('CoreExperimentationService', () => { storageService, telemetryService, productService, - contextKeyService + contextKeyService, + environmentService )); const experiment = service.getExperiment(); @@ -254,7 +262,8 @@ suite('CoreExperimentationService', () => { storageService, telemetryService, productService, - contextKeyService + contextKeyService, + environmentService )); const experiment = service.getExperiment(); @@ -285,7 +294,8 @@ suite('CoreExperimentationService', () => { storageService, telemetryService, productService, - contextKeyService + contextKeyService, + environmentService )); const experiment = service.getExperiment(); @@ -309,7 +319,8 @@ suite('CoreExperimentationService', () => { storageService, telemetryService, productService, - contextKeyService + contextKeyService, + environmentService )); const experiment = service.getExperiment(); diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index 7223b54c3ad..a50ad52689b 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -236,6 +236,9 @@ export class BrowserWorkbenchEnvironmentService implements IBrowserWorkbenchEnvi @memoize get disableTelemetry(): boolean { return false; } + @memoize + get disableExperiments(): boolean { return false; } + @memoize get verbose(): boolean { return this.payload?.get('verbose') === 'true'; } diff --git a/src/vs/workbench/services/environment/common/environmentService.ts b/src/vs/workbench/services/environment/common/environmentService.ts index 5312892fe6f..69961cce91c 100644 --- a/src/vs/workbench/services/environment/common/environmentService.ts +++ b/src/vs/workbench/services/environment/common/environmentService.ts @@ -36,6 +36,7 @@ export interface IWorkbenchEnvironmentService extends IEnvironmentService { readonly skipWelcome: boolean; readonly disableWorkspaceTrust: boolean; readonly webviewExternalEndpoint: string; + readonly startupExperimentGroup?: string; // --- Development readonly debugRenderer: boolean; diff --git a/src/vs/workbench/services/environment/electron-browser/environmentService.ts b/src/vs/workbench/services/environment/electron-browser/environmentService.ts index 6cfa51701be..87b2df16ead 100644 --- a/src/vs/workbench/services/environment/electron-browser/environmentService.ts +++ b/src/vs/workbench/services/environment/electron-browser/environmentService.ts @@ -147,6 +147,15 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment @memoize get filesToWait(): IPathsToWaitFor | undefined { return this.configuration.filesToWait; } + @memoize + get startupExperimentGroup(): string | undefined { + const group = this.args['startup-experiment-group']; + if (typeof group === 'string') { + return group; + } + return undefined; + } + constructor( private readonly configuration: INativeWindowConfiguration, productService: IProductService diff --git a/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts b/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts index b5123044a0a..c1c1e01c961 100644 --- a/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts +++ b/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts @@ -26,6 +26,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { isCancellationError } from '../../../../base/common/errors.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { equalsIgnoreCase } from '../../../../base/common/strings.js'; const FIVE_MINUTES = 5 * 60 * 1000; const THIRTY_SECONDS = 30 * 1000; @@ -181,7 +182,7 @@ class ExtensionUrlHandler implements IExtensionUrlHandler, IURLHandler { } const trusted = options?.trusted - || this.productService.trustedExtensionProtocolHandlers?.includes(extensionId) + || this.productService.trustedExtensionProtocolHandlers?.some(value => equalsIgnoreCase(value, extensionId)) || this.didUserTrustExtension(ExtensionIdentifier.toKey(extensionId)); if (!trusted) { diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index 859b9769123..451dbcd28f3 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -398,11 +398,21 @@ export const schema: IJSONSchema = { body: 'onLanguageModelTool:${1:toolId}', description: nls.localize('vscode.extension.activationEvents.onLanguageModelTool', 'An activation event emitted when the specified language model tool is invoked.'), }, + { + label: 'onTerminal', + body: 'onTerminal:{1:shellType}', + description: nls.localize('vscode.extension.activationEvents.onTerminal', 'An activation event emitted when a terminal of the given shell type is opened.'), + }, { label: 'onTerminalCompletionsRequested', body: 'onTerminalCompletionsRequested', description: nls.localize('vscode.extension.activationEvents.onTerminalCompletionsRequested', 'An activation event emitted when terminal completions are requested.'), }, + { + label: 'onTerminalShellIntegration', + body: 'onTerminalShellIntegration:${1:shellType}', + description: nls.localize('vscode.extension.activationEvents.onTerminalShellIntegration', 'An activation event emitted when terminal shell integration is activated for the given shell type.'), + }, { label: 'onMcpCollection', description: nls.localize('vscode.extension.activationEvents.onMcpCollection', 'An activation event emitted whenver a tool from the MCP server is requested.'), diff --git a/src/vs/workbench/services/mcp/browser/mcpWorkbenchManagementService.ts b/src/vs/workbench/services/mcp/browser/mcpWorkbenchManagementService.ts new file mode 100644 index 00000000000..1b7256ce155 --- /dev/null +++ b/src/vs/workbench/services/mcp/browser/mcpWorkbenchManagementService.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 { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; +import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; +import { IRemoteUserDataProfilesService } from '../../userDataProfile/common/remoteUserDataProfiles.js'; +import { WorkbenchMcpManagementService as BaseWorkbenchMcpManagementService, IWorkbenchMcpManagementService } from '../common/mcpWorkbenchManagementService.js'; +import { McpManagementService } from '../../../../platform/mcp/common/mcpManagementService.js'; + +export class WorkbenchMcpManagementService extends BaseWorkbenchMcpManagementService { + + constructor( + @IUserDataProfileService userDataProfileService: IUserDataProfileService, + @IUriIdentityService uriIdentityService: IUriIdentityService, + @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IRemoteAgentService remoteAgentService: IRemoteAgentService, + @IUserDataProfilesService userDataProfilesService: IUserDataProfilesService, + @IRemoteUserDataProfilesService remoteUserDataProfilesService: IRemoteUserDataProfilesService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + const mMcpManagementService = instantiationService.createInstance(McpManagementService); + super(mMcpManagementService, userDataProfileService, uriIdentityService, workspaceContextService, remoteAgentService, userDataProfilesService, remoteUserDataProfilesService, instantiationService); + this._register(mMcpManagementService); + } +} + +registerSingleton(IWorkbenchMcpManagementService, WorkbenchMcpManagementService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/mcp/common/mcpWorkbenchManagementService.ts b/src/vs/workbench/services/mcp/common/mcpWorkbenchManagementService.ts index 4169ec6db83..9bf2786d826 100644 --- a/src/vs/workbench/services/mcp/common/mcpWorkbenchManagementService.ts +++ b/src/vs/workbench/services/mcp/common/mcpWorkbenchManagementService.ts @@ -5,9 +5,8 @@ import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { ILocalMcpServer, IMcpManagementService, IGalleryMcpServer, InstallOptions, InstallMcpServerEvent, UninstallMcpServerEvent, DidUninstallMcpServerEvent, InstallMcpServerResult, IInstallableMcpServer, IMcpGalleryService, UninstallOptions } from '../../../../platform/mcp/common/mcpManagement.js'; -import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, refineServiceDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IMcpResourceScannerService, McpResourceTarget } from '../../../../platform/mcp/common/mcpResourceScannerService.js'; import { isWorkspaceFolder, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent } from '../../../../platform/workspace/common/workspace.js'; @@ -43,7 +42,9 @@ export interface IWorkbenchMcpServerInstallResult extends InstallMcpServerResult readonly local?: IWorkbenchLocalMcpServer; } +export const IWorkbenchMcpManagementService = refineServiceDecorator(IMcpManagementService); export interface IWorkbenchMcpManagementService extends IMcpManagementService { + readonly _serviceBrand: undefined; readonly onDidInstallMcpServers: Event; @@ -52,14 +53,13 @@ export interface IWorkbenchMcpManagementService extends IMcpManagementService { readonly onDidUpdateMcpServersInCurrentProfile: Event; readonly onUninstallMcpServerInCurrentProfile: Event; readonly onDidUninstallMcpServerInCurrentProfile: Event; + readonly onDidChangeProfile: Event; getInstalled(): Promise; - install(server: IInstallableMcpServer, options?: IWorkbencMcpServerInstallOptions): Promise; + install(server: IInstallableMcpServer | URI, options?: IWorkbencMcpServerInstallOptions): Promise; } -export const IWorkbenchMcpManagementService = createDecorator('workbenchMcpManagementService'); - -class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpManagementService { +export class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpManagementService { readonly _serviceBrand: undefined; @@ -93,14 +93,17 @@ class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpM private readonly _onDidUninstallMcpServerInCurrentProfile = this._register(new Emitter()); readonly onDidUninstallMcpServerInCurrentProfile = this._onDidUninstallMcpServerInCurrentProfile.event; + private readonly _onDidChangeProfile = this._register(new Emitter()); + readonly onDidChangeProfile = this._onDidChangeProfile.event; + private readonly workspaceMcpManagementService: IMcpManagementService; private readonly remoteMcpManagementService: IMcpManagementService | undefined; constructor( + private readonly mcpManagementService: IMcpManagementService, @IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IMcpManagementService private readonly mcpManagementService: IMcpManagementService, @IRemoteAgentService remoteAgentService: IRemoteAgentService, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, @IRemoteUserDataProfilesService private readonly remoteUserDataProfilesService: IRemoteUserDataProfilesService, @@ -121,8 +124,21 @@ class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpM } })); - this._register(this.mcpManagementService.onDidInstallMcpServers(e => this.handleInstallMcpServerResultsFromEvent(e, this._onDidInstallMcpServers, this._onDidInstallMcpServersInCurrentProfile))); - this._register(this.mcpManagementService.onDidUpdateMcpServers(e => this.handleInstallMcpServerResultsFromEvent(e, this._onDidUpdateMcpServers, this._onDidUpdateMcpServersInCurrentProfile))); + this._register(this.mcpManagementService.onDidInstallMcpServers(e => { + const { mcpServerInstallResult, mcpServerInstallResultInCurrentProfile } = this.createInstallMcpServerResultsFromEvent(e, LocalMcpServerScope.User); + this._onDidInstallMcpServers.fire(mcpServerInstallResult); + if (mcpServerInstallResultInCurrentProfile.length) { + this._onDidInstallMcpServersInCurrentProfile.fire(mcpServerInstallResultInCurrentProfile); + } + })); + + this._register(this.mcpManagementService.onDidUpdateMcpServers(e => { + const { mcpServerInstallResult, mcpServerInstallResultInCurrentProfile } = this.createInstallMcpServerResultsFromEvent(e, LocalMcpServerScope.User); + this._onDidUpdateMcpServers.fire(mcpServerInstallResult); + if (mcpServerInstallResultInCurrentProfile.length) { + this._onDidUpdateMcpServersInCurrentProfile.fire(mcpServerInstallResultInCurrentProfile); + } + })); this._register(this.mcpManagementService.onUninstallMcpServer(e => { this._onUninstallMcpServer.fire(e); @@ -144,15 +160,8 @@ class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpM })); this._register(this.workspaceMcpManagementService.onDidInstallMcpServers(async e => { - const mcpServerInstallResult: IWorkbenchMcpServerInstallResult[] = []; - for (const result of e) { - const workbenchResult = { - ...result, - local: result.local ? this.toWorkspaceMcpServer(result.local, LocalMcpServerScope.Workspace) : undefined - }; - mcpServerInstallResult.push(workbenchResult); - } - this._onDidInstallMcpServersInCurrentProfile.fire(mcpServerInstallResult); + const { mcpServerInstallResult } = this.createInstallMcpServerResultsFromEvent(e, LocalMcpServerScope.Workspace); + this._onDidInstallMcpServers.fire(mcpServerInstallResult); this._onDidInstallMcpServersInCurrentProfile.fire(mcpServerInstallResult); })); @@ -166,6 +175,12 @@ class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpM this._onDidUninstallMcpServerInCurrentProfile.fire(e); })); + this._register(this.workspaceMcpManagementService.onDidUpdateMcpServers(e => { + const { mcpServerInstallResult } = this.createInstallMcpServerResultsFromEvent(e, LocalMcpServerScope.Workspace); + this._onDidUpdateMcpServers.fire(mcpServerInstallResult); + this._onDidUpdateMcpServersInCurrentProfile.fire(mcpServerInstallResult); + })); + if (this.remoteMcpManagementService) { this._register(this.remoteMcpManagementService.onInstallMcpServer(async e => { this._onInstallMcpServer.fire(e); @@ -194,15 +209,21 @@ class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpM } })); } + + this._register(userDataProfileService.onDidChangeCurrentProfile(e => { + if (!this.uriIdentityService.extUri.isEqual(e.previous.mcpResource, e.profile.mcpResource)) { + this._onDidChangeProfile.fire(); + } + })); } - private handleInstallMcpServerResultsFromEvent(e: readonly InstallMcpServerResult[], emitter: Emitter, currentProfileEmitter: Emitter): void { + private createInstallMcpServerResultsFromEvent(e: readonly InstallMcpServerResult[], scope: LocalMcpServerScope): { mcpServerInstallResult: IWorkbenchMcpServerInstallResult[]; mcpServerInstallResultInCurrentProfile: IWorkbenchMcpServerInstallResult[] } { const mcpServerInstallResult: IWorkbenchMcpServerInstallResult[] = []; const mcpServerInstallResultInCurrentProfile: IWorkbenchMcpServerInstallResult[] = []; for (const result of e) { const workbenchResult = { ...result, - local: result.local ? this.toWorkspaceMcpServer(result.local, LocalMcpServerScope.User) : undefined + local: result.local ? this.toWorkspaceMcpServer(result.local, scope) : undefined }; mcpServerInstallResult.push(workbenchResult); if (this.uriIdentityService.extUri.isEqual(result.mcpResource, this.userDataProfileService.currentProfile.mcpResource)) { @@ -210,10 +231,7 @@ class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpM } } - emitter.fire(mcpServerInstallResult); - if (mcpServerInstallResultInCurrentProfile.length) { - currentProfileEmitter.fire(mcpServerInstallResultInCurrentProfile); - } + return { mcpServerInstallResult, mcpServerInstallResultInCurrentProfile }; } private async handleRemoteInstallMcpServerResultsFromEvent(e: readonly InstallMcpServerResult[], emitter: Emitter, currentProfileEmitter: Emitter): Promise { @@ -298,6 +316,21 @@ class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpM return this.mcpManagementService.installFromGallery(server, options); } + updateMetadata(local: IWorkbenchLocalMcpServer, server: IGalleryMcpServer, profileLocation: URI): Promise { + if (local.scope === LocalMcpServerScope.Workspace) { + return this.workspaceMcpManagementService.updateMetadata(local, server, profileLocation); + } + + if (local.scope === LocalMcpServerScope.RemoteUser) { + if (!this.remoteMcpManagementService) { + throw new Error(`Illegal target: ${local.scope}`); + } + return this.remoteMcpManagementService.updateMetadata(local, server, profileLocation); + } + + return this.mcpManagementService.updateMetadata(local, server, profileLocation); + } + async uninstall(server: IWorkbenchLocalMcpServer): Promise { if (server.scope === LocalMcpServerScope.Workspace) { return this.workspaceMcpManagementService.uninstall(server); @@ -322,9 +355,9 @@ class WorkbenchMcpManagementService extends Disposable implements IWorkbenchMcpM if (profile) { profile = await this.remoteUserDataProfilesService.getRemoteProfile(profile); } else { - profile = (await this.remoteUserDataProfilesService.getRemoteProfiles()).find(p => this.uriIdentityService.extUri.isEqual(p.extensionsResource, mcpResource)); + profile = (await this.remoteUserDataProfilesService.getRemoteProfiles()).find(p => this.uriIdentityService.extUri.isEqual(p.mcpResource, mcpResource)); } - return profile?.extensionsResource; + return profile?.mcpResource; } } @@ -346,10 +379,17 @@ class WorkspaceMcpResourceManagementService extends AbstractMcpResourceManagemen throw new Error('Not supported'); } + override updateMetadata(): Promise { + throw new Error('Not supported'); + } + + protected override installFromUri(): Promise { + throw new Error('Not supported'); + } + protected override async getLocalServerInfo(): Promise { return undefined; } - } class WorkspaceMcpManagementService extends Disposable implements IMcpManagementService { @@ -522,7 +562,11 @@ class WorkspaceMcpManagementService extends Disposable implements IMcpManagement return mcpManagementServiceItem.service.uninstall(server, options); } - async installFromGallery(): Promise { + installFromGallery(): Promise { + throw new Error('Not supported'); + } + + updateMetadata(): Promise { throw new Error('Not supported'); } @@ -532,5 +576,3 @@ class WorkspaceMcpManagementService extends Disposable implements IMcpManagement super.dispose(); } } - -registerSingleton(IWorkbenchMcpManagementService, WorkbenchMcpManagementService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/mcp/electron-browser/mcpWorkbenchManagementService.ts b/src/vs/workbench/services/mcp/electron-browser/mcpWorkbenchManagementService.ts new file mode 100644 index 00000000000..6989b92cfd7 --- /dev/null +++ b/src/vs/workbench/services/mcp/electron-browser/mcpWorkbenchManagementService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; +import { McpManagementChannelClient } from '../../../../platform/mcp/common/mcpManagementIpc.js'; +import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; +import { IRemoteUserDataProfilesService } from '../../userDataProfile/common/remoteUserDataProfiles.js'; +import { WorkbenchMcpManagementService as BaseWorkbenchMcpManagementService, IWorkbenchMcpManagementService } from '../common/mcpWorkbenchManagementService.js'; +import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js'; + +export class WorkbenchMcpManagementService extends BaseWorkbenchMcpManagementService { + + constructor( + @IUserDataProfileService userDataProfileService: IUserDataProfileService, + @IUriIdentityService uriIdentityService: IUriIdentityService, + @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IRemoteAgentService remoteAgentService: IRemoteAgentService, + @IUserDataProfilesService userDataProfilesService: IUserDataProfilesService, + @IRemoteUserDataProfilesService remoteUserDataProfilesService: IRemoteUserDataProfilesService, + @IInstantiationService instantiationService: IInstantiationService, + @ISharedProcessService sharedProcessService: ISharedProcessService, + ) { + const mcpManagementService = new McpManagementChannelClient(sharedProcessService.getChannel('mcpManagement')); + super(mcpManagementService, userDataProfileService, uriIdentityService, workspaceContextService, remoteAgentService, userDataProfilesService, remoteUserDataProfilesService, instantiationService); + this._register(mcpManagementService); + } +} + +registerSingleton(IWorkbenchMcpManagementService, WorkbenchMcpManagementService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts b/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts index ab471ff40a2..fc285392044 100644 --- a/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts +++ b/src/vs/workbench/services/suggest/browser/simpleSuggestWidget.ts @@ -719,76 +719,85 @@ export class SimpleSuggestWidget, TI this._status.element.style.height = `${info.itemHeight}px`; } - // if (this._state === State.Empty || this._state === State.Loading) { - // // showing a message only - // height = info.itemHeight + info.borderHeight; - // width = info.defaultSize.width / 2; - // this.element.enableSashes(false, false, false, false); - // this.element.minSize = this.element.maxSize = new dom.Dimension(width, height); - // this._contentWidget.setPreference(ContentWidgetPositionPreference.BELOW); - - // } else { - // showing items - - // width math - const maxWidth = bodyBox.width - info.borderHeight - 2 * info.horizontalPadding; - if (width > maxWidth) { - width = maxWidth; - } - const preferredWidth = this._completionModel ? this._completionModel.stats.pLabelLen * info.typicalHalfwidthCharacterWidth : width; - - // height math - const fullHeight = info.statusBarHeight + this._list.contentHeight + this._messageElement.clientHeight + info.borderHeight; - const minHeight = info.itemHeight + info.statusBarHeight; - // const editorBox = dom.getDomNodePagePosition(this.editor.getDomNode()); - // const cursorBox = this.editor.getScrolledVisiblePosition(this.editor.getPosition()); - const editorBox = dom.getDomNodePagePosition(this._container); - const cursorBox = this._cursorPosition; //this.editor.getScrolledVisiblePosition(this.editor.getPosition()); - const cursorBottom = editorBox.top + cursorBox.top + cursorBox.height; - const maxHeightBelow = Math.min(bodyBox.height - cursorBottom - info.verticalPadding, fullHeight); - const availableSpaceAbove = editorBox.top + cursorBox.top - info.verticalPadding; - const maxHeightAbove = Math.min(availableSpaceAbove, fullHeight); - let maxHeight = Math.min(Math.max(maxHeightAbove, maxHeightBelow) + info.borderHeight, fullHeight); - - if (height === this._cappedHeight?.capped) { - // Restore the old (wanted) height when the current - // height is capped to fit - height = this._cappedHeight.wanted; - } - - if (height < minHeight) { - height = minHeight; - } - if (height > maxHeight) { - height = maxHeight; - } - - const forceRenderingAboveRequiredSpace = 150; - if (height > maxHeightBelow || (this._forceRenderingAbove && availableSpaceAbove > forceRenderingAboveRequiredSpace)) { - this._preference = WidgetPositionPreference.Above; - this.element.enableSashes(true, true, false, false); - maxHeight = maxHeightAbove; - } else { + if (this._state === State.Empty || this._state === State.Loading) { + // showing a message only + height = info.itemHeight + info.borderHeight; + width = info.defaultSize.width / 2; + this.element.enableSashes(false, false, false, false); + this.element.minSize = this.element.maxSize = new dom.Dimension(width, height); this._preference = WidgetPositionPreference.Below; - this.element.enableSashes(false, true, true, false); - maxHeight = maxHeightBelow; - } - this.element.preferredSize = new dom.Dimension(preferredWidth, info.defaultSize.height); - this.element.maxSize = new dom.Dimension(maxWidth, maxHeight); - this.element.minSize = new dom.Dimension(220, minHeight); - // Know when the height was capped to fit and remember - // the wanted height for later. This is required when going - // left to widen suggestions. - this._cappedHeight = height === fullHeight - ? { wanted: this._cappedHeight?.wanted ?? size.height, capped: height } - : undefined; - // } - this.element.domNode.style.left = `${this._cursorPosition.left}px`; - if (this._preference === WidgetPositionPreference.Above) { - this.element.domNode.style.top = `${this._cursorPosition.top - height - info.borderHeight}px`; } else { - this.element.domNode.style.top = `${this._cursorPosition.top + this._cursorPosition.height}px`; + // showing items + + // width math + const maxWidth = bodyBox.width - info.borderHeight - 2 * info.horizontalPadding; + if (width > maxWidth) { + width = maxWidth; + } + const preferredWidth = this._completionModel ? this._completionModel.stats.pLabelLen * info.typicalHalfwidthCharacterWidth : width; + + // height math + const fullHeight = info.statusBarHeight + this._list.contentHeight + this._messageElement.clientHeight + info.borderHeight; + const minHeight = info.itemHeight + info.statusBarHeight; + // const editorBox = dom.getDomNodePagePosition(this.editor.getDomNode()); + // const cursorBox = this.editor.getScrolledVisiblePosition(this.editor.getPosition()); + const editorBox = dom.getDomNodePagePosition(this._container); + const cursorBox = this._cursorPosition; //this.editor.getScrolledVisiblePosition(this.editor.getPosition()); + const cursorBottom = editorBox.top + cursorBox.top + cursorBox.height; + const maxHeightBelow = Math.min(bodyBox.height - cursorBottom - info.verticalPadding, fullHeight); + const availableSpaceAbove = editorBox.top + cursorBox.top - info.verticalPadding; + const maxHeightAbove = Math.min(availableSpaceAbove, fullHeight); + let maxHeight = Math.min(Math.max(maxHeightAbove, maxHeightBelow) + info.borderHeight, fullHeight); + + if (height === this._cappedHeight?.capped) { + // Restore the old (wanted) height when the current + // height is capped to fit + height = this._cappedHeight.wanted; + } + + if (height < minHeight) { + height = minHeight; + } + if (height > maxHeight) { + height = maxHeight; + } + + const forceRenderingAboveRequiredSpace = 150; + if (height > maxHeightBelow || (this._forceRenderingAbove && availableSpaceAbove > forceRenderingAboveRequiredSpace)) { + this._preference = WidgetPositionPreference.Above; + this.element.enableSashes(true, true, false, false); + maxHeight = maxHeightAbove; + } else { + this._preference = WidgetPositionPreference.Below; + this.element.enableSashes(false, true, true, false); + maxHeight = maxHeightBelow; + } + this.element.preferredSize = new dom.Dimension(preferredWidth, info.defaultSize.height); + this.element.maxSize = new dom.Dimension(maxWidth, maxHeight); + this.element.minSize = new dom.Dimension(220, minHeight); + + // Know when the height was capped to fit and remember + // the wanted height for later. This is required when going + // left to widen suggestions. + this._cappedHeight = height === fullHeight + ? { wanted: this._cappedHeight?.wanted ?? size.height, capped: height } + : undefined; + // } + this.element.domNode.style.left = `${this._cursorPosition.left}px`; + + // Move anchor if widget will overflow the edge of the container + const containerWidth = this._container.clientWidth; + let anchorLeft = this._cursorPosition.left; + if (width > containerWidth) { + anchorLeft = Math.max(0, this._cursorPosition.left - width + containerWidth); + this.element.domNode.style.left = `${anchorLeft}px`; + } + if (this._preference === WidgetPositionPreference.Above) { + this.element.domNode.style.top = `${this._cursorPosition.top - height - info.borderHeight}px`; + } else { + this.element.domNode.style.top = `${this._cursorPosition.top + this._cursorPosition.height}px`; + } } this._resize(width, height); } diff --git a/src/vs/workbench/services/themes/common/workbenchThemeService.ts b/src/vs/workbench/services/themes/common/workbenchThemeService.ts index 3806f1a2d6c..6ba25af1b33 100644 --- a/src/vs/workbench/services/themes/common/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/common/workbenchThemeService.ts @@ -52,57 +52,297 @@ export enum ThemeSettingDefaults { } export const COLOR_THEME_DARK_INITIAL_COLORS = { - 'activityBar.activeBorder': '#0078d4', + 'actionBar.toggledBackground': '#383a49', + 'activityBar.activeBorder': '#0078D4', 'activityBar.background': '#181818', - 'activityBar.border': '#2b2b2b', - 'activityBar.foreground': '#d7d7d7', + 'activityBar.border': '#2B2B2B', + 'activityBar.foreground': '#D7D7D7', 'activityBar.inactiveForeground': '#868686', - 'editorGroup.border': '#ffffff17', + 'activityBarBadge.background': '#0078D4', + 'activityBarBadge.foreground': '#FFFFFF', + 'badge.background': '#616161', + 'badge.foreground': '#F8F8F8', + 'button.background': '#0078D4', + 'button.border': '#FFFFFF12', + 'button.foreground': '#FFFFFF', + 'button.hoverBackground': '#026EC1', + 'button.secondaryBackground': '#313131', + 'button.secondaryForeground': '#CCCCCC', + 'button.secondaryHoverBackground': '#3C3C3C', + 'chat.slashCommandBackground': '#26477866', + 'chat.slashCommandForeground': '#85B6FF', + 'chat.editedFileForeground': '#E2C08D', + 'checkbox.background': '#313131', + 'checkbox.border': '#3C3C3C', + 'debugToolBar.background': '#181818', + 'descriptionForeground': '#9D9D9D', + 'dropdown.background': '#313131', + 'dropdown.border': '#3C3C3C', + 'dropdown.foreground': '#CCCCCC', + 'dropdown.listBackground': '#1F1F1F', + 'editor.background': '#1F1F1F', + 'editor.findMatchBackground': '#9E6A03', + 'editor.foreground': '#CCCCCC', + 'editor.inactiveSelectionBackground': '#3A3D41', + 'editor.selectionHighlightBackground': '#ADD6FF26', + 'editorGroup.border': '#FFFFFF17', 'editorGroupHeader.tabsBackground': '#181818', - 'editorGroupHeader.tabsBorder': '#2b2b2b', + 'editorGroupHeader.tabsBorder': '#2B2B2B', + 'editorGutter.addedBackground': '#2EA043', + 'editorGutter.deletedBackground': '#F85149', + 'editorGutter.modifiedBackground': '#0078D4', + 'editorIndentGuide.activeBackground1': '#707070', + 'editorIndentGuide.background1': '#404040', + 'editorLineNumber.activeForeground': '#CCCCCC', + 'editorLineNumber.foreground': '#6E7681', + 'editorOverviewRuler.border': '#010409', + 'editorWidget.background': '#202020', + 'errorForeground': '#F85149', + 'focusBorder': '#0078D4', + 'foreground': '#CCCCCC', + 'icon.foreground': '#CCCCCC', + 'input.background': '#313131', + 'input.border': '#3C3C3C', + 'input.foreground': '#CCCCCC', + 'input.placeholderForeground': '#989898', + 'inputOption.activeBackground': '#2489DB82', + 'inputOption.activeBorder': '#2488DB', + 'keybindingLabel.foreground': '#CCCCCC', + 'list.activeSelectionIconForeground': '#FFF', + 'list.dropBackground': '#383B3D', + 'menu.background': '#1F1F1F', + 'menu.border': '#454545', + 'menu.foreground': '#CCCCCC', + 'menu.selectionBackground': '#0078d4', + 'menu.separatorBackground': '#454545', + 'notificationCenterHeader.background': '#1F1F1F', + 'notificationCenterHeader.foreground': '#CCCCCC', + 'notifications.background': '#1F1F1F', + 'notifications.border': '#2B2B2B', + 'notifications.foreground': '#CCCCCC', + 'panel.background': '#181818', + 'panel.border': '#2B2B2B', + 'panelInput.border': '#2B2B2B', + 'panelTitle.activeBorder': '#0078D4', + 'panelTitle.activeForeground': '#CCCCCC', + 'panelTitle.inactiveForeground': '#9D9D9D', + 'peekViewEditor.background': '#1F1F1F', + 'peekViewEditor.matchHighlightBackground': '#BB800966', + 'peekViewResult.background': '#1F1F1F', + 'peekViewResult.matchHighlightBackground': '#BB800966', + 'pickerGroup.border': '#3C3C3C', + 'ports.iconRunningProcessForeground': '#369432', + 'progressBar.background': '#0078D4', + 'quickInput.background': '#222222', + 'quickInput.foreground': '#CCCCCC', + 'settings.dropdownBackground': '#313131', + 'settings.dropdownBorder': '#3C3C3C', + 'settings.headerForeground': '#FFFFFF', + 'settings.modifiedItemIndicator': '#BB800966', + 'sideBar.background': '#181818', + 'sideBar.border': '#2B2B2B', + 'sideBar.foreground': '#CCCCCC', + 'sideBarSectionHeader.background': '#181818', + 'sideBarSectionHeader.border': '#2B2B2B', + 'sideBarSectionHeader.foreground': '#CCCCCC', + 'sideBarTitle.foreground': '#CCCCCC', 'statusBar.background': '#181818', - 'statusBar.border': '#2b2b2b', - 'statusBar.foreground': '#cccccc', - 'statusBar.noFolderBackground': '#1f1f1f', - 'tab.activeBackground': '#1f1f1f', - 'tab.activeBorder': '#1f1f1f', - 'tab.activeBorderTop': '#0078d4', - 'tab.activeForeground': '#ffffff', - 'tab.border': '#2b2b2b', + 'statusBar.border': '#2B2B2B', + 'statusBar.debuggingBackground': '#0078D4', + 'statusBar.debuggingForeground': '#FFFFFF', + 'statusBar.focusBorder': '#0078D4', + 'statusBar.foreground': '#CCCCCC', + 'statusBar.noFolderBackground': '#1F1F1F', + 'statusBarItem.focusBorder': '#0078D4', + 'statusBarItem.prominentBackground': '#6E768166', + 'statusBarItem.remoteBackground': '#0078D4', + 'statusBarItem.remoteForeground': '#FFFFFF', + 'tab.activeBackground': '#1F1F1F', + 'tab.activeBorder': '#1F1F1F', + 'tab.activeBorderTop': '#0078D4', + 'tab.activeForeground': '#FFFFFF', + 'tab.border': '#2B2B2B', + 'tab.hoverBackground': '#1F1F1F', + 'tab.inactiveBackground': '#181818', + 'tab.inactiveForeground': '#9D9D9D', + 'tab.lastPinnedBorder': '#ccc3', + 'tab.selectedBackground': '#222222', + 'tab.selectedBorderTop': '#6caddf', + 'tab.selectedForeground': '#ffffffa0', + 'tab.unfocusedActiveBorder': '#1F1F1F', + 'tab.unfocusedActiveBorderTop': '#2B2B2B', + 'tab.unfocusedHoverBackground': '#1F1F1F', + 'terminal.foreground': '#CCCCCC', + 'terminal.inactiveSelectionBackground': '#3A3D41', + 'terminal.tab.activeBorder': '#0078D4', + 'textBlockQuote.background': '#2B2B2B', + 'textBlockQuote.border': '#616161', + 'textCodeBlock.background': '#2B2B2B', + 'textLink.activeForeground': '#4daafc', 'textLink.foreground': '#4daafc', + 'textPreformat.background': '#3C3C3C', + 'textPreformat.foreground': '#D0D0D0', + 'textSeparator.foreground': '#21262D', 'titleBar.activeBackground': '#181818', - 'titleBar.activeForeground': '#cccccc', - 'titleBar.border': '#2b2b2b', - 'titleBar.inactiveBackground': '#1f1f1f', - 'titleBar.inactiveForeground': '#9d9d9d', - 'welcomePage.tileBackground': '#2b2b2b' + 'titleBar.activeForeground': '#CCCCCC', + 'titleBar.border': '#2B2B2B', + 'titleBar.inactiveBackground': '#1F1F1F', + 'titleBar.inactiveForeground': '#9D9D9D', + 'welcomePage.progress.foreground': '#0078D4', + 'welcomePage.tileBackground': '#2B2B2B', + 'widget.border': '#313131' }; export const COLOR_THEME_LIGHT_INITIAL_COLORS = { + 'actionBar.toggledBackground': '#dddddd', 'activityBar.activeBorder': '#005FB8', - 'activityBar.background': '#f8f8f8', - 'activityBar.border': '#e5e5e5', - 'activityBar.foreground': '#1f1f1f', + 'activityBar.background': '#F8F8F8', + 'activityBar.border': '#E5E5E5', + 'activityBar.foreground': '#1F1F1F', 'activityBar.inactiveForeground': '#616161', - 'editorGroup.border': '#e5e5e5', - 'editorGroupHeader.tabsBackground': '#f8f8f8', - 'editorGroupHeader.tabsBorder': '#e5e5e5', - 'statusBar.background': '#f8f8f8', - 'statusBar.border': '#e5e5e5', - 'statusBar.foreground': '#3b3b3b', - 'statusBar.noFolderBackground': '#f8f8f8', - 'tab.activeBackground': '#ffffff', - 'tab.activeBorder': '#f8f8f8', - 'tab.activeBorderTop': '#005fb8', - 'tab.activeForeground': '#3b3b3b', - 'tab.border': '#e5e5e5', - 'textLink.foreground': '#005fb8', - 'titleBar.activeBackground': '#f8f8f8', - 'titleBar.activeForeground': '#1e1e1e', + 'activityBarBadge.background': '#005FB8', + 'activityBarBadge.foreground': '#FFFFFF', + 'badge.background': '#CCCCCC', + 'badge.foreground': '#3B3B3B', + 'button.background': '#005FB8', + 'button.border': '#0000001a', + 'button.foreground': '#FFFFFF', + 'button.hoverBackground': '#0258A8', + 'button.secondaryBackground': '#E5E5E5', + 'button.secondaryForeground': '#3B3B3B', + 'button.secondaryHoverBackground': '#CCCCCC', + 'chat.slashCommandBackground': '#ADCEFF7A', + 'chat.slashCommandForeground': '#26569E', + 'chat.editedFileForeground': '#895503', + 'checkbox.background': '#F8F8F8', + 'checkbox.border': '#CECECE', + 'descriptionForeground': '#3B3B3B', + 'diffEditor.unchangedRegionBackground': '#f8f8f8', + 'dropdown.background': '#FFFFFF', + 'dropdown.border': '#CECECE', + 'dropdown.foreground': '#3B3B3B', + 'dropdown.listBackground': '#FFFFFF', + 'editor.background': '#FFFFFF', + 'editor.foreground': '#3B3B3B', + 'editor.inactiveSelectionBackground': '#E5EBF1', + 'editor.selectionHighlightBackground': '#ADD6FF80', + 'editorGroup.border': '#E5E5E5', + 'editorGroupHeader.tabsBackground': '#F8F8F8', + 'editorGroupHeader.tabsBorder': '#E5E5E5', + 'editorGutter.addedBackground': '#2EA043', + 'editorGutter.deletedBackground': '#F85149', + 'editorGutter.modifiedBackground': '#005FB8', + 'editorIndentGuide.activeBackground1': '#939393', + 'editorIndentGuide.background1': '#D3D3D3', + 'editorLineNumber.activeForeground': '#171184', + 'editorLineNumber.foreground': '#6E7681', + 'editorOverviewRuler.border': '#E5E5E5', + 'editorSuggestWidget.background': '#F8F8F8', + 'editorWidget.background': '#F8F8F8', + 'errorForeground': '#F85149', + 'focusBorder': '#005FB8', + 'foreground': '#3B3B3B', + 'icon.foreground': '#3B3B3B', + 'input.background': '#FFFFFF', + 'input.border': '#CECECE', + 'input.foreground': '#3B3B3B', + 'input.placeholderForeground': '#767676', + 'inputOption.activeBackground': '#BED6ED', + 'inputOption.activeBorder': '#005FB8', + 'inputOption.activeForeground': '#000000', + 'keybindingLabel.foreground': '#3B3B3B', + 'list.activeSelectionBackground': '#E8E8E8', + 'list.activeSelectionForeground': '#000000', + 'list.activeSelectionIconForeground': '#000000', + 'list.focusAndSelectionOutline': '#005FB8', + 'list.hoverBackground': '#F2F2F2', + 'menu.border': '#CECECE', + 'menu.selectionBackground': '#005FB8', + 'menu.selectionForeground': '#ffffff', + 'notebook.cellBorderColor': '#E5E5E5', + 'notebook.selectedCellBackground': '#C8DDF150', + 'notificationCenterHeader.background': '#FFFFFF', + 'notificationCenterHeader.foreground': '#3B3B3B', + 'notifications.background': '#FFFFFF', + 'notifications.border': '#E5E5E5', + 'notifications.foreground': '#3B3B3B', + 'panel.background': '#F8F8F8', + 'panel.border': '#E5E5E5', + 'panelInput.border': '#E5E5E5', + 'panelTitle.activeBorder': '#005FB8', + 'panelTitle.activeForeground': '#3B3B3B', + 'panelTitle.inactiveForeground': '#3B3B3B', + 'peekViewEditor.matchHighlightBackground': '#BB800966', + 'peekViewResult.background': '#FFFFFF', + 'peekViewResult.matchHighlightBackground': '#BB800966', + 'pickerGroup.border': '#E5E5E5', + 'pickerGroup.foreground': '#8B949E', + 'ports.iconRunningProcessForeground': '#369432', + 'progressBar.background': '#005FB8', + 'quickInput.background': '#F8F8F8', + 'quickInput.foreground': '#3B3B3B', + 'searchEditor.textInputBorder': '#CECECE', + 'settings.dropdownBackground': '#FFFFFF', + 'settings.dropdownBorder': '#CECECE', + 'settings.headerForeground': '#1F1F1F', + 'settings.modifiedItemIndicator': '#BB800966', + 'settings.numberInputBorder': '#CECECE', + 'settings.textInputBorder': '#CECECE', + 'sideBar.background': '#F8F8F8', + 'sideBar.border': '#E5E5E5', + 'sideBar.foreground': '#3B3B3B', + 'sideBarSectionHeader.background': '#F8F8F8', + 'sideBarSectionHeader.border': '#E5E5E5', + 'sideBarSectionHeader.foreground': '#3B3B3B', + 'sideBarTitle.foreground': '#3B3B3B', + 'statusBar.background': '#F8F8F8', + 'statusBar.border': '#E5E5E5', + 'statusBar.debuggingBackground': '#FD716C', + 'statusBar.debuggingForeground': '#000000', + 'statusBar.focusBorder': '#005FB8', + 'statusBar.foreground': '#3B3B3B', + 'statusBar.noFolderBackground': '#F8F8F8', + 'statusBarItem.compactHoverBackground': '#CCCCCC', + 'statusBarItem.errorBackground': '#C72E0F', + 'statusBarItem.focusBorder': '#005FB8', + 'statusBarItem.hoverBackground': '#B8B8B850', + 'statusBarItem.prominentBackground': '#6E768166', + 'statusBarItem.remoteBackground': '#005FB8', + 'statusBarItem.remoteForeground': '#FFFFFF', + 'tab.activeBackground': '#FFFFFF', + 'tab.activeBorder': '#F8F8F8', + 'tab.activeBorderTop': '#005FB8', + 'tab.activeForeground': '#3B3B3B', + 'tab.border': '#E5E5E5', + 'tab.hoverBackground': '#FFFFFF', + 'tab.inactiveBackground': '#F8F8F8', + 'tab.inactiveForeground': '#868686', + 'tab.lastPinnedBorder': '#D4D4D4', + 'tab.selectedBackground': '#ffffffa5', + 'tab.selectedBorderTop': '#68a3da', + 'tab.selectedForeground': '#333333b3', + 'tab.unfocusedActiveBorder': '#F8F8F8', + 'tab.unfocusedActiveBorderTop': '#E5E5E5', + 'tab.unfocusedHoverBackground': '#F8F8F8', + 'terminal.foreground': '#3B3B3B', + 'terminal.inactiveSelectionBackground': '#E5EBF1', + 'terminal.tab.activeBorder': '#005FB8', + 'terminalCursor.foreground': '#005FB8', + 'textBlockQuote.background': '#F8F8F8', + 'textBlockQuote.border': '#E5E5E5', + 'textCodeBlock.background': '#F8F8F8', + 'textLink.activeForeground': '#005FB8', + 'textLink.foreground': '#005FB8', + 'textPreformat.background': '#0000001F', + 'textPreformat.foreground': '#3B3B3B', + 'textSeparator.foreground': '#21262D', + 'titleBar.activeBackground': '#F8F8F8', + 'titleBar.activeForeground': '#1E1E1E', 'titleBar.border': '#E5E5E5', - 'titleBar.inactiveBackground': '#f8f8f8', - 'titleBar.inactiveForeground': '#8b949e', - 'welcomePage.tileBackground': '#f3f3f3' + 'titleBar.inactiveBackground': '#F8F8F8', + 'titleBar.inactiveForeground': '#8B949E', + 'welcomePage.tileBackground': '#F3F3F3', + 'widget.border': '#E5E5E5' }; export interface IWorkbenchTheme { diff --git a/src/vs/workbench/services/timer/browser/timerService.ts b/src/vs/workbench/services/timer/browser/timerService.ts index 9b2a06f7a90..dd80dce0eb4 100644 --- a/src/vs/workbench/services/timer/browser/timerService.ts +++ b/src/vs/workbench/services/timer/browser/timerService.ts @@ -64,6 +64,7 @@ export interface IMemoryInfo { "timers.ellapsedExtensions" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "timers.ellapsedExtensionsReady" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "timers.ellapsedViewletRestore" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, + "timers.ellapsedAuxiliaryViewletRestore" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "timers.ellapsedPanelRestore" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "timers.ellapsedEditorRestore" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "timers.ellapsedWorkbenchContributions" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, @@ -123,6 +124,11 @@ export interface IStartupMetrics { */ readonly viewletId?: string; + /** + * The active auxiliary viewlet id or `undedined` + */ + readonly auxiliaryViewletId?: string; + /** * The active panel id or `undefined` */ @@ -338,7 +344,7 @@ export interface IStartupMetrics { readonly ellapsedExtensionsReady: number; /** - * The time it took to restore the viewlet. + * The time it took to restore the primary sidebar viewlet. * * * Happens in the renderer-process * * Measured with the `willRestoreViewlet` and `didRestoreViewlet` performance marks. @@ -347,6 +353,16 @@ export interface IStartupMetrics { */ readonly ellapsedViewletRestore: number; + /** + * The time it took to restore the auxiliary bar viewlet. + * + * * Happens in the renderer-process + * * Measured with the `willRestoreAuxiliaryBar` and `didRestoreAuxiliaryBar` performance marks. + * * This should be looked at per viewlet-type/id. + * * Happens in parallel to other things, depends on async timing + */ + readonly ellapsedAuxiliaryViewletRestore: number; + /** * The time it took to restore the panel. * @@ -676,6 +692,7 @@ export abstract class AbstractTimerService implements ITimerService { } const activeViewlet = this._paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar); + const activeAuxiliaryViewlet = this._paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar); const activePanel = this._paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel); const info: Writeable = { @@ -687,6 +704,7 @@ export abstract class AbstractTimerService implements ITimerService { windowKind: this._lifecycleService.startupKind, windowCount: await this._getWindowCount(), viewletId: activeViewlet?.getId(), + auxiliaryViewletId: activeAuxiliaryViewlet?.getId(), editorIds: this._editorService.visibleEditors.map(input => input.typeId), panelId: activePanel ? activePanel.getId() : undefined, @@ -714,6 +732,7 @@ export abstract class AbstractTimerService implements ITimerService { ellapsedExtensions: this._marks.getDuration('code/willLoadExtensions', 'code/didLoadExtensions'), ellapsedEditorRestore: this._marks.getDuration('code/willRestoreEditors', 'code/didRestoreEditors'), ellapsedViewletRestore: this._marks.getDuration('code/willRestoreViewlet', 'code/didRestoreViewlet'), + ellapsedAuxiliaryViewletRestore: this._marks.getDuration('code/willRestoreAuxiliaryBar', 'code/didRestoreAuxiliaryBar'), ellapsedPanelRestore: this._marks.getDuration('code/willRestorePanel', 'code/didRestorePanel'), ellapsedWorkbenchContributions: this._marks.getDuration('code/willCreateWorkbenchContributions/1', 'code/didCreateWorkbenchContributions/2'), ellapsedWorkbench: this._marks.getDuration('code/willStartWorkbench', 'code/didStartWorkbench'), diff --git a/src/vs/workbench/services/userDataSync/browser/userDataSyncWorkbenchService.ts b/src/vs/workbench/services/userDataSync/browser/userDataSyncWorkbenchService.ts index e8abfebf49e..28905f3c93f 100644 --- a/src/vs/workbench/services/userDataSync/browser/userDataSyncWorkbenchService.ts +++ b/src/vs/workbench/services/userDataSync/browser/userDataSyncWorkbenchService.ts @@ -258,9 +258,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat let value: { token: string; authenticationProviderId: string } | undefined = undefined; if (current) { try { - this.logService.trace('Settings Sync: Updating the token for the account', current.accountName); const token = current.token; - this.traceOrInfo('Settings Sync: Token updated for the account', current.accountName); value = { token, authenticationProviderId: current.authenticationProviderId }; } catch (e) { this.logService.error(e); @@ -269,19 +267,11 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat await this.userDataSyncAccountService.updateAccount(value); } - private traceOrInfo(msg: string, ...args: any[]): void { - if (this.environmentService.isBuilt) { - this.logService.info(msg, ...args); - } else { - this.logService.trace(msg, ...args); - } - } - private updateAccountStatus(accountStatus: AccountStatus): void { this.logService.trace(`Settings Sync: Updating the account status to ${accountStatus}`); if (this._accountStatus !== accountStatus) { const previous = this._accountStatus; - this.traceOrInfo(`Settings Sync: Account status changed from ${previous} to ${accountStatus}`); + this.logService.info(`Settings Sync: Account status changed from ${previous} to ${accountStatus}`); this._accountStatus = accountStatus; this.accountStatusContext.set(accountStatus); diff --git a/src/vs/workbench/services/views/browser/viewsService.ts b/src/vs/workbench/services/views/browser/viewsService.ts index 9e4053c2535..0381326cbff 100644 --- a/src/vs/workbench/services/views/browser/viewsService.ts +++ b/src/vs/workbench/services/views/browser/viewsService.ts @@ -531,7 +531,7 @@ export class ViewsService extends Disposable implements IViewsService { layoutService.setPartHidden(true, getPartByLocation(viewLocation)); } } else { - viewsService.openView(viewDescriptor.id, !options?.preserveFocus); + await viewsService.openView(viewDescriptor.id, !options?.preserveFocus); } } })); diff --git a/src/vs/workbench/services/workingCopy/common/workingCopyBackup.ts b/src/vs/workbench/services/workingCopy/common/workingCopyBackup.ts index 36f2f555c73..31fe1078fd8 100644 --- a/src/vs/workbench/services/workingCopy/common/workingCopyBackup.ts +++ b/src/vs/workbench/services/workingCopy/common/workingCopyBackup.ts @@ -38,11 +38,6 @@ export interface IWorkingCopyBackupService { readonly _serviceBrand: undefined; - /** - * Finds out if there are any working copy backups stored. - */ - hasBackups(): Promise; - /** * Finds out if a working copy backup with the given identifier * and optional version exists. diff --git a/src/vs/workbench/services/workingCopy/common/workingCopyBackupService.ts b/src/vs/workbench/services/workingCopy/common/workingCopyBackupService.ts index 5bc0f5c326e..8ced7d7bbff 100644 --- a/src/vs/workbench/services/workingCopy/common/workingCopyBackupService.ts +++ b/src/vs/workbench/services/workingCopy/common/workingCopyBackupService.ts @@ -152,10 +152,6 @@ export abstract class WorkingCopyBackupService extends Disposable implements IWo } } - hasBackups(): Promise { - return this.impl.hasBackups(); - } - hasBackupSync(identifier: IWorkingCopyIdentifier, versionId?: number, meta?: IWorkingCopyBackupMeta): boolean { return this.impl.hasBackupSync(identifier, versionId, meta); } @@ -227,15 +223,6 @@ class WorkingCopyBackupServiceImpl extends Disposable implements IWorkingCopyBac return this.model; } - async hasBackups(): Promise { - const model = await this.ready; - - // Ensure to await any pending backup operations - await this.joinBackups(); - - return model.count() > 0; - } - hasBackupSync(identifier: IWorkingCopyIdentifier, versionId?: number, meta?: IWorkingCopyBackupMeta): boolean { if (!this.model) { return false; @@ -545,10 +532,6 @@ export class InMemoryWorkingCopyBackupService extends Disposable implements IWor super(); } - async hasBackups(): Promise { - return this.backups.size > 0; - } - hasBackupSync(identifier: IWorkingCopyIdentifier, versionId?: number): boolean { const backupResource = this.toBackupResource(identifier); diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index 84d5af6fe3f..d71604c3c3f 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -104,11 +104,12 @@ export class TestNativeHostService implements INativeHostService { 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 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 { } async setMinimumSize(width: number | undefined, height: number | undefined): Promise { } async saveWindowSplash(value: IPartsSplash): Promise { } + async setBackgroundThrottling(throttling: boolean): Promise { } async focusWindow(options?: INativeHostOptions): Promise { } async showMessageBox(options: Electron.MessageBoxOptions): Promise { throw new Error('Method not implemented.'); } async showSaveDialog(options: Electron.SaveDialogOptions): Promise { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 27d5c5a4b13..924f024d86e 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -83,7 +83,6 @@ import './services/notebook/common/notebookDocumentService.js'; import './services/commands/common/commandService.js'; import './services/themes/browser/workbenchThemeService.js'; import './services/label/common/labelService.js'; -import './services/mcp/common/mcpWorkbenchManagementService.js'; import './services/extensions/common/extensionManifestPropertiesService.js'; import './services/extensionManagement/common/extensionGalleryService.js'; import './services/extensionManagement/browser/extensionEnablementService.js'; @@ -157,9 +156,8 @@ import { ExtensionStorageService, IExtensionStorageService } from '../platform/e import { IUserDataSyncLogService } from '../platform/userDataSync/common/userDataSync.js'; import { UserDataSyncLogService } from '../platform/userDataSync/common/userDataSyncLog.js'; import { AllowedExtensionsService } from '../platform/extensionManagement/common/allowedExtensionsService.js'; -import { IMcpGalleryService, IMcpManagementService } from '../platform/mcp/common/mcpManagement.js'; +import { IMcpGalleryService } from '../platform/mcp/common/mcpManagement.js'; import { McpGalleryService } from '../platform/mcp/common/mcpGalleryService.js'; -import { McpManagementService } from '../platform/mcp/common/mcpManagementService.js'; registerSingleton(IUserDataSyncLogService, UserDataSyncLogService, InstantiationType.Delayed); registerSingleton(IAllowedExtensionsService, AllowedExtensionsService, InstantiationType.Delayed); @@ -176,7 +174,6 @@ registerSingleton(ITextResourceConfigurationService, TextResourceConfigurationSe registerSingleton(IDownloadService, DownloadService, InstantiationType.Delayed); registerSingleton(IOpenerService, OpenerService, InstantiationType.Delayed); registerSingleton(IMcpGalleryService, McpGalleryService, InstantiationType.Delayed); -registerSingleton(IMcpManagementService, McpManagementService, InstantiationType.Delayed); //#endregion @@ -417,6 +414,8 @@ import './contrib/inlineCompletions/browser/inlineCompletions.contribution.js'; // Drop or paste into import './contrib/dropOrPasteInto/browser/dropOrPasteInto.contribution.js'; +// Edit Telemetry +import './contrib/editTelemetry/browser/editTelemetry.contribution.js'; //#endregion diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index c9df215ea42..af84852ebd2 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -53,6 +53,7 @@ import './services/keybinding/electron-browser/nativeKeyboardLayout.js'; import './services/path/electron-browser/pathService.js'; import './services/themes/electron-browser/nativeHostColorSchemeService.js'; import './services/extensionManagement/electron-browser/extensionManagementService.js'; +import './services/mcp/electron-browser/mcpWorkbenchManagementService.js'; import './services/encryption/electron-browser/encryptionService.js'; import './services/browserElements/electron-browser/browserElementsService.js'; import './services/secrets/electron-browser/secretStorageService.js'; diff --git a/src/vs/workbench/workbench.web.main.internal.ts b/src/vs/workbench/workbench.web.main.internal.ts index 460c888e390..eae3a102dee 100644 --- a/src/vs/workbench/workbench.web.main.internal.ts +++ b/src/vs/workbench/workbench.web.main.internal.ts @@ -43,6 +43,7 @@ import './services/extensionManagement/browser/extensionsProfileScannerService.j import './services/extensions/browser/extensionsScannerService.js'; import './services/extensionManagement/browser/webExtensionsScannerService.js'; import './services/extensionManagement/common/extensionManagementServerService.js'; +import './services/mcp/browser/mcpWorkbenchManagementService.js'; import './services/extensionManagement/browser/extensionGalleryManifestService.js'; import './services/telemetry/browser/telemetryService.js'; import './services/url/browser/urlService.js'; diff --git a/src/vscode-dts/vscode.proposed.taskExecutionTerminal.d.ts b/src/vscode-dts/vscode.proposed.taskExecutionTerminal.d.ts new file mode 100644 index 00000000000..a0884721146 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.taskExecutionTerminal.d.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// #234440 +declare module 'vscode' { + + export interface TaskExecution { + /** + * The terminal associated with this task execution, if any. + */ + readonly terminal?: Terminal; + } +} diff --git a/test/automation/src/editors.ts b/test/automation/src/editors.ts index 0f2e54722d0..4fcee5e14af 100644 --- a/test/automation/src/editors.ts +++ b/test/automation/src/editors.ts @@ -15,6 +15,7 @@ export class Editors { } else { await this.code.sendKeybinding('ctrl+s'); } + await this.code.waitForElements('.tab.active.dirty', false, results => results.length === 0); } async selectTab(fileName: string): Promise { diff --git a/test/automation/src/electron.ts b/test/automation/src/electron.ts index 7d162daf1d2..7935aea1dd1 100644 --- a/test/automation/src/electron.ts +++ b/test/automation/src/electron.ts @@ -27,6 +27,7 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom '--skip-release-notes', '--skip-welcome', '--disable-telemetry', + '--disable-experiments', '--no-cached-data', '--disable-updates', '--use-inmemory-secretstorage', diff --git a/test/automation/src/playwrightBrowser.ts b/test/automation/src/playwrightBrowser.ts index f4f63875b2a..6a2334fdc78 100644 --- a/test/automation/src/playwrightBrowser.ts +++ b/test/automation/src/playwrightBrowser.ts @@ -44,6 +44,7 @@ async function launchServer(options: LaunchOptions) { const args = [ '--disable-telemetry', + '--disable-experiments', '--disable-workspace-trust', `--port=${port++}`, '--enable-smoke-test-driver', diff --git a/test/integration/browser/src/index.ts b/test/integration/browser/src/index.ts index 0c3cd8efd32..47ac09d9f8c 100644 --- a/test/integration/browser/src/index.ts +++ b/test/integration/browser/src/index.ts @@ -169,7 +169,7 @@ async function launchServer(browserType: BrowserType, browserChannel: BrowserCha ...process.env }; - const serverArgs = ['--enable-proposed-api', '--disable-telemetry', '--server-data-dir', userDataDir, '--accept-server-license-terms', '--disable-workspace-trust']; + const serverArgs = ['--enable-proposed-api', '--disable-telemetry', '--disable-experiments', '--server-data-dir', userDataDir, '--accept-server-license-terms', '--disable-workspace-trust']; let serverLocation: string; if (process.env.VSCODE_REMOTE_SERVER_PATH) { diff --git a/test/monaco/package-lock.json b/test/monaco/package-lock.json index 224680dd597..0b5274978bb 100644 --- a/test/monaco/package-lock.json +++ b/test/monaco/package-lock.json @@ -68,10 +68,11 @@ } }, "node_modules/get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true, + "license": "MIT", "engines": { "node": "*" } diff --git a/test/smoke/src/areas/terminal/terminal-shellIntegration.test.ts b/test/smoke/src/areas/terminal/terminal-shellIntegration.test.ts index b0d0db48bf3..7443c02b30a 100644 --- a/test/smoke/src/areas/terminal/terminal-shellIntegration.test.ts +++ b/test/smoke/src/areas/terminal/terminal-shellIntegration.test.ts @@ -94,14 +94,19 @@ export function setup(options?: { skipSuite: boolean }) { after(async function () { await settingsEditor.clearUserSettings(); }); - beforeEach(async function () { + + // Don't use beforeEach as that ignores the retry count, createEmptyTerminal has been + // flaky in the past + async function beforeEachSetup() { // Use the simplest profile to get as little process interaction as possible await terminal.createEmptyTerminal(); // Erase all content and reset cursor to top await terminal.runCommandWithValue(TerminalCommandIdWithValue.WriteDataToTerminal, `${csi('2J')}${csi('H')}`); - }); + } + describe('VS Code sequences', () => { it('should handle the simple case', async () => { + await beforeEachSetup(); await terminal.runCommandWithValue(TerminalCommandIdWithValue.WriteDataToTerminal, `${vsc('A')}Prompt> ${vsc('B')}exitcode 0`); await terminal.assertCommandDecorations({ placeholder: 1, success: 0, error: 0 }); await terminal.runCommandWithValue(TerminalCommandIdWithValue.WriteDataToTerminal, `\\r\\n${vsc('C')}Success\\r\\n${vsc('D;0')}`); @@ -116,6 +121,7 @@ export function setup(options?: { skipSuite: boolean }) { }); describe('Final Term sequences', () => { it('should handle the simple case', async () => { + await beforeEachSetup(); await terminal.runCommandWithValue(TerminalCommandIdWithValue.WriteDataToTerminal, `${ft('A')}Prompt> ${ft('B')}exitcode 0`); await terminal.assertCommandDecorations({ placeholder: 1, success: 0, error: 0 }); await terminal.runCommandWithValue(TerminalCommandIdWithValue.WriteDataToTerminal, `\\r\\n${ft('C')}Success\\r\\n${ft('D;0')}`); diff --git a/test/smoke/src/areas/terminal/terminal-stickyScroll.test.ts b/test/smoke/src/areas/terminal/terminal-stickyScroll.test.ts index 495a3bf033f..67871470bee 100644 --- a/test/smoke/src/areas/terminal/terminal-stickyScroll.test.ts +++ b/test/smoke/src/areas/terminal/terminal-stickyScroll.test.ts @@ -50,12 +50,16 @@ export function setup(options?: { skipSuite: boolean }) { throw new Error(`Failed for command ${command}, exitcode ${exitCode}, text content ${element?.textContent}`); } - beforeEach(async () => { + // Don't use beforeEach as that ignores the retry count, createEmptyTerminal has been + // flaky in the past + async function beforeEachSetup() { // Create the simplest system profile to get as little process interaction as possible await terminal.createEmptyTerminal(); - }); + } it('should show sticky scroll when appropriate', async () => { + await beforeEachSetup(); + // Write prompt, fill viewport, finish command, print new prompt, verify sticky scroll await checkCommandAndOutput('sticky scroll 1', 0); @@ -64,6 +68,8 @@ export function setup(options?: { skipSuite: boolean }) { }); it('should support multi-line prompt', async () => { + await beforeEachSetup(); + // Standard multi-line prompt await checkCommandAndOutput('sticky scroll 1', 0, "Multi-line\\r\\nPrompt> ", 2); diff --git a/test/smoke/src/areas/terminal/terminal.test.ts b/test/smoke/src/areas/terminal/terminal.test.ts index dc28a7c1190..57e4ee9b5fc 100644 --- a/test/smoke/src/areas/terminal/terminal.test.ts +++ b/test/smoke/src/areas/terminal/terminal.test.ts @@ -45,7 +45,7 @@ export function setup(logger: Logger) { setupTerminalProfileTests({ skipSuite: process.platform === 'linux' }); setupTerminalTabsTests({ skipSuite: process.platform === 'linux' }); setupTerminalShellIntegrationTests({ skipSuite: process.platform === 'linux' }); - setupTerminalStickyScrollTests({ skipSuite: process.platform === 'linux' }); + setupTerminalStickyScrollTests({ skipSuite: true }); // https://github.com/microsoft/vscode/pull/141974 // Windows is skipped here as well as it was never enabled from the start setupTerminalSplitCwdTests({ skipSuite: process.platform === 'linux' || process.platform === 'win32' }); diff --git a/test/smoke/src/main.ts b/test/smoke/src/main.ts index bdc5a3776e2..45bf283ce04 100644 --- a/test/smoke/src/main.ts +++ b/test/smoke/src/main.ts @@ -365,7 +365,7 @@ before(async function () { verbose: opts.verbose, remote: opts.remote, web: opts.web, - tracing: opts.tracing, + tracing: opts.tracing || process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE, headless: opts.headless, browser: opts.browser, extraArgs: (opts.electronArgs || '').split(' ').map(arg => arg.trim()).filter(arg => !!arg) diff --git a/test/smoke/test/index.js b/test/smoke/test/index.js index 8e9646c0d65..ee1ac564720 100644 --- a/test/smoke/test/index.js +++ b/test/smoke/test/index.js @@ -25,13 +25,14 @@ const options = { grep: opts['f'] || opts['g'] }; -if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { +if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { testsuitesTitle: `${suite} ${process.platform}`, - mochaFile: join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + mochaFile: join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE || __dirname, + `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) } }; } @@ -52,6 +53,23 @@ mocha.run(failures => { # Logs are attached as build artefact and can be downloaded # # from the build Summary page (Summary -> Related -> N published) # # # +# Please also scan through attached crash logs in case the # +# failure was caused by a native crash. # +# # +# Show playwright traces on: https://trace.playwright.dev/ # +# # +################################################################### + `); + } else if (process.env.GITHUB_WORKSPACE) { + console.log(` +################################################################### +# # +# Logs are attached as build artefact and can be downloaded # +# from the build Summary page (Summary -> Artifacts) # +# # +# Please also scan through attached crash logs in case the # +# failure was caused by a native crash. # +# # # Show playwright traces on: https://trace.playwright.dev/ # # # ################################################################### diff --git a/test/unit/browser/index.js b/test/unit/browser/index.js index d112d64ea80..7789165f19e 100644 --- a/test/unit/browser/index.js +++ b/test/unit/browser/index.js @@ -89,12 +89,13 @@ const isDebug = !!args.debug; const withReporter = (function () { if (args.tfs) { { + const testResultsRoot = process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE; return (browserType, runner) => { new mocha.reporters.Spec(runner); new MochaJUnitReporter(runner, { reporterOptions: { testsuitesTitle: `${args.tfs} ${process.platform}`, - mochaFile: process.env.BUILD_ARTIFACTSTAGINGDIRECTORY ? path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${browserType}-${args.tfs.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) : undefined + mochaFile: testResultsRoot ? path.join(testResultsRoot, `test-results/${process.platform}-${process.arch}-${browserType}-${args.tfs.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) : undefined } }); }; @@ -249,7 +250,7 @@ async function runTestsInBrowser(testModules, browserType, browserChannel) { if (args.build) { target.searchParams.set('build', 'true'); } - if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) { + if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { target.searchParams.set('ci', 'true'); } diff --git a/test/unit/electron/index.js b/test/unit/electron/index.js index f69104799ca..bf89650d0aa 100644 --- a/test/unit/electron/index.js +++ b/test/unit/electron/index.js @@ -30,6 +30,7 @@ const minimist = require('minimist'); * grep: string; * run: string; * runGlob: string; + * testSplit: string; * dev: boolean; * reporter: string; * 'reporter-options': string; @@ -46,7 +47,7 @@ const minimist = require('minimist'); * }} */ const args = minimist(process.argv.slice(2), { - string: ['grep', 'run', 'runGlob', 'reporter', 'reporter-options', 'waitServer', 'timeout', 'crash-reporter-directory', 'tfs', 'coveragePath', 'coverageFormats'], + string: ['grep', 'run', 'runGlob', 'reporter', 'reporter-options', 'waitServer', 'timeout', 'crash-reporter-directory', 'tfs', 'coveragePath', 'coverageFormats', 'testSplit'], boolean: ['build', 'coverage', 'help', 'dev', 'per-test-coverage'], alias: { 'grep': ['g', 'f'], @@ -67,6 +68,7 @@ Options: --grep, -g, -f only run tests matching --run only run tests from --runGlob, --glob, --runGrep only run tests matching +--testSplit / split tests into parts and run the th part --build run with build output (out-build) --coverage generate coverage report --per-test-coverage generate a per-test V8 coverage report, only valid with the full-json-stream reporter @@ -326,12 +328,13 @@ app.on('ready', () => { const reporters = []; if (args.tfs) { + const testResultsRoot = process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE; reporters.push( new mocha.reporters.Spec(runner), new MochaJUnitReporter(runner, { reporterOptions: { testsuitesTitle: `${args.tfs} ${process.platform}`, - mochaFile: process.env.BUILD_ARTIFACTSTAGINGDIRECTORY ? path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${args.tfs.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) : undefined + mochaFile: testResultsRoot ? path.join(testResultsRoot, `test-results/${process.platform}-${process.arch}-${args.tfs.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) : undefined } }), ); diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index 661be873561..23f66cc2fe5 100644 --- a/test/unit/electron/renderer.js +++ b/test/unit/electron/renderer.js @@ -88,7 +88,7 @@ Object.assign(globalThis, { __mkdirPInTests: path => fs.promises.mkdir(path, { recursive: true }), }); -const IS_CI = !!process.env.BUILD_ARTIFACTSTAGINGDIRECTORY; +const IS_CI = !!process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || !!process.env.GITHUB_WORKSPACE; const _tests_glob = '**/test/**/*.test.js'; @@ -171,7 +171,14 @@ async function loadTestModules(opts) { const pattern = opts.runGlob || _tests_glob; const files = await globAsync(pattern, { cwd: loadFn._out }); - const modules = files.map(file => file.replace(/\.js$/, '')); + let modules = files.map(file => file.replace(/\.js$/, '')); + if (opts.testSplit) { + const [i, n] = opts.testSplit.split('/').map(Number); + const chunkSize = Math.floor(modules.length / n); + const start = (i - 1) * chunkSize; + const end = i === n ? modules.length : i * chunkSize; + modules = modules.slice(start, end); + } return loadModules(modules); }