diff --git a/.eslint-ignore b/.eslint-ignore index 12da4a432e1..6fbdf94696e 100644 --- a/.eslint-ignore +++ b/.eslint-ignore @@ -12,6 +12,7 @@ **/extensions/markdown-math/notebook-out/** **/extensions/notebook-renderers/renderer-out/index.js **/extensions/simple-browser/media/index.js +**/extensions/terminal-suggest/src/completions/** **/extensions/typescript-language-features/test-workspace/** **/extensions/typescript-language-features/extension.webpack.config.js **/extensions/typescript-language-features/extension-browser.webpack.config.js diff --git a/.eslint-plugin-local/vscode-dts-use-export.ts b/.eslint-plugin-local/vscode-dts-use-export.ts new file mode 100644 index 00000000000..904feaeec36 --- /dev/null +++ b/.eslint-plugin-local/vscode-dts-use-export.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TSESTree } from '@typescript-eslint/utils'; +import * as eslint from 'eslint'; + +export = new class VscodeDtsUseExport implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + useExport: `Public api types must use 'export'`, + }, + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + return { + ['TSModuleDeclaration :matches(TSInterfaceDeclaration, ClassDeclaration, VariableDeclaration, TSEnumDeclaration, TSTypeAliasDeclaration)']: (node: any) => { + const parent = (node).parent; + if (parent && parent.type !== TSESTree.AST_NODE_TYPES.ExportNamedDeclaration) { + context.report({ + node, + messageId: 'useExport' + }); + } + } + }; + } +}; + diff --git a/.github/workflows/monaco-editor.yml b/.github/workflows/monaco-editor.yml index 426999ce43b..1f5694faec2 100644 --- a/.github/workflows/monaco-editor.yml +++ b/.github/workflows/monaco-editor.yml @@ -45,11 +45,11 @@ jobs: path: ${{ steps.npmCacheDirPath.outputs.dir }} key: ${{ runner.os }}-npmCacheDir-${{ steps.nodeModulesCacheKey.outputs.value }} restore-keys: ${{ runner.os }}-npmCacheDir- - - name: Install libkrb5-dev + - name: Install system dependencies if: ${{ steps.cacheNodeModules.outputs.cache-hit != 'true' }} run: | sudo apt update - sudo apt install -y libkrb5-dev + sudo apt install -y libxkbfile-dev pkg-config libkrb5-dev libxss1 - name: Execute npm if: ${{ steps.cacheNodeModules.outputs.cache-hit != 'true' }} env: diff --git a/.npmrc b/.npmrc index 22256e5d8e7..27692c2409e 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="32.2.6" -ms_build_id="10629634" +target="32.2.7" +ms_build_id="10660205" runtime="electron" build_from_source="true" legacy-peer-deps="true" diff --git a/.nvmrc b/.nvmrc index 2a393af592b..d4b7699d36c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.18.0 +20.18.1 diff --git a/.vscode/notebooks/api.github-issues b/.vscode/notebooks/api.github-issues index 0423e9e3afc..d29f2bc441d 100644 --- a/.vscode/notebooks/api.github-issues +++ b/.vscode/notebooks/api.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"November 2024\"" + "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"January 2025\"" }, { "kind": 1, diff --git a/.vscode/notebooks/my-work.github-issues b/.vscode/notebooks/my-work.github-issues index b47a08b5a18..e8b184f8e57 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:\"November 2024\"\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:\"January 2025\"\n" }, { "kind": 1, diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 39075d82283..94da67c3ff8 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -178,6 +178,9 @@ extends: template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines parameters: sdl: + binskim: + enabled: false + justificationForDisabling: "BinSkim rebaselining is failing" tsa: enabled: true configFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/tsaoptions.json diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 17ec96faa2a..293250496af 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -bb4164f7b554606b2c4daaf43e81bf2e2b5cf0d4441cfdd74f04653237fcf655 *chromedriver-v32.2.6-darwin-arm64.zip -a0fc3df1c6cd17bfe62ffbb1eba3655ca625dea5046e5d2b3dbb0e9e349cd10e *chromedriver-v32.2.6-darwin-x64.zip -671d6dab890747ea73ba5589327eef7612670950a20e5f88c7d8a301b5491e26 *chromedriver-v32.2.6-linux-arm64.zip -55bfd4e33fef1506261d4cb3074988e1970c2a762ca76a8f1197512a1766723c *chromedriver-v32.2.6-linux-armv7l.zip -d3c7a45c8c75152db927b3596f506995e72631df870b302b7dbcbd3399e54a3a *chromedriver-v32.2.6-linux-x64.zip -567f77d09708942901c6cdce6708b995f6ac779faceebb4ed383ca5003e2de4e *chromedriver-v32.2.6-mas-arm64.zip -b3a28181b1d077742f1be632a802e15b5a36a260b1cfe0e429735de9f52d074a *chromedriver-v32.2.6-mas-x64.zip -a113f5bd747b6eeb033f4d6ea2f53cf332d9b45d6340af514dd938bac7f99419 *chromedriver-v32.2.6-win32-arm64.zip -3b3237a788fad0a6be63a69b93c28b6052db23aeaa1a75d2589be15b4c2c0f2f *chromedriver-v32.2.6-win32-ia32.zip -1096c131cf8e3f98a01525e93d573eaf4fd23492d8dd78a211e39c448e69e463 *chromedriver-v32.2.6-win32-x64.zip -8e6fcf3171c3fcdcb117f641ec968bb53be3d38696e388636bf34f04c10b987d *electron-api.json -b1b20784a97e64992c92480e69af828a110d834372479b26759f1559b3da80fc *electron-v32.2.6-darwin-arm64-dsym-snapshot.zip -f11dd5a84229430ec59b4335415a4b308dc4330ff7b9febae20165fbdd862e92 *electron-v32.2.6-darwin-arm64-dsym.zip -cc96cf91f6b108dc927d8f7daee2fe27ae8a492c932993051508aa779e816445 *electron-v32.2.6-darwin-arm64-symbols.zip -fcb6bbb6aa3c1020b4045dbe9f2a5286173d5025248550f55631e70568e91775 *electron-v32.2.6-darwin-arm64.zip -d2bfeea27fc91936b4f71d0f5c577e5ad0ea094edba541dfa348948fd65c3331 *electron-v32.2.6-darwin-x64-dsym-snapshot.zip -5e7684cc12c0dee11fb933b68301d0fe68d3198d1daeadd5e1b4cf52743f79bf *electron-v32.2.6-darwin-x64-dsym.zip -6d2a7d41ab14fc7d3c5e4b35d5d425edb2d13978dcc332e781ec8b7bcfe6a794 *electron-v32.2.6-darwin-x64-symbols.zip -c0964ee5fdcefb1003ffd7ef574b07e5147856f3a94bb4335f78c409f8cf2eca *electron-v32.2.6-darwin-x64.zip -604d88b9d434ea66ddf234dd129dcef3d468b95b0da47e2f1555a682c8d0de28 *electron-v32.2.6-linux-arm64-debug.zip -f448e91df42fc84177bcd46378e49ee648f6114984fc57af4a84690a5197c23e *electron-v32.2.6-linux-arm64-symbols.zip -9e4f9345cae06e8e5679b228e7b7ac21b8733e3fcda8903e3dcbc8171c53f8be *electron-v32.2.6-linux-arm64.zip -604d88b9d434ea66ddf234dd129dcef3d468b95b0da47e2f1555a682c8d0de28 *electron-v32.2.6-linux-armv7l-debug.zip -c85d5ca3f38dc4140040bcde6a37ac9c7510bb542f12e1ffce695a35f68e3c13 *electron-v32.2.6-linux-armv7l-symbols.zip -df4b490a9c501d83c5305f20b2a9d1aa100d2e878e59ebafde477f21d35e3300 *electron-v32.2.6-linux-armv7l.zip -a5aa67da85ee318ff0770d55a506f62e6e5a10e967dfab272a94bcd91922e075 *electron-v32.2.6-linux-x64-debug.zip -671eb342a58e056f0dee5a6e9c69a6a96ee2141f81d306fa1a0e2635e22aa7c0 *electron-v32.2.6-linux-x64-symbols.zip -a3231409db7f8ac2cc446708f17e2abac0f8549c166eaab2f427e8d0f864208d *electron-v32.2.6-linux-x64.zip -8b8d0aeadcf21633216a9cce87d323ad6aa21e38ec82092cd5f65bf171be025f *electron-v32.2.6-mas-arm64-dsym-snapshot.zip -298931236955b83d174738d3325931e9672a8333bf854fc5f471168b0f7e70be *electron-v32.2.6-mas-arm64-dsym.zip -53e4c666a6f5f87aa150b1c2f34532e3711e00b0237fb103dcbef64d65979429 *electron-v32.2.6-mas-arm64-symbols.zip -bedbc78acc3bc6cb30e5fe1f133562104152022273ec21a83cd32a0eece9003f *electron-v32.2.6-mas-arm64.zip -9ba3def756c90460867d968af5417996991bf3b4b306dba4c9fbde43de2f771d *electron-v32.2.6-mas-x64-dsym-snapshot.zip -e4a3f9392934bb4ef82a214fec2ce1f319ea409b89bf03b2a2a4ab7a55688d0c *electron-v32.2.6-mas-x64-dsym.zip -55c54fd01faf5767493183001a440b837b63f8b8d64c36f7b42fa7217af36dcd *electron-v32.2.6-mas-x64-symbols.zip -1efe974cd426a288d617750c864e6edbf28495c3b851a5bc806af19cda7a274d *electron-v32.2.6-mas-x64.zip -88c50d34dc48a55a11014349d2d278f895f1615405614dbfcf27dff5f5030cf1 *electron-v32.2.6-win32-arm64-pdb.zip -b0b2211bf0f11924bcd693b6783627c7f6c9a066117bcf05c10766064c79794c *electron-v32.2.6-win32-arm64-symbols.zip -48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.6-win32-arm64-toolchain-profile.zip -2b7962348f23410863cb6562d79654ce534666bab9f75965b5c8ebee61f49657 *electron-v32.2.6-win32-arm64.zip -d248ab4ec8b4a5aec414c7a404e0efd9b9a73083f7c5cb6c657c2ed58c4cbe94 *electron-v32.2.6-win32-ia32-pdb.zip -2ef98197d66d94aee4978047f22ba07538d259b25a8f5f301d9564a13649e72c *electron-v32.2.6-win32-ia32-symbols.zip -48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.6-win32-ia32-toolchain-profile.zip -e85dbf85d58cab5b06cdb8e76fde3a25306e7c1808f5bb30925ba7e29ff14d13 *electron-v32.2.6-win32-ia32.zip -9d76b0c0d475cc062b2a951fbfb3b17837880102fb6cc042e2736dfebbfa0e5d *electron-v32.2.6-win32-x64-pdb.zip -3d7b93bafc633429f5c9f820bcb4843d504b12e454b3ecebb1b69c15b5f4080f *electron-v32.2.6-win32-x64-symbols.zip -48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.6-win32-x64-toolchain-profile.zip -77d5e5b76b49767e6a3ad292dc315fbc7cdccd557ac38da9093b8ac6da9262d5 *electron-v32.2.6-win32-x64.zip -a52935712eb4fc2c12ac4ae611a57e121da3b6165c2de1abd7392ed4261287e2 *electron.d.ts -6345ea55fda07544434c90c276cdceb2662044b9e0355894db67ca95869af22a *ffmpeg-v32.2.6-darwin-arm64.zip -4c1347e8653727513a22be013008c2760d19200977295b98506b3b9947e74090 *ffmpeg-v32.2.6-darwin-x64.zip -3f1eafaf4cd90ab43ba0267429189be182435849a166a2cbe1faefc0d07217c4 *ffmpeg-v32.2.6-linux-arm64.zip -3db919bc57e1a5bf7c1bae1d7aeacf4a331990ea82750391c0b24a046d9a2812 *ffmpeg-v32.2.6-linux-armv7l.zip -fe7d779dddbfb5da5999a7607fc5e3c7a6ab7c65e8da9fee1384918865231612 *ffmpeg-v32.2.6-linux-x64.zip -e09ae881113d1b3103aec918e7c95c36f82b2db63657320c380c94386f689138 *ffmpeg-v32.2.6-mas-arm64.zip -ee316e435662201a81fcededc62582dc87a0bd5c9fd0f6a8a55235eca806652f *ffmpeg-v32.2.6-mas-x64.zip -415395968d31e13056cefcb589ed550a0e80d7c3d0851ee3ba29e4dcdf174210 *ffmpeg-v32.2.6-win32-arm64.zip -17f61a5293b707c984cee52b57a7e505cde994d22828e31c016434521638e419 *ffmpeg-v32.2.6-win32-ia32.zip -f9b47951a553eec21636b3cc15eccf0a2ba272567146ec8a6e2eeb91a985fd62 *ffmpeg-v32.2.6-win32-x64.zip -7d2b596bd94e4d5c7befba11662dc563a02f18c183da12ebd56f38bb6f4382e9 *hunspell_dictionaries.zip -06bca9a33142b5834fbca6d10d7f3303b0b5c52e7222fe783db109cd4c5260ed *libcxx-objects-v32.2.6-linux-arm64.zip -7ab8ff5a4e1d3a6639978ed718d2732df9c1a4dd4dcd3e18f6746a2168d353a9 *libcxx-objects-v32.2.6-linux-armv7l.zip -ba96792896751e11fdba63e5e336e323979986176aa1848122ca3757c854352e *libcxx-objects-v32.2.6-linux-x64.zip -1f858d484f4ce27f9f3c4a120b2881f88c17c81129ca10e495b50398fb2eed64 *libcxx_headers.zip -4566afb06a6dd8bd895dba5350a5705868203321116a1ae0a216d5a4a6bfb289 *libcxxabi_headers.zip -350f5419c14aede5802c4f0ef5204ddbbe0eb7d5263524da38d19f43620d8530 *mksnapshot-v32.2.6-darwin-arm64.zip -9f4e4943df4502943994ffa17525b7b5b9a1d889dbc5aeb28bfc40f9146323ec *mksnapshot-v32.2.6-darwin-x64.zip -6295ad1a4ab3b24ac99ec85d35ebfce3861e58185b94d20077e364e81ad935f8 *mksnapshot-v32.2.6-linux-arm64-x64.zip -ed9e1931165a2ff85c1af9f10b3bf8ba05d2dae31331d1d4f5ff1512078e4411 *mksnapshot-v32.2.6-linux-armv7l-x64.zip -6680c63b11e4638708d88c130474ceb2ee92fc58c62cfcd3bf33dda9fee771c2 *mksnapshot-v32.2.6-linux-x64.zip -5a4c45b755b7bbdcad51345f5eb2935ba988987650f9b8540c7ef04015207a2f *mksnapshot-v32.2.6-mas-arm64.zip -1e6243d6a1b68f327457b9dae244ffaeadc265b07a99d9c36f1abcff31e36856 *mksnapshot-v32.2.6-mas-x64.zip -407099537b17860ce7dcea6ba582a854314c29dc152a6df0abcc77ebb0187b4c *mksnapshot-v32.2.6-win32-arm64-x64.zip -f9107536378e19ae9305386dacf6fae924c7ddaad0cf0a6f49694e1e8af6ded5 *mksnapshot-v32.2.6-win32-ia32.zip -82a142db76a2cc5dfb9a89e4cd721cfebc0f3077d2415d8f3d86bb7411f0fe27 *mksnapshot-v32.2.6-win32-x64.zip +0729d2cb830425c4591b40d7189c2f7da020f5adb887a49a4faa022d551b1e6f *chromedriver-v32.2.7-darwin-arm64.zip +a7d61c68d3b3522c0ec383915b6ab3d9f269d9ada0e09aa87a4e7d9fc24fe928 *chromedriver-v32.2.7-darwin-x64.zip +45314c8c7127f6083469c2c067aa78beb20055ba4d7a63eb2108b27e594a647a *chromedriver-v32.2.7-linux-arm64.zip +7e976a7131dcfd55f781c073ae59c8a24a1393119d831fbac13c6a335eb71467 *chromedriver-v32.2.7-linux-armv7l.zip +3437feb5d8e7157476d2e7a6558346061cd7e46506874bc7870eed8a3a43642a *chromedriver-v32.2.7-linux-x64.zip +3737301add80a936374acb17b84bb3a715fab9fbce049816ea7a51fa53d3b35e *chromedriver-v32.2.7-mas-arm64.zip +8b8b62f48a5e8b8a340b47348a2cc5dd4ba38789f76bc5567c039587538081a9 *chromedriver-v32.2.7-mas-x64.zip +24b666e3ab41eb1c66ab0f2361af0529b2b8e1e5ef153cfcef36adc700f9ed6d *chromedriver-v32.2.7-win32-arm64.zip +6d40251661afb1835adbef85e317fd520c0f1378156614c82befb348c3122c2d *chromedriver-v32.2.7-win32-ia32.zip +483012da9903d8d75e5e251a3262667c9a0a012a492b93dbe1237c7827eba778 *chromedriver-v32.2.7-win32-x64.zip +1e9b2b9011f56fa26f4d9fa57254ef1d0bdb34405a9bdf83a652f6258347e46d *electron-api.json +c0ea4a21f2e7e946300bf587a4e31f72ef996c497eaa94e6b8f788b917b896e5 *electron-v32.2.7-darwin-arm64-dsym-snapshot.zip +1e6e84e56cfb3a2473cab41577c160d3afcbda8337dda17c5295da90266433c9 *electron-v32.2.7-darwin-arm64-dsym.zip +9f460100fb71ef098bec26e9a09978ec1b1663165d6a358bfc398f5548a844c3 *electron-v32.2.7-darwin-arm64-symbols.zip +71e76a0a81a0c1c10e9e4862caf96437ba85a18c8fa7d8e15d59e3c057b893bd *electron-v32.2.7-darwin-arm64.zip +e406d690365f332826843c86c6a1b5c0320a84b0527ad8700a0e995b12a35f8c *electron-v32.2.7-darwin-x64-dsym-snapshot.zip +53d6fb64d717af80f024284161a432aaffb47631ef7548f18f33016c3376871a *electron-v32.2.7-darwin-x64-dsym.zip +3c9187db2cc0570d7b01651fa78294df7d451c87c361335cee80a61c1c561b67 *electron-v32.2.7-darwin-x64-symbols.zip +34310ed51d32b6c02ba3e3f447b0807ea85804d1f2b239e02a9de58b9080fbf8 *electron-v32.2.7-darwin-x64.zip +e884f2f9f3d001488888929b8affe053a60a7a780af7d0ec8d7023023f40ca52 *electron-v32.2.7-linux-arm64-debug.zip +fd88e47e7b564b006f68641b5c328721bbc8d87cfc9e569d9733354d263cddee *electron-v32.2.7-linux-arm64-symbols.zip +fb6e1f24385c3058844bd768320d5b332b4cbd011ab930e7252dc330c8ee17b3 *electron-v32.2.7-linux-arm64.zip +e884f2f9f3d001488888929b8affe053a60a7a780af7d0ec8d7023023f40ca52 *electron-v32.2.7-linux-armv7l-debug.zip +f338ea7ea592c3ccdad1bb788e9b936610f825ac69290e48d394790d5266dce3 *electron-v32.2.7-linux-armv7l-symbols.zip +4a95643e88cadfb011354d25cafe242cdb8c5327d65f86b4fbabe64717367ed2 *electron-v32.2.7-linux-armv7l.zip +e6e0fce9f6d95a84653b537b741967cae48c4c70c8026c02293c979074225b46 *electron-v32.2.7-linux-x64-debug.zip +122cc565d0ccd2774e298645473869752d27d2632aa97583d93b499e9b02f22b *electron-v32.2.7-linux-x64-symbols.zip +98007545e1d3700b32de5cb5eebcc10b9d105fb0dad6396155fdab1b40abb638 *electron-v32.2.7-linux-x64.zip +556d9ca239ee1206c9d67affa836ebb651db88eea6bee48cb7b43fa75851c72d *electron-v32.2.7-mas-arm64-dsym-snapshot.zip +662a3742b94fcbf7ab91a7c20e1430825ae7852e915fcb558d6357a310d631c6 *electron-v32.2.7-mas-arm64-dsym.zip +edd0763ead7ffd5bf5072539e5ca0be9252b9590e674e6e44e69b2057c329d79 *electron-v32.2.7-mas-arm64-symbols.zip +a4483f5246ecadfa48b1fc671d92b5dfbc09fbd88fe386f2ce48f10de79f2127 *electron-v32.2.7-mas-arm64.zip +a9aad4c413d4851fa3463eeef7015e3a3e77a501192965db1c5b870fa31a9660 *electron-v32.2.7-mas-x64-dsym-snapshot.zip +96c20e5c4b73febd3458679e9cc939f5f8255a327b06f49188ab2e3fe8311ea3 *electron-v32.2.7-mas-x64-dsym.zip +6ac844957373114e04411d3af1cb6507e35174d1dc279cce41cb92bbf2ea5d26 *electron-v32.2.7-mas-x64-symbols.zip +888b830b991dab6cf2c4351e112a48f24a4748efefcd763d693a79161199e65a *electron-v32.2.7-mas-x64.zip +27759db6bcdd16d4ff5548684361ba4372d885d3142bf02db59837c3634b1934 *electron-v32.2.7-win32-arm64-pdb.zip +6019e6ec58e9b6da335f20874efebc42d034a179163180b3b6faedf2963ae577 *electron-v32.2.7-win32-arm64-symbols.zip +48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.7-win32-arm64-toolchain-profile.zip +2c755fdd4f9fda618b2db6b8c7210c5f3106a88b1e87b83e8433b4ab4a628cc2 *electron-v32.2.7-win32-arm64.zip +4dce0b21d1c2093cc4f7c0eaf9453a38377e0076d811da3c7391f105fc1d6afb *electron-v32.2.7-win32-ia32-pdb.zip +9a0a9c3746cd40ddc9c926755633b16676714e2138d7a2d888f658a26f617039 *electron-v32.2.7-win32-ia32-symbols.zip +48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.7-win32-ia32-toolchain-profile.zip +6c338c5cd0b0587349ab0f119ca8f7d2728b1c3a43fe241741087f5fdf139c9c *electron-v32.2.7-win32-ia32.zip +fa240d324c5376aa12ed2aef26597764d9bfc2fdd0d16d7f76afc2c3e3c65a29 *electron-v32.2.7-win32-x64-pdb.zip +f645b53771cbcdfaa041d9cf9581348821d82c1b185ddb913759e2d62ee2410a *electron-v32.2.7-win32-x64-symbols.zip +48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.7-win32-x64-toolchain-profile.zip +819ab19b7111dfd39dff506b3cb5cd2e1d8f4bb17f96ba74b987b2eac14b6c63 *electron-v32.2.7-win32-x64.zip +ce41b10c28bd43249cd3b409e081b1c83a2b691381bdd2e3bf208ec40ca176b8 *electron.d.ts +d2491071a641ce2e0f63c1f52e3a412856dd83ca17d021af1166d6e5b4de5638 *ffmpeg-v32.2.7-darwin-arm64.zip +5c5589b2c93f834e595eb692aa768b934245d2631df69bc4cad3a6602bba0e67 *ffmpeg-v32.2.7-darwin-x64.zip +3f1eafaf4cd90ab43ba0267429189be182435849a166a2cbe1faefc0d07217c4 *ffmpeg-v32.2.7-linux-arm64.zip +3db919bc57e1a5bf7c1bae1d7aeacf4a331990ea82750391c0b24a046d9a2812 *ffmpeg-v32.2.7-linux-armv7l.zip +fe7d779dddbfb5da5999a7607fc5e3c7a6ab7c65e8da9fee1384918865231612 *ffmpeg-v32.2.7-linux-x64.zip +feeef1ab10543c813f730cc7a482b43eda35d40f1285b950e1a6d7805db2332a *ffmpeg-v32.2.7-mas-arm64.zip +96ef45180589c854fedf2d0601a20e70a65220c0820c45d0dfd4ec64724c58e0 *ffmpeg-v32.2.7-mas-x64.zip +ab4ab9cd62e40c4d3064004caa9de680cb72d8180d4facc1be06bdc886c23410 *ffmpeg-v32.2.7-win32-arm64.zip +90b5e2ebd4ff683eda97cc43ebbdee9b133b27edd2a34ae7ef37e7969d1d68be *ffmpeg-v32.2.7-win32-ia32.zip +8452085c0a650035f30a4b76e2ce1791f9b392ea7262109d29f7fe383fc41ddb *ffmpeg-v32.2.7-win32-x64.zip +78b415ebb9040dacabb6eb776a8d4837dda9a9b1ec9d64ee15db28dbb8598862 *hunspell_dictionaries.zip +a30057c37e6be5732944084575a2278616297242ae51bd474c683263cbc0c3e4 *libcxx-objects-v32.2.7-linux-arm64.zip +f9e9d1ff1a03a3e609ab8e727b1f89e77934509a4afdb849698b70e701c2176f *libcxx-objects-v32.2.7-linux-armv7l.zip +bb66e3b48f8e0706126b2b8b08827a4adda6f56c509eae4d136fcffd5414c353 *libcxx-objects-v32.2.7-linux-x64.zip +5181518d7da83fea5d8b033ab4fb7ed300f73bd8d20b8c26b624128233bd6ab2 *libcxx_headers.zip +6030ad099859b62cbdd9021b2cdb453a744a2751cb1dab30519e3e8708ad72d6 *libcxxabi_headers.zip +d3dcc4925a6bd55bc305fd41805ffee77dc8821730ac75cf4ee9ed2ca4ebdccb *mksnapshot-v32.2.7-darwin-arm64.zip +e6dfad3c30f4f38509b2fc972dd05cef06142c4832d931edba19742e06161279 *mksnapshot-v32.2.7-darwin-x64.zip +25ba5be47a721700f16af10945e71408ed86ffd6800b5d5ef04d38c0d77aa446 *mksnapshot-v32.2.7-linux-arm64-x64.zip +f7e8b50691712206587d81844bd63271f2dd49253c946a5b66bd6f169ccf94d6 *mksnapshot-v32.2.7-linux-armv7l-x64.zip +a0b119abe93c0231601b6c699cce4b78e89def766c24f9a8a06cfab3feca8f6c *mksnapshot-v32.2.7-linux-x64.zip +e3e8a496a1eaf6c8ce623fa4b139e5458cf3ce3702ea3560cded839087b60792 *mksnapshot-v32.2.7-mas-arm64.zip +c03219273c82022c29e277d07ce1d0980d25c22d39269fa3eef9547f57ec410b *mksnapshot-v32.2.7-mas-x64.zip +7684cb9c6f621db05b6e68080fade81f46d0ff8eeac94080bd635f035069d13e *mksnapshot-v32.2.7-win32-arm64-x64.zip +f7ca1d557e3d0f878b13f57dc0e00932f7a97f3dd0f0cc3bbbd565a06718bd17 *mksnapshot-v32.2.7-win32-ia32.zip +d9d8dd33561eb648e5ebd00f99418122d9a915ec63fe967e7cb0ff64ef8ee199 *mksnapshot-v32.2.7-win32-x64.zip diff --git a/build/filters.js b/build/filters.js index 1705d4b32c3..70e175463dc 100644 --- a/build/filters.js +++ b/build/filters.js @@ -49,6 +49,7 @@ module.exports.unicodeFilter = [ '!extensions/ipynb/notebook-out/**', '!extensions/notebook-renderers/renderer-out/**', '!extensions/php-language-features/src/features/phpGlobalFunctions.ts', + '!extensions/terminal-suggest/src/completions/upstream/**', '!extensions/typescript-language-features/test-workspace/**', '!extensions/vscode-api-tests/testWorkspace/**', '!extensions/vscode-api-tests/testWorkspace2/**', @@ -88,6 +89,7 @@ module.exports.indentationFilter = [ '!test/automation/out/**', '!test/monaco/out/**', '!test/smoke/out/**', + '!extensions/terminal-suggest/src/completions/upstream/**', '!extensions/typescript-language-features/test-workspace/**', '!extensions/typescript-language-features/resources/walkthroughs/**', '!extensions/typescript-language-features/package-manager/node-maintainer/**', @@ -170,6 +172,7 @@ module.exports.copyrightFilter = [ '!extensions/markdown-math/notebook-out/**', '!extensions/ipynb/notebook-out/**', '!extensions/simple-browser/media/codicon.css', + '!extensions/terminal-suggest/src/completions/upstream/**', '!extensions/typescript-language-features/node-maintainer/**', '!extensions/html-language-features/server/src/modes/typescript/*', '!extensions/*/server/bin/*', diff --git a/build/gulpfile.reh.js b/build/gulpfile.reh.js index 4f00317173d..e0df76f1c8f 100644 --- a/build/gulpfile.reh.js +++ b/build/gulpfile.reh.js @@ -63,6 +63,9 @@ const serverResourceIncludes = [ 'out-build/vs/base/node/cpuUsage.sh', 'out-build/vs/base/node/ps.sh', + // External Terminal + 'out-build/vs/workbench/contrib/externalTerminal/**/*.scpt', + // Terminal shell integration 'out-build/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1', 'out-build/vs/workbench/contrib/terminal/common/scripts/CodeTabExpansion.psm1', diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 23979f5686a..d6eb99d2f39 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -370,6 +370,7 @@ "--vscode-inlineEdit-originalBackground", "--vscode-inlineEdit-originalChangedLineBackground", "--vscode-inlineEdit-originalChangedTextBackground", + "--vscode-inlineEdit-acceptedBackground", "--vscode-input-background", "--vscode-input-border", "--vscode-input-foreground", diff --git a/build/monaco/monaco.usage.recipe b/build/monaco/monaco.usage.recipe index e3c8cdd0916..a3369eb25a7 100644 --- a/build/monaco/monaco.usage.recipe +++ b/build/monaco/monaco.usage.recipe @@ -35,6 +35,6 @@ import * as editorAPI from './vs/editor/editor.api'; a = editorAPI.editor; a = editorAPI.languages; - const o: IObservable = null!; + const o: IObservable = null!; o.TChange; })(); diff --git a/build/npm/preinstall.js b/build/npm/preinstall.js index 6619382d247..31821ee2393 100644 --- a/build/npm/preinstall.js +++ b/build/npm/preinstall.js @@ -9,8 +9,8 @@ const minorNodeVersion = parseInt(nodeVersion[2]); const patchNodeVersion = parseInt(nodeVersion[3]); if (!process.env['VSCODE_SKIP_NODE_VERSION_CHECK']) { - if (majorNodeVersion < 20 || (majorNodeVersion === 20 && minorNodeVersion < 18)) { - console.error('\x1b[1;31m*** Please use Node.js v20.18.0 or later for development.\x1b[0;0m'); + if (majorNodeVersion < 20 || (majorNodeVersion === 20 && minorNodeVersion < 18) || (majorNodeVersion === 20 && minorNodeVersion === 18 && patchNodeVersion < 1)) { + console.error('\x1b[1;31m*** Please use Node.js v20.18.1 or later for development.\x1b[0;0m'); throw new Error(); } } diff --git a/cgmanifest.json b/cgmanifest.json index 832a3f604b7..1795c44c937 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -528,12 +528,12 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "10e0ce069260b2cc984c301d5a7ecb3492f0c2f0" + "commitHash": "3007f859dad930ae80bafffc6042a146a45e4e4d" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "32.2.6" + "version": "32.2.7" }, { "component": { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 27fe79896a2..5da9906e1ac 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -649,6 +649,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -1149,13 +1160,142 @@ dependencies = [ ] [[package]] -name = "idna" -version = "0.5.0" +name = "icu_collections" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -1336,6 +1476,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + [[package]] name = "lock_api" version = "0.4.12" @@ -2358,6 +2504,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2404,6 +2556,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", +] + [[package]] name = "sysinfo" version = "0.29.11" @@ -2502,20 +2665,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] -name = "tinyvec" -version = "1.6.0" +name = "tinystr" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ - "tinyvec_macros", + "displaydoc", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.37.0" @@ -2721,27 +2879,12 @@ dependencies = [ "winapi", ] -[[package]] -name = "unicode-bidi" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" - [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" -[[package]] -name = "unicode-normalization" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-width" version = "0.1.12" @@ -2756,9 +2899,9 @@ checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" [[package]] name = "url" -version = "2.5.0" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", @@ -2777,6 +2920,18 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.1" @@ -3119,6 +3274,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + [[package]] name = "xattr" version = "1.3.1" @@ -3150,6 +3317,30 @@ dependencies = [ "num-bigint", ] +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", + "synstructure", +] + [[package]] name = "zbus" version = "3.15.2" @@ -3211,12 +3402,55 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", + "synstructure", +] + [[package]] name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", +] + [[package]] name = "zip" version = "0.6.6" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2907ff3d7e7..2c87d662e07 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -41,7 +41,7 @@ hyper = { version = "0.14.26", features = ["server", "http1", "runtime"] } indicatif = "0.17.4" tempfile = "3.5.0" clap_lex = "0.7.0" -url = "2.3.1" +url = "2.5.4" async-trait = "0.1.68" log = "0.4.18" const_format = "0.2.31" diff --git a/cli/src/bin/code/main.rs b/cli/src/bin/code/main.rs index 0e4809b78c7..b73d0aa885b 100644 --- a/cli/src/bin/code/main.rs +++ b/cli/src/bin/code/main.rs @@ -103,7 +103,7 @@ async fn main() -> Result<(), std::convert::Infallible> { serve_web::serve_web(context!(), sw_args).await } - Some(args::Commands::Tunnel(tunnel_args)) => match tunnel_args.subcommand { + Some(args::Commands::Tunnel(mut tunnel_args)) => match tunnel_args.subcommand.take() { Some(args::TunnelSubcommand::Prune) => tunnels::prune(context!()).await, Some(args::TunnelSubcommand::Unregister) => tunnels::unregister(context!()).await, Some(args::TunnelSubcommand::Kill) => tunnels::kill(context!()).await, @@ -116,7 +116,7 @@ async fn main() -> Result<(), std::convert::Infallible> { tunnels::user(context!(), user_command).await } Some(args::TunnelSubcommand::Service(service_args)) => { - tunnels::service(context_no_logger(), service_args).await + tunnels::service(context_no_logger(), tunnel_args, service_args).await } Some(args::TunnelSubcommand::ForwardInternal(forward_args)) => { tunnels::forward(context_no_logger(), forward_args).await diff --git a/cli/src/commands/tunnels.rs b/cli/src/commands/tunnels.rs index 2a0b4c7ddce..f52fa714793 100644 --- a/cli/src/commands/tunnels.rs +++ b/cli/src/commands/tunnels.rs @@ -21,7 +21,7 @@ use tokio::{ use super::{ args::{ - AuthProvider, CliCore, CommandShellArgs, ExistingTunnelArgs, TunnelForwardArgs, + AuthProvider, CliCore, CommandShellArgs, ExistingTunnelArgs, TunnelArgs, TunnelForwardArgs, TunnelRenameArgs, TunnelServeArgs, TunnelServiceSubCommands, TunnelUserSubCommands, }, CommandContext, @@ -104,12 +104,16 @@ fn fulfill_existing_tunnel_args( } struct TunnelServiceContainer { - args: CliCore, + core_args: CliCore, + tunnel_args: TunnelArgs, } impl TunnelServiceContainer { - fn new(args: CliCore) -> Self { - Self { args } + fn new(core_args: CliCore, tunnel_args: TunnelArgs) -> Self { + Self { + core_args, + tunnel_args, + } } } @@ -120,7 +124,8 @@ impl ServiceContainer for TunnelServiceContainer { log: log::Logger, launcher_paths: LauncherPaths, ) -> Result<(), AnyError> { - let csa = (&self.args).into(); + let mut csa = (&self.core_args).into(); + self.tunnel_args.serve_args.server_args.apply_to(&mut csa); serve_with_csa( launcher_paths, log, @@ -242,8 +247,27 @@ async fn is_port_available(host: IpAddr, port: u16) -> bool { .is_ok() } +fn make_service_args<'a: 'c, 'b: 'c, 'c>( + root_path: &'a str, + tunnel_args: &'b TunnelArgs, +) -> Vec<&'c str> { + let mut args = ["--verbose", "--cli-data-dir", root_path, "tunnel"].to_vec(); + + if let Some(d) = tunnel_args.serve_args.server_args.extensions_dir.as_ref() { + args.extend_from_slice(&["--extensions-dir", d]); + } + if let Some(d) = tunnel_args.serve_args.server_args.server_data_dir.as_ref() { + args.extend_from_slice(&["--server-data-dir", d]); + } + + args.extend_from_slice(&["service", "internal-run"]); + + args +} + pub async fn service( ctx: CommandContext, + tunnel_args: TunnelArgs, service_args: TunnelServiceSubCommands, ) -> Result { let manager = create_service_manager(ctx.log.clone(), &ctx.paths); @@ -265,20 +289,10 @@ pub async fn service( legal::require_consent(&ctx.paths, args.accept_server_license_terms)?; let current_exe = canonical_exe().map_err(|e| wrap(e, "could not get current exe"))?; + let root_path = ctx.paths.root().as_os_str().to_string_lossy(); + let args = make_service_args(&root_path, &tunnel_args); - manager - .register( - current_exe, - &[ - "--verbose", - "--cli-data-dir", - ctx.paths.root().as_os_str().to_string_lossy().as_ref(), - "tunnel", - "service", - "internal-run", - ], - ) - .await?; + manager.register(current_exe, &args).await?; ctx.log.result(format!("Service successfully installed! You can use `{APPLICATION_NAME} tunnel service log` to monitor it, and `{APPLICATION_NAME} tunnel service uninstall` to remove it.")); } TunnelServiceSubCommands::Uninstall => { @@ -289,7 +303,10 @@ pub async fn service( } TunnelServiceSubCommands::InternalRun => { manager - .run(ctx.paths.clone(), TunnelServiceContainer::new(ctx.args)) + .run( + ctx.paths.clone(), + TunnelServiceContainer::new(ctx.args, tunnel_args), + ) .await?; } } diff --git a/eslint.config.js b/eslint.config.js index aa9fd4930c5..f9f120acd41 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -229,7 +229,6 @@ export default tseslint.config( 'src/vs/workbench/api/test/node/extHostTunnelService.test.ts', 'src/vs/workbench/contrib/bulkEdit/test/browser/bulkCellEdits.test.ts', 'src/vs/workbench/contrib/chat/test/common/chatWordCounter.test.ts', - 'src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts', 'src/vs/workbench/contrib/extensions/test/common/extensionQuery.test.ts', 'src/vs/workbench/contrib/notebook/test/browser/notebookExecutionService.test.ts', 'src/vs/workbench/contrib/notebook/test/browser/notebookExecutionStateService.test.ts', @@ -268,6 +267,7 @@ export default tseslint.config( 'local/vscode-dts-string-type-literals': 'warn', 'local/vscode-dts-interface-naming': 'warn', 'local/vscode-dts-cancellation': 'warn', + 'local/vscode-dts-use-export': 'warn', 'local/vscode-dts-use-thenable': 'warn', 'local/vscode-dts-region-comments': 'warn', 'local/vscode-dts-vscode-in-comments': 'warn', diff --git a/extensions/css-language-features/package.json b/extensions/css-language-features/package.json index af75de5386b..0533881380c 100644 --- a/extensions/css-language-features/package.json +++ b/extensions/css-language-features/package.json @@ -23,9 +23,6 @@ "supported": true } }, - "enabledApiProposals": [ - "documentPaste" - ], "scripts": { "compile": "npx gulp compile-extension:css-language-features-client compile-extension:css-language-features-server", "watch": "npx gulp watch-extension:css-language-features-client watch-extension:css-language-features-server", diff --git a/extensions/git-base/src/api/api1.ts b/extensions/git-base/src/api/api1.ts index 005a7930356..74edc7f4452 100644 --- a/extensions/git-base/src/api/api1.ts +++ b/extensions/git-base/src/api/api1.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, commands } from 'vscode'; +import { Command, Disposable, commands } from 'vscode'; import { Model } from '../model'; -import { getRemoteSourceActions, pickRemoteSource } from '../remoteSource'; +import { getRemoteSourceActions, getRemoteSourceControlHistoryItemCommands, pickRemoteSource } from '../remoteSource'; import { GitBaseExtensionImpl } from './extension'; import { API, PickRemoteSourceOptions, PickRemoteSourceResult, RemoteSourceAction, RemoteSourceProvider } from './git-base'; @@ -21,6 +21,10 @@ export class ApiImpl implements API { return getRemoteSourceActions(this._model, url); } + getRemoteSourceControlHistoryItemCommands(url: string): Promise { + return getRemoteSourceControlHistoryItemCommands(this._model, url); + } + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable { return this._model.registerRemoteSourceProvider(provider); } diff --git a/extensions/git-base/src/api/git-base.d.ts b/extensions/git-base/src/api/git-base.d.ts index 53cac4d5c70..37dd2c4229c 100644 --- a/extensions/git-base/src/api/git-base.d.ts +++ b/extensions/git-base/src/api/git-base.d.ts @@ -3,11 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, Event, ProviderResult, Uri } from 'vscode'; +import { Command, Disposable, Event, ProviderResult } from 'vscode'; export { ProviderResult } from 'vscode'; export interface API { registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + getRemoteSourceActions(url: string): Promise; + getRemoteSourceControlHistoryItemCommands(url: string): Promise; pickRemoteSource(options: PickRemoteSourceOptions): Promise; } @@ -80,6 +82,7 @@ export interface RemoteSourceProvider { getBranches?(url: string): ProviderResult; getRemoteSourceActions?(url: string): ProviderResult; + getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult; getRecentRemoteSources?(query?: string): ProviderResult; getRemoteSources(query?: string): ProviderResult; } diff --git a/extensions/git-base/src/remoteSource.ts b/extensions/git-base/src/remoteSource.ts index eb86b27367a..8d8d4ab102f 100644 --- a/extensions/git-base/src/remoteSource.ts +++ b/extensions/git-base/src/remoteSource.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { QuickPickItem, window, QuickPick, QuickPickItemKind, l10n, Disposable } from 'vscode'; +import { QuickPickItem, window, QuickPick, QuickPickItemKind, l10n, Disposable, Command } from 'vscode'; import { RemoteSourceProvider, RemoteSource, PickRemoteSourceOptions, PickRemoteSourceResult, RemoteSourceAction } from './api/git-base'; import { Model } from './model'; import { throttle, debounce } from './decorators'; @@ -123,6 +123,20 @@ export async function getRemoteSourceActions(model: Model, url: string): Promise return remoteSourceActions; } +export async function getRemoteSourceControlHistoryItemCommands(model: Model, url: string): Promise { + const providers = model.getRemoteProviders(); + + const remoteSourceCommands = []; + for (const provider of providers) { + const providerCommands = await provider.getRemoteSourceControlHistoryItemCommands?.(url); + if (providerCommands?.length) { + remoteSourceCommands.push(...providerCommands); + } + } + + return remoteSourceCommands; +} + export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch?: false | undefined }): Promise; export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch: true }): Promise; export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions = {}): Promise { diff --git a/extensions/git/package.json b/extensions/git/package.json index d63027619d1..66e8dd4981d 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -163,14 +163,14 @@ "title": "%command.stageAll%", "category": "Git", "icon": "$(add)", - "enablement": "!operationInProgress" + "enablement": "!operationInProgress && scmResourceGroupResourceCount > 0" }, { "command": "git.stageAllTracked", "title": "%command.stageAllTracked%", "category": "Git", "icon": "$(add)", - "enablement": "!operationInProgress" + "enablement": "!operationInProgress && scmResourceGroupResourceCount > 0" }, { "command": "git.stageAllUntracked", @@ -243,7 +243,7 @@ "title": "%command.unstageAll%", "category": "Git", "icon": "$(remove)", - "enablement": "!operationInProgress" + "enablement": "!operationInProgress && scmResourceGroupResourceCount > 0" }, { "command": "git.unstageSelectedRanges", @@ -270,14 +270,14 @@ "title": "%command.cleanAll%", "category": "Git", "icon": "$(discard)", - "enablement": "!operationInProgress" + "enablement": "!operationInProgress && scmResourceGroupResourceCount > 0" }, { "command": "git.cleanAllTracked", "title": "%command.cleanAllTracked%", "category": "Git", "icon": "$(discard)", - "enablement": "!operationInProgress" + "enablement": "!operationInProgress && scmResourceGroupResourceCount > 0" }, { "command": "git.cleanAllUntracked", @@ -889,14 +889,14 @@ "title": "%command.viewChanges%", "icon": "$(diff-multiple)", "category": "Git", - "enablement": "!operationInProgress" + "enablement": "!operationInProgress && scmResourceGroupResourceCount > 0" }, { "command": "git.viewStagedChanges", "title": "%command.viewStagedChanges%", "icon": "$(diff-multiple)", "category": "Git", - "enablement": "!operationInProgress" + "enablement": "!operationInProgress && scmResourceGroupResourceCount > 0" }, { "command": "git.viewUntrackedChanges", @@ -912,13 +912,6 @@ "category": "Git", "enablement": "!operationInProgress" }, - { - "command": "git.viewAllChanges", - "title": "%command.viewAllChanges%", - "icon": "$(diff-multiple)", - "category": "Git", - "enablement": "!operationInProgress" - }, { "command": "git.copyCommitId", "title": "%command.timelineCopyCommitId%", @@ -1444,10 +1437,6 @@ "command": "git.viewCommit", "when": "false" }, - { - "command": "git.viewAllChanges", - "when": "false" - }, { "command": "git.stageFile", "when": "false" @@ -2050,9 +2039,19 @@ "group": "navigation", "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInNotebookTextDiffEditor && resourceScheme =~ /^git$|^file$/" }, + { + "command": "git.stage", + "group": "navigation@1", + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasUnstagedChanges" + }, + { + "command": "git.unstage", + "group": "navigation@1", + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasStagedChanges" + }, { "command": "git.openChange", - "group": "navigation", + "group": "navigation@2", "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && scmActiveResourceHasChanges" }, { @@ -3203,38 +3202,30 @@ "git.blame.editorDecoration.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%config.blameEditorDecoration.enabled%", - "scope": "resource", - "tags": [ - "experimental" - ] + "markdownDescription": "%config.blameEditorDecoration.enabled%" }, "git.blame.editorDecoration.template": { "type": "string", "default": "${subject}, ${authorName} (${authorDateAgo})", - "markdownDescription": "%config.blameEditorDecoration.template%", - "scope": "resource", - "tags": [ - "experimental" - ] + "markdownDescription": "%config.blameEditorDecoration.template%" }, "git.blame.statusBarItem.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%config.blameStatusBarItem.enabled%", - "scope": "resource", - "tags": [ - "experimental" - ] + "markdownDescription": "%config.blameStatusBarItem.enabled%" }, "git.blame.statusBarItem.template": { "type": "string", "default": "${authorName} (${authorDateAgo})", - "markdownDescription": "%config.blameStatusBarItem.template%", - "scope": "resource", - "tags": [ - "experimental" - ] + "markdownDescription": "%config.blameStatusBarItem.template%" + }, + "git.commitShortHashLength": { + "type": "number", + "default": 7, + "minimum": 7, + "maximum": 40, + "markdownDescription": "%config.commitShortHashLength%", + "scope": "resource" } } }, @@ -3372,22 +3363,22 @@ { "view": "scm", "contents": "%view.workbench.scm.missing%", - "when": "config.git.enabled && git.missing" + "when": "config.git.enabled && git.missing && remoteName != ''" }, { "view": "scm", "contents": "%view.workbench.scm.missing.mac%", - "when": "config.git.enabled && git.missing && isMac" + "when": "config.git.enabled && git.missing && remoteName == '' && isMac" }, { "view": "scm", "contents": "%view.workbench.scm.missing.windows%", - "when": "config.git.enabled && git.missing && isWindows" + "when": "config.git.enabled && git.missing && remoteName == '' && isWindows" }, { "view": "scm", "contents": "%view.workbench.scm.missing.linux%", - "when": "config.git.enabled && git.missing && isLinux" + "when": "config.git.enabled && git.missing && remoteName == '' && isLinux" }, { "view": "scm", diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 6db253137e1..5233f764f13 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -122,11 +122,10 @@ "command.timelineCompareWithSelected": "Compare with Selected", "command.manageUnsafeRepositories": "Manage Unsafe Repositories", "command.openRepositoriesInParentFolders": "Open Repositories In Parent Folders", - "command.viewChanges": "View Changes", - "command.viewStagedChanges": "View Staged Changes", - "command.viewUntrackedChanges": "View Untracked Changes", - "command.viewAllChanges": "View All Changes", - "command.viewCommit": "View Commit", + "command.viewChanges": "Open Changes", + "command.viewStagedChanges": "Open Staged Changes", + "command.viewUntrackedChanges": "Open Untracked Changes", + "command.viewCommit": "Open Commit", "command.api.getRepositories": "Get Repositories", "command.api.getRepositoryState": "Get Repository State", "command.api.getRemoteSources": "Get Remote Sources", @@ -278,9 +277,10 @@ "config.publishBeforeContinueOn.prompt": "Prompt to publish unpublished Git state when using Continue Working On from a Git repository", "config.similarityThreshold": "Controls the threshold of the similarity index (the amount of additions/deletions compared to the file's size) for changes in a pair of added/deleted files to be considered a rename. **Note:** Requires Git version `2.18.0` or later.", "config.blameEditorDecoration.enabled": "Controls whether to show blame information in the editor using editor decorations.", - "config.blameEditorDecoration.template": "Template for the blame information editor decoration. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First 8 characters of the commit hash\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n", + "config.blameEditorDecoration.template": "Template for the blame information editor decoration. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First N characters of the commit hash according to `#git.commitShortHashLength#`\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n", "config.blameStatusBarItem.enabled": "Controls whether to show blame information in the status bar.", - "config.blameStatusBarItem.template": "Template for the blame information status bar item. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First 8 characters of the commit hash\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n", + "config.blameStatusBarItem.template": "Template for the blame information status bar item. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First N characters of the commit hash according to `#git.commitShortHashLength#`\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n", + "config.commitShortHashLength": "Controls the length of the commit short hash.", "submenu.explorer": "Git", "submenu.commit": "Commit", "submenu.commit.amend": "Amend", @@ -352,7 +352,7 @@ ] }, "view.workbench.scm.empty": { - "message": "In order to use Git features, you can open a folder containing a Git repository or clone from a URL.\n[Open Folder](command:vscode.openFolder)\n[Clone Repository](command:git.clone)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", + "message": "In order to use Git features, you can open a folder containing a Git repository or clone from a URL.\n[Open Folder](command:vscode.openFolder)\n[Clone Repository](command:git.cloneRecursive)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", "comment": [ "{Locked='](command:vscode.openFolder'}", "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index e559c0cb807..518269c4162 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -13,7 +13,7 @@ import { combinedDisposable, filterEvent, mapEvent } from '../util'; import { toGitUri } from '../uri'; import { GitExtensionImpl } from './extension'; import { GitBaseApi } from '../git-base'; -import { PickRemoteSourceOptions } from './git-base'; +import { PickRemoteSourceOptions } from '../typings/git-base'; import { OperationKind, OperationResult } from '../operation'; class ApiInputBox implements InputBox { @@ -112,6 +112,10 @@ export class ApiRepository implements Repository { return this.#repository.setConfig(key, value); } + unsetConfig(key: string): Promise { + return this.#repository.unsetConfig(key); + } + getGlobalConfig(key: string): Promise { return this.#repository.getGlobalConfig(key); } diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 851d5734977..ea78ac4d99a 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -204,6 +204,7 @@ export interface Repository { getConfigs(): Promise<{ key: string; value: string; }[]>; getConfig(key: string): Promise; setConfig(key: string, value: string): Promise; + unsetConfig(key: string): Promise; getGlobalConfig(key: string): Promise; getObjectDetails(treeish: string, path: string): Promise<{ mode: string, object: string, size: number }>; diff --git a/extensions/git/src/blame.ts b/extensions/git/src/blame.ts index 5c65fbcf1fc..9ab8e3e58bd 100644 --- a/extensions/git/src/blame.ts +++ b/extensions/git/src/blame.ts @@ -3,15 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DecorationOptions, l10n, Position, Range, TextEditor, TextEditorChange, TextEditorDecorationType, TextEditorChangeKind, ThemeColor, Uri, window, workspace, EventEmitter, ConfigurationChangeEvent, StatusBarItem, StatusBarAlignment, Command, MarkdownString } from 'vscode'; +import { DecorationOptions, l10n, Position, Range, TextEditor, TextEditorChange, TextEditorDecorationType, TextEditorChangeKind, ThemeColor, Uri, window, workspace, EventEmitter, ConfigurationChangeEvent, StatusBarItem, StatusBarAlignment, Command, MarkdownString, languages, HoverProvider, CancellationToken, Hover, TextDocument } from 'vscode'; import { Model } from './model'; -import { dispose, fromNow, IDisposable } from './util'; +import { dispose, fromNow, getCommitShortHash, IDisposable } from './util'; import { Repository } from './repository'; import { throttle } from './decorators'; -import { BlameInformation } from './git'; +import { BlameInformation, Commit } from './git'; import { fromGitUri, isGitUri } from './uri'; import { emojify, ensureEmojis } from './emoji'; import { getWorkingTreeAndIndexDiffInformation, getWorkingTreeDiffInformation } from './staging'; +import { getRemoteSourceControlHistoryItemCommands } from './remoteSource'; function lineRangesContainLine(changes: readonly TextEditorChange[], lineNumber: number): boolean { return changes.some(c => c.modified.startLineNumber <= lineNumber && lineNumber < c.modified.endLineNumberExclusive); @@ -55,6 +56,15 @@ function mapModifiedLineNumberToOriginalLineNumber(lineNumber: number, changes: return lineNumber; } +function getEditorDecorationRange(lineNumber: number): Range { + const position = new Position(lineNumber, Number.MAX_SAFE_INTEGER); + return new Range(position, position); +} + +function isResourceSchemeSupported(uri: Uri): boolean { + return uri.scheme === 'file' || isGitUri(uri); +} + type BlameInformationTemplateTokens = { readonly hash: string; readonly hashShort: string; @@ -147,38 +157,40 @@ class GitBlameInformationCache { export class GitBlameController { private readonly _subjectMaxLength = 50; - private readonly _onDidChangeBlameInformation = new EventEmitter(); + private readonly _onDidChangeBlameInformation = new EventEmitter(); public readonly onDidChangeBlameInformation = this._onDidChangeBlameInformation.event; - readonly textEditorBlameInformation = new Map(); + private _textEditorBlameInformation: LineBlameInformation[] | undefined; + get textEditorBlameInformation(): readonly LineBlameInformation[] | undefined { + return this._textEditorBlameInformation; + } + private set textEditorBlameInformation(blameInformation: LineBlameInformation[] | undefined) { + this._textEditorBlameInformation = blameInformation; + this._onDidChangeBlameInformation.fire(); + } private readonly _repositoryBlameCache = new GitBlameInformationCache(); + private _editorDecoration: GitBlameEditorDecoration | undefined; + private _statusBarItem: GitBlameStatusBarItem | undefined; + private _repositoryDisposables = new Map(); + private _enablementDisposables: IDisposable[] = []; private _disposables: IDisposable[] = []; constructor(private readonly _model: Model) { - this._disposables.push(new GitBlameEditorDecoration(this)); - this._disposables.push(new GitBlameStatusBarItem(this)); - - this._model.onDidOpenRepository(this._onDidOpenRepository, this, this._disposables); - this._model.onDidCloseRepository(this._onDidCloseRepository, this, this._disposables); - - window.onDidChangeActiveTextEditor(e => this._updateTextEditorBlameInformation(e), this, this._disposables); - window.onDidChangeTextEditorSelection(e => this._updateTextEditorBlameInformation(e.textEditor, true), this, this._disposables); - window.onDidChangeTextEditorDiffInformation(e => this._updateTextEditorBlameInformation(e.textEditor), this, this._disposables); - - this._updateTextEditorBlameInformation(window.activeTextEditor); + workspace.onDidChangeConfiguration(this._onDidChangeConfiguration, this, this._disposables); + this._onDidChangeConfiguration(); } - formatBlameInformationMessage(template: string, blameInformation: BlameInformation): string { + formatBlameInformationMessage(documentUri: Uri, template: string, blameInformation: BlameInformation): string { const subject = blameInformation.subject && blameInformation.subject.length > this._subjectMaxLength ? `${blameInformation.subject.substring(0, this._subjectMaxLength)}\u2026` : blameInformation.subject; const templateTokens = { hash: blameInformation.hash, - hashShort: blameInformation.hash.substring(0, 8), + hashShort: getCommitShortHash(documentUri, blameInformation.hash), subject: emojify(subject ?? ''), authorName: blameInformation.authorName ?? '', authorEmail: blameInformation.authorEmail ?? '', @@ -191,36 +203,154 @@ export class GitBlameController { }); } - getBlameInformationHover(documentUri: Uri, blameInformation: BlameInformation | string): MarkdownString { - if (typeof blameInformation === 'string') { - return new MarkdownString(blameInformation, true); + async getBlameInformationHover(documentUri: Uri, blameInformation: BlameInformation, includeCommitDetails = false): Promise { + let commitInformation: Commit | undefined; + const remoteSourceCommands: Command[] = []; + + const repository = this._model.getRepository(documentUri); + if (repository) { + // Commit details + if (includeCommitDetails) { + try { + commitInformation = await repository.getCommit(blameInformation.hash); + } catch { } + } + + // Remote commands + const defaultRemote = repository.getDefaultRemote(); + const unpublishedCommits = await repository.getUnpublishedCommits(); + + if (defaultRemote?.fetchUrl && !unpublishedCommits.has(blameInformation.hash)) { + remoteSourceCommands.push(...await getRemoteSourceControlHistoryItemCommands(defaultRemote.fetchUrl)); + } } const markdownString = new MarkdownString(); - markdownString.supportThemeIcons = true; markdownString.isTrusted = true; + markdownString.supportHtml = true; + markdownString.supportThemeIcons = true; - if (blameInformation.authorName) { - markdownString.appendMarkdown(`$(account) **${blameInformation.authorName}**`); + // Author, date + const authorName = commitInformation?.authorName ?? blameInformation.authorName; + const authorEmail = commitInformation?.authorEmail ?? blameInformation.authorEmail; + const authorDate = commitInformation?.authorDate ?? blameInformation.authorDate; - if (blameInformation.authorDate) { - const dateString = new Date(blameInformation.authorDate).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }); - markdownString.appendMarkdown(`, $(history) ${fromNow(blameInformation.authorDate, true, true)} (${dateString})`); + if (authorName) { + if (authorEmail) { + const emailTitle = l10n.t('Email'); + markdownString.appendMarkdown(`$(account) [**${authorName}**](mailto:${authorEmail} "${emailTitle} ${authorName}")`); + } else { + markdownString.appendMarkdown(`$(account) **${authorName}**`); + } + + if (authorDate) { + const dateString = new Date(authorDate).toLocaleString(undefined, { + year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' + }); + markdownString.appendMarkdown(`, $(history) ${fromNow(authorDate, true, true)} (${dateString})`); } markdownString.appendMarkdown('\n\n'); } - markdownString.appendMarkdown(`${emojify(blameInformation.subject ?? '')}\n\n`); + // Subject | Message + markdownString.appendMarkdown(`${emojify(commitInformation?.message ?? blameInformation.subject ?? '')}\n\n`); markdownString.appendMarkdown(`---\n\n`); - markdownString.appendMarkdown(`[$(eye) View Commit](command:git.blameStatusBarItem.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, blameInformation.hash]))} "${l10n.t('View Commit')}")`); - markdownString.appendMarkdown('    '); - markdownString.appendMarkdown(`[$(copy) ${blameInformation.hash.substring(0, 8)}](command:git.blameStatusBarItem.copyContent?${encodeURIComponent(JSON.stringify(blameInformation.hash))} "${l10n.t('Copy Commit Hash')}")`); + // Short stats + if (commitInformation?.shortStat) { + markdownString.appendMarkdown(`${commitInformation.shortStat.files === 1 ? + l10n.t('{0} file changed', commitInformation.shortStat.files) : + l10n.t('{0} files changed', commitInformation.shortStat.files)}`); + + if (commitInformation.shortStat.insertions) { + markdownString.appendMarkdown(`, ${commitInformation.shortStat.insertions === 1 ? + l10n.t('{0} insertion{1}', commitInformation.shortStat.insertions, '(+)') : + l10n.t('{0} insertions{1}', commitInformation.shortStat.insertions, '(+)')}`); + } + + if (commitInformation.shortStat.deletions) { + markdownString.appendMarkdown(`, ${commitInformation.shortStat.deletions === 1 ? + l10n.t('{0} deletion{1}', commitInformation.shortStat.deletions, '(-)') : + l10n.t('{0} deletions{1}', commitInformation.shortStat.deletions, '(-)')}`); + } + + markdownString.appendMarkdown(`\n\n---\n\n`); + } + + // Commands + const hash = commitInformation?.hash ?? blameInformation.hash; + + markdownString.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(documentUri, hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, hash]))} "${l10n.t('Open Commit')}")`); + markdownString.appendMarkdown(' '); + markdownString.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`); + + // Remote commands + if (remoteSourceCommands.length > 0) { + markdownString.appendMarkdown('  |  '); + + const remoteCommandsMarkdown = remoteSourceCommands + .map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify([...command.arguments ?? [], hash]))} "${command.tooltip}")`); + markdownString.appendMarkdown(remoteCommandsMarkdown.join(' ')); + } + + markdownString.appendMarkdown('  |  '); + markdownString.appendMarkdown(`[$(gear)](command:workbench.action.openSettings?%5B%22git.blame%22%5D "${l10n.t('Open Settings')}")`); return markdownString; } + private _onDidChangeConfiguration(e?: ConfigurationChangeEvent): void { + if (e && + !e.affectsConfiguration('git.blame.editorDecoration.enabled') && + !e.affectsConfiguration('git.blame.statusBarItem.enabled')) { + return; + } + + const config = workspace.getConfiguration('git'); + const editorDecorationEnabled = config.get('blame.editorDecoration.enabled') === true; + const statusBarItemEnabled = config.get('blame.statusBarItem.enabled') === true; + + // Editor decoration + if (editorDecorationEnabled) { + if (!this._editorDecoration) { + this._editorDecoration = new GitBlameEditorDecoration(this); + } + } else { + this._editorDecoration?.dispose(); + this._editorDecoration = undefined; + } + + // StatusBar item + if (statusBarItemEnabled) { + if (!this._statusBarItem) { + this._statusBarItem = new GitBlameStatusBarItem(this); + } + } else { + this._statusBarItem?.dispose(); + this._statusBarItem = undefined; + } + + // Listeners + if (editorDecorationEnabled || statusBarItemEnabled) { + if (this._enablementDisposables.length === 0) { + this._model.onDidOpenRepository(this._onDidOpenRepository, this, this._enablementDisposables); + this._model.onDidCloseRepository(this._onDidCloseRepository, this, this._enablementDisposables); + for (const repository of this._model.repositories) { + this._onDidOpenRepository(repository); + } + + window.onDidChangeActiveTextEditor(e => this._updateTextEditorBlameInformation(e), this, this._enablementDisposables); + window.onDidChangeTextEditorSelection(e => this._updateTextEditorBlameInformation(e.textEditor, true), this, this._enablementDisposables); + window.onDidChangeTextEditorDiffInformation(e => this._updateTextEditorBlameInformation(e.textEditor), this, this._enablementDisposables); + } + } else { + this._enablementDisposables = dispose(this._enablementDisposables); + } + + this._updateTextEditorBlameInformation(window.activeTextEditor); + } + private _onDidOpenRepository(repository: Repository): void { const repositoryDisposables: IDisposable[] = []; repository.onDidRunGitStatus(() => this._onDidRunGitStatus(repository), this, repositoryDisposables); @@ -250,9 +380,7 @@ export class GitBlameController { this._repositoryBlameCache.deleteBlameInformation(repository, 'file'); this._repositoryBlameCache.setRepositoryHEAD(repository, repository.HEAD.commit); - for (const textEditor of window.visibleTextEditors) { - this._updateTextEditorBlameInformation(textEditor); - } + this._updateTextEditorBlameInformation(window.activeTextEditor); } } @@ -280,7 +408,12 @@ export class GitBlameController { @throttle private async _updateTextEditorBlameInformation(textEditor: TextEditor | undefined, showBlameInformationForPositionZero = false): Promise { - if (!textEditor?.diffInformation || textEditor !== window.activeTextEditor) { + if (textEditor) { + if (!textEditor.diffInformation || textEditor !== window.activeTextEditor) { + return; + } + } else { + this.textEditorBlameInformation = undefined; return; } @@ -289,14 +422,19 @@ export class GitBlameController { return; } + // Only support resources with `file` and `git` schemes + if (!isResourceSchemeSupported(textEditor.document.uri)) { + this.textEditorBlameInformation = undefined; + return; + } + // Do not show blame information when there is a single selection and it is at the beginning // of the file [0, 0, 0, 0] unless the user explicitly navigates the cursor there. We do this // to avoid showing blame information when the editor is not focused. if (!showBlameInformationForPositionZero && textEditor.selections.length === 1 && textEditor.selections[0].start.line === 0 && textEditor.selections[0].start.character === 0 && textEditor.selections[0].end.line === 0 && textEditor.selections[0].end.character === 0) { - this.textEditorBlameInformation.set(textEditor, []); - this._onDidChangeBlameInformation.fire(textEditor); + this.textEditorBlameInformation = undefined; return; } @@ -397,8 +535,7 @@ export class GitBlameController { } } - this.textEditorBlameInformation.set(textEditor, lineBlameInformation); - this._onDidChangeBlameInformation.fire(textEditor); + this.textEditorBlameInformation = lineBlameInformation; } dispose() { @@ -411,178 +548,169 @@ export class GitBlameController { } } -class GitBlameEditorDecoration { - private readonly _decorationType: TextEditorDecorationType; +class GitBlameEditorDecoration implements HoverProvider { + private _decoration: TextEditorDecorationType; + + private _hoverDisposable: IDisposable | undefined; private _disposables: IDisposable[] = []; constructor(private readonly _controller: GitBlameController) { - this._decorationType = window.createTextEditorDecorationType({ + this._decoration = window.createTextEditorDecorationType({ after: { color: new ThemeColor('git.blame.editorDecorationForeground') } }); - this._disposables.push(this._decorationType); + this._disposables.push(this._decoration); workspace.onDidChangeConfiguration(this._onDidChangeConfiguration, this, this._disposables); window.onDidChangeActiveTextEditor(this._onDidChangeActiveTextEditor, this, this._disposables); + this._controller.onDidChangeBlameInformation(() => this._onDidChangeBlameInformation(), this, this._disposables); - this._controller.onDidChangeBlameInformation(e => this._updateDecorations(e), this, this._disposables); + this._onDidChangeConfiguration(); } - private _onDidChangeConfiguration(e: ConfigurationChangeEvent): void { - if (!e.affectsConfiguration('git.blame.editorDecoration.enabled') && + async provideHover(document: TextDocument, position: Position, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + + const textEditor = window.activeTextEditor; + if (!textEditor) { + return undefined; + } + + // Position must be at the end of the line + if (position.character !== document.lineAt(position.line).range.end.character) { + return undefined; + } + + // Get blame information + const blameInformation = this._controller.textEditorBlameInformation; + const lineBlameInformation = blameInformation?.find(blame => blame.lineNumber === position.line); + + if (!lineBlameInformation || typeof lineBlameInformation.blameInformation === 'string') { + return undefined; + } + + const contents = await this._controller.getBlameInformationHover(textEditor.document.uri, lineBlameInformation.blameInformation, true); + + if (!contents || token.isCancellationRequested) { + return undefined; + } + + return { range: getEditorDecorationRange(position.line), contents: [contents] }; + } + + private _onDidChangeConfiguration(e?: ConfigurationChangeEvent): void { + if (e && + !e.affectsConfiguration('git.commitShortHashLength') && !e.affectsConfiguration('git.blame.editorDecoration.template')) { return; } - for (const textEditor of window.visibleTextEditors) { - if (this._getConfiguration().enabled) { - this._updateDecorations(textEditor); - } else { - textEditor.setDecorations(this._decorationType, []); - } - } + this._registerHoverProvider(); + this._onDidChangeBlameInformation(); } private _onDidChangeActiveTextEditor(): void { - if (!this._getConfiguration().enabled) { - return; - } - + // Clear decorations for (const editor of window.visibleTextEditors) { if (editor !== window.activeTextEditor) { - editor.setDecorations(this._decorationType, []); + editor.setDecorations(this._decoration, []); } } + + // Register hover provider + this._registerHoverProvider(); } - private _getConfiguration(): { enabled: boolean; template: string } { - const config = workspace.getConfiguration('git'); - const enabled = config.get('blame.editorDecoration.enabled', false); - const template = config.get('blame.editorDecoration.template', '${subject}, ${authorName} (${authorDateAgo})'); - - return { enabled, template }; - } - - private _updateDecorations(textEditor: TextEditor): void { - const { enabled, template } = this._getConfiguration(); - if (!enabled) { - return; - } - - // Only support resources with `file` and `git` schemes - if (textEditor.document.uri.scheme !== 'file' && !isGitUri(textEditor.document.uri)) { - textEditor.setDecorations(this._decorationType, []); + private _onDidChangeBlameInformation(): void { + const textEditor = window.activeTextEditor; + if (!textEditor) { return; } // Get blame information - const blameInformation = this._controller.textEditorBlameInformation.get(textEditor); + const blameInformation = this._controller.textEditorBlameInformation; if (!blameInformation) { - textEditor.setDecorations(this._decorationType, []); + textEditor.setDecorations(this._decoration, []); return; } // Set decorations for the editor + const config = workspace.getConfiguration('git'); + const template = config.get('blame.editorDecoration.template', '${subject}, ${authorName} (${authorDateAgo})'); + const decorations = blameInformation.map(blame => { const contentText = typeof blame.blameInformation !== 'string' - ? this._controller.formatBlameInformationMessage(template, blame.blameInformation) + ? this._controller.formatBlameInformationMessage(textEditor.document.uri, template, blame.blameInformation) : blame.blameInformation; - const hoverMessage = typeof blame.blameInformation !== 'string' - ? this._controller.getBlameInformationHover(textEditor.document.uri, blame.blameInformation) - : undefined; - return this._createDecoration(blame.lineNumber, contentText, hoverMessage); + return this._createDecoration(blame.lineNumber, contentText); }); - textEditor.setDecorations(this._decorationType, decorations); + textEditor.setDecorations(this._decoration, decorations); } - private _createDecoration(lineNumber: number, contentText: string, hoverMessage: MarkdownString | undefined): DecorationOptions { - const position = new Position(lineNumber, Number.MAX_SAFE_INTEGER); - + private _createDecoration(lineNumber: number, contentText: string): DecorationOptions { return { - hoverMessage, - range: new Range(position, position), + range: getEditorDecorationRange(lineNumber), renderOptions: { after: { - contentText: `${contentText}`, + contentText, margin: '0 0 0 50px' } }, }; } + private _registerHoverProvider(): void { + this._hoverDisposable?.dispose(); + + if (window.activeTextEditor && isResourceSchemeSupported(window.activeTextEditor.document.uri)) { + this._hoverDisposable = languages.registerHoverProvider({ + pattern: window.activeTextEditor.document.uri.fsPath + }, this); + } + } + dispose() { + this._hoverDisposable?.dispose(); + this._hoverDisposable = undefined; + this._disposables = dispose(this._disposables); } } class GitBlameStatusBarItem { - private _statusBarItem: StatusBarItem | undefined; - + private _statusBarItem: StatusBarItem; private _disposables: IDisposable[] = []; constructor(private readonly _controller: GitBlameController) { - workspace.onDidChangeConfiguration(this._onDidChangeConfiguration, this, this._disposables); - window.onDidChangeActiveTextEditor(this._onDidChangeActiveTextEditor, this, this._disposables); + this._statusBarItem = window.createStatusBarItem('git.blame', StatusBarAlignment.Right, 200); + this._statusBarItem.name = l10n.t('Git Blame Information'); + this._disposables.push(this._statusBarItem); - this._controller.onDidChangeBlameInformation(e => this._updateStatusBarItem(e), this, this._disposables); + workspace.onDidChangeConfiguration(this._onDidChangeConfiguration, this, this._disposables); + this._controller.onDidChangeBlameInformation(() => this._onDidChangeBlameInformation(), this, this._disposables); } private _onDidChangeConfiguration(e: ConfigurationChangeEvent): void { - if (!e.affectsConfiguration('git.blame.statusBarItem.enabled') && + if (!e.affectsConfiguration('git.commitShortHashLength') && !e.affectsConfiguration('git.blame.statusBarItem.template')) { return; } - if (this._getConfiguration().enabled) { - if (window.activeTextEditor) { - this._updateStatusBarItem(window.activeTextEditor); - } - } else { - this._statusBarItem?.dispose(); - this._statusBarItem = undefined; - } + this._onDidChangeBlameInformation(); } - private _onDidChangeActiveTextEditor(): void { - if (!this._getConfiguration().enabled) { - return; - } - + private async _onDidChangeBlameInformation(): Promise { if (!window.activeTextEditor) { - this._statusBarItem?.hide(); - } - } - - private _getConfiguration(): { enabled: boolean; template: string } { - const config = workspace.getConfiguration('git'); - const enabled = config.get('blame.statusBarItem.enabled', false); - const template = config.get('blame.statusBarItem.template', '${authorName} (${authorDateAgo})'); - - return { enabled, template }; - } - - private _updateStatusBarItem(textEditor: TextEditor): void { - const { enabled, template } = this._getConfiguration(); - if (!enabled || textEditor !== window.activeTextEditor) { - return; - } - - if (!this._statusBarItem) { - this._statusBarItem = window.createStatusBarItem('git.blame', StatusBarAlignment.Right, 200); - this._statusBarItem.name = l10n.t('Git Blame Information'); - this._disposables.push(this._statusBarItem); - } - - // Only support resources with `file` and `git` schemes - if (textEditor.document.uri.scheme !== 'file' && !isGitUri(textEditor.document.uri)) { this._statusBarItem.hide(); return; } - const blameInformation = this._controller.textEditorBlameInformation.get(textEditor); + const blameInformation = this._controller.textEditorBlameInformation; if (!blameInformation || blameInformation.length === 0) { this._statusBarItem.hide(); return; @@ -593,12 +721,15 @@ class GitBlameStatusBarItem { this._statusBarItem.tooltip = l10n.t('Git Blame Information'); this._statusBarItem.command = undefined; } else { - this._statusBarItem.text = `$(git-commit) ${this._controller.formatBlameInformationMessage(template, blameInformation[0].blameInformation)}`; - this._statusBarItem.tooltip = this._controller.getBlameInformationHover(textEditor.document.uri, blameInformation[0].blameInformation); + const config = workspace.getConfiguration('git'); + const template = config.get('blame.statusBarItem.template', '${authorName} (${authorDateAgo})'); + + this._statusBarItem.text = `$(git-commit) ${this._controller.formatBlameInformationMessage(window.activeTextEditor.document.uri, template, blameInformation[0].blameInformation)}`; + this._statusBarItem.tooltip = await this._controller.getBlameInformationHover(window.activeTextEditor.document.uri, blameInformation[0].blameInformation); this._statusBarItem.command = { - title: l10n.t('View Commit'), - command: 'git.blameStatusBarItem.viewCommit', - arguments: [textEditor.document.uri, blameInformation[0].blameInformation.hash] + title: l10n.t('Open Commit'), + command: 'git.viewCommit', + arguments: [window.activeTextEditor.document.uri, blameInformation[0].blameInformation.hash] } satisfies Command; } diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index dde1c99049a..d3e852ceab8 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -14,11 +14,11 @@ import { Model } from './model'; import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository'; import { DiffEditorSelectionHunkToolbarContext, applyLineChanges, getModifiedRange, getWorkingTreeAndIndexDiffInformation, getWorkingTreeDiffInformation, intersectDiffWithRange, invertLineChange, toLineChanges, toLineRanges } from './staging'; import { fromGitUri, toGitUri, isGitUri, toMergeUris, toMultiFileDiffEditorUris } from './uri'; -import { dispose, grep, isDefined, isDescendant, pathEquals, relativePath, truncate } from './util'; +import { dispose, getCommitShortHash, grep, isDefined, isDescendant, pathEquals, relativePath, truncate } from './util'; import { GitTimelineItem } from './timelineProvider'; import { ApiRepository } from './api/api1'; import { getRemoteSourceActions, pickRemoteSource } from './remoteSource'; -import { RemoteSourceAction } from './api/git-base'; +import { RemoteSourceAction } from './typings/git-base'; abstract class CheckoutCommandItem implements QuickPickItem { abstract get label(): string; @@ -309,7 +309,7 @@ async function categorizeResourceByResolution(resources: Resource[]): Promise<{ const isBothAddedOrModified = (s: Resource) => s.type === Status.BOTH_MODIFIED || s.type === Status.BOTH_ADDED; const isAnyDeleted = (s: Resource) => s.type === Status.DELETED_BY_THEM || s.type === Status.DELETED_BY_US; const possibleUnresolved = merge.filter(isBothAddedOrModified); - const promises = possibleUnresolved.map(s => grep(s.resourceUri.fsPath, /^<{7}|^={7}|^>{7}/)); + const promises = possibleUnresolved.map(s => grep(s.resourceUri.fsPath, /^<{7}\s|^={7}$|^>{7}\s/)); const unresolvedBothModified = await Promise.all(promises); const resolved = possibleUnresolved.filter((_s, i) => !unresolvedBothModified[i]); const deletionConflicts = merge.filter(s => isAnyDeleted(s)); @@ -4271,61 +4271,6 @@ export class CommandCenter { }); } - @command('git.viewCommit', { repository: true }) - async viewCommit(repository: Repository, historyItem1: SourceControlHistoryItem, historyItem2?: SourceControlHistoryItem): Promise { - if (!repository || !historyItem1) { - return; - } - - if (historyItem2) { - const mergeBase = await repository.getMergeBase(historyItem1.id, historyItem2.id); - if (!mergeBase || (mergeBase !== historyItem1.id && mergeBase !== historyItem2.id)) { - return; - } - } - - let title: string | undefined; - let historyItemParentId: string | undefined; - - // If historyItem2 is not provided, we are viewing a single commit. If historyItem2 is - // provided, we are viewing a range and we have to include both start and end commits. - // TODO@lszomoru - handle the case when historyItem2 is the first commit in the repository - if (!historyItem2) { - const commit = await repository.getCommit(historyItem1.id); - title = `${historyItem1.id.substring(0, 8)} - ${truncate(commit.message)}`; - historyItemParentId = historyItem1.parentIds.length > 0 ? historyItem1.parentIds[0] : `${historyItem1.id}^`; - } else { - title = l10n.t('All Changes ({0} ↔ {1})', historyItem2.id.substring(0, 8), historyItem1.id.substring(0, 8)); - historyItemParentId = historyItem2.parentIds.length > 0 ? historyItem2.parentIds[0] : `${historyItem2.id}^`; - } - - const multiDiffSourceUri = Uri.from({ scheme: 'scm-history-item', path: `${repository.root}/${historyItemParentId}..${historyItem1.id}` }); - - await this._viewChanges(repository, historyItem1.id, historyItemParentId, multiDiffSourceUri, title); - } - - @command('git.viewAllChanges', { repository: true }) - async viewAllChanges(repository: Repository, historyItem: SourceControlHistoryItem): Promise { - if (!repository || !historyItem) { - return; - } - - const modifiedShortRef = historyItem.id.substring(0, 8); - const originalShortRef = historyItem.parentIds.length > 0 ? historyItem.parentIds[0].substring(0, 8) : `${modifiedShortRef}^`; - const title = l10n.t('All Changes ({0} ↔ {1})', originalShortRef, modifiedShortRef); - - const multiDiffSourceUri = toGitUri(Uri.file(repository.root), historyItem.id, { scheme: 'git-changes' }); - - await this._viewChanges(repository, modifiedShortRef, originalShortRef, multiDiffSourceUri, title); - } - - async _viewChanges(repository: Repository, historyItemId: string, historyItemParentId: string, multiDiffSourceUri: Uri, title: string): Promise { - const changes = await repository.diffBetween(historyItemParentId, historyItemId); - const resources = changes.map(c => toMultiFileDiffEditorUris(c, historyItemParentId, historyItemId)); - - await commands.executeCommand('_workbench.openMultiDiffEditor', { multiDiffSourceUri, title, resources }); - } - @command('git.copyCommitId', { repository: true }) async copyCommitId(repository: Repository, historyItem: SourceControlHistoryItem): Promise { if (!repository || !historyItem) { @@ -4344,14 +4289,15 @@ export class CommandCenter { env.clipboard.writeText(historyItem.message); } - @command('git.blameStatusBarItem.viewCommit', { repository: true }) - async viewStatusBarCommit(repository: Repository, historyItemId: string): Promise { + @command('git.viewCommit', { repository: true }) + async viewCommit(repository: Repository, historyItemId: string): Promise { if (!repository || !historyItemId) { return; } + const rootUri = Uri.file(repository.root); const commit = await repository.getCommit(historyItemId); - const title = `${historyItemId.substring(0, 8)} - ${truncate(commit.message)}`; + const title = `${getCommitShortHash(rootUri, historyItemId)} - ${truncate(commit.message)}`; const historyItemParentId = commit.parents.length > 0 ? commit.parents[0] : `${historyItemId}^`; const multiDiffSourceUri = Uri.from({ scheme: 'scm-history-item', path: `${repository.root}/${historyItemParentId}..${historyItemId}` }); @@ -4362,8 +4308,8 @@ export class CommandCenter { await commands.executeCommand('_workbench.openMultiDiffEditor', { multiDiffSourceUri, title, resources }); } - @command('git.blameStatusBarItem.copyContent') - async blameStatusBarCopyContent(content: string): Promise { + @command('git.copyContentToClipboard') + async copyContentToClipboard(content: string): Promise { if (typeof content !== 'string') { return; } diff --git a/extensions/git/src/fileSystemProvider.ts b/extensions/git/src/fileSystemProvider.ts index af80924ae13..0847fe8d745 100644 --- a/extensions/git/src/fileSystemProvider.ts +++ b/extensions/git/src/fileSystemProvider.ts @@ -192,7 +192,9 @@ export class GitFileSystemProvider implements FileSystemProvider { try { return await repository.buffer(sanitizeRef(ref, path, repository), path); } catch (err) { - return new Uint8Array(0); + // File does not exist in git. This could be + // because the file is untracked or ignored + throw FileSystemError.FileNotFound(); } } diff --git a/extensions/git/src/git-base.ts b/extensions/git/src/git-base.ts index 6cef535cfa5..437a126c9f3 100644 --- a/extensions/git/src/git-base.ts +++ b/extensions/git/src/git-base.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { extensions } from 'vscode'; -import { API as GitBaseAPI, GitBaseExtension } from './api/git-base'; +import { API as GitBaseAPI, GitBaseExtension } from './typings/git-base'; export class GitBaseApi { diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 87ea23e3a85..7e2fd062f88 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -62,6 +62,7 @@ export interface LogFileOptions { /** Optional. Specifies whether to start retrieving log entries in reverse order. */ readonly reverse?: boolean; readonly sortByAuthorDate?: boolean; + readonly shortStats?: boolean; } function parseVersion(raw: string): string { @@ -1166,11 +1167,11 @@ export class Repository { return this.git.spawn(args, options); } - async config(scope: string, key: string, value: any = null, options: SpawnOptions = {}): Promise { - const args = ['config']; + async config(command: string, scope: string, key: string, value: any = null, options: SpawnOptions = {}): Promise { + const args = ['config', `--${command}`]; if (scope) { - args.push('--' + scope); + args.push(`--${scope}`); } args.push(key); @@ -1285,6 +1286,10 @@ export class Repository { } } + if (options?.shortStats) { + args.push('--shortstat'); + } + if (options?.sortByAuthorDate) { args.push('--author-date-order'); } @@ -2774,8 +2779,8 @@ export class Repository { return Promise.reject(new Error(`No such branch: ${name}.`)); } - async getDefaultBranch(): Promise { - const result = await this.exec(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']); + async getDefaultBranch(remoteName: string): Promise { + const result = await this.exec(['symbolic-ref', '--short', `refs/remotes/${remoteName}/HEAD`]); if (!result.stdout || result.stderr) { throw new Error('No default branch'); } @@ -2843,6 +2848,15 @@ export class Repository { return commits[0]; } + async revList(ref1: string, ref2: string): Promise { + const result = await this.exec(['rev-list', `${ref1}..${ref2}`]); + if (result.stderr) { + return []; + } + + return result.stdout.trim().split('\n'); + } + async revParse(ref: string): Promise { try { const result = await fs.readFile(path.join(this.dotGit.path, ref), 'utf8'); diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index 896c46851c4..6d65c5226b6 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -6,20 +6,22 @@ import { Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, SourceControlHistoryItem, SourceControlHistoryItemChange, SourceControlHistoryOptions, SourceControlHistoryProvider, ThemeIcon, Uri, window, LogOutputChannel, SourceControlHistoryItemRef, l10n, SourceControlHistoryItemRefsChangeEvent } from 'vscode'; import { Repository, Resource } from './repository'; -import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent } from './util'; +import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, getCommitShortHash } from './util'; import { toGitUri } from './uri'; import { Branch, LogOptions, Ref, RefType } from './api/git'; import { emojify, ensureEmojis } from './emoji'; import { Commit } from './git'; import { OperationKind, OperationResult } from './operation'; -function toSourceControlHistoryItemRef(ref: Ref): SourceControlHistoryItemRef { +function toSourceControlHistoryItemRef(repository: Repository, ref: Ref): SourceControlHistoryItemRef { + const rootUri = Uri.file(repository.root); + switch (ref.type) { case RefType.RemoteHead: return { id: `refs/remotes/${ref.name}`, name: ref.name ?? '', - description: ref.commit ? l10n.t('Remote branch at {0}', ref.commit.substring(0, 8)) : undefined, + description: ref.commit ? l10n.t('Remote branch at {0}', getCommitShortHash(rootUri, ref.commit)) : undefined, revision: ref.commit, icon: new ThemeIcon('cloud'), category: l10n.t('remote branches') @@ -28,7 +30,7 @@ function toSourceControlHistoryItemRef(ref: Ref): SourceControlHistoryItemRef { return { id: `refs/tags/${ref.name}`, name: ref.name ?? '', - description: ref.commit ? l10n.t('Tag at {0}', ref.commit.substring(0, 8)) : undefined, + description: ref.commit ? l10n.t('Tag at {0}', getCommitShortHash(rootUri, ref.commit)) : undefined, revision: ref.commit, icon: new ThemeIcon('tag'), category: l10n.t('tags') @@ -37,7 +39,7 @@ function toSourceControlHistoryItemRef(ref: Ref): SourceControlHistoryItemRef { return { id: `refs/heads/${ref.name}`, name: ref.name ?? '', - description: ref.commit ? ref.commit.substring(0, 8) : undefined, + description: ref.commit ? getCommitShortHash(rootUri, ref.commit) : undefined, revision: ref.commit, icon: new ThemeIcon('git-branch'), category: l10n.t('branches') @@ -88,7 +90,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec readonly onDidChangeHistoryItemRefs: Event = this._onDidChangeHistoryItemRefs.event; private _HEAD: Branch | undefined; - private historyItemRefs: SourceControlHistoryItemRef[] = []; + private _historyItemRefs: SourceControlHistoryItemRef[] = []; private historyItemDecorations = new Map(); @@ -110,6 +112,14 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec return; } + // Refs (alphabetically) + const historyItemRefs = this.repository.refs + .map(ref => toSourceControlHistoryItemRef(this.repository, ref)) + .sort((a, b) => a.id.localeCompare(b.id)); + + const delta = deltaHistoryItemRefs(this._historyItemRefs, historyItemRefs); + this._historyItemRefs = historyItemRefs; + let historyItemRefId = ''; let historyItemRefName = ''; @@ -128,8 +138,9 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec icon: new ThemeIcon('cloud') } : undefined; - // Base - compute only if the branch has changed + // Base if (this._HEAD?.name !== this.repository.HEAD.name) { + // Compute base if the branch has changed const mergeBase = await this.resolveHEADMergeBase(); this._currentHistoryItemBaseRef = mergeBase && @@ -140,6 +151,17 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec revision: mergeBase.commit, icon: new ThemeIcon('cloud') } : undefined; + } else { + // Update base revision if it has changed + const mergeBaseModified = delta.modified + .find(ref => ref.id === this._currentHistoryItemBaseRef?.id); + + if (this._currentHistoryItemBaseRef && mergeBaseModified) { + this._currentHistoryItemBaseRef = { + ...this._currentHistoryItemBaseRef, + revision: mergeBaseModified.revision + }; + } } } else { // Detached commit @@ -176,18 +198,10 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec this.logger.trace(`[GitHistoryProvider][onDidRunWriteOperation] currentHistoryItemRemoteRef: ${JSON.stringify(this._currentHistoryItemRemoteRef)}`); this.logger.trace(`[GitHistoryProvider][onDidRunWriteOperation] currentHistoryItemBaseRef: ${JSON.stringify(this._currentHistoryItemBaseRef)}`); - // Refs (alphabetically) - const historyItemRefs = this.repository.refs - .map(ref => toSourceControlHistoryItemRef(ref)) - .sort((a, b) => a.id.localeCompare(b.id)); - // Auto-fetch const silent = result.operation.kind === OperationKind.Fetch && result.operation.showProgress === false; - const delta = deltaHistoryItemRefs(this.historyItemRefs, historyItemRefs); this._onDidChangeHistoryItemRefs.fire({ ...delta, silent }); - this.historyItemRefs = historyItemRefs; - const deltaLog = { added: delta.added.map(ref => ref.id), modified: delta.modified.map(ref => ref.id), @@ -207,13 +221,13 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec for (const ref of refs) { switch (ref.type) { case RefType.RemoteHead: - remoteBranches.push(toSourceControlHistoryItemRef(ref)); + remoteBranches.push(toSourceControlHistoryItemRef(this.repository, ref)); break; case RefType.Tag: - tags.push(toSourceControlHistoryItemRef(ref)); + tags.push(toSourceControlHistoryItemRef(this.repository, ref)); break; default: - branches.push(toSourceControlHistoryItemRef(ref)); + branches.push(toSourceControlHistoryItemRef(this.repository, ref)); break; } } @@ -258,8 +272,9 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec parentIds: commit.parents, message: emojify(commit.message), author: commit.authorName, + authorEmail: commit.authorEmail, icon: new ThemeIcon('git-commit'), - displayId: commit.hash.substring(0, 8), + displayId: getCommitShortHash(Uri.file(this.repository.root), commit.hash), timestamp: commit.authorDate?.getTime(), statistics: commit.shortStat ?? { files: 0, insertions: 0, deletions: 0 }, references: references.length !== 0 ? references : undefined @@ -354,6 +369,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec id: ref.substring('HEAD -> '.length), name: ref.substring('HEAD -> refs/heads/'.length), revision: commit.hash, + category: l10n.t('branches'), icon: new ThemeIcon('target') }); break; @@ -362,6 +378,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec id: ref, name: ref.substring('refs/heads/'.length), revision: commit.hash, + category: l10n.t('branches'), icon: new ThemeIcon('git-branch') }); break; @@ -370,6 +387,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec id: ref, name: ref.substring('refs/remotes/'.length), revision: commit.hash, + category: l10n.t('remote branches'), icon: new ThemeIcon('cloud') }); break; @@ -378,6 +396,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec id: ref.substring('tag: '.length), name: ref.substring('tag: refs/tags/'.length), revision: commit.hash, + category: l10n.t('tags'), icon: new ThemeIcon('tag') }); break; diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index 48e66d5e9ff..142d073914f 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -272,6 +272,7 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi workspace.onDidChangeWorkspaceFolders(this.onDidChangeWorkspaceFolders, this, this.disposables); window.onDidChangeVisibleTextEditors(this.onDidChangeVisibleTextEditors, this, this.disposables); + window.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditor, this, this.disposables); workspace.onDidChangeConfiguration(this.onDidChangeConfiguration, this, this.disposables); const fsWatcher = workspace.createFileSystemWatcher('**'); @@ -519,6 +520,31 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi } } + private onDidChangeActiveTextEditor(): void { + const textEditor = window.activeTextEditor; + + if (textEditor === undefined) { + commands.executeCommand('setContext', 'git.activeResourceHasUnstagedChanges', false); + commands.executeCommand('setContext', 'git.activeResourceHasStagedChanges', false); + return; + } + + const repository = this.getRepository(textEditor.document.uri); + if (!repository) { + commands.executeCommand('setContext', 'git.activeResourceHasUnstagedChanges', false); + commands.executeCommand('setContext', 'git.activeResourceHasStagedChanges', false); + return; + } + + const indexResource = repository.indexGroup.resourceStates + .find(resource => pathEquals(resource.resourceUri.fsPath, textEditor.document.uri.fsPath)); + const workingTreeResource = repository.workingTreeGroup.resourceStates + .find(resource => pathEquals(resource.resourceUri.fsPath, textEditor.document.uri.fsPath)); + + commands.executeCommand('setContext', 'git.activeResourceHasStagedChanges', indexResource !== undefined); + commands.executeCommand('setContext', 'git.activeResourceHasUnstagedChanges', workingTreeResource !== undefined); + } + @sequentialize async openRepository(repoPath: string, openIfClosed = false): Promise { this.logger.trace(`[Model][openRepository] Repository: ${repoPath}`); @@ -727,8 +753,10 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi const statusListener = repository.onDidRunGitStatus(() => { checkForSubmodules(); updateMergeChanges(); + this.onDidChangeActiveTextEditor(); }); checkForSubmodules(); + this.onDidChangeActiveTextEditor(); const updateOperationInProgressContext = () => { let operationInProgress = false; diff --git a/extensions/git/src/remoteSource.ts b/extensions/git/src/remoteSource.ts index 4fdd6f06c1d..dfdb36fc11f 100644 --- a/extensions/git/src/remoteSource.ts +++ b/extensions/git/src/remoteSource.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PickRemoteSourceOptions, PickRemoteSourceResult } from './api/git-base'; +import { PickRemoteSourceOptions, PickRemoteSourceResult } from './typings/git-base'; import { GitBaseApi } from './git-base'; export async function pickRemoteSource(options: PickRemoteSourceOptions & { branch?: false | undefined }): Promise; @@ -15,3 +15,7 @@ export async function pickRemoteSource(options: PickRemoteSourceOptions = {}): P export async function getRemoteSourceActions(url: string) { return GitBaseApi.getAPI().getRemoteSourceActions(url); } + +export async function getRemoteSourceControlHistoryItemCommands(url: string) { + return GitBaseApi.getAPI().getRemoteSourceControlHistoryItemCommands(url); +} diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 0c4b50cd5e5..9b70bef793c 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -23,7 +23,7 @@ import { IPushErrorHandlerRegistry } from './pushError'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { StatusBarCommands } from './statusbar'; import { toGitUri } from './uri'; -import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, IDisposable, isDescendant, onceEvent, pathEquals, relativePath } from './util'; +import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isDescendant, onceEvent, pathEquals, relativePath } from './util'; import { IFileWatcher, watch } from './watch'; import { detectEncoding } from './encoding'; @@ -560,13 +560,11 @@ class ResourceCommandResolver { switch (resource.type) { case Status.INDEX_MODIFIED: case Status.INDEX_RENAMED: - case Status.INDEX_ADDED: case Status.INTENT_TO_RENAME: case Status.TYPE_CHANGED: return { original: toGitUri(resource.original, 'HEAD') }; case Status.MODIFIED: - case Status.UNTRACKED: return { original: toGitUri(resource.resourceUri, '~') }; case Status.DELETED_BY_US: @@ -843,6 +841,7 @@ export class Repository implements Disposable { private isRepositoryHuge: false | { limit: number } = false; private didWarnAboutLimit = false; + private unpublishedCommits: Set | undefined = undefined; private branchProtection = new Map(); private commitCommandCenter: CommitCommandsCenter; private resourceCommandResolver = new ResourceCommandResolver(this); @@ -1082,15 +1081,19 @@ export class Repository implements Disposable { } getConfig(key: string): Promise { - return this.run(Operation.Config(true), () => this.repository.config('local', key)); + return this.run(Operation.Config(true), () => this.repository.config('get', 'local', key)); } getGlobalConfig(key: string): Promise { - return this.run(Operation.Config(true), () => this.repository.config('global', key)); + return this.run(Operation.Config(true), () => this.repository.config('get', 'global', key)); } setConfig(key: string, value: string): Promise { - return this.run(Operation.Config(false), () => this.repository.config('local', key, value)); + return this.run(Operation.Config(false), () => this.repository.config('add', 'local', key, value)); + } + + unsetConfig(key: string): Promise { + return this.run(Operation.Config(false), () => this.repository.config('unset', 'local', key)); } log(options?: LogOptions & { silent?: boolean }): Promise { @@ -1465,7 +1468,10 @@ export class Repository implements Disposable { } async deleteBranch(name: string, force?: boolean): Promise { - await this.run(Operation.DeleteBranch, () => this.repository.deleteBranch(name, force)); + return this.run(Operation.DeleteBranch, async () => { + await this.repository.deleteBranch(name, force); + await this.repository.config('unset', 'local', `branch.${name}.vscode-merge-base`); + }); } async renameBranch(name: string): Promise { @@ -1587,8 +1593,13 @@ export class Repository implements Disposable { } private async getDefaultBranch(): Promise { + const defaultRemote = this.getDefaultRemote(); + if (!defaultRemote) { + return undefined; + } + try { - const defaultBranch = await this.repository.getDefaultBranch(); + const defaultBranch = await this.repository.getDefaultBranch(defaultRemote.name); return defaultBranch; } catch (err) { @@ -1656,7 +1667,7 @@ export class Repository implements Disposable { } async checkout(treeish: string, opts?: { detached?: boolean; pullBeforeCheckout?: boolean }): Promise { - const refLabel = opts?.detached ? treeish.substring(0, 8) : treeish; + const refLabel = opts?.detached ? getCommitShortHash(Uri.file(this.root), treeish) : treeish; await this.run(Operation.Checkout(refLabel), async () => { @@ -1674,7 +1685,7 @@ export class Repository implements Disposable { } async checkoutTracking(treeish: string, opts: { detached?: boolean } = {}): Promise { - const refLabel = opts.detached ? treeish.substring(0, 8) : treeish; + const refLabel = opts.detached ? getCommitShortHash(Uri.file(this.root), treeish) : treeish; await this.run(Operation.CheckoutTracking(refLabel), () => this.repository.checkout(treeish, [], { ...opts, track: true })); } @@ -1703,6 +1714,14 @@ export class Repository implements Disposable { await this.run(Operation.DeleteRef, () => this.repository.deleteRef(ref)); } + getDefaultRemote(): Remote | undefined { + if (this.remotes.length === 0) { + return undefined; + } + + return this.remotes.find(r => r.name === 'origin') ?? this.remotes[0]; + } + async addRemote(name: string, url: string): Promise { await this.run(Operation.Remote, () => this.repository.addRemote(name, url)); } @@ -2252,6 +2271,17 @@ export class Repository implements Disposable { this.isCherryPickInProgress(), this.getInputTemplate()]); + // Reset the list of unpublished commits if HEAD has + // changed (ex: checkout, fetch, pull, push, publish, etc.). + // The list of unpublished commits will be computed lazily + // on demand. + if (this.HEAD?.name !== HEAD?.name || + this.HEAD?.commit !== HEAD?.commit || + this.HEAD?.ahead !== HEAD?.ahead || + this.HEAD?.upstream !== HEAD?.upstream) { + this.unpublishedCommits = undefined; + } + this._HEAD = HEAD; this._remotes = remotes!; this._submodules = submodules!; @@ -2359,24 +2389,26 @@ export class Repository implements Disposable { const yes = { title: l10n.t('Yes') }; const no = { title: l10n.t('No') }; - const result = await window.showWarningMessage(`${gitWarn} ${addKnown}`, yes, no, neverAgain); - if (result === yes) { - this.ignore([Uri.file(folderPath)]); - } else { + window.showWarningMessage(`${gitWarn} ${addKnown}`, yes, no, neverAgain).then(result => { + if (result === yes) { + this.ignore([Uri.file(folderPath)]); + } else { + if (result === neverAgain) { + config.update('ignoreLimitWarning', true, false); + } + + this.didWarnAboutLimit = true; + } + }); + } else { + const ok = { title: l10n.t('OK') }; + window.showWarningMessage(gitWarn, ok, neverAgain).then(result => { if (result === neverAgain) { config.update('ignoreLimitWarning', true, false); } this.didWarnAboutLimit = true; - } - } else { - const ok = { title: l10n.t('OK') }; - const result = await window.showWarningMessage(gitWarn, ok, neverAgain); - if (result === neverAgain) { - config.update('ignoreLimitWarning', true, false); - } - - this.didWarnAboutLimit = true; + }); } } @@ -2726,6 +2758,24 @@ export class Repository implements Disposable { return false; } + async getUnpublishedCommits(): Promise> { + if (this.unpublishedCommits) { + return this.unpublishedCommits; + } + + if (this.HEAD && this.HEAD.name && this.HEAD.upstream && this.HEAD.ahead && this.HEAD.ahead > 0) { + const ref1 = `${this.HEAD.upstream.remote}/${this.HEAD.upstream.name}`; + const ref2 = this.HEAD.name; + + const revList = await this.repository.revList(ref1, ref2); + this.unpublishedCommits = new Set(revList); + } else { + this.unpublishedCommits = new Set(); + } + + return this.unpublishedCommits; + } + dispose(): void { this.disposables = dispose(this.disposables); } diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts index 5788ecc53dd..b243d72ed4b 100644 --- a/extensions/git/src/timelineProvider.ts +++ b/extensions/git/src/timelineProvider.ts @@ -3,13 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken, ConfigurationChangeEvent, Disposable, env, Event, EventEmitter, MarkdownString, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace, l10n } from 'vscode'; +import { CancellationToken, ConfigurationChangeEvent, Disposable, env, Event, EventEmitter, MarkdownString, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace, l10n, Command } from 'vscode'; import { Model } from './model'; import { Repository, Resource } from './repository'; import { debounce } from './decorators'; import { emojify, ensureEmojis } from './emoji'; import { CommandCenter } from './commands'; import { OperationKind, OperationResult } from './operation'; +import { getCommitShortHash } from './util'; +import { CommitShortStat } from './git'; +import { getRemoteSourceControlHistoryItemCommands } from './remoteSource'; export class GitTimelineItem extends TimelineItem { static is(item: TimelineItem): item is GitTimelineItem { @@ -48,18 +51,56 @@ export class GitTimelineItem extends TimelineItem { return this.shortenRef(this.previousRef); } - setItemDetails(author: string, email: string | undefined, date: string, message: string): void { + setItemDetails(uri: Uri, hash: string | undefined, author: string, email: string | undefined, date: string, message: string, shortStat?: CommitShortStat, remoteSourceCommands: Command[] = []): void { this.tooltip = new MarkdownString('', true); + this.tooltip.isTrusted = true; + this.tooltip.supportHtml = true; if (email) { const emailTitle = l10n.t('Email'); - this.tooltip.appendMarkdown(`$(account) [**${author}**](mailto:${email} "${emailTitle} ${author}")\n\n`); + this.tooltip.appendMarkdown(`$(account) [**${author}**](mailto:${email} "${emailTitle} ${author}")`); } else { - this.tooltip.appendMarkdown(`$(account) **${author}**\n\n`); + this.tooltip.appendMarkdown(`$(account) **${author}**`); } - this.tooltip.appendMarkdown(`$(history) ${date}\n\n`); - this.tooltip.appendMarkdown(message); + this.tooltip.appendMarkdown(`, $(history) ${date}\n\n`); + this.tooltip.appendMarkdown(`${message}\n\n`); + + if (shortStat) { + this.tooltip.appendMarkdown(`---\n\n`); + + const labels: string[] = []; + if (shortStat.insertions) { + labels.push(`${shortStat.insertions === 1 ? + l10n.t('{0} insertion{1}', shortStat.insertions, '(+)') : + l10n.t('{0} insertions{1}', shortStat.insertions, '(+)')}`); + } + + if (shortStat.deletions) { + labels.push(`${shortStat.deletions === 1 ? + l10n.t('{0} deletion{1}', shortStat.deletions, '(-)') : + l10n.t('{0} deletions{1}', shortStat.deletions, '(-)')}`); + } + + this.tooltip.appendMarkdown(`${labels.join(', ')}\n\n`); + } + + if (hash) { + this.tooltip.appendMarkdown(`---\n\n`); + + this.tooltip.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(uri, hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([uri, hash]))} "${l10n.t('Open Commit')}")`); + this.tooltip.appendMarkdown(' '); + this.tooltip.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`); + + // Remote commands + if (remoteSourceCommands.length > 0) { + this.tooltip.appendMarkdown('  |  '); + + const remoteCommandsMarkdown = remoteSourceCommands + .map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify([...command.arguments ?? [], hash]))} "${command.tooltip}")`); + this.tooltip.appendMarkdown(remoteCommandsMarkdown.join(' ')); + } + } } private shortenRef(ref: string): string { @@ -153,6 +194,7 @@ export class GitTimelineProvider implements TimelineProvider { maxEntries: limit, hash: options.cursor, follow: true, + shortStats: true, // sortByAuthorDate: true }); @@ -173,6 +215,12 @@ export class GitTimelineProvider implements TimelineProvider { const openComparison = l10n.t('Open Comparison'); + const defaultRemote = repo.getDefaultRemote(); + const unpublishedCommits = await repo.getUnpublishedCommits(); + const remoteSourceCommands: Command[] = defaultRemote?.fetchUrl + ? await getRemoteSourceControlHistoryItemCommands(defaultRemote.fetchUrl) + : []; + const items = commits.map((c, i) => { const date = dateType === 'authored' ? c.authorDate : c.commitDate; @@ -184,7 +232,8 @@ export class GitTimelineProvider implements TimelineProvider { item.description = c.authorName; } - item.setItemDetails(c.authorName!, c.authorEmail, dateFormatter.format(date), message); + const commitRemoteSourceCommands = !unpublishedCommits.has(c.hash) ? remoteSourceCommands : []; + item.setItemDetails(uri, c.hash, c.authorName!, c.authorEmail, dateFormatter.format(date), message, c.shortStat, commitRemoteSourceCommands); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { @@ -209,7 +258,7 @@ export class GitTimelineProvider implements TimelineProvider { // TODO@eamodio: Replace with a better icon -- reflecting its status maybe? item.iconPath = new ThemeIcon('git-commit'); item.description = ''; - item.setItemDetails(you, undefined, dateFormatter.format(date), Resource.getStatusText(index.type)); + item.setItemDetails(uri, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(index.type)); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { @@ -231,7 +280,7 @@ export class GitTimelineProvider implements TimelineProvider { const item = new GitTimelineItem('', index ? '~' : 'HEAD', l10n.t('Uncommitted Changes'), date.getTime(), 'working', 'git:file:working'); item.iconPath = new ThemeIcon('circle-outline'); item.description = ''; - item.setItemDetails(you, undefined, dateFormatter.format(date), Resource.getStatusText(working.type)); + item.setItemDetails(uri, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(working.type)); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { diff --git a/extensions/git/src/api/git-base.d.ts b/extensions/git/src/typings/git-base.d.ts similarity index 91% rename from extensions/git/src/api/git-base.d.ts rename to extensions/git/src/typings/git-base.d.ts index 1eeb1739901..37dd2c4229c 100644 --- a/extensions/git/src/api/git-base.d.ts +++ b/extensions/git/src/typings/git-base.d.ts @@ -3,13 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, Event, ProviderResult, Uri } from 'vscode'; +import { Command, Disposable, Event, ProviderResult } from 'vscode'; export { ProviderResult } from 'vscode'; export interface API { - pickRemoteSource(options: PickRemoteSourceOptions): Promise; - getRemoteSourceActions(url: string): Promise; registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + getRemoteSourceActions(url: string): Promise; + getRemoteSourceControlHistoryItemCommands(url: string): Promise; + pickRemoteSource(options: PickRemoteSourceOptions): Promise; } export interface GitBaseExtension { @@ -81,6 +82,7 @@ export interface RemoteSourceProvider { getBranches?(url: string): ProviderResult; getRemoteSourceActions?(url: string): ProviderResult; + getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult; getRecentRemoteSources?(query?: string): ProviderResult; getRemoteSources(query?: string): ProviderResult; } diff --git a/extensions/git/src/typings/vscode.proposed.canonicalUriProvider.d.ts b/extensions/git/src/typings/vscode.proposed.canonicalUriProvider.d.ts deleted file mode 100644 index 84ee599797d..00000000000 --- a/extensions/git/src/typings/vscode.proposed.canonicalUriProvider.d.ts +++ /dev/null @@ -1,47 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/180582 - - export namespace workspace { - /** - * - * @param scheme The URI scheme that this provider can provide canonical URIs for. - * A canonical URI represents the conversion of a resource's alias into a source of truth URI. - * Multiple aliases may convert to the same source of truth URI. - * @param provider A provider which can convert URIs of scheme @param scheme to - * a canonical URI which is stable across machines. - */ - export function registerCanonicalUriProvider(scheme: string, provider: CanonicalUriProvider): Disposable; - - /** - * - * @param uri The URI to provide a canonical URI for. - * @param token A cancellation token for the request. - */ - export function getCanonicalUri(uri: Uri, options: CanonicalUriRequestOptions, token: CancellationToken): ProviderResult; - } - - export interface CanonicalUriProvider { - /** - * - * @param uri The URI to provide a canonical URI for. - * @param options Options that the provider should honor in the URI it returns. - * @param token A cancellation token for the request. - * @returns The canonical URI for the requested URI or undefined if no canonical URI can be provided. - */ - provideCanonicalUri(uri: Uri, options: CanonicalUriRequestOptions, token: CancellationToken): ProviderResult; - } - - export interface CanonicalUriRequestOptions { - /** - * - * The desired scheme of the canonical URI. - */ - targetScheme: string; - } -} diff --git a/extensions/git/src/typings/vscode.proposed.editSessionIdentityProvider.d.ts b/extensions/git/src/typings/vscode.proposed.editSessionIdentityProvider.d.ts deleted file mode 100644 index e09d0e142b4..00000000000 --- a/extensions/git/src/typings/vscode.proposed.editSessionIdentityProvider.d.ts +++ /dev/null @@ -1,71 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/157734 - - export namespace workspace { - /** - * An event that is emitted when an edit session identity is about to be requested. - */ - export const onWillCreateEditSessionIdentity: Event; - - /** - * - * @param scheme The URI scheme that this provider can provide edit session identities for. - * @param provider A provider which can convert URIs for workspace folders of scheme @param scheme to - * an edit session identifier which is stable across machines. This enables edit sessions to be resolved. - */ - export function registerEditSessionIdentityProvider(scheme: string, provider: EditSessionIdentityProvider): Disposable; - } - - export interface EditSessionIdentityProvider { - /** - * - * @param workspaceFolder The workspace folder to provide an edit session identity for. - * @param token A cancellation token for the request. - * @returns A string representing the edit session identity for the requested workspace folder. - */ - provideEditSessionIdentity(workspaceFolder: WorkspaceFolder, token: CancellationToken): ProviderResult; - - /** - * - * @param identity1 An edit session identity. - * @param identity2 A second edit session identity to compare to @param identity1. - * @param token A cancellation token for the request. - * @returns An {@link EditSessionIdentityMatch} representing the edit session identity match confidence for the provided identities. - */ - provideEditSessionIdentityMatch(identity1: string, identity2: string, token: CancellationToken): ProviderResult; - } - - export enum EditSessionIdentityMatch { - Complete = 100, - Partial = 50, - None = 0 - } - - export interface EditSessionIdentityWillCreateEvent { - - /** - * A cancellation token. - */ - readonly token: CancellationToken; - - /** - * The workspace folder to create an edit session identity for. - */ - readonly workspaceFolder: WorkspaceFolder; - - /** - * Allows to pause the event until the provided thenable resolves. - * - * *Note:* This function can only be called during event dispatch. - * - * @param thenable A thenable that delays saving. - */ - waitUntil(thenable: Thenable): void; - } -} diff --git a/extensions/git/src/util.ts b/extensions/git/src/util.ts index 8fb85493a9b..759ccdf82de 100644 --- a/extensions/git/src/util.ts +++ b/extensions/git/src/util.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n } from 'vscode'; +import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n, workspace, Uri } from 'vscode'; import { dirname, sep, relative } from 'path'; import { Readable } from 'stream'; import { promises as fs, createReadStream } from 'fs'; @@ -766,3 +766,9 @@ export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTi } } } + +export function getCommitShortHash(scope: Uri, hash: string): string { + const config = workspace.getConfiguration('git', scope); + const shortHashLength = config.get('commitShortHashLength', 7); + return hash.substring(0, shortHashLength); +} diff --git a/extensions/git/tsconfig.json b/extensions/git/tsconfig.json index ccf029e2df3..5a65f5c82ae 100644 --- a/extensions/git/tsconfig.json +++ b/extensions/git/tsconfig.json @@ -10,7 +10,9 @@ "include": [ "src/**/*", "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.canonicalUriProvider.d.ts", "../../src/vscode-dts/vscode.proposed.diffCommand.d.ts", + "../../src/vscode-dts/vscode.proposed.editSessionIdentityProvider.d.ts", "../../src/vscode-dts/vscode.proposed.quickDiffProvider.d.ts", "../../src/vscode-dts/vscode.proposed.quickPickSortByLabel.d.ts", "../../src/vscode-dts/vscode.proposed.scmActionButton.d.ts", diff --git a/extensions/github/package.json b/extensions/github/package.json index f99a41d5979..5ad7b749348 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -27,9 +27,11 @@ } }, "enabledApiProposals": [ - "contribShareMenu", - "contribEditSessions", "canonicalUriProvider", + "contribEditSessions", + "contribShareMenu", + "contribSourceControlHistoryItemMenu", + "scmHistoryProvider", "shareProvider" ], "contributes": { @@ -54,6 +56,11 @@ "command": "github.openOnVscodeDev", "title": "Open in vscode.dev", "icon": "$(globe)" + }, + { + "command": "github.openOnGitHub2", + "title": "Open on GitHub", + "icon": "$(github)" } ], "continueEditSession": [ @@ -71,6 +78,10 @@ "command": "github.publish", "when": "git-base.gitEnabled && workspaceFolderCount != 0 && remoteName != 'codespaces'" }, + { + "command": "github.openOnGitHub2", + "when": "false" + }, { "command": "github.copyVscodeDevLink", "when": "false" @@ -127,6 +138,13 @@ "when": "github.hasGitHubRepo && resourceScheme != untitled && remoteName != 'codespaces'", "group": "0_vscode@0" } + ], + "scm/historyItem/context": [ + { + "command": "github.openOnGitHub2", + "when": "github.hasGitHubRepo", + "group": "0_view@2" + } ] }, "configuration": [ diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 1f1504521f8..0889628cf0d 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { API as GitAPI } from './typings/git'; +import { API as GitAPI, RefType } from './typings/git'; import { publishRepository } from './publish'; -import { DisposableStore } from './util'; -import { LinkContext, getLink, getVscodeDevHost } from './links'; +import { DisposableStore, getRepositoryFromUrl } from './util'; +import { LinkContext, getCommitLink, getLink, getVscodeDevHost } from './links'; async function copyVscodeDevLink(gitAPI: GitAPI, useSelection: boolean, context: LinkContext, includeRange = true) { try { @@ -57,6 +57,41 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable { return copyVscodeDevLink(gitAPI, true, context, false); })); + disposables.add(vscode.commands.registerCommand('github.openOnGitHub', async (url: string, historyItemId: string) => { + const link = getCommitLink(url, historyItemId); + vscode.env.openExternal(vscode.Uri.parse(link)); + })); + + disposables.add(vscode.commands.registerCommand('github.openOnGitHub2', async (repository: vscode.SourceControl, historyItem: vscode.SourceControlHistoryItem) => { + if (!repository || !historyItem) { + return; + } + + const apiRepository = gitAPI.repositories.find(r => r.rootUri.fsPath === repository.rootUri?.fsPath); + if (!apiRepository) { + return; + } + + // Get the unique remotes that contain the commit + const branches = await apiRepository.getBranches({ contains: historyItem.id }); + const remoteNames = new Set(branches.filter(b => b.type === RefType.RemoteHead && b.remote).map(b => b.remote!)); + + // GitHub remotes that contain the commit + const remotes = apiRepository.state.remotes + .filter(r => remoteNames.has(r.name) && r.fetchUrl && getRepositoryFromUrl(r.fetchUrl)); + + if (remotes.length === 0) { + vscode.window.showInformationMessage(vscode.l10n.t('No GitHub remotes found that contain this commit.')); + return; + } + + // Default remote (origin, or the first remote) + const defaultRemote = remotes.find(r => r.name === 'origin') ?? remotes[0]; + + const link = getCommitLink(defaultRemote.fetchUrl!, historyItem.id); + vscode.env.openExternal(vscode.Uri.parse(link)); + })); + disposables.add(vscode.commands.registerCommand('github.openOnVscodeDev', async () => { return openVscodeDevLink(gitAPI); })); diff --git a/extensions/github/src/links.ts b/extensions/github/src/links.ts index 911f0e5376b..fe97d172249 100644 --- a/extensions/github/src/links.ts +++ b/extensions/github/src/links.ts @@ -186,6 +186,15 @@ export function getBranchLink(url: string, branch: string, hostPrefix: string = return `${hostPrefix}/${repo.owner}/${repo.repo}/tree/${branch}`; } +export function getCommitLink(url: string, hash: string, hostPrefix: string = 'https://github.com') { + const repo = getRepositoryFromUrl(url); + if (!repo) { + throw new Error('Invalid repository URL provided'); + } + + return `${hostPrefix}/${repo.owner}/${repo.repo}/commit/${hash}`; +} + export function getVscodeDevHost(): string { return `https://${vscode.env.appName.toLowerCase().includes('insiders') ? 'insiders.' : ''}vscode.dev/github`; } diff --git a/extensions/github/src/remoteSourceProvider.ts b/extensions/github/src/remoteSourceProvider.ts index 0d8b9340695..0c2ef166832 100644 --- a/extensions/github/src/remoteSourceProvider.ts +++ b/extensions/github/src/remoteSourceProvider.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Uri, env, l10n, workspace } from 'vscode'; +import { Command, Uri, env, l10n, workspace } from 'vscode'; import { RemoteSourceProvider, RemoteSource, RemoteSourceAction } from './typings/git-base'; import { getOctokit } from './auth'; import { Octokit } from '@octokit/rest'; @@ -136,4 +136,18 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { } }]; } + + async getRemoteSourceControlHistoryItemCommands(url: string): Promise { + const repository = getRepositoryFromUrl(url); + if (!repository) { + return []; + } + + return [{ + title: l10n.t('{0} Open on GitHub', '$(github)'), + tooltip: l10n.t('Open on GitHub'), + command: 'github.openOnGitHub', + arguments: [url] + }]; + } } diff --git a/extensions/github/src/typings/git-base.d.ts b/extensions/github/src/typings/git-base.d.ts index 53cac4d5c70..37dd2c4229c 100644 --- a/extensions/github/src/typings/git-base.d.ts +++ b/extensions/github/src/typings/git-base.d.ts @@ -3,11 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, Event, ProviderResult, Uri } from 'vscode'; +import { Command, Disposable, Event, ProviderResult } from 'vscode'; export { ProviderResult } from 'vscode'; export interface API { registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + getRemoteSourceActions(url: string): Promise; + getRemoteSourceControlHistoryItemCommands(url: string): Promise; pickRemoteSource(options: PickRemoteSourceOptions): Promise; } @@ -80,6 +82,7 @@ export interface RemoteSourceProvider { getBranches?(url: string): ProviderResult; getRemoteSourceActions?(url: string): ProviderResult; + getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult; getRecentRemoteSources?(query?: string): ProviderResult; getRemoteSources(query?: string): ProviderResult; } diff --git a/extensions/github/src/typings/vscode.proposed.canonicalUriProvider.d.ts b/extensions/github/src/typings/vscode.proposed.canonicalUriProvider.d.ts deleted file mode 100644 index 84ee599797d..00000000000 --- a/extensions/github/src/typings/vscode.proposed.canonicalUriProvider.d.ts +++ /dev/null @@ -1,47 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/180582 - - export namespace workspace { - /** - * - * @param scheme The URI scheme that this provider can provide canonical URIs for. - * A canonical URI represents the conversion of a resource's alias into a source of truth URI. - * Multiple aliases may convert to the same source of truth URI. - * @param provider A provider which can convert URIs of scheme @param scheme to - * a canonical URI which is stable across machines. - */ - export function registerCanonicalUriProvider(scheme: string, provider: CanonicalUriProvider): Disposable; - - /** - * - * @param uri The URI to provide a canonical URI for. - * @param token A cancellation token for the request. - */ - export function getCanonicalUri(uri: Uri, options: CanonicalUriRequestOptions, token: CancellationToken): ProviderResult; - } - - export interface CanonicalUriProvider { - /** - * - * @param uri The URI to provide a canonical URI for. - * @param options Options that the provider should honor in the URI it returns. - * @param token A cancellation token for the request. - * @returns The canonical URI for the requested URI or undefined if no canonical URI can be provided. - */ - provideCanonicalUri(uri: Uri, options: CanonicalUriRequestOptions, token: CancellationToken): ProviderResult; - } - - export interface CanonicalUriRequestOptions { - /** - * - * The desired scheme of the canonical URI. - */ - targetScheme: string; - } -} diff --git a/extensions/github/src/typings/vscode.proposed.shareProvider.d.ts b/extensions/github/src/typings/vscode.proposed.shareProvider.d.ts deleted file mode 100644 index 6470557cac1..00000000000 --- a/extensions/github/src/typings/vscode.proposed.shareProvider.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// https://github.com/microsoft/vscode/issues/176316 - -declare module 'vscode' { - export interface TreeItem { - shareableItem?: ShareableItem; - } - - export interface ShareableItem { - resourceUri: Uri; - selection?: Range; - } - - export interface ShareProvider { - readonly id: string; - readonly label: string; - readonly priority: number; - - provideShare(item: ShareableItem, token: CancellationToken): ProviderResult; - } - - export namespace window { - export function registerShareProvider(selector: DocumentSelector, provider: ShareProvider): Disposable; - } -} diff --git a/extensions/github/tsconfig.json b/extensions/github/tsconfig.json index d7aed1836ee..6b9a6e7db0a 100644 --- a/extensions/github/tsconfig.json +++ b/extensions/github/tsconfig.json @@ -9,6 +9,9 @@ }, "include": [ "src/**/*", - "../../src/vscode-dts/vscode.d.ts" + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.canonicalUriProvider.d.ts", + "../../src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts", + "../../src/vscode-dts/vscode.proposed.shareProvider.d.ts" ] } diff --git a/extensions/ipynb/package.json b/extensions/ipynb/package.json index 4209ddc130a..d9a9dd7a514 100644 --- a/extensions/ipynb/package.json +++ b/extensions/ipynb/package.json @@ -10,7 +10,6 @@ "vscode": "^1.57.0" }, "enabledApiProposals": [ - "documentPaste", "diffContentOptions" ], "activationEvents": [ diff --git a/extensions/ipynb/src/deserializers.ts b/extensions/ipynb/src/deserializers.ts index de467f66077..ad99a4fcea8 100644 --- a/extensions/ipynb/src/deserializers.ts +++ b/extensions/ipynb/src/deserializers.ts @@ -149,8 +149,12 @@ function getNotebookCellMetadata(cell: nbformat.IBaseCell): { // We put this only for VSC to display in diff view. // Else we don't use this. const cellMetadata: CellMetadata = {}; - if (cell.cell_type === 'code' && typeof cell['execution_count'] === 'number') { - cellMetadata.execution_count = cell['execution_count']; + if (cell.cell_type === 'code') { + if (typeof cell['execution_count'] === 'number') { + cellMetadata.execution_count = cell['execution_count']; + } else { + cellMetadata.execution_count = null; + } } if (cell['metadata']) { diff --git a/extensions/ipynb/src/notebookModelStoreSync.ts b/extensions/ipynb/src/notebookModelStoreSync.ts index dc1bae1de2f..836e1c8afc5 100644 --- a/extensions/ipynb/src/notebookModelStoreSync.ts +++ b/extensions/ipynb/src/notebookModelStoreSync.ts @@ -165,6 +165,13 @@ function onDidChangeNotebookCells(e: NotebookDocumentChangeEventEx) { metadata.execution_count = null; metadataUpdated = true; pendingCellUpdates.delete(e.cell); + } else if (!e.executionSummary?.executionOrder && !e.executionSummary?.success && !e.executionSummary?.timing + && !e.metadata && !e.outputs && currentMetadata.execution_count && !pendingCellUpdates.has(e.cell)) { + // This is a result of the cell without outupts but has execution count being cleared + // Create two cells, one that produces output and one that doesn't. Run both and then clear the output or all cells. + // This condition will be satisfied for first cell without outputs. + metadata.execution_count = null; + metadataUpdated = true; } if (e.document?.languageId && e.document?.languageId !== preferredCellLanguage && e.document?.languageId !== languageIdInMetadata) { diff --git a/extensions/ipynb/src/test/clearOutputs.test.ts b/extensions/ipynb/src/test/clearOutputs.test.ts new file mode 100644 index 00000000000..0437e9838bb --- /dev/null +++ b/extensions/ipynb/src/test/clearOutputs.test.ts @@ -0,0 +1,736 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from 'sinon'; +import type * as nbformat from '@jupyterlab/nbformat'; +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { jupyterNotebookModelToNotebookData } from '../deserializers'; +import { activate } from '../notebookModelStoreSync'; + + +suite(`ipynb Clear Outputs`, () => { + const disposables: vscode.Disposable[] = []; + const context = { subscriptions: disposables } as vscode.ExtensionContext; + setup(() => { + disposables.length = 0; + activate(context); + }); + teardown(async () => { + disposables.forEach(d => d.dispose()); + disposables.length = 0; + sinon.restore(); + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + }); + + test('Clear outputs after opening Notebook', async () => { + const cells: nbformat.ICell[] = [ + { + cell_type: 'code', + execution_count: 10, + outputs: [{ output_type: 'stream', name: 'stdout', text: ['Hello'] }], + source: 'print(1)', + metadata: {} + }, + { + cell_type: 'code', + outputs: [], + source: 'print(2)', + metadata: {} + }, + { + cell_type: 'markdown', + source: '# HEAD', + metadata: {} + } + ]; + const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python'); + + const notebookDocument = await vscode.workspace.openNotebookDocument('jupyter-notebook', notebook); + await vscode.window.showNotebookDocument(notebookDocument); + + assert.strictEqual(notebookDocument.cellCount, 3); + assert.strictEqual(notebookDocument.cellAt(0).metadata.execution_count, 10); + assert.strictEqual(notebookDocument.cellAt(1).metadata.execution_count, null); + assert.strictEqual(notebookDocument.cellAt(2).metadata.execution_count, undefined); + + // Clear all outputs + await vscode.commands.executeCommand('notebook.clearAllCellsOutputs'); + + // Wait for all changes to be applied, could take a few ms. + const verifyMetadataChanges = () => { + assert.strictEqual(notebookDocument.cellAt(0).metadata.execution_count, null); + assert.strictEqual(notebookDocument.cellAt(1).metadata.execution_count, null); + assert.strictEqual(notebookDocument.cellAt(2).metadata.execution_count, undefined); + }; + + vscode.workspace.onDidChangeNotebookDocument(() => verifyMetadataChanges(), undefined, disposables); + + await new Promise((resolve, reject) => { + const interval = setInterval(() => { + try { + verifyMetadataChanges(); + clearInterval(interval); + resolve(); + } catch { + // Ignore + } + }, 50); + disposables.push({ dispose: () => clearInterval(interval) }); + const timeout = setTimeout(() => { + try { + verifyMetadataChanges(); + resolve(); + } catch (ex) { + reject(ex); + } + }, 1000); + disposables.push({ dispose: () => clearTimeout(timeout) }); + }); + }); + + + // test('Serialize', async () => { + // const markdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# header1', 'markdown'); + // markdownCell.metadata = { + // attachments: { + // 'image.png': { + // 'image/png': 'abc' + // } + // }, + // id: '123', + // metadata: { + // foo: 'bar' + // } + // }; + + // const cellMetadata = getCellMetadata({ cell: markdownCell }); + // assert.deepStrictEqual(cellMetadata, { + // id: '123', + // metadata: { + // foo: 'bar', + // }, + // attachments: { + // 'image.png': { + // 'image/png': 'abc' + // } + // } + // }); + + // const markdownCell2 = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# header1', 'markdown'); + // markdownCell2.metadata = { + // id: '123', + // metadata: { + // foo: 'bar' + // }, + // attachments: { + // 'image.png': { + // 'image/png': 'abc' + // } + // } + // }; + + // const nbMarkdownCell = createMarkdownCellFromNotebookCell(markdownCell); + // const nbMarkdownCell2 = createMarkdownCellFromNotebookCell(markdownCell2); + // assert.deepStrictEqual(nbMarkdownCell, nbMarkdownCell2); + + // assert.deepStrictEqual(nbMarkdownCell, { + // cell_type: 'markdown', + // source: ['# header1'], + // metadata: { + // foo: 'bar', + // }, + // attachments: { + // 'image.png': { + // 'image/png': 'abc' + // } + // }, + // id: '123' + // }); + // }); + + // suite('Outputs', () => { + // function validateCellOutputTranslation( + // outputs: nbformat.IOutput[], + // expectedOutputs: vscode.NotebookCellOutput[], + // propertiesToExcludeFromComparison: string[] = [] + // ) { + // const cells: nbformat.ICell[] = [ + // { + // cell_type: 'code', + // execution_count: 10, + // outputs, + // source: 'print(1)', + // metadata: {} + // } + // ]; + // const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python'); + + // // OutputItems contain an `id` property generated by VSC. + // // Exclude that property when comparing. + // const propertiesToExclude = propertiesToExcludeFromComparison.concat(['id']); + // const actualOuts = notebook.cells[0].outputs; + // deepStripProperties(actualOuts, propertiesToExclude); + // deepStripProperties(expectedOutputs, propertiesToExclude); + // assert.deepStrictEqual(actualOuts, expectedOutputs); + // } + + // test('Empty output', () => { + // validateCellOutputTranslation([], []); + // }); + + // test('Stream output', () => { + // validateCellOutputTranslation( + // [ + // { + // output_type: 'stream', + // name: 'stderr', + // text: 'Error' + // }, + // { + // output_type: 'stream', + // name: 'stdout', + // text: 'NoError' + // } + // ], + // [ + // new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr('Error')], { + // outputType: 'stream' + // }), + // new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('NoError')], { + // outputType: 'stream' + // }) + // ] + // ); + // }); + // test('Stream output and line endings', () => { + // validateCellOutputTranslation( + // [ + // { + // output_type: 'stream', + // name: 'stdout', + // text: [ + // 'Line1\n', + // '\n', + // 'Line3\n', + // 'Line4' + // ] + // } + // ], + // [ + // new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('Line1\n\nLine3\nLine4')], { + // outputType: 'stream' + // }) + // ] + // ); + // validateCellOutputTranslation( + // [ + // { + // output_type: 'stream', + // name: 'stdout', + // text: [ + // 'Hello\n', + // 'Hello\n', + // 'Hello\n', + // 'Hello\n', + // 'Hello\n', + // 'Hello\n' + // ] + // } + // ], + // [ + // new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('Hello\nHello\nHello\nHello\nHello\nHello\n')], { + // outputType: 'stream' + // }) + // ] + // ); + // }); + // test('Multi-line Stream output', () => { + // validateCellOutputTranslation( + // [ + // { + // name: 'stdout', + // output_type: 'stream', + // text: [ + // 'Epoch 1/5\n', + // '...\n', + // 'Epoch 2/5\n', + // '...\n', + // 'Epoch 3/5\n', + // '...\n', + // 'Epoch 4/5\n', + // '...\n', + // 'Epoch 5/5\n', + // '...\n' + // ] + // } + // ], + // [ + // new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout(['Epoch 1/5\n', + // '...\n', + // 'Epoch 2/5\n', + // '...\n', + // 'Epoch 3/5\n', + // '...\n', + // 'Epoch 4/5\n', + // '...\n', + // 'Epoch 5/5\n', + // '...\n'].join(''))], { + // outputType: 'stream' + // }) + // ] + // ); + // }); + + // test('Multi-line Stream output (last empty line should not be saved in ipynb)', () => { + // validateCellOutputTranslation( + // [ + // { + // name: 'stderr', + // output_type: 'stream', + // text: [ + // 'Epoch 1/5\n', + // '...\n', + // 'Epoch 2/5\n', + // '...\n', + // 'Epoch 3/5\n', + // '...\n', + // 'Epoch 4/5\n', + // '...\n', + // 'Epoch 5/5\n', + // '...\n' + // ] + // } + // ], + // [ + // new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr(['Epoch 1/5\n', + // '...\n', + // 'Epoch 2/5\n', + // '...\n', + // 'Epoch 3/5\n', + // '...\n', + // 'Epoch 4/5\n', + // '...\n', + // 'Epoch 5/5\n', + // '...\n', + // // This last empty line should not be saved in ipynb. + // '\n'].join(''))], { + // outputType: 'stream' + // }) + // ] + // ); + // }); + + // test('Streamed text with Ansi characters', async () => { + // validateCellOutputTranslation( + // [ + // { + // name: 'stderr', + // text: '\u001b[K\u001b[33m✅ \u001b[0m Loading\n', + // output_type: 'stream' + // } + // ], + // [ + // new vscode.NotebookCellOutput( + // [vscode.NotebookCellOutputItem.stderr('\u001b[K\u001b[33m✅ \u001b[0m Loading\n')], + // { + // outputType: 'stream' + // } + // ) + // ] + // ); + // }); + + // test('Streamed text with angle bracket characters', async () => { + // validateCellOutputTranslation( + // [ + // { + // name: 'stderr', + // text: '1 is < 2', + // output_type: 'stream' + // } + // ], + // [ + // new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr('1 is < 2')], { + // outputType: 'stream' + // }) + // ] + // ); + // }); + + // test('Streamed text with angle bracket characters and ansi chars', async () => { + // validateCellOutputTranslation( + // [ + // { + // name: 'stderr', + // text: '1 is < 2\u001b[K\u001b[33m✅ \u001b[0m Loading\n', + // output_type: 'stream' + // } + // ], + // [ + // new vscode.NotebookCellOutput( + // [vscode.NotebookCellOutputItem.stderr('1 is < 2\u001b[K\u001b[33m✅ \u001b[0m Loading\n')], + // { + // outputType: 'stream' + // } + // ) + // ] + // ); + // }); + + // test('Error', async () => { + // validateCellOutputTranslation( + // [ + // { + // ename: 'Error Name', + // evalue: 'Error Value', + // traceback: ['stack1', 'stack2', 'stack3'], + // output_type: 'error' + // } + // ], + // [ + // new vscode.NotebookCellOutput( + // [ + // vscode.NotebookCellOutputItem.error({ + // name: 'Error Name', + // message: 'Error Value', + // stack: ['stack1', 'stack2', 'stack3'].join('\n') + // }) + // ], + // { + // outputType: 'error', + // originalError: { + // ename: 'Error Name', + // evalue: 'Error Value', + // traceback: ['stack1', 'stack2', 'stack3'], + // output_type: 'error' + // } + // } + // ) + // ] + // ); + // }); + + // ['display_data', 'execute_result'].forEach(output_type => { + // suite(`Rich output for output_type = ${output_type}`, () => { + // // Properties to exclude when comparing. + // let propertiesToExcludeFromComparison: string[] = []; + // setup(() => { + // if (output_type === 'display_data') { + // // With display_data the execution_count property will never exist in the output. + // // We can ignore that (as it will never exist). + // // But we leave it in the case of `output_type === 'execute_result'` + // propertiesToExcludeFromComparison = ['execution_count', 'executionCount']; + // } + // }); + + // test('Text mimeType output', async () => { + // validateCellOutputTranslation( + // [ + // { + // data: { + // 'text/plain': 'Hello World!' + // }, + // output_type, + // metadata: {}, + // execution_count: 1 + // } + // ], + // [ + // new vscode.NotebookCellOutput( + // [new vscode.NotebookCellOutputItem(Buffer.from('Hello World!', 'utf8'), 'text/plain')], + // { + // outputType: output_type, + // metadata: {}, // display_data & execute_result always have metadata. + // executionCount: 1 + // } + // ) + // ], + // propertiesToExcludeFromComparison + // ); + // }); + + // test('png,jpeg images', async () => { + // validateCellOutputTranslation( + // [ + // { + // execution_count: 1, + // data: { + // 'image/png': base64EncodedImage, + // 'image/jpeg': base64EncodedImage + // }, + // metadata: {}, + // output_type + // } + // ], + // [ + // new vscode.NotebookCellOutput( + // [ + // new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png'), + // new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/jpeg') + // ], + // { + // executionCount: 1, + // outputType: output_type, + // metadata: {} // display_data & execute_result always have metadata. + // } + // ) + // ], + // propertiesToExcludeFromComparison + // ); + // }); + + // test('png image with a light background', async () => { + // validateCellOutputTranslation( + // [ + // { + // execution_count: 1, + // data: { + // 'image/png': base64EncodedImage + // }, + // metadata: { + // needs_background: 'light' + // }, + // output_type + // } + // ], + // [ + // new vscode.NotebookCellOutput( + // [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + // { + // executionCount: 1, + // metadata: { + // needs_background: 'light' + // }, + // outputType: output_type + // } + // ) + // ], + // propertiesToExcludeFromComparison + // ); + // }); + + // test('png image with a dark background', async () => { + // validateCellOutputTranslation( + // [ + // { + // execution_count: 1, + // data: { + // 'image/png': base64EncodedImage + // }, + // metadata: { + // needs_background: 'dark' + // }, + // output_type + // } + // ], + // [ + // new vscode.NotebookCellOutput( + // [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + // { + // executionCount: 1, + // metadata: { + // needs_background: 'dark' + // }, + // outputType: output_type + // } + // ) + // ], + // propertiesToExcludeFromComparison + // ); + // }); + + // test('png image with custom dimensions', async () => { + // validateCellOutputTranslation( + // [ + // { + // execution_count: 1, + // data: { + // 'image/png': base64EncodedImage + // }, + // metadata: { + // 'image/png': { height: '111px', width: '999px' } + // }, + // output_type + // } + // ], + // [ + // new vscode.NotebookCellOutput( + // [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + // { + // executionCount: 1, + // metadata: { + // 'image/png': { height: '111px', width: '999px' } + // }, + // outputType: output_type + // } + // ) + // ], + // propertiesToExcludeFromComparison + // ); + // }); + + // test('png allowed to scroll', async () => { + // validateCellOutputTranslation( + // [ + // { + // execution_count: 1, + // data: { + // 'image/png': base64EncodedImage + // }, + // metadata: { + // unconfined: true, + // 'image/png': { width: '999px' } + // }, + // output_type + // } + // ], + // [ + // new vscode.NotebookCellOutput( + // [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + // { + // executionCount: 1, + // metadata: { + // unconfined: true, + // 'image/png': { width: '999px' } + // }, + // outputType: output_type + // } + // ) + // ], + // propertiesToExcludeFromComparison + // ); + // }); + // }); + // }); + // }); + + // suite('Output Order', () => { + // test('Verify order of outputs', async () => { + // const dataAndExpectedOrder: { output: nbformat.IDisplayData; expectedMimeTypesOrder: string[] }[] = [ + // { + // output: { + // data: { + // 'application/vnd.vegalite.v4+json': 'some json', + // 'text/html': 'Hello' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['application/vnd.vegalite.v4+json', 'text/html'] + // }, + // { + // output: { + // data: { + // 'application/vnd.vegalite.v4+json': 'some json', + // 'application/javascript': 'some js', + // 'text/plain': 'some text', + // 'text/html': 'Hello' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: [ + // 'application/vnd.vegalite.v4+json', + // 'text/html', + // 'application/javascript', + // 'text/plain' + // ] + // }, + // { + // output: { + // data: { + // 'application/vnd.vegalite.v4+json': '', // Empty, should give preference to other mimetypes. + // 'application/javascript': 'some js', + // 'text/plain': 'some text', + // 'text/html': 'Hello' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: [ + // 'text/html', + // 'application/javascript', + // 'text/plain', + // 'application/vnd.vegalite.v4+json' + // ] + // }, + // { + // output: { + // data: { + // 'text/plain': 'some text', + // 'text/html': 'Hello' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['text/html', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'application/javascript': 'some js', + // 'text/plain': 'some text' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['application/javascript', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'image/svg+xml': 'some svg', + // 'text/plain': 'some text' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['image/svg+xml', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'text/latex': 'some latex', + // 'text/plain': 'some text' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['text/latex', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'application/vnd.jupyter.widget-view+json': 'some widget', + // 'text/plain': 'some text' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['application/vnd.jupyter.widget-view+json', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'text/plain': 'some text', + // 'image/svg+xml': 'some svg', + // 'image/png': 'some png' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['image/png', 'image/svg+xml', 'text/plain'] + // } + // ]; + + // dataAndExpectedOrder.forEach(({ output, expectedMimeTypesOrder }) => { + // const sortedOutputs = jupyterCellOutputToCellOutput(output); + // const mimeTypes = sortedOutputs.items.map((item) => item.mime).join(','); + // assert.equal(mimeTypes, expectedMimeTypesOrder.join(',')); + // }); + // }); + // }); +}); diff --git a/extensions/ipynb/src/test/serializers.test.ts b/extensions/ipynb/src/test/serializers.test.ts index cb461539c5d..e132b6b2b1d 100644 --- a/extensions/ipynb/src/test/serializers.test.ts +++ b/extensions/ipynb/src/test/serializers.test.ts @@ -41,6 +41,12 @@ suite(`ipynb serializer`, () => { source: 'print(1)', metadata: {} }, + { + cell_type: 'code', + outputs: [], + source: 'print(2)', + metadata: {} + }, { cell_type: 'markdown', source: '# HEAD', @@ -55,13 +61,18 @@ suite(`ipynb serializer`, () => { expectedCodeCell.metadata = { execution_count: 10, metadata: {} }; expectedCodeCell.executionSummary = { executionOrder: 10 }; + const expectedCodeCell2 = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'print(2)', 'python'); + expectedCodeCell2.outputs = []; + expectedCodeCell2.metadata = { execution_count: null, metadata: {} }; + expectedCodeCell2.executionSummary = {}; + const expectedMarkdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# HEAD', 'markdown'); expectedMarkdownCell.outputs = []; expectedMarkdownCell.metadata = { metadata: {} }; - assert.deepStrictEqual(notebook.cells, [expectedCodeCell, expectedMarkdownCell]); + assert.deepStrictEqual(notebook.cells, [expectedCodeCell, expectedCodeCell2, expectedMarkdownCell]); }); diff --git a/extensions/json-language-features/server/src/node/jsonServerMain.ts b/extensions/json-language-features/server/src/node/jsonServerMain.ts index c22cd14834d..71da2377c77 100644 --- a/extensions/json-language-features/server/src/node/jsonServerMain.ts +++ b/extensions/json-language-features/server/src/node/jsonServerMain.ts @@ -8,6 +8,7 @@ import { formatError } from '../utils/runner'; import { RequestService, RuntimeEnvironment, startServer } from '../jsonServer'; import { xhr, XHRResponse, configure as configureHttpRequests, getErrorStatusDescription } from 'request-light'; +import { URI as Uri } from 'vscode-uri'; import { promises as fs } from 'fs'; import * as l10n from '@vscode/l10n'; @@ -38,7 +39,8 @@ function getFileRequestService(): RequestService { return { async getContent(location: string, encoding?: BufferEncoding) { try { - return (await fs.readFile(location, encoding)).toString(); + const uri = Uri.parse(location); + return (await fs.readFile(uri.fsPath, encoding)).toString(); } catch (e) { if (e.code === 'ENOENT') { throw new Error(l10n.t('Schema not found: {0}', location)); diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index d7eddbf31bd..6d097be5470 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -15,9 +15,6 @@ "categories": [ "Programming Languages" ], - "enabledApiProposals": [ - "documentPaste" - ], "activationEvents": [ "onLanguage:markdown", "onCommand:markdown.api.render", diff --git a/extensions/markdown-language-features/src/util/dom.ts b/extensions/markdown-language-features/src/util/dom.ts index 0f6c00da9da..8bbce79c303 100644 --- a/extensions/markdown-language-features/src/util/dom.ts +++ b/extensions/markdown-language-features/src/util/dom.ts @@ -5,7 +5,10 @@ import * as vscode from 'vscode'; export function escapeAttribute(value: string | vscode.Uri): string { - return value.toString().replace(/"/g, '"'); + return value.toString() + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, '''); } export function getNonce() { diff --git a/extensions/microsoft-authentication/src/common/async.ts b/extensions/microsoft-authentication/src/common/async.ts index 9eebbb24f65..3555bb09502 100644 --- a/extensions/microsoft-authentication/src/common/async.ts +++ b/extensions/microsoft-authentication/src/common/async.ts @@ -5,11 +5,6 @@ import { CancellationError, CancellationToken, Disposable, Event } from 'vscode'; -/** - * Can be passed into the Delayed to defer using a microtask - */ -export const MicrotaskDelay = Symbol('MicrotaskDelay'); - export class SequencerByKey { private promiseMap = new Map>(); @@ -57,7 +52,7 @@ export class IntervalTimer extends Disposable { * Returns a promise that rejects with an {@CancellationError} as soon as the passed token is cancelled. * @see {@link raceCancellation} */ -export function raceCancellationError(promise: Promise, token: CancellationToken): Promise { +function raceCancellationError(promise: Promise, token: CancellationToken): Promise { return new Promise((resolve, reject) => { const ref = token.onCancellationRequested(() => { ref.dispose(); @@ -67,7 +62,7 @@ export function raceCancellationError(promise: Promise, token: Cancellatio }); } -export function raceTimeoutError(promise: Promise, timeout: number): Promise { +function raceTimeoutError(promise: Promise, timeout: number): Promise { return new Promise((resolve, reject) => { const ref = setTimeout(() => { reject(new CancellationError()); @@ -80,138 +75,12 @@ export function raceCancellationAndTimeoutError(promise: Promise, token: C return raceCancellationError(raceTimeoutError(promise, timeout), token); } -interface IScheduledLater extends Disposable { - isTriggered(): boolean; -} - -const timeoutDeferred = (timeout: number, fn: () => void): IScheduledLater => { - let scheduled = true; - const handle = setTimeout(() => { - scheduled = false; - fn(); - }, timeout); - return { - isTriggered: () => scheduled, - dispose: () => { - clearTimeout(handle); - scheduled = false; - }, - }; -}; - -const microtaskDeferred = (fn: () => void): IScheduledLater => { - let scheduled = true; - queueMicrotask(() => { - if (scheduled) { - scheduled = false; - fn(); - } - }); - - return { - isTriggered: () => scheduled, - dispose: () => { scheduled = false; }, - }; -}; - -/** - * A helper to delay (debounce) execution of a task that is being requested often. - * - * Following the throttler, now imagine the mail man wants to optimize the number of - * trips proactively. The trip itself can be long, so he decides not to make the trip - * as soon as a letter is submitted. Instead he waits a while, in case more - * letters are submitted. After said waiting period, if no letters were submitted, he - * decides to make the trip. Imagine that N more letters were submitted after the first - * one, all within a short period of time between each other. Even though N+1 - * submissions occurred, only 1 delivery was made. - * - * The delayer offers this behavior via the trigger() method, into which both the task - * to be executed and the waiting period (delay) must be passed in as arguments. Following - * the example: - * - * const delayer = new Delayer(WAITING_PERIOD); - * const letters = []; - * - * function letterReceived(l) { - * letters.push(l); - * delayer.trigger(() => { return makeTheTrip(); }); - * } - */ -export class Delayer implements Disposable { - - private deferred: IScheduledLater | null; - private completionPromise: Promise | null; - private doResolve: ((value?: any | Promise) => void) | null; - private doReject: ((err: any) => void) | null; - private task: (() => T | Promise) | null; - - constructor(public defaultDelay: number | typeof MicrotaskDelay) { - this.deferred = null; - this.completionPromise = null; - this.doResolve = null; - this.doReject = null; - this.task = null; - } - - trigger(task: () => T | Promise, delay = this.defaultDelay): Promise { - this.task = task; - this.cancelTimeout(); - - if (!this.completionPromise) { - this.completionPromise = new Promise((resolve, reject) => { - this.doResolve = resolve; - this.doReject = reject; - }).then(() => { - this.completionPromise = null; - this.doResolve = null; - if (this.task) { - const task = this.task; - this.task = null; - return task(); - } - return undefined; - }); - } - - const fn = () => { - this.deferred = null; - this.doResolve?.(null); - }; - - this.deferred = delay === MicrotaskDelay ? microtaskDeferred(fn) : timeoutDeferred(delay, fn); - - return this.completionPromise; - } - - isTriggered(): boolean { - return !!this.deferred?.isTriggered(); - } - - cancel(): void { - this.cancelTimeout(); - - if (this.completionPromise) { - this.doReject?.(new CancellationError()); - this.completionPromise = null; - } - } - - private cancelTimeout(): void { - this.deferred?.dispose(); - this.deferred = null; - } - - dispose(): void { - this.cancel(); - } -} - /** * Given an event, returns another event which only fires once. * * @param event The event source for the new event. */ -export function once(event: Event): Event { +function once(event: Event): Event { return (listener, thisArgs = null, disposables?) => { // we need this, in case the event fires during the listener call let didFire = false; diff --git a/extensions/microsoft-authentication/src/node/authProvider.ts b/extensions/microsoft-authentication/src/node/authProvider.ts index af34273afa4..0a352c8eb86 100644 --- a/extensions/microsoft-authentication/src/node/authProvider.ts +++ b/extensions/microsoft-authentication/src/node/authProvider.ts @@ -2,19 +2,18 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AccountInfo, AuthenticationResult, ServerError } from '@azure/msal-node'; -import { AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, env, EventEmitter, ExtensionContext, l10n, LogOutputChannel, Uri, window } from 'vscode'; +import { AccountInfo, AuthenticationResult, ClientAuthError, ClientAuthErrorCodes, ServerError } from '@azure/msal-node'; +import { AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, EventEmitter, ExtensionContext, ExtensionKind, l10n, LogOutputChannel, window } from 'vscode'; import { Environment } from '@azure/ms-rest-azure-env'; import { CachedPublicClientApplicationManager } from './publicClientCache'; -import { UriHandlerLoopbackClient } from '../common/loopbackClientAndOpener'; import { UriEventHandler } from '../UriEventHandler'; import { ICachedPublicClientApplication } from '../common/publicClientCache'; import { MicrosoftAccountType, MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter'; -import { loopbackTemplate } from './loopbackTemplate'; import { ScopeData } from '../common/scopeData'; import { EventBufferer } from '../common/event'; import { BetterTokenStorage } from '../betterSecretStorage'; import { IStoredSession } from '../AADHelper'; +import { ExtensionHost, getMsalFlows } from './flows'; const redirectUri = 'https://vscode.dev/redirect'; const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad'; @@ -187,80 +186,70 @@ export class MsalAuthProvider implements AuthenticationProvider { this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting'); const cachedPca = await this.getOrCreatePublicClientApplication(scopeData.clientId, scopeData.tenant); - let result: AuthenticationResult | undefined; - try { - const windowHandle = window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined; - result = await cachedPca.acquireTokenInteractive({ - openBrowser: async (url: string) => { await env.openExternal(Uri.parse(url)); }, - scopes: scopeData.scopesToSend, - // The logic for rendering one or the other of these templates is in the - // template itself, so we pass the same one for both. - successTemplate: loopbackTemplate, - errorTemplate: loopbackTemplate, - // Pass the label of the account to the login hint so that we prefer signing in to that account - loginHint: options.account?.label, - // If we aren't logging in to a specific account, then we can use the prompt to make sure they get - // the option to choose a different account. - prompt: options.account?.label ? undefined : 'select_account', - windowHandle - }); - } catch (e) { - if (e instanceof CancellationError) { - const yes = l10n.t('Yes'); - const result = await window.showErrorMessage( - l10n.t('Having trouble logging in?'), - { - modal: true, - detail: l10n.t('Would you like to try a different way to sign in to your Microsoft account? ({0})', 'protocol handler') - }, - yes - ); - if (!result) { + // Used for showing a friendlier message to the user when the explicitly cancel a flow. + let userCancelled: boolean | undefined; + const yes = l10n.t('Yes'); + const no = l10n.t('No'); + const promptToContinue = async (mode: string) => { + if (userCancelled === undefined) { + // We haven't had a failure yet so wait to prompt + return; + } + const message = userCancelled + ? l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode) + : l10n.t('You have not yet finished authorizing this extension to use your Microsoft Account. Would you like to try a different way? ({0})', mode); + const result = await window.showWarningMessage(message, yes, no); + if (result !== yes) { + throw new CancellationError(); + } + }; + + const flows = getMsalFlows({ + extensionHost: typeof navigator === 'undefined' + ? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote + : ExtensionHost.WebWorker, + }); + + let lastError: Error | undefined; + for (const flow of flows) { + if (flow !== flows[0]) { + try { + await promptToContinue(flow.label); + } finally { + this._telemetryReporter.sendLoginFailedEvent(); + } + } + try { + const result = await flow.trigger({ + cachedPca, + scopes: scopeData.scopesToSend, + loginHint: options.account?.label, + windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined, + logger: this._logger, + uriHandler: this._uriHandler + }); + + const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes); + this._telemetryReporter.sendLoginEvent(session.scopes); + this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'returned session'); + this._onDidChangeSessionsEmitter.fire({ added: [session], changed: [], removed: [] }); + return session; + } catch (e) { + lastError = e; + if (e instanceof ServerError || (e as ClientAuthError)?.errorCode === ClientAuthErrorCodes.userCanceled) { this._telemetryReporter.sendLoginFailedEvent(); throw e; } - } - // This error comes from the backend and is likely not due to the user's machine - // failing to open a port or something local that would require us to try the - // URL handler loopback client. - if (e instanceof ServerError) { - this._telemetryReporter.sendLoginFailedEvent(); - throw e; - } - - // The user wants to try the loopback client or we got an error likely due to spinning up the server - const loopbackClient = new UriHandlerLoopbackClient(this._uriHandler, redirectUri, this._logger); - try { - const windowHandle = window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined; - result = await cachedPca.acquireTokenInteractive({ - openBrowser: (url: string) => loopbackClient.openBrowser(url), - scopes: scopeData.scopesToSend, - loopbackClient, - loginHint: options.account?.label, - prompt: options.account?.label ? undefined : 'select_account', - windowHandle - }); - } catch (e) { - this._telemetryReporter.sendLoginFailedEvent(); - throw e; + // Continue to next flow + if (e instanceof CancellationError) { + userCancelled = true; + } } } - if (!result) { - this._telemetryReporter.sendLoginFailedEvent(); - throw new Error('No result returned from MSAL'); - } - - const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes); - this._telemetryReporter.sendLoginEvent(session.scopes); - this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'returned session'); - // This is the only scenario in which we need to fire the _onDidChangeSessionsEmitter out of band... - // the badge flow (when the client passes no options in to getSession) will only remove a badge if a session - // was created that _matches the scopes_ that that badge requests. See `onDidChangeSessions` for more info. - // TODO: This should really be fixed in Core. - this._onDidChangeSessionsEmitter.fire({ added: [session], changed: [], removed: [] }); - return session; + this._telemetryReporter.sendLoginFailedEvent(); + throw lastError ?? new Error('No auth flow succeeded'); } async removeSession(sessionId: string): Promise { diff --git a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts index 0f27c2c0e4d..a986b217983 100644 --- a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts +++ b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts @@ -6,44 +6,29 @@ import { PublicClientApplication, AccountInfo, Configuration, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel, RefreshTokenRequest } from '@azure/msal-node'; import { NativeBrokerPlugin } from '@azure/msal-node-extensions'; import { Disposable, Memento, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter } from 'vscode'; -import { Delayer, raceCancellationAndTimeoutError } from '../common/async'; +import { raceCancellationAndTimeoutError } from '../common/async'; import { SecretStorageCachePlugin } from '../common/cachePlugin'; import { MsalLoggerOptions } from '../common/loggerOptions'; import { ICachedPublicClientApplication } from '../common/publicClientCache'; import { ScopedAccountAccess } from '../common/accountAccess'; export class CachedPublicClientApplication implements ICachedPublicClientApplication { + // Core properties private _pca: PublicClientApplication; - private _sequencer = new Sequencer(); - // private readonly _refreshDelayer = new DelayerByKey(); - private _accounts: AccountInfo[] = []; + private _sequencer = new Sequencer(); private readonly _disposable: Disposable; - private readonly _loggerOptions = new MsalLoggerOptions(this._logger); + // Cache properties private readonly _secretStorageCachePlugin = new SecretStorageCachePlugin( this._secretStorage, // Include the prefix as a differentiator to other secrets `pca:${JSON.stringify({ clientId: this._clientId, authority: this._authority })}` ); + + // Broker properties private readonly _accountAccess = new ScopedAccountAccess(this._secretStorage, this._cloudName, this._clientId, this._authority); - private readonly _config: Configuration = { - auth: { clientId: this._clientId, authority: this._authority }, - system: { - loggerOptions: { - correlationId: `${this._clientId}] [${this._authority}`, - loggerCallback: (level, message, containsPii) => this._loggerOptions.loggerCallback(level, message, containsPii), - logLevel: LogLevel.Trace - } - }, - broker: { - nativeBrokerPlugin: new NativeBrokerPlugin() - }, - cache: { - cachePlugin: this._secretStorageCachePlugin - } - }; - private readonly _isBrokerAvailable = this._config.broker?.nativeBrokerPlugin?.isBrokerAvailable ?? false; + private readonly _isBrokerAvailable: boolean; //#region Events @@ -65,7 +50,21 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica ) { // TODO:@TylerLeonhardt clean up old use of memento. Remove this in an iteration this._globalMemento.update(`lastRemoval:${this._clientId}:${this._authority}`, undefined); - this._pca = new PublicClientApplication(this._config); + const loggerOptions = new MsalLoggerOptions(_logger); + const nativeBrokerPlugin = new NativeBrokerPlugin(); + this._isBrokerAvailable = nativeBrokerPlugin.isBrokerAvailable ?? false; + this._pca = new PublicClientApplication({ + auth: { clientId: _clientId, authority: _authority }, + system: { + loggerOptions: { + correlationId: `${_clientId}] [${_authority}`, + loggerCallback: (level, message, containsPii) => loggerOptions.loggerCallback(level, message, containsPii), + logLevel: LogLevel.Trace + } + }, + broker: { nativeBrokerPlugin }, + cache: { cachePlugin: this._secretStorageCachePlugin } + }); this._disposable = Disposable.from( this._registerOnSecretStorageChanged(), this._onDidAccountsChangeEmitter, @@ -94,6 +93,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] got result`); // Check expiration of id token and if it's 5min before expiration, force a refresh. // this is what MSAL does for access tokens already so we're just adding it for id tokens since we care about those. + // NOTE: Once we stop depending on id tokens for some things we can remove all of this. const idTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp; if (idTokenExpirationInSecs) { const fiveMinutesBefore = new Date( @@ -107,17 +107,39 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica ? { ...request, claims: '{ "id_token": {}}' } : { ...request, forceRefresh: true }; result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest)); - this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] got refreshed result`); + this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result`); } const newIdTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp; if (newIdTokenExpirationInSecs) { - if (new Date(newIdTokenExpirationInSecs * 1000) < new Date()) { + const fiveMinutesBefore = new Date( + (newIdTokenExpirationInSecs - 5 * 60) // subtract 5 minutes + * 1000 // convert to milliseconds + ); + if (fiveMinutesBefore < new Date()) { this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`); + + // HACK: Only for the Broker we try one more time with different claims to force a refresh. Why? We've seen the Broker caching tokens by the claims requested, thus + // there has been a situation where both tokens are expired. + if (this._isBrokerAvailable) { + this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] forcing refresh with different claims...`); + const newRequest = { ...request, claims: '{ "access_token": {}}' }; + result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest)); + this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result with different claims`); + const newIdTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp; + if (newIdTokenExpirationInSecs) { + const fiveMinutesBefore = new Date( + (newIdTokenExpirationInSecs - 5 * 60) // subtract 5 minutes + * 1000 // convert to milliseconds + ); + if (fiveMinutesBefore < new Date()) { + this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`); + } + } + } } } } - // this._setupRefresh(result); if (result.account && !result.fromCache && this._verifyIfUsingBroker(result)) { this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] firing event due to change`); this._onDidAccountsChangeEmitter.fire({ added: [], changed: [result.account], deleted: [] }); @@ -133,13 +155,12 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica cancellable: true, title: l10n.t('Signing in to Microsoft...') }, - (_process, token) => raceCancellationAndTimeoutError( - this._sequencer.queue(() => this._pca.acquireTokenInteractive(request)), + (_process, token) => this._sequencer.queue(() => raceCancellationAndTimeoutError( + this._pca.acquireTokenInteractive(request), token, 1000 * 60 * 5 - ) + )) ); - // this._setupRefresh(result); if (this._isBrokerAvailable) { await this._accountAccess.setAllowedAccess(result.account!, true); } @@ -226,24 +247,6 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica } this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update complete`); } - - // private _setupRefresh(result: AuthenticationResult) { - // const on = result.refreshOn || result.expiresOn; - // if (!result.account || !on) { - // return; - // } - - // const account = result.account; - // const scopes = result.scopes; - // const timeToRefresh = on.getTime() - Date.now() - 5 * 60 * 1000; // 5 minutes before expiry - // const key = JSON.stringify({ accountId: account.homeAccountId, scopes }); - // this._logger.debug(`[_setupRefresh] [${this._clientId}] [${this._authority}] [${scopes.join(' ')}] [${account.username}] timeToRefresh: ${timeToRefresh}`); - // this._refreshDelayer.trigger( - // key, - // () => this.acquireTokenSilent({ account, scopes, redirectUri: 'https://vscode.dev/redirect', forceRefresh: true }), - // timeToRefresh > 0 ? timeToRefresh : 0 - // ); - // } } export class Sequencer { @@ -254,17 +257,3 @@ export class Sequencer { return this.current = this.current.then(() => promiseTask(), () => promiseTask()); } } - -// class DelayerByKey { -// private _delayers = new Map>(); - -// trigger(key: string, fn: () => Promise, delay: number): Promise { -// let delayer = this._delayers.get(key); -// if (!delayer) { -// delayer = new Delayer(delay); -// this._delayers.set(key, delayer); -// } - -// return delayer.trigger(fn, delay); -// } -// } diff --git a/extensions/microsoft-authentication/src/node/flows.ts b/extensions/microsoft-authentication/src/node/flows.ts new file mode 100644 index 00000000000..3e1d8c513f0 --- /dev/null +++ b/extensions/microsoft-authentication/src/node/flows.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 { AuthenticationResult } from '@azure/msal-node'; +import { Uri, LogOutputChannel, env } from 'vscode'; +import { ICachedPublicClientApplication } from '../common/publicClientCache'; +import { UriHandlerLoopbackClient } from '../common/loopbackClientAndOpener'; +import { UriEventHandler } from '../UriEventHandler'; +import { loopbackTemplate } from './loopbackTemplate'; + +const redirectUri = 'https://vscode.dev/redirect'; + +export const enum ExtensionHost { + WebWorker, + Remote, + Local +} + +interface IMsalFlowOptions { + supportsRemoteExtensionHost: boolean; + supportsWebWorkerExtensionHost: boolean; +} + +interface IMsalFlowTriggerOptions { + cachedPca: ICachedPublicClientApplication; + scopes: string[]; + loginHint?: string; + windowHandle?: Buffer; + logger: LogOutputChannel; + uriHandler: UriEventHandler; +} + +interface IMsalFlow { + readonly label: string; + readonly options: IMsalFlowOptions; + trigger(options: IMsalFlowTriggerOptions): Promise; +} + +class DefaultLoopbackFlow implements IMsalFlow { + label = 'default'; + options: IMsalFlowOptions = { + supportsRemoteExtensionHost: true, + supportsWebWorkerExtensionHost: true + }; + + async trigger({ cachedPca, scopes, loginHint, windowHandle, logger }: IMsalFlowTriggerOptions): Promise { + logger.info('Trying default msal flow...'); + return await cachedPca.acquireTokenInteractive({ + openBrowser: async (url: string) => { await env.openExternal(Uri.parse(url)); }, + scopes, + successTemplate: loopbackTemplate, + errorTemplate: loopbackTemplate, + loginHint, + prompt: loginHint ? undefined : 'select_account', + windowHandle + }); + } +} + +class UrlHandlerFlow implements IMsalFlow { + label = 'protocol handler'; + options: IMsalFlowOptions = { + supportsRemoteExtensionHost: false, + supportsWebWorkerExtensionHost: false + }; + + async trigger({ cachedPca, scopes, loginHint, windowHandle, logger, uriHandler }: IMsalFlowTriggerOptions): Promise { + logger.info('Trying protocol handler flow...'); + const loopbackClient = new UriHandlerLoopbackClient(uriHandler, redirectUri, logger); + return await cachedPca.acquireTokenInteractive({ + openBrowser: (url: string) => loopbackClient.openBrowser(url), + scopes, + loopbackClient, + loginHint, + prompt: loginHint ? undefined : 'select_account', + windowHandle + }); + } +} + +const allFlows: IMsalFlow[] = [ + new DefaultLoopbackFlow(), + new UrlHandlerFlow() +]; + +export interface IMsalFlowQuery { + extensionHost: ExtensionHost; +} + +export function getMsalFlows(query: IMsalFlowQuery): IMsalFlow[] { + return allFlows.filter(flow => { + let useFlow: boolean = true; + switch (query.extensionHost) { + case ExtensionHost.Remote: + useFlow &&= flow.options.supportsRemoteExtensionHost; + break; + case ExtensionHost.WebWorker: + useFlow &&= flow.options.supportsWebWorkerExtensionHost; + break; + } + return useFlow; + }); +} diff --git a/extensions/npm/src/preferred-pm.ts b/extensions/npm/src/preferred-pm.ts index c85b65b6ea3..452319671ea 100644 --- a/extensions/npm/src/preferred-pm.ts +++ b/extensions/npm/src/preferred-pm.ts @@ -28,6 +28,10 @@ async function isBunPreferred(pkgPath: string): Promise { return { isPreferred: true, hasLockfile: true }; } + if (await pathExists(path.join(pkgPath, 'bun.lock'))) { + return { isPreferred: true, hasLockfile: true }; + } + return { isPreferred: false, hasLockfile: false }; } diff --git a/extensions/php-language-features/src/features/phpGlobalFunctions.ts b/extensions/php-language-features/src/features/phpGlobalFunctions.ts index caf9eb11b71..ab1c5487ae8 100644 --- a/extensions/php-language-features/src/features/phpGlobalFunctions.ts +++ b/extensions/php-language-features/src/features/phpGlobalFunctions.ts @@ -1360,7 +1360,7 @@ export const globalfunctions: IEntries = { }, date_add: { description: 'Adds an amount of days, months, years, hours, minutes and seconds to a DateTime object', - signature: '( DateInterval $interval , DateTime $object ): DateTime' + signature: '( DateTime $object , DateInterval $interval ): DateTime' }, date_create: { description: 'Returns new DateTime object', @@ -1376,31 +1376,31 @@ export const globalfunctions: IEntries = { }, date_modify: { description: 'Alters the timestamp', - signature: '( string $modify , DateTime $object ): DateTime' + signature: '( DateTime $object , string $modify ): DateTime' }, date_date_set: { description: 'Sets the date', - signature: '( int $year , int $month , int $day , DateTime $object ): DateTime' + signature: '( DateTime $object , int $year , int $month , int $day ): DateTime' }, date_isodate_set: { description: 'Sets the ISO date', - signature: '( int $year , int $week [, int $day = 1 , DateTime $object ]): DateTime' + signature: '( DateTime $object , int $year , int $week [, int $day = 1 ]): DateTime' }, date_time_set: { description: 'Sets the time', - signature: '( int $hour , int $minute [, int $second = 0 [, int $microseconds = 0 , DateTime $object ]]): DateTime' + signature: '( DateTime $object , int $hour , int $minute [, int $second = 0 [, int $microseconds = 0 ]]): DateTime' }, date_timestamp_set: { description: 'Sets the date and time based on an Unix timestamp', - signature: '( int $unixtimestamp , DateTime $object ): DateTime' + signature: '( DateTime $object , int $unixtimestamp ): DateTime' }, date_timezone_set: { description: 'Sets the time zone for the DateTime object', - signature: '( DateTimeZone $timezone , DateTime $object ): object' + signature: '( DateTime $object , DateTimeZone $timezone ): object' }, date_sub: { description: 'Subtracts an amount of days, months, years, hours, minutes and seconds from a DateTime object', - signature: '( DateInterval $interval , DateTime $object ): DateTime' + signature: '( DateTime $object , DateInterval $interval ): DateTime' }, date_create_immutable: { description: 'Returns new DateTimeImmutable object', diff --git a/extensions/terminal-suggest/.gitignore b/extensions/terminal-suggest/.gitignore new file mode 100644 index 00000000000..76b510c710f --- /dev/null +++ b/extensions/terminal-suggest/.gitignore @@ -0,0 +1 @@ +third_party/ diff --git a/extensions/terminal-suggest/package.json b/extensions/terminal-suggest/package.json index 611ef00ed4c..981112636bc 100644 --- a/extensions/terminal-suggest/package.json +++ b/extensions/terminal-suggest/package.json @@ -14,7 +14,8 @@ "Other" ], "enabledApiProposals": [ - "terminalCompletionProvider" + "terminalCompletionProvider", + "terminalShellEnv" ], "scripts": { "compile": "npx gulp compile-extension:terminal-suggest", diff --git a/extensions/terminal-suggest/scripts/clone-fig.ps1 b/extensions/terminal-suggest/scripts/clone-fig.ps1 new file mode 100644 index 00000000000..11ec13e560e --- /dev/null +++ b/extensions/terminal-suggest/scripts/clone-fig.ps1 @@ -0,0 +1 @@ +git clone https://github.com/withfig/autocomplete third_party/autocomplete diff --git a/extensions/terminal-suggest/scripts/clone-fig.sh b/extensions/terminal-suggest/scripts/clone-fig.sh new file mode 100644 index 00000000000..11ec13e560e --- /dev/null +++ b/extensions/terminal-suggest/scripts/clone-fig.sh @@ -0,0 +1 @@ +git clone https://github.com/withfig/autocomplete third_party/autocomplete diff --git a/extensions/terminal-suggest/scripts/update-specs.js b/extensions/terminal-suggest/scripts/update-specs.js new file mode 100644 index 00000000000..3573c6664d6 --- /dev/null +++ b/extensions/terminal-suggest/scripts/update-specs.js @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const fs = require('fs'); +const path = require('path'); + +const upstreamSpecs = require('../out/constants.js').upstreamSpecs; + +const extRoot = path.resolve(path.join(__dirname, '..')); +for (const spec of upstreamSpecs) { + const source = path.join(extRoot, `third_party/autocomplete/src/${spec}.ts`); + const destination = path.join(extRoot, `src/completions/upstream/${spec}.ts`); + fs.copyFileSync(source, destination); +} diff --git a/extensions/terminal-suggest/scripts/update-specs.ps1 b/extensions/terminal-suggest/scripts/update-specs.ps1 new file mode 100644 index 00000000000..0f129190379 --- /dev/null +++ b/extensions/terminal-suggest/scripts/update-specs.ps1 @@ -0,0 +1 @@ +node "$PSScriptRoot/update-specs.js" diff --git a/extensions/terminal-suggest/scripts/update-specs.sh b/extensions/terminal-suggest/scripts/update-specs.sh new file mode 100644 index 00000000000..4efd5bbf20d --- /dev/null +++ b/extensions/terminal-suggest/scripts/update-specs.sh @@ -0,0 +1 @@ +node ./update-specs.js diff --git a/extensions/terminal-suggest/src/completions/index.d.ts b/extensions/terminal-suggest/src/completions/index.d.ts index e56afd803ca..de76233ecb4 100644 --- a/extensions/terminal-suggest/src/completions/index.d.ts +++ b/extensions/terminal-suggest/src/completions/index.d.ts @@ -666,7 +666,7 @@ declare namespace Fig { * }, * ``` */ - generateSpec?: (tokens: string[], executeCommand: ExecuteCommandFunction) => Promise; + generateSpec?: (tokens: string[], executeCommand: ExecuteCommandFunction) => Promise; /** * Generating a spec can be expensive, but due to current guarantees they are not cached. diff --git a/extensions/terminal-suggest/src/completions/upstream/echo.ts b/extensions/terminal-suggest/src/completions/upstream/echo.ts new file mode 100644 index 00000000000..8ca21b858b3 --- /dev/null +++ b/extensions/terminal-suggest/src/completions/upstream/echo.ts @@ -0,0 +1,42 @@ +const environmentVariableGenerator: Fig.Generator = { + custom: async (tokens, _, context) => { + if (tokens.length < 3 || tokens[tokens.length - 1].startsWith("$")) { + return Object.keys(context.environmentVariables).map((suggestion) => ({ + name: `$${suggestion}`, + type: "arg", + description: "Environment Variable", + })); + } else { + return []; + } + }, + trigger: "$", +}; + +const completionSpec: Fig.Spec = { + name: "echo", + description: "Write arguments to the standard output", + args: { + name: "string", + isVariadic: true, + optionsCanBreakVariadicArg: false, + suggestCurrentToken: true, + generators: environmentVariableGenerator, + }, + options: [ + { + name: "-n", + description: "Do not print the trailing newline character", + }, + { + name: "-e", + description: "Interpret escape sequences", + }, + { + name: "-E", + description: "Disable escape sequences", + }, + ], +}; + +export default completionSpec; diff --git a/extensions/terminal-suggest/src/completions/upstream/ls.ts b/extensions/terminal-suggest/src/completions/upstream/ls.ts new file mode 100644 index 00000000000..91afc0b75ed --- /dev/null +++ b/extensions/terminal-suggest/src/completions/upstream/ls.ts @@ -0,0 +1,227 @@ +const completionSpec: Fig.Spec = { + name: "ls", + description: "List directory contents", + args: { + isVariadic: true, + template: ["filepaths", "folders"], + filterStrategy: "fuzzy", + }, + options: [ + { + name: "-@", + description: + "Display extended attribute keys and sizes in long (-l) output", + }, + { + name: "-1", + description: + "(The numeric digit ``one''.) Force output to be one entry per line. This is the default when output is not to a terminal", + }, + { + name: "-A", + description: + "List all entries except for . and ... Always set for the super-user", + }, + { + name: "-a", + description: "Include directory entries whose names begin with a dot (.)", + }, + { + name: "-B", + description: + "Force printing of non-printable characters (as defined by ctype(3) and current locale settings) in file names as xxx, where xxx is the numeric value of the character in octal", + }, + { + name: "-b", + description: "As -B, but use C escape codes whenever possible", + }, + { + name: "-C", + description: + "Force multi-column output; this is the default when output is to a terminal", + }, + { + name: "-c", + description: + "Use time when file status was last changed for sorting (-t) or long printing (-l)", + }, + { + name: "-d", + description: + "Directories are listed as plain files (not searched recursively)", + }, + { + name: "-e", + description: + "Print the Access Control List (ACL) associated with the file, if present, in long (-l) output", + }, + { + name: "-F", + description: + "Display a slash (/) immediately after each pathname that is a directory, an asterisk (*) after each that is executable, an at sign (@) after each symbolic link, an equals sign (=) after each socket, a percent sign (%) after each whiteout, and a vertical bar (|) after each that is a FIFO", + }, + { + name: "-f", + description: "Output is not sorted. This option turns on the -a option", + }, + { + name: "-G", + description: + "Enable colorized output. This option is equivalent to defining CLICOLOR in the environment. (See below.)", + }, + { + name: "-g", + description: + "This option is only available for compatibility with POSIX; it is used to display the group name in the long (-l) format output (the owner name is suppressed)", + }, + { + name: "-H", + description: + "Symbolic links on the command line are followed. This option is assumed if none of the -F, -d, or -l options are specified", + }, + { + name: "-h", + description: + "When used with the -l option, use unit suffixes: Byte, Kilobyte, Megabyte, Gigabyte, Terabyte and Petabyte in order to reduce the number of digits to three or less using base 2 for sizes", + }, + { + name: "-i", + description: + "For each file, print the file's file serial number (inode number)", + }, + { + name: "-k", + description: + "If the -s option is specified, print the file size allocation in kilobytes, not blocks. This option overrides the environment variable BLOCKSIZE", + }, + { + name: "-L", + description: + "Follow all symbolic links to final target and list the file or directory the link references rather than the link itself. This option cancels the -P option", + }, + { + name: "-l", + description: + "(The lowercase letter ``ell''.) List in long format. (See below.) A total sum for all the file sizes is output on a line before the long listing", + }, + { + name: "-m", + description: + "Stream output format; list files across the page, separated by commas", + }, + { + name: "-n", + description: + "Display user and group IDs numerically, rather than converting to a user or group name in a long (-l) output. This option turns on the -l option", + }, + { + name: "-O", + description: "Include the file flags in a long (-l) output", + }, + { name: "-o", description: "List in long format, but omit the group id" }, + { + name: "-P", + description: + "If argument is a symbolic link, list the link itself rather than the object the link references. This option cancels the -H and -L options", + }, + { + name: "-p", + description: + "Write a slash (`/') after each filename if that file is a directory", + }, + { + name: "-q", + description: + "Force printing of non-graphic characters in file names as the character `?'; this is the default when output is to a terminal", + }, + { name: "-R", description: "Recursively list subdirectories encountered" }, + { + name: "-r", + description: + "Reverse the order of the sort to get reverse lexicographical order or the oldest entries first (or largest files last, if combined with sort by size", + }, + { name: "-S", description: "Sort files by size" }, + { + name: "-s", + description: + "Display the number of file system blocks actually used by each file, in units of 512 bytes, where partial units are rounded up to the next integer value. If the output is to a terminal, a total sum for all the file sizes is output on a line before the listing. The environment variable BLOCKSIZE overrides the unit size of 512 bytes", + }, + { + name: "-T", + description: + "When used with the -l (lowercase letter ``ell'') option, display complete time information for the file, including month, day, hour, minute, second, and year", + }, + { + name: "-t", + description: + "Sort by time modified (most recently modified first) before sorting the operands by lexicographical order", + }, + { + name: "-u", + description: + "Use time of last access, instead of last modification of the file for sorting (-t) or long printing (-l)", + }, + { + name: "-U", + description: + "Use time of file creation, instead of last modification for sorting (-t) or long output (-l)", + }, + { + name: "-v", + description: + "Force unedited printing of non-graphic characters; this is the default when output is not to a terminal", + }, + { + name: "-W", + description: "Display whiteouts when scanning directories. (-S) flag)", + }, + { + name: "-w", + description: + "Force raw printing of non-printable characters. This is the default when output is not to a terminal", + }, + { + name: "-x", + description: + "The same as -C, except that the multi-column output is produced with entries sorted across, rather than down, the columns", + }, + { + name: "-%", + description: + "Distinguish dataless files and directories with a '%' character in long (-l) output, and don't materialize dataless directories when listing them", + }, + { + name: "-,", + description: `When the -l option is set, print file sizes grouped and separated by thousands using the non-monetary separator returned +by localeconv(3), typically a comma or period. If no locale is set, or the locale does not have a non-monetary separator, this +option has no effect. This option is not defined in IEEE Std 1003.1-2001 (“POSIX.1”)`, + dependsOn: ["-l"], + }, + { + name: "--color", + description: `Output colored escape sequences based on when, which may be set to either always, auto, or never`, + requiresSeparator: true, + args: { + name: "when", + suggestions: [ + { + name: ["always", "yes", "force"], + description: "Will make ls always output color", + }, + { + name: "auto", + description: + "Will make ls output escape sequences based on termcap(5), but only if stdout is a tty and either the -G flag is specified or the COLORTERM environment variable is set and not empty", + }, + { + name: ["never", "no", "none"], + description: + "Will disable color regardless of environment variables", + }, + ], + }, + }, + ], +}; + +export default completionSpec; diff --git a/extensions/terminal-suggest/src/completions/upstream/mkdir.ts b/extensions/terminal-suggest/src/completions/upstream/mkdir.ts new file mode 100644 index 00000000000..90a6530c3bd --- /dev/null +++ b/extensions/terminal-suggest/src/completions/upstream/mkdir.ts @@ -0,0 +1,37 @@ +const completionSpec: Fig.Spec = { + name: "mkdir", + description: "Make directories", + args: { + name: "directory name", + template: "folders", + suggestCurrentToken: true, + }, + options: [ + { + name: ["-m", "--mode"], + description: "Set file mode (as in chmod), not a=rwx - umask", + args: { name: "MODE" }, + }, + { + name: ["-p", "--parents"], + description: "No error if existing, make parent directories as needed", + }, + { + name: ["-v", "--verbose"], + description: "Print a message for each created directory", + }, + { + name: ["-Z", "--context"], + description: + "Set the SELinux security context of each created directory to CTX", + args: { name: "CTX" }, + }, + { name: "--help", description: "Display this help and exit" }, + { + name: "--version", + description: "Output version information and exit", + }, + ], +}; + +export default completionSpec; diff --git a/extensions/terminal-suggest/src/completions/upstream/rm.ts b/extensions/terminal-suggest/src/completions/upstream/rm.ts new file mode 100644 index 00000000000..7b52f909527 --- /dev/null +++ b/extensions/terminal-suggest/src/completions/upstream/rm.ts @@ -0,0 +1,43 @@ +const completionSpec: Fig.Spec = { + name: "rm", + description: "Remove directory entries", + args: { + isVariadic: true, + template: ["folders", "filepaths"], + }, + + options: [ + { + name: ["-r", "-R"], + description: + "Recursive. Attempt to remove the file hierarchy rooted in each file argument", + isDangerous: true, + }, + { + name: "-P", + description: "Overwrite regular files before deleting them", + isDangerous: true, + }, + { + name: "-d", + description: + "Attempt to remove directories as well as other types of files", + }, + { + name: "-f", + description: + "⚠️ Attempt to remove the files without prompting for confirmation", + isDangerous: true, + }, + { + name: "-i", + description: "Request confirmation before attempting to remove each file", + }, + { + name: "-v", + description: "Be verbose when deleting files", + }, + ], +}; + +export default completionSpec; diff --git a/extensions/terminal-suggest/src/completions/upstream/rmdir.ts b/extensions/terminal-suggest/src/completions/upstream/rmdir.ts new file mode 100644 index 00000000000..92790e75d0d --- /dev/null +++ b/extensions/terminal-suggest/src/completions/upstream/rmdir.ts @@ -0,0 +1,18 @@ +const completionSpec: Fig.Spec = { + name: "rmdir", + description: "Remove directories", + args: { + isVariadic: true, + template: "folders", + }, + + options: [ + { + name: "-p", + description: "Remove each directory of path", + isDangerous: true, + }, + ], +}; + +export default completionSpec; diff --git a/extensions/terminal-suggest/src/completions/upstream/touch.ts b/extensions/terminal-suggest/src/completions/upstream/touch.ts new file mode 100644 index 00000000000..45208878313 --- /dev/null +++ b/extensions/terminal-suggest/src/completions/upstream/touch.ts @@ -0,0 +1,59 @@ +const completionSpec: Fig.Spec = { + name: "touch", + description: "Change file access and modification times", + args: { + name: "file", + isVariadic: true, + template: "folders", + suggestCurrentToken: true, + }, + options: [ + { + name: "-A", + description: + "Adjust the access and modification time stamps for the file by the specified value", + args: { + name: "time", + description: "[-][[hh]mm]SS", + }, + }, + { name: "-a", description: "Change the access time of the file" }, + { + name: "-c", + description: "Do not create the file if it does not exist", + }, + { + name: "-f", + description: + "Attempt to force the update, even if the file permissions do not currently permit it", + }, + { + name: "-h", + description: + "If the file is a symbolic link, change the times of the link itself rather than the file that the link points to", + }, + { + name: "-m", + description: "Change the modification time of the file", + }, + { + name: "-r", + description: + "Use the access and modifications times from the specified file instead of the current time of day", + args: { + name: "file", + }, + }, + { + name: "-t", + description: + "Change the access and modification times to the specified time instead of the current time of day", + args: { + name: "timestamp", + description: "[[CC]YY]MMDDhhmm[.SS]", + }, + }, + ], +}; + +export default completionSpec; diff --git a/extensions/terminal-suggest/src/constants.ts b/extensions/terminal-suggest/src/constants.ts new file mode 100644 index 00000000000..37d08189da6 --- /dev/null +++ b/extensions/terminal-suggest/src/constants.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const upstreamSpecs = [ + 'echo', + 'ls', + 'mkdir', + 'rm', + 'rmdir', + 'touch', +]; diff --git a/extensions/terminal-suggest/src/terminalSuggestMain.ts b/extensions/terminal-suggest/src/terminalSuggestMain.ts index 13ff032037a..4ed1a26fe6d 100644 --- a/extensions/terminal-suggest/src/terminalSuggestMain.ts +++ b/extensions/terminal-suggest/src/terminalSuggestMain.ts @@ -7,14 +7,23 @@ import * as os from 'os'; import * as fs from 'fs/promises'; import * as path from 'path'; import { ExecOptionsWithStringEncoding, execSync } from 'child_process'; -import codeInsidersCompletionSpec from './completions/code-insiders'; +import { upstreamSpecs } from './constants'; import codeCompletionSpec from './completions/code'; import cdSpec from './completions/cd'; +import codeInsidersCompletionSpec from './completions/code-insiders'; +let cachedAvailableCommandsPath: string | undefined; let cachedAvailableCommands: Set | undefined; const cachedBuiltinCommands: Map = new Map(); -export const availableSpecs = [codeCompletionSpec, codeInsidersCompletionSpec, cdSpec]; +export const availableSpecs: Fig.Spec[] = [ + cdSpec, + codeInsidersCompletionSpec, + codeCompletionSpec, +]; +for (const spec of upstreamSpecs) { + availableSpecs.push(require(`./completions/upstream/${spec}`).default); +} function getBuiltinCommands(shell: string): string[] | undefined { try { @@ -80,7 +89,7 @@ export async function activate(context: vscode.ExtensionContext) { return; } - const commandsInPath = await getCommandsInPath(); + const commandsInPath = await getCommandsInPath(terminal.shellIntegration?.env); const builtinCommands = getBuiltinCommands(shellPath); if (!commandsInPath || !builtinCommands) { return; @@ -129,6 +138,7 @@ export async function resolveCwdFromPrefix(prefix: string, currentCwd?: vscode.U // Resolve the absolute path of the prefix const resolvedPath = path.resolve(currentCwd?.fsPath, relativeFolder); + const stat = await fs.stat(resolvedPath); // Check if the resolved path exists and is a directory @@ -164,7 +174,7 @@ function createCompletionItem(cursorPosition: number, prefix: string, label: str label, detail: description ?? '', replacementIndex: cursorPosition - lastWord.length, - replacementLength: lastWord.length > 0 ? lastWord.length : cursorPosition, + replacementLength: lastWord.length, kind: kind ?? vscode.TerminalCompletionItemKind.Method }; } @@ -187,15 +197,30 @@ async function isExecutable(filePath: string): Promise { } } -async function getCommandsInPath(): Promise | undefined> { - if (cachedAvailableCommands) { - return cachedAvailableCommands; +async function getCommandsInPath(env: { [key: string]: string | undefined } = process.env): Promise | undefined> { + // Get PATH value + let pathValue: string | undefined; + if (osIsWindows()) { + const caseSensitivePathKey = Object.keys(env).find(key => key.toLowerCase() === 'path'); + if (caseSensitivePathKey) { + pathValue = env[caseSensitivePathKey]; + } + } else { + pathValue = env.PATH; } - const paths = osIsWindows() ? process.env.PATH?.split(';') : process.env.PATH?.split(':'); - if (!paths) { + if (pathValue === undefined) { return; } - const pathSeparator = osIsWindows() ? '\\' : '/'; + + // Check cache + if (cachedAvailableCommands && cachedAvailableCommandsPath === pathValue) { + return cachedAvailableCommands; + } + + // Extract executables from PATH + const isWindows = osIsWindows(); + const paths = pathValue.split(isWindows ? ';' : ':'); + const pathSeparator = isWindows ? '\\' : '/'; const executables = new Set(); for (const path of paths) { try { @@ -246,21 +271,33 @@ export function asArray(x: T | T[]): T[] { return Array.isArray(x) ? x : [x]; } -export async function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: { commandLine: string; cursorPosition: number }, availableCommands: string[], prefix: string, shellIntegrationCwd?: vscode.Uri, token?: vscode.CancellationToken): Promise<{ items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean; cwd?: vscode.Uri }> { +export async function getCompletionItemsFromSpecs( + specs: Fig.Spec[], + terminalContext: { commandLine: string; cursorPosition: number }, + availableCommands: string[], + prefix: string, + shellIntegrationCwd?: vscode.Uri, + token?: vscode.CancellationToken +): Promise<{ items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean; cwd?: vscode.Uri }> { const items: vscode.TerminalCompletionItem[] = []; let filesRequested = false; let foldersRequested = false; + const firstCommand = getFirstCommand(terminalContext.commandLine); + const precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition + 1); + for (const spec of specs) { const specLabels = getLabel(spec); + if (!specLabels) { continue; } + for (const specLabel of specLabels) { - if (!availableCommands.includes(specLabel) || (token && token?.isCancellationRequested)) { + if (!availableCommands.includes(specLabel) || (token && token.isCancellationRequested)) { continue; } - // + if ( // If the prompt is empty !terminalContext.commandLine @@ -270,76 +307,38 @@ export async function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalCon // push it to the completion items items.push(createCompletionItem(terminalContext.cursorPosition, prefix, specLabel)); } + if (!terminalContext.commandLine.startsWith(specLabel)) { // the spec label is not the first word in the command line, so do not provide options or args continue; } - const precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition + 1); - if ('options' in spec && spec.options) { - for (const option of spec.options) { - const optionLabels = getLabel(option); - if (!optionLabels) { - continue; - } - for (const optionLabel of optionLabels) { - if (!items.find(i => i.label === optionLabel) && optionLabel.startsWith(prefix) || (prefix.length > specLabel.length && prefix.trim() === specLabel)) { - items.push(createCompletionItem(terminalContext.cursorPosition, prefix, optionLabel, option.description, vscode.TerminalCompletionItemKind.Flag)); - } - const expectedText = `${specLabel} ${optionLabel} `; - if (!precedingText.includes(expectedText)) { - continue; - } - const indexOfPrecedingText = terminalContext.commandLine.lastIndexOf(expectedText); - const currentPrefix = precedingText.slice(indexOfPrecedingText + expectedText.length); - const argsCompletions = getCompletionItemsFromArgs(option.args, currentPrefix, terminalContext); - if (!argsCompletions) { - continue; - } - const argCompletions = argsCompletions.items; - foldersRequested = foldersRequested || argsCompletions.foldersRequested; - filesRequested = filesRequested || argsCompletions.filesRequested; - let cwd: vscode.Uri | undefined; - if (shellIntegrationCwd && (filesRequested || foldersRequested)) { - cwd = await resolveCwdFromPrefix(prefix, shellIntegrationCwd) ?? shellIntegrationCwd; - } - return { items: argCompletions, filesRequested, foldersRequested, cwd }; - } - } + + const argsCompletionResult = handleArguments(specLabel, spec, terminalContext, precedingText); + if (argsCompletionResult) { + items.push(...argsCompletionResult.items); + filesRequested ||= argsCompletionResult.filesRequested; + foldersRequested ||= argsCompletionResult.foldersRequested; } - if ('args' in spec && asArray(spec.args)) { - const expectedText = `${specLabel} `; - if (!precedingText.includes(expectedText)) { - continue; - } - const indexOfPrecedingText = terminalContext.commandLine.lastIndexOf(expectedText); - const currentPrefix = precedingText.slice(indexOfPrecedingText + expectedText.length); - const argsCompletions = getCompletionItemsFromArgs(spec.args, currentPrefix, terminalContext); - if (!argsCompletions) { - continue; - } - items.push(...argsCompletions.items); - filesRequested = filesRequested || argsCompletions.filesRequested; - foldersRequested = foldersRequested || argsCompletions.foldersRequested; + + const optionsCompletionResult = handleOptions(specLabel, spec, terminalContext, precedingText, prefix); + if (optionsCompletionResult) { + items.push(...optionsCompletionResult.items); + filesRequested ||= optionsCompletionResult.filesRequested; + foldersRequested ||= optionsCompletionResult.foldersRequested; } } } const shouldShowResourceCompletions = - ( - // If the command line is empty - terminalContext.commandLine.trim().length === 0 - // or no completions are found and the prefix is empty - || !items?.length - // or all of the items are '.' or '..' IE file paths - || items.length && items.every(i => ['.', '..'].includes(i.label)) - ) - // and neither files nor folders are going to be requested (for a specific spec's argument) - && (!filesRequested && !foldersRequested); + (!terminalContext.commandLine.trim() || !items.length) && + !filesRequested && + !foldersRequested; const shouldShowCommands = !terminalContext.commandLine.substring(0, terminalContext.cursorPosition).trimStart().includes(' '); - if (shouldShowCommands && (filesRequested === foldersRequested)) { + + if (shouldShowCommands && !filesRequested && !foldersRequested) { // Include builitin/available commands in the results - const labels = new Set(items.map(i => i.label)); + const labels = new Set(items.map((i) => i.label)); for (const command of availableCommands) { if (!labels.has(command)) { items.push(createCompletionItem(terminalContext.cursorPosition, prefix, command)); @@ -351,13 +350,90 @@ export async function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalCon filesRequested = true; foldersRequested = true; } + let cwd: vscode.Uri | undefined; if (shellIntegrationCwd && (filesRequested || foldersRequested)) { cwd = await resolveCwdFromPrefix(prefix, shellIntegrationCwd) ?? shellIntegrationCwd; } + return { items, filesRequested, foldersRequested, cwd }; } +function handleArguments(specLabel: string, spec: Fig.Spec, terminalContext: { commandLine: string; cursorPosition: number }, precedingText: string): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean } | undefined { + let args; + if ('args' in spec && spec.args && asArray(spec.args)) { + args = asArray(spec.args); + } + const expectedText = `${specLabel} `; + + if (!precedingText.includes(expectedText)) { + return; + } + + const currentPrefix = precedingText.slice(precedingText.lastIndexOf(expectedText) + expectedText.length); + const argsCompletions = getCompletionItemsFromArgs(args, currentPrefix, terminalContext); + + if (!argsCompletions) { + return; + } + + return argsCompletions; +} + +function handleOptions(specLabel: string, spec: Fig.Spec, terminalContext: { commandLine: string; cursorPosition: number }, precedingText: string, prefix: string): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean } | undefined { + let options; + if ('options' in spec && spec.options) { + options = spec.options; + } + if (!options) { + return; + } + + const optionItems: vscode.TerminalCompletionItem[] = []; + + for (const option of options) { + const optionLabels = getLabel(option); + + if (!optionLabels) { + continue; + } + + for (const optionLabel of optionLabels) { + if ( + // Already includes this option + optionItems.find((i) => i.label === optionLabel) + ) { + continue; + } + + optionItems.push( + createCompletionItem( + terminalContext.cursorPosition, + prefix, + optionLabel, + option.description, + vscode.TerminalCompletionItemKind.Flag + ) + ); + + const expectedText = `${specLabel} ${optionLabel} `; + if (!precedingText.includes(expectedText)) { + continue; + } + + const currentPrefix = precedingText.slice(precedingText.lastIndexOf(expectedText) + expectedText.length); + const argsCompletions = getCompletionItemsFromArgs(option.args, currentPrefix, terminalContext); + + if (argsCompletions) { + return { items: argsCompletions.items, filesRequested: argsCompletions.filesRequested, foldersRequested: argsCompletions.foldersRequested }; + } + } + } + + return { items: optionItems, filesRequested: false, foldersRequested: false }; +} + + function getCompletionItemsFromArgs(args: Fig.SingleOrArray | undefined, currentPrefix: string, terminalContext: { commandLine: string; cursorPosition: number }): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean } | undefined { if (!args) { return; diff --git a/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts b/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts index 70694606d99..6e4520e2df1 100644 --- a/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts +++ b/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts @@ -62,9 +62,9 @@ function createCodeTestSpecs(executable: string): ITestSpec2[] { { input: `${executable} --merge ./file1 ./file2 ./base |`, expectedResourceRequests: { type: 'files', cwd: testCwd } }, { input: `${executable} --goto |`, expectedResourceRequests: { type: 'files', cwd: testCwd } }, { input: `${executable} --user-data-dir |`, expectedResourceRequests: { type: 'folders', cwd: testCwd } }, - { input: `${executable} --profile |` }, - { input: `${executable} --install-extension |` }, - { input: `${executable} --uninstall-extension |` }, + { input: `${executable} --profile |`, expectedResourceRequests: { type: 'both', cwd: testCwd } }, + { input: `${executable} --install-extension |`, expectedResourceRequests: { type: 'both', cwd: testCwd } }, + { input: `${executable} --uninstall-extension |`, expectedResourceRequests: { type: 'both', cwd: testCwd } }, { input: `${executable} --log |`, expectedCompletions: logOptions }, { input: `${executable} --sync |`, expectedCompletions: syncOptions }, { input: `${executable} --extensions-dir |`, expectedResourceRequests: { type: 'folders', cwd: testCwd } }, diff --git a/extensions/terminal-suggest/tsconfig.json b/extensions/terminal-suggest/tsconfig.json index 151a29616bb..f3d3aa73975 100644 --- a/extensions/terminal-suggest/tsconfig.json +++ b/extensions/terminal-suggest/tsconfig.json @@ -11,11 +11,16 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, + + // Needed to suppress warnings in upstream completions + "noImplicitReturns": false, + "noUnusedParameters": false }, "include": [ "src/**/*", "src/completions/index.d.ts", "../../src/vscode-dts/vscode.d.ts", - "../../src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts" + "../../src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts", + "../../src/vscode-dts/vscode.proposed.terminalShellEnv.d.ts" ] } diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 65038311f76..ac6d0487a4c 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -13,7 +13,6 @@ "mappedEditsProvider", "codeActionAI", "codeActionRanges", - "documentPaste", "editorHoverVerbosityLevel" ], "capabilities": { @@ -384,12 +383,6 @@ "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", "scope": "resource" }, - "javascript.inlayHints.enumMemberValues.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.enumMemberValues.enabled%", - "scope": "resource" - }, "javascript.suggest.includeCompletionsForImportStatements": { "type": "boolean", "default": true, diff --git a/extensions/typescript-language-features/src/languageFeatures/copyPaste.ts b/extensions/typescript-language-features/src/languageFeatures/copyPaste.ts index 71b6797f585..30a73d0adf7 100644 --- a/extensions/typescript-language-features/src/languageFeatures/copyPaste.ts +++ b/extensions/typescript-language-features/src/languageFeatures/copyPaste.ts @@ -43,7 +43,7 @@ const enabledSettingId = 'updateImportsOnPaste.enabled'; class DocumentPasteProvider implements vscode.DocumentPasteEditProvider { - static readonly kind = vscode.DocumentDropOrPasteEditKind.Text.append('updateImports', 'jsts'); + static readonly kind = vscode.DocumentDropOrPasteEditKind.TextUpdateImports.append('jsts'); static readonly metadataMimeType = 'application/vnd.code.jsts.metadata'; constructor( diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 5f7681bf9d6..60b35b4dd9e 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -17,7 +17,6 @@ "defaultChatParticipant", "diffCommand", "documentFiltersExclusive", - "documentPaste", "editorInsets", "embeddings", "extensionRuntime", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/terminal.shellIntegration.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/terminal.shellIntegration.test.ts index 521a41ea865..db48c2593e0 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/terminal.shellIntegration.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/terminal.shellIntegration.test.ts @@ -212,7 +212,12 @@ import { assertNoRpc } from '../utils'; await closeTerminalAsync(terminal); }); - test('executeCommand(executable, args)', async () => { + test('executeCommand(executable, args)', async function () { + // HACK: This test has flaked before where the `value` was `e`, not `echo hello`. After an + // investigation it's not clear how this happened, so in order to keep some of the value + // that the test adds, it will retry after a failure. + this.retries(3); + const { terminal, shellIntegration } = await createTerminalAndWaitForShellIntegration(); const { execution, endEvent } = executeCommandAsync(shellIntegration, 'echo', ['hello']); const executionSync = await execution; diff --git a/package-lock.json b/package-lock.json index ac46ac9d114..ccbcd22f335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,25 +17,26 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.0", "@vscode/policy-watcher": "^1.1.8", - "@vscode/proxy-agent": "^0.28.0", + "@vscode/proxy-agent": "^0.29.0", "@vscode/ripgrep": "^1.15.9", "@vscode/spdlog": "^0.15.0", "@vscode/sqlite3": "5.1.8-vscode", "@vscode/sudo-prompt": "9.3.1", - "@vscode/tree-sitter-wasm": "^0.0.4", + "@vscode/tree-sitter-wasm": "^0.0.5", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/headless": "^5.6.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.79", + "@xterm/addon-image": "^0.9.0-beta.96", + "@xterm/addon-ligatures": "^0.10.0-beta.96", + "@xterm/addon-progress": "^0.2.0-beta.2", + "@xterm/addon-search": "^0.16.0-beta.96", + "@xterm/addon-serialize": "^0.14.0-beta.96", + "@xterm/addon-unicode11": "^0.9.0-beta.96", + "@xterm/addon-webgl": "^0.19.0-beta.96", + "@xterm/headless": "^5.6.0-beta.96", + "@xterm/xterm": "^5.6.0-beta.96", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -95,7 +96,7 @@ "cssnano": "^6.0.3", "debounce": "^1.0.0", "deemon": "^1.8.0", - "electron": "32.2.6", + "electron": "32.2.7", "eslint": "^9.11.1", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -153,7 +154,7 @@ "ts-node": "^10.9.1", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^5.8.0-dev.20241217", + "typescript": "^5.8.0-dev.20250110", "typescript-eslint": "^8.8.0", "util": "^0.12.4", "webpack": "^5.94.0", @@ -2848,9 +2849,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.28.0.tgz", - "integrity": "sha512-7rYF8ju0dP/ASpjjnuOCvzRosGLoKz0WOyNohREUskRdrvMEnYuEUXy84lHlH+4+MD8CZZjw2SUzhjHaJK1hxg==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.29.0.tgz", + "integrity": "sha512-zwpDvm5rwtJjXZv4TC8IXFRDDOU+fUNRe3asmls92Tz0dM0AJ8/WVfNgki5YOKxQMjVzWHAt0w53ZJxXj567EQ==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", @@ -2859,7 +2860,7 @@ "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "socks-proxy-agent": "^8.0.1", - "undici": "^6.20.1" + "undici": "^7.2.0" }, "optionalDependencies": { "@vscode/windows-ca-certs": "^0.3.1" @@ -3173,9 +3174,10 @@ } }, "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.0.4.tgz", - "integrity": "sha512-vOONG3Zxsh1I4JOA48WdQ5KiXjJAdfMvYTuHbW7b27tGtRqsPLY5WZyTwLXc5uujKHyhG3LJXE9poxRZSxTIiA==" + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.0.5.tgz", + "integrity": "sha512-qA+BkB2UgkfXMQVGsqPeG3vR3pXv0inP6WQ/dq6BALy7dIX9KQvGXvDCiqehdFvZZO4tDFt4qb5DdSsvwR4Y9Q==", + "license": "MIT" }, "node_modules/@vscode/v8-heap-parser": { "version": "0.1.0", @@ -3457,30 +3459,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.2.0-beta.53", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.53.tgz", - "integrity": "sha512-kCcBuGvF8mwzExU+Tm9eylPvp1kXTkvm+kO0V4qP7HI3ZCw5vfKmnlRn41FvNIylsK2hnmrFtxauPHEGBy/dfA==", + "version": "0.2.0-beta.79", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.79.tgz", + "integrity": "sha512-zpwf43fzBG01fA8E75JQ/jsm/85bHCtFMOcURNAEbCoj0RSpZaq4rE5cPoy49IhYnctPZdYNxEU/+PzgtsSQ6Q==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-image": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.70.tgz", - "integrity": "sha512-QLhy77i0sjnffkLuxj1yB/mBUJI64bbL86eMW+1g5XEsZnSevY8YwU/cEJg02PAGK0ggwQNNfiRwoO6VBCdYFg==", + "version": "0.9.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.96.tgz", + "integrity": "sha512-LDA03uA5gTBfFeEIUdHXoCpxGHuPIke03aSXZc6cllRRp9jCaOK2kbWwTOoXfzRvjreKK6pxD0vJcepwdKaNcw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.10.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.70.tgz", - "integrity": "sha512-BPDHHOybUWO6mjHf/RMDBjSKDl9QdyyGTyHvmlyhuI/2sma3lu98bA4U03F8nBnvj/6otzEFARuOeoN7rkfRng==", + "version": "0.10.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.96.tgz", + "integrity": "sha512-uI86EDxCab345YR/Piwzs3R82DptKp/PC+sMKSX24hehhvWVAkpZus0toGhYwLZxF1r0U6yw4Oq0aCEzW362dQ==", "license": "MIT", "dependencies": { "font-finder": "^1.1.0", @@ -3490,55 +3492,64 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" + } + }, + "node_modules/@xterm/addon-progress": { + "version": "0.2.0-beta.2", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0-beta.2.tgz", + "integrity": "sha512-5BcRexNJanTMG2N9TjZpDcaemDqQuvNkGN8HK3509+QNmqBF9sinJXSBWWNwn+o/f4OheBtBmlmC5mDanh4kig==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-search": { - "version": "0.16.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.70.tgz", - "integrity": "sha512-uaNBf77cr5Jikj69TDkTfe3V3wHA+4tDTFcv8DwJ/KGORPLUfBk8cn9HpbIJ+0jc1kOZr5xDrEjaYNwEVDkGeQ==", + "version": "0.16.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.96.tgz", + "integrity": "sha512-AEEXqxkT6OgDOII4SxQJFKn7NhM7PnFeMLxQwrRQzZyrtFPg5peCesA07xHYvYzh0aKQ1PVvgycfrPgbPEeQDQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.14.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.70.tgz", - "integrity": "sha512-4ijqHU7xDRcZ4Gm6yN/tKsZzovUwzttE9/pPfhxpWIgSdm9c8i5R4rGyOAlAZCnuU3DT5vPplQO7W/0zwAmJsQ==", + "version": "0.14.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.96.tgz", + "integrity": "sha512-+00J2K3WsbMPy02UlehNumen//Opmr5B0MBrS4KFqhRwVaO8RD0hMPfr8IuhY3YqPaVny4HMOU88yaWtscxl7A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.70.tgz", - "integrity": "sha512-9UE4v2SpWtqd85Hqvk8LW4QA9Phe93RMVltrNtt9jCUmkAok/QLFOEbLqoq2JUOvlmfsYfXmIl11Wg4CYIsvwA==", + "version": "0.9.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.96.tgz", + "integrity": "sha512-jBUSErJtrf6Jhz3itT5JXLeIIl5BkJ1ii8/YdzZQMh9602ZLYvqwS+7ih2FzYYjk0pZ+E1y4sYbT5FpBlVOM5w==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.70.tgz", - "integrity": "sha512-3BkmF24i66SHCfAkcHy1VC6H715qyelfOIjd6n7sTqHon56J1bUO782Q1al/MK0vSvplElBRKVdR31SDvITzpg==", + "version": "0.19.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.96.tgz", + "integrity": "sha512-iAno2CMYRBHasr5aE3I7WN6DZ0mlHsEAIYAhvdhH+be1YxX4/DceBAtN0Ak7nFcQ70ZbtYzs1aNLTVKtli2TKA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/headless": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.70.tgz", - "integrity": "sha512-npSJzmtJq8LLAzV3nwD9KkBQLNWSusi+cOBPyc3zlYYE63Vkqbtbp/iQS27zH2GxJ95rzCzDwnX6VOn0OoUPYQ==", + "version": "5.6.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.96.tgz", + "integrity": "sha512-lLumuO7sQbPNIxQkXa3CxwQWNlO4C5XF80VRgQ4PzjUcwPWGkGtkJJG9jcucHAKymQnH6AokCW171mcveU8cgg==", "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.70.tgz", - "integrity": "sha512-qviQMVWhRtgPn4z8PHNH6D/ffSKkNBHmUX1HyJxl325QM2xF8M8met83uFv7JZm7a5OQYScnLGsFAoTreSgdew==", + "version": "5.6.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.96.tgz", + "integrity": "sha512-XdOZyAaqOW67J3kJGJDgVb5skTD2nDQLmBICyhQ0cEqThTKrX5CzB11RswG6rZE8dI+nDE+pc93NjAMXSrQgFg==", "license": "MIT" }, "node_modules/@xtuc/ieee754": { @@ -6103,9 +6114,9 @@ "dev": true }, "node_modules/electron": { - "version": "32.2.6", - "resolved": "https://registry.npmjs.org/electron/-/electron-32.2.6.tgz", - "integrity": "sha512-aGG1MLvWCf+ECUFBCmaCF52F8312OPAJfph2D0FSsFmlbfnJuNevZCbty2lFzsiIMtU7/QRo6d0ksbgR4s7y3w==", + "version": "32.2.7", + "resolved": "https://registry.npmjs.org/electron/-/electron-32.2.7.tgz", + "integrity": "sha512-y8jbQRG3xogF70XPlk5c+dWe5iRfUBo28o2NMpKd/CcW7ENIaWtBlGima8/8nmRdAaYTy1+yIt6KB0Lon9H8cA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -17599,9 +17610,9 @@ "dev": true }, "node_modules/typescript": { - "version": "5.8.0-dev.20241217", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.0-dev.20241217.tgz", - "integrity": "sha512-Q/I+eHfiwN0aWhitenThTT2FcA1lTlUZR1z+6d2WaD/8/wHfdjQjdHynCpYXjAwDkfG8Apf9LdzZ3rLRD3O9iQ==", + "version": "5.8.0-dev.20250110", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.0-dev.20250110.tgz", + "integrity": "sha512-+qwHVEvUm4CeQGtZIvlwE8HmRFcBMV4F/8OPKv+mIyGRGx4Chrj2v0VCsReVJwRdjjs6Dat/lPzkJW1E18+eOg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -17781,12 +17792,12 @@ "dev": true }, "node_modules/undici": { - "version": "6.20.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.20.1.tgz", - "integrity": "sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.2.0.tgz", + "integrity": "sha512-klt+0S55GBViA9nsq48/NSCo4YX5mjydjypxD7UmHh/brMu8h/Mhd/F7qAeoH2NOO8SDTk6kjnTFc4WpzmfYpQ==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 3ccd7b25286..7efebd89619 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.97.0", - "distro": "45ffe59ad7c51acf28b541a2aa76cc73437ff118", + "distro": "d631a7e71cfab757e241fd75ecb1594c6b427920", "author": { "name": "Microsoft Corporation" }, @@ -75,25 +75,26 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.0", "@vscode/policy-watcher": "^1.1.8", - "@vscode/proxy-agent": "^0.28.0", + "@vscode/proxy-agent": "^0.29.0", "@vscode/ripgrep": "^1.15.9", "@vscode/spdlog": "^0.15.0", "@vscode/sqlite3": "5.1.8-vscode", "@vscode/sudo-prompt": "9.3.1", - "@vscode/tree-sitter-wasm": "^0.0.4", + "@vscode/tree-sitter-wasm": "^0.0.5", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/headless": "^5.6.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.79", + "@xterm/addon-image": "^0.9.0-beta.96", + "@xterm/addon-ligatures": "^0.10.0-beta.96", + "@xterm/addon-progress": "^0.2.0-beta.2", + "@xterm/addon-search": "^0.16.0-beta.96", + "@xterm/addon-serialize": "^0.14.0-beta.96", + "@xterm/addon-unicode11": "^0.9.0-beta.96", + "@xterm/addon-webgl": "^0.19.0-beta.96", + "@xterm/headless": "^5.6.0-beta.96", + "@xterm/xterm": "^5.6.0-beta.96", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -153,7 +154,7 @@ "cssnano": "^6.0.3", "debounce": "^1.0.0", "deemon": "^1.8.0", - "electron": "32.2.6", + "electron": "32.2.7", "eslint": "^9.11.1", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -211,7 +212,7 @@ "ts-node": "^10.9.1", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^5.8.0-dev.20241217", + "typescript": "^5.8.0-dev.20250110", "typescript-eslint": "^8.8.0", "util": "^0.12.4", "webpack": "^5.94.0", diff --git a/remote/package-lock.json b/remote/package-lock.json index 71119e97cf6..cac53e8d721 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -13,22 +13,23 @@ "@parcel/watcher": "2.5.0", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.0", - "@vscode/proxy-agent": "^0.28.0", + "@vscode/proxy-agent": "^0.29.0", "@vscode/ripgrep": "^1.15.9", "@vscode/spdlog": "^0.15.0", - "@vscode/tree-sitter-wasm": "^0.0.4", + "@vscode/tree-sitter-wasm": "^0.0.5", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/headless": "^5.6.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.79", + "@xterm/addon-image": "^0.9.0-beta.96", + "@xterm/addon-ligatures": "^0.10.0-beta.96", + "@xterm/addon-progress": "^0.2.0-beta.2", + "@xterm/addon-search": "^0.16.0-beta.96", + "@xterm/addon-serialize": "^0.14.0-beta.96", + "@xterm/addon-unicode11": "^0.9.0-beta.96", + "@xterm/addon-webgl": "^0.19.0-beta.96", + "@xterm/headless": "^5.6.0-beta.96", + "@xterm/xterm": "^5.6.0-beta.96", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -418,9 +419,9 @@ "integrity": "sha512-bRRFxLfg5dtAyl5XyiVWz/ZBPahpOpPrNYnnHpOpUZvam4tKH35wdhP4Kj6PbM0+KdliOsPzbGWpkxcdpNB/sg==" }, "node_modules/@vscode/proxy-agent": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.28.0.tgz", - "integrity": "sha512-7rYF8ju0dP/ASpjjnuOCvzRosGLoKz0WOyNohREUskRdrvMEnYuEUXy84lHlH+4+MD8CZZjw2SUzhjHaJK1hxg==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.29.0.tgz", + "integrity": "sha512-zwpDvm5rwtJjXZv4TC8IXFRDDOU+fUNRe3asmls92Tz0dM0AJ8/WVfNgki5YOKxQMjVzWHAt0w53ZJxXj567EQ==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", @@ -429,7 +430,7 @@ "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "socks-proxy-agent": "^8.0.1", - "undici": "^6.20.1" + "undici": "^7.2.0" }, "optionalDependencies": { "@vscode/windows-ca-certs": "^0.3.1" @@ -468,9 +469,10 @@ } }, "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.0.4.tgz", - "integrity": "sha512-vOONG3Zxsh1I4JOA48WdQ5KiXjJAdfMvYTuHbW7b27tGtRqsPLY5WZyTwLXc5uujKHyhG3LJXE9poxRZSxTIiA==" + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.0.5.tgz", + "integrity": "sha512-qA+BkB2UgkfXMQVGsqPeG3vR3pXv0inP6WQ/dq6BALy7dIX9KQvGXvDCiqehdFvZZO4tDFt4qb5DdSsvwR4Y9Q==", + "license": "MIT" }, "node_modules/@vscode/vscode-languagedetection": { "version": "1.0.21", @@ -520,30 +522,30 @@ "hasInstallScript": true }, "node_modules/@xterm/addon-clipboard": { - "version": "0.2.0-beta.53", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.53.tgz", - "integrity": "sha512-kCcBuGvF8mwzExU+Tm9eylPvp1kXTkvm+kO0V4qP7HI3ZCw5vfKmnlRn41FvNIylsK2hnmrFtxauPHEGBy/dfA==", + "version": "0.2.0-beta.79", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.79.tgz", + "integrity": "sha512-zpwf43fzBG01fA8E75JQ/jsm/85bHCtFMOcURNAEbCoj0RSpZaq4rE5cPoy49IhYnctPZdYNxEU/+PzgtsSQ6Q==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-image": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.70.tgz", - "integrity": "sha512-QLhy77i0sjnffkLuxj1yB/mBUJI64bbL86eMW+1g5XEsZnSevY8YwU/cEJg02PAGK0ggwQNNfiRwoO6VBCdYFg==", + "version": "0.9.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.96.tgz", + "integrity": "sha512-LDA03uA5gTBfFeEIUdHXoCpxGHuPIke03aSXZc6cllRRp9jCaOK2kbWwTOoXfzRvjreKK6pxD0vJcepwdKaNcw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.10.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.70.tgz", - "integrity": "sha512-BPDHHOybUWO6mjHf/RMDBjSKDl9QdyyGTyHvmlyhuI/2sma3lu98bA4U03F8nBnvj/6otzEFARuOeoN7rkfRng==", + "version": "0.10.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.96.tgz", + "integrity": "sha512-uI86EDxCab345YR/Piwzs3R82DptKp/PC+sMKSX24hehhvWVAkpZus0toGhYwLZxF1r0U6yw4Oq0aCEzW362dQ==", "license": "MIT", "dependencies": { "font-finder": "^1.1.0", @@ -553,55 +555,64 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" + } + }, + "node_modules/@xterm/addon-progress": { + "version": "0.2.0-beta.2", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0-beta.2.tgz", + "integrity": "sha512-5BcRexNJanTMG2N9TjZpDcaemDqQuvNkGN8HK3509+QNmqBF9sinJXSBWWNwn+o/f4OheBtBmlmC5mDanh4kig==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-search": { - "version": "0.16.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.70.tgz", - "integrity": "sha512-uaNBf77cr5Jikj69TDkTfe3V3wHA+4tDTFcv8DwJ/KGORPLUfBk8cn9HpbIJ+0jc1kOZr5xDrEjaYNwEVDkGeQ==", + "version": "0.16.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.96.tgz", + "integrity": "sha512-AEEXqxkT6OgDOII4SxQJFKn7NhM7PnFeMLxQwrRQzZyrtFPg5peCesA07xHYvYzh0aKQ1PVvgycfrPgbPEeQDQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.14.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.70.tgz", - "integrity": "sha512-4ijqHU7xDRcZ4Gm6yN/tKsZzovUwzttE9/pPfhxpWIgSdm9c8i5R4rGyOAlAZCnuU3DT5vPplQO7W/0zwAmJsQ==", + "version": "0.14.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.96.tgz", + "integrity": "sha512-+00J2K3WsbMPy02UlehNumen//Opmr5B0MBrS4KFqhRwVaO8RD0hMPfr8IuhY3YqPaVny4HMOU88yaWtscxl7A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.70.tgz", - "integrity": "sha512-9UE4v2SpWtqd85Hqvk8LW4QA9Phe93RMVltrNtt9jCUmkAok/QLFOEbLqoq2JUOvlmfsYfXmIl11Wg4CYIsvwA==", + "version": "0.9.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.96.tgz", + "integrity": "sha512-jBUSErJtrf6Jhz3itT5JXLeIIl5BkJ1ii8/YdzZQMh9602ZLYvqwS+7ih2FzYYjk0pZ+E1y4sYbT5FpBlVOM5w==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.70.tgz", - "integrity": "sha512-3BkmF24i66SHCfAkcHy1VC6H715qyelfOIjd6n7sTqHon56J1bUO782Q1al/MK0vSvplElBRKVdR31SDvITzpg==", + "version": "0.19.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.96.tgz", + "integrity": "sha512-iAno2CMYRBHasr5aE3I7WN6DZ0mlHsEAIYAhvdhH+be1YxX4/DceBAtN0Ak7nFcQ70ZbtYzs1aNLTVKtli2TKA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/headless": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.70.tgz", - "integrity": "sha512-npSJzmtJq8LLAzV3nwD9KkBQLNWSusi+cOBPyc3zlYYE63Vkqbtbp/iQS27zH2GxJ95rzCzDwnX6VOn0OoUPYQ==", + "version": "5.6.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.96.tgz", + "integrity": "sha512-lLumuO7sQbPNIxQkXa3CxwQWNlO4C5XF80VRgQ4PzjUcwPWGkGtkJJG9jcucHAKymQnH6AokCW171mcveU8cgg==", "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.70.tgz", - "integrity": "sha512-qviQMVWhRtgPn4z8PHNH6D/ffSKkNBHmUX1HyJxl325QM2xF8M8met83uFv7JZm7a5OQYScnLGsFAoTreSgdew==", + "version": "5.6.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.96.tgz", + "integrity": "sha512-XdOZyAaqOW67J3kJGJDgVb5skTD2nDQLmBICyhQ0cEqThTKrX5CzB11RswG6rZE8dI+nDE+pc93NjAMXSrQgFg==", "license": "MIT" }, "node_modules/agent-base": { @@ -1397,12 +1408,12 @@ } }, "node_modules/undici": { - "version": "6.20.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.20.1.tgz", - "integrity": "sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.2.0.tgz", + "integrity": "sha512-klt+0S55GBViA9nsq48/NSCo4YX5mjydjypxD7UmHh/brMu8h/Mhd/F7qAeoH2NOO8SDTk6kjnTFc4WpzmfYpQ==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/universalify": { diff --git a/remote/package.json b/remote/package.json index 26c002943e6..4579099c221 100644 --- a/remote/package.json +++ b/remote/package.json @@ -8,22 +8,23 @@ "@parcel/watcher": "2.5.0", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.0", - "@vscode/proxy-agent": "^0.28.0", + "@vscode/proxy-agent": "^0.29.0", "@vscode/ripgrep": "^1.15.9", "@vscode/spdlog": "^0.15.0", - "@vscode/tree-sitter-wasm": "^0.0.4", + "@vscode/tree-sitter-wasm": "^0.0.5", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/headless": "^5.6.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.79", + "@xterm/addon-image": "^0.9.0-beta.96", + "@xterm/addon-ligatures": "^0.10.0-beta.96", + "@xterm/addon-progress": "^0.2.0-beta.2", + "@xterm/addon-search": "^0.16.0-beta.96", + "@xterm/addon-serialize": "^0.14.0-beta.96", + "@xterm/addon-unicode11": "^0.9.0-beta.96", + "@xterm/addon-webgl": "^0.19.0-beta.96", + "@xterm/headless": "^5.6.0-beta.96", + "@xterm/xterm": "^5.6.0-beta.96", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 4d27bc8a29c..45f7724805d 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -11,16 +11,17 @@ "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@vscode/iconv-lite-umd": "0.7.0", - "@vscode/tree-sitter-wasm": "^0.0.4", + "@vscode/tree-sitter-wasm": "^0.0.5", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.79", + "@xterm/addon-image": "^0.9.0-beta.96", + "@xterm/addon-ligatures": "^0.10.0-beta.96", + "@xterm/addon-progress": "^0.2.0-beta.2", + "@xterm/addon-search": "^0.16.0-beta.96", + "@xterm/addon-serialize": "^0.14.0-beta.96", + "@xterm/addon-unicode11": "^0.9.0-beta.96", + "@xterm/addon-webgl": "^0.19.0-beta.96", + "@xterm/xterm": "^5.6.0-beta.96", "jschardet": "3.1.4", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", @@ -75,9 +76,10 @@ "integrity": "sha512-bRRFxLfg5dtAyl5XyiVWz/ZBPahpOpPrNYnnHpOpUZvam4tKH35wdhP4Kj6PbM0+KdliOsPzbGWpkxcdpNB/sg==" }, "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.0.4.tgz", - "integrity": "sha512-vOONG3Zxsh1I4JOA48WdQ5KiXjJAdfMvYTuHbW7b27tGtRqsPLY5WZyTwLXc5uujKHyhG3LJXE9poxRZSxTIiA==" + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.0.5.tgz", + "integrity": "sha512-qA+BkB2UgkfXMQVGsqPeG3vR3pXv0inP6WQ/dq6BALy7dIX9KQvGXvDCiqehdFvZZO4tDFt4qb5DdSsvwR4Y9Q==", + "license": "MIT" }, "node_modules/@vscode/vscode-languagedetection": { "version": "1.0.21", @@ -88,30 +90,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.2.0-beta.53", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.53.tgz", - "integrity": "sha512-kCcBuGvF8mwzExU+Tm9eylPvp1kXTkvm+kO0V4qP7HI3ZCw5vfKmnlRn41FvNIylsK2hnmrFtxauPHEGBy/dfA==", + "version": "0.2.0-beta.79", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.79.tgz", + "integrity": "sha512-zpwf43fzBG01fA8E75JQ/jsm/85bHCtFMOcURNAEbCoj0RSpZaq4rE5cPoy49IhYnctPZdYNxEU/+PzgtsSQ6Q==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-image": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.70.tgz", - "integrity": "sha512-QLhy77i0sjnffkLuxj1yB/mBUJI64bbL86eMW+1g5XEsZnSevY8YwU/cEJg02PAGK0ggwQNNfiRwoO6VBCdYFg==", + "version": "0.9.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.96.tgz", + "integrity": "sha512-LDA03uA5gTBfFeEIUdHXoCpxGHuPIke03aSXZc6cllRRp9jCaOK2kbWwTOoXfzRvjreKK6pxD0vJcepwdKaNcw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.10.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.70.tgz", - "integrity": "sha512-BPDHHOybUWO6mjHf/RMDBjSKDl9QdyyGTyHvmlyhuI/2sma3lu98bA4U03F8nBnvj/6otzEFARuOeoN7rkfRng==", + "version": "0.10.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.96.tgz", + "integrity": "sha512-uI86EDxCab345YR/Piwzs3R82DptKp/PC+sMKSX24hehhvWVAkpZus0toGhYwLZxF1r0U6yw4Oq0aCEzW362dQ==", "license": "MIT", "dependencies": { "font-finder": "^1.1.0", @@ -121,49 +123,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" + } + }, + "node_modules/@xterm/addon-progress": { + "version": "0.2.0-beta.2", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0-beta.2.tgz", + "integrity": "sha512-5BcRexNJanTMG2N9TjZpDcaemDqQuvNkGN8HK3509+QNmqBF9sinJXSBWWNwn+o/f4OheBtBmlmC5mDanh4kig==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-search": { - "version": "0.16.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.70.tgz", - "integrity": "sha512-uaNBf77cr5Jikj69TDkTfe3V3wHA+4tDTFcv8DwJ/KGORPLUfBk8cn9HpbIJ+0jc1kOZr5xDrEjaYNwEVDkGeQ==", + "version": "0.16.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.96.tgz", + "integrity": "sha512-AEEXqxkT6OgDOII4SxQJFKn7NhM7PnFeMLxQwrRQzZyrtFPg5peCesA07xHYvYzh0aKQ1PVvgycfrPgbPEeQDQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.14.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.70.tgz", - "integrity": "sha512-4ijqHU7xDRcZ4Gm6yN/tKsZzovUwzttE9/pPfhxpWIgSdm9c8i5R4rGyOAlAZCnuU3DT5vPplQO7W/0zwAmJsQ==", + "version": "0.14.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.96.tgz", + "integrity": "sha512-+00J2K3WsbMPy02UlehNumen//Opmr5B0MBrS4KFqhRwVaO8RD0hMPfr8IuhY3YqPaVny4HMOU88yaWtscxl7A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.70.tgz", - "integrity": "sha512-9UE4v2SpWtqd85Hqvk8LW4QA9Phe93RMVltrNtt9jCUmkAok/QLFOEbLqoq2JUOvlmfsYfXmIl11Wg4CYIsvwA==", + "version": "0.9.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.96.tgz", + "integrity": "sha512-jBUSErJtrf6Jhz3itT5JXLeIIl5BkJ1ii8/YdzZQMh9602ZLYvqwS+7ih2FzYYjk0pZ+E1y4sYbT5FpBlVOM5w==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.70.tgz", - "integrity": "sha512-3BkmF24i66SHCfAkcHy1VC6H715qyelfOIjd6n7sTqHon56J1bUO782Q1al/MK0vSvplElBRKVdR31SDvITzpg==", + "version": "0.19.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.96.tgz", + "integrity": "sha512-iAno2CMYRBHasr5aE3I7WN6DZ0mlHsEAIYAhvdhH+be1YxX4/DceBAtN0Ak7nFcQ70ZbtYzs1aNLTVKtli2TKA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.96" } }, "node_modules/@xterm/xterm": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.70.tgz", - "integrity": "sha512-qviQMVWhRtgPn4z8PHNH6D/ffSKkNBHmUX1HyJxl325QM2xF8M8met83uFv7JZm7a5OQYScnLGsFAoTreSgdew==", + "version": "5.6.0-beta.96", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.96.tgz", + "integrity": "sha512-XdOZyAaqOW67J3kJGJDgVb5skTD2nDQLmBICyhQ0cEqThTKrX5CzB11RswG6rZE8dI+nDE+pc93NjAMXSrQgFg==", "license": "MIT" }, "node_modules/font-finder": { diff --git a/remote/web/package.json b/remote/web/package.json index 3bd5d4a937d..f1f5ce1ecb9 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -6,16 +6,17 @@ "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@vscode/iconv-lite-umd": "0.7.0", - "@vscode/tree-sitter-wasm": "^0.0.4", + "@vscode/tree-sitter-wasm": "^0.0.5", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.79", + "@xterm/addon-image": "^0.9.0-beta.96", + "@xterm/addon-ligatures": "^0.10.0-beta.96", + "@xterm/addon-progress": "^0.2.0-beta.2", + "@xterm/addon-search": "^0.16.0-beta.96", + "@xterm/addon-serialize": "^0.14.0-beta.96", + "@xterm/addon-unicode11": "^0.9.0-beta.96", + "@xterm/addon-webgl": "^0.19.0-beta.96", + "@xterm/xterm": "^5.6.0-beta.96", "jschardet": "3.1.4", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", diff --git a/scripts/xterm-update.js b/scripts/xterm-update.js index 5a7db71abac..35c2084f794 100644 --- a/scripts/xterm-update.js +++ b/scripts/xterm-update.js @@ -11,6 +11,7 @@ const moduleNames = [ '@xterm/addon-clipboard', '@xterm/addon-image', '@xterm/addon-ligatures', + '@xterm/addon-progress', '@xterm/addon-search', '@xterm/addon-serialize', '@xterm/addon-unicode11', diff --git a/src/typings/crypto.d.ts b/src/typings/crypto.d.ts new file mode 100644 index 00000000000..378904595fb --- /dev/null +++ b/src/typings/crypto.d.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// NOTE that this is a partial copy from lib.dom.d.ts which is NEEDED because these utils are used in the /common/ +// layer which has no dependency on the DOM/browser-context. However, `crypto` is available as global in all browsers and +// in nodejs. Therefore it's OK to spell out its typings here + +declare global { + + /** + * This Web Crypto API interface provides a number of low-level cryptographic functions. It is accessed via the Crypto.subtle properties available in a window context (via Window.crypto). + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto) + */ + interface SubtleCrypto { + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt) */ + // decrypt(algorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, key: CryptoKey, data: BufferSource): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) */ + // deriveBits(algorithm: AlgorithmIdentifier | EcdhKeyDeriveParams | HkdfParams | Pbkdf2Params, baseKey: CryptoKey, length?: number | null): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey) */ + // deriveKey(algorithm: AlgorithmIdentifier | EcdhKeyDeriveParams | HkdfParams | Pbkdf2Params, baseKey: CryptoKey, derivedKeyType: AlgorithmIdentifier | AesDerivedKeyParams | HmacImportParams | HkdfParams | Pbkdf2Params, extractable: boolean, keyUsages: KeyUsage[]): Promise; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) */ + digest(algorithm: { name: string } | string, data: ArrayBufferView | ArrayBuffer): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt) */ + // encrypt(algorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, key: CryptoKey, data: BufferSource): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/exportKey) */ + // exportKey(format: "jwk", key: CryptoKey): Promise; + // exportKey(format: Exclude, key: CryptoKey): Promise; + // exportKey(format: KeyFormat, key: CryptoKey): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) */ + // generateKey(algorithm: "Ed25519", extractable: boolean, keyUsages: ReadonlyArray<"sign" | "verify">): Promise; + // generateKey(algorithm: RsaHashedKeyGenParams | EcKeyGenParams, extractable: boolean, keyUsages: ReadonlyArray): Promise; + // generateKey(algorithm: AesKeyGenParams | HmacKeyGenParams | Pbkdf2Params, extractable: boolean, keyUsages: ReadonlyArray): Promise; + // generateKey(algorithm: AlgorithmIdentifier, extractable: boolean, keyUsages: KeyUsage[]): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey) */ + // importKey(format: "jwk", keyData: JsonWebKey, algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: ReadonlyArray): Promise; + // importKey(format: Exclude, keyData: BufferSource, algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: KeyUsage[]): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/sign) */ + // sign(algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams, key: CryptoKey, data: BufferSource): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) */ + // unwrapKey(format: KeyFormat, wrappedKey: BufferSource, unwrappingKey: CryptoKey, unwrapAlgorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, unwrappedKeyAlgorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: KeyUsage[]): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify) */ + // verify(algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams, key: CryptoKey, signature: BufferSource, data: BufferSource): Promise; + // /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) */ + // wrapKey(format: KeyFormat, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams): Promise; + } + + /** + * Basic cryptography features available in the current context. It allows access to a cryptographically strong random number generator and to cryptographic primitives. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto) + */ + interface Crypto { + /** + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle) + */ + readonly subtle: SubtleCrypto; + /** + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues) + */ + getRandomValues(array: T): T; + /** + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/randomUUID) + */ + randomUUID(): `${string}-${string}-${string}-${string}-${string}`; + } + + var Crypto: { + prototype: Crypto; + new(): Crypto; + }; + + var crypto: Crypto; + +} +export { } diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 36355a377ba..e305b7de4b9 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -567,6 +567,8 @@ export class ListView implements IListView { if (this.supportDynamicHeights) { this._rerender(this.lastRenderTop, this.lastRenderHeight); + } else { + this._onDidChangeContentHeight.fire(this.contentHeight); // otherwise fired in _rerender() } } diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index eefff7f8b48..8aec45d8fac 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -902,8 +902,6 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { // This allows the menu constructor to calculate the proper max height const computedStyles = getWindow(this.parentData.parent.domNode).getComputedStyle(this.parentData.parent.domNode); const paddingTop = parseFloat(computedStyles.paddingTop || '0') || 0; - // this.submenuContainer.style.top = `${this.element.offsetTop - this.parentData.parent.scrollOffset - paddingTop}px`; - this.submenuContainer.style.zIndex = '1'; this.submenuContainer.style.position = 'fixed'; this.submenuContainer.style.top = '0'; this.submenuContainer.style.left = '0'; @@ -1371,6 +1369,10 @@ ${formatRule(Codicon.menuSubmenu)} height: 3px; width: 3px; } + /* Fix for https://github.com/microsoft/vscode/issues/103170 */ + .monaco-menu .action-item .monaco-submenu { + z-index: 1; + } `; // Scrollbars diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 09ce418bda9..cff0e6614d3 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -947,7 +947,7 @@ interface IAbstractFindControllerOptions extends IFindWidgetOptions { showNotFoundMessage?: boolean; } -interface IFindControllerOptions extends IAbstractFindControllerOptions { +export interface IFindControllerOptions extends IAbstractFindControllerOptions { defaultFindMode?: TreeFindMode; defaultFindMatchType?: TreeFindMatchType; } diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index dd6059051f0..f6c47bb9283 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -7,7 +7,7 @@ import { IDragAndDropData } from '../../dnd.js'; import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListVirtualDelegate } from '../list/list.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../list/listView.js'; import { IListStyles } from '../list/listWidget.js'; -import { ComposedTreeDelegate, TreeFindMode as TreeFindMode, IAbstractTreeOptions, IAbstractTreeOptionsUpdate, TreeFindMatchType, AbstractTreePart, LabelFuzzyScore, FindFilter, FindController, ITreeFindToggleChangeEvent } from './abstractTree.js'; +import { ComposedTreeDelegate, TreeFindMode as TreeFindMode, IAbstractTreeOptions, IAbstractTreeOptionsUpdate, TreeFindMatchType, AbstractTreePart, LabelFuzzyScore, FindFilter, FindController, ITreeFindToggleChangeEvent, IFindControllerOptions } from './abstractTree.js'; import { ICompressedTreeElement, ICompressedTreeNode } from './compressedObjectTreeModel.js'; import { getVisibleState, isFilterResult } from './indexTreeModel.js'; import { CompressibleObjectTree, ICompressibleKeyboardNavigationLabelProvider, ICompressibleObjectTreeOptions, ICompressibleTreeRenderer, IObjectTreeOptions, IObjectTreeSetChildrenOptions, ObjectTree } from './objectTree.js'; @@ -629,7 +629,12 @@ export class AsyncDataTree implements IDisposable this.tree.onDidChangeCollapseState(this._onDidChangeCollapseState, this, this.disposables); if (asyncFindEnabled) { - const findOptions = { styles: options.findWidgetStyles, showNotFoundMessage: options.showNotFoundMessage }; + const findOptions: IFindControllerOptions = { + styles: options.findWidgetStyles, + showNotFoundMessage: options.showNotFoundMessage, + defaultFindMatchType: options.defaultFindMatchType, + defaultFindMode: options.defaultFindMode, + }; this.findController = this.disposables.add(new AsyncFindController(this.tree, options.findProvider!, findFilter!, this.tree.options.contextViewProvider!, findOptions)); this.focusNavigationFilter = node => this.findController!.shouldFocusWhenNavigating(node); @@ -657,8 +662,18 @@ export class AsyncDataTree implements IDisposable return new ObjectTree(user, container, objectTreeDelegate, objectTreeRenderers, objectTreeOptions); } - updateOptions(options: IAsyncDataTreeOptionsUpdate = {}): void { - this.tree.updateOptions(options); + updateOptions(optionsUpdate: IAsyncDataTreeOptionsUpdate = {}): void { + if (this.findController) { + if (optionsUpdate.defaultFindMode !== undefined) { + this.findController.mode = optionsUpdate.defaultFindMode; + } + + if (optionsUpdate.defaultFindMatchType !== undefined) { + this.findController.matchType = optionsUpdate.defaultFindMatchType; + } + } + + this.tree.updateOptions(optionsUpdate); } get options(): IAsyncDataTreeOptions { @@ -1513,10 +1528,6 @@ export class CompressibleAsyncDataTree extends As }; } - override updateOptions(options: ICompressibleAsyncDataTreeOptionsUpdate = {}): void { - this.tree.updateOptions(options); - } - override getViewState(): IAsyncDataTreeViewState { if (!this.identityProvider) { throw new TreeError(this.user, 'Can\'t get tree view state without an identity provider'); diff --git a/src/vs/base/common/color.ts b/src/vs/base/common/color.ts index 8b1a68294a5..9f20f7080ab 100644 --- a/src/vs/base/common/color.ts +++ b/src/vs/base/common/color.ts @@ -535,17 +535,17 @@ export class Color { return this._toString; } - private _toNumber24Bit?: number; - toNumber24Bit(): number { - if (!this._toNumber24Bit) { - this._toNumber24Bit = ( + private _toNumber32Bit?: number; + toNumber32Bit(): number { + if (!this._toNumber32Bit) { + this._toNumber32Bit = ( this.rgba.r /* */ << 24 | this.rgba.g /* */ << 16 | this.rgba.b /* */ << 8 | this.rgba.a * 0xFF << 0 ) >>> 0; } - return this._toNumber24Bit; + return this._toNumber32Bit; } static getLighterColor(of: Color, relative: Color, factor?: number): Color { diff --git a/src/vs/base/common/errors.ts b/src/vs/base/common/errors.ts index a6930ef51cf..888aa3b0630 100644 --- a/src/vs/base/common/errors.ts +++ b/src/vs/base/common/errors.ts @@ -126,9 +126,14 @@ export interface SerializedError { readonly message: string; readonly stack: string; readonly noTelemetry: boolean; + readonly code?: string; readonly cause?: SerializedError; } +type ErrorWithCode = Error & { + code: string | undefined; +}; + export function transformErrorForSerialization(error: Error): SerializedError; export function transformErrorForSerialization(error: any): any; export function transformErrorForSerialization(error: any): any { @@ -141,7 +146,8 @@ export function transformErrorForSerialization(error: any): any { message, stack, noTelemetry: ErrorNoTelemetry.isErrorNoTelemetry(error), - cause: cause ? transformErrorForSerialization(cause) : undefined + cause: cause ? transformErrorForSerialization(cause) : undefined, + code: (error).code }; } @@ -159,6 +165,9 @@ export function transformErrorFromSerialization(data: SerializedError): Error { } error.message = data.message; error.stack = data.stack; + if (data.code) { + (error).code = data.code; + } if (data.cause) { error.cause = transformErrorFromSerialization(data.cause); } diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index 82921d00dac..fe10aaf1c89 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -9,16 +9,10 @@ import { onUnexpectedError } from './errors.js'; import { createSingleCallFunction } from './functional.js'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from './lifecycle.js'; import { LinkedList } from './linkedList.js'; -import { IObservable, IObserver } from './observable.js'; +import { IObservable, IObservableWithChange, IObserver } from './observable.js'; import { StopWatch } from './stopwatch.js'; import { MicrotaskDelay } from './symbols.js'; -// ----------------------------------------------------------------------------------------------------------------------- -// Uncomment the next line to print warnings whenever a listener is GC'ed without having been disposed. This is a LEAK. -// ----------------------------------------------------------------------------------------------------------------------- -const _enableListenerGCedWarning = false - // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed - ; // ----------------------------------------------------------------------------------------------------------------------- // Uncomment the next line to print warnings whenever an emitter with listeners is disposed. That is a sign of code smell. @@ -666,7 +660,7 @@ export namespace Event { private _counter = 0; private _hasChanged = false; - constructor(readonly _observable: IObservable, store: DisposableStore | undefined) { + constructor(readonly _observable: IObservable, store: DisposableStore | undefined) { const options: EmitterOptions = { onWillAddFirstListener: () => { _observable.addObserver(this); @@ -687,21 +681,21 @@ export namespace Event { } } - beginUpdate(_observable: IObservable): void { + beginUpdate(_observable: IObservable): void { // assert(_observable === this.obs); this._counter++; } - handlePossibleChange(_observable: IObservable): void { + handlePossibleChange(_observable: IObservable): void { // assert(_observable === this.obs); } - handleChange(_observable: IObservable, _change: TChange): void { + handleChange(_observable: IObservableWithChange, _change: TChange): void { // assert(_observable === this.obs); this._hasChanged = true; } - endUpdate(_observable: IObservable): void { + endUpdate(_observable: IObservable): void { // assert(_observable === this.obs); this._counter--; if (this._counter === 0) { @@ -718,7 +712,7 @@ export namespace Event { * Creates an event emitter that is fired when the observable changes. * Each listeners subscribes to the emitter. */ - export function fromObservable(obs: IObservable, store?: DisposableStore): Event { + export function fromObservable(obs: IObservable, store?: DisposableStore): Event { const observer = new EmitterObserver(obs, store); return observer.emitter.event; } @@ -726,7 +720,7 @@ export namespace Event { /** * Each listener is attached to the observable directly. */ - export function fromObservableLight(observable: IObservable): Event { + export function fromObservableLight(observable: IObservable): Event { return (listener, thisArgs, disposables) => { let count = 0; let didChange = false; @@ -984,28 +978,6 @@ const forEachListener = (listeners: ListenerOrListeners, fn: (c: ListenerC } }; - -let _listenerFinalizers: FinalizationRegistry | undefined; - -if (_enableListenerGCedWarning) { - const leaks: string[] = []; - - setInterval(() => { - if (leaks.length === 0) { - return; - } - console.warn('[LEAKING LISTENERS] GC\'ed these listeners that were NOT yet disposed:'); - console.warn(leaks.join('\n')); - leaks.length = 0; - }, 3000); - - _listenerFinalizers = new FinalizationRegistry(heldValue => { - if (typeof heldValue === 'string') { - leaks.push(heldValue); - } - }); -} - /** * The Emitter can be used to expose an Event to the public * to fire it from the insides. @@ -1161,7 +1133,6 @@ export class Emitter { const result = toDisposable(() => { - _listenerFinalizers?.unregister(result); removeMonitor?.(); this._removeListener(contained); }); @@ -1171,12 +1142,6 @@ export class Emitter { disposables.push(result); } - if (_listenerFinalizers) { - const stack = new Error().stack!.split('\n').slice(2, 3).join('\n').trim(); - const match = /(file:|vscode-file:\/\/vscode-app)?(\/[^:]*:\d+:\d+)/.exec(stack); - _listenerFinalizers.register(result, match?.[2] ?? stack, result); - } - return result; }; diff --git a/src/vs/base/common/hash.ts b/src/vs/base/common/hash.ts index 5ab5846682b..78697a888d7 100644 --- a/src/vs/base/common/hash.ts +++ b/src/vs/base/common/hash.ts @@ -69,6 +69,8 @@ function objectHash(obj: any, initialHashVal: number): number { }, initialHashVal); } + + /** Hashes the input as SHA-1, returning a hex-encoded string. */ export const hashAsync = (input: string | ArrayBufferView | VSBuffer) => { // Note: I would very much like to expose a streaming interface for hashing diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index f220611a722..e77c500097a 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -173,7 +173,7 @@ class RemoteAuthoritiesImpl { } setServerRootPath(product: { quality?: string; commit?: string }, serverBasePath: string | undefined): void { - this._serverRootPath = getServerRootPath(product, serverBasePath); + this._serverRootPath = paths.posix.join(serverBasePath ?? '/', getServerProductSegment(product)); } getServerRootPath(): string { @@ -228,8 +228,8 @@ class RemoteAuthoritiesImpl { export const RemoteAuthorities = new RemoteAuthoritiesImpl(); -export function getServerRootPath(product: { quality?: string; commit?: string }, basePath: string | undefined): string { - return paths.posix.join(basePath ?? '/', `${product.quality ?? 'oss'}-${product.commit ?? 'dev'}`); +export function getServerProductSegment(product: { quality?: string; commit?: string }) { + return `${product.quality ?? 'oss'}-${product.commit ?? 'dev'}`; } /** diff --git a/src/vs/base/common/observableInternal/autorun.ts b/src/vs/base/common/observableInternal/autorun.ts index b8a62629937..d2425e11012 100644 --- a/src/vs/base/common/observableInternal/autorun.ts +++ b/src/vs/base/common/observableInternal/autorun.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IChangeContext, IObservable, IObserver, IReader } from './base.js'; +import { IChangeContext, IObservable, IObservableWithChange, IObserver, IReader } from './base.js'; import { DebugNameData, IDebugNameData } from './debugName.js'; import { assertFn, BugIndicatingError, DisposableStore, IDisposable, markAsDisposed, onBugIndicatingError, toDisposable, trackDisposable } from './commonFacade/deps.js'; import { getLogger } from './logging.js'; @@ -286,7 +286,7 @@ export class AutorunObserver implements IObserver, IReader } } - public handleChange(observable: IObservable, change: TChange): void { + public handleChange(observable: IObservableWithChange, change: TChange): void { if (this.dependencies.has(observable) && !this.dependenciesToBeRemoved.has(observable)) { try { const shouldReact = this._handleChange ? this._handleChange({ diff --git a/src/vs/base/common/observableInternal/base.ts b/src/vs/base/common/observableInternal/base.ts index c28e773b268..f2aa0466f78 100644 --- a/src/vs/base/common/observableInternal/base.ts +++ b/src/vs/base/common/observableInternal/base.ts @@ -9,6 +9,13 @@ import type { derivedOpts } from './derived.js'; import { getLogger, logObservable } from './logging.js'; import { keepObserved, recomputeInitiallyAndOnChange } from './utils.js'; +/** + * Represents an observable value. + * + * @template T The type of the values the observable can hold. + */ +export interface IObservable extends IObservableWithChange { } + /** * Represents an observable value. * @@ -18,7 +25,7 @@ import { keepObserved, recomputeInitiallyAndOnChange } from './utils.js'; * While observers can miss temporary values of an observable, * they will receive all change values (as long as they are subscribed)! */ -export interface IObservable { +export interface IObservableWithChange { /** * Returns the current value. * @@ -71,7 +78,7 @@ export interface IObservable { * ONLY FOR DEBUGGING! * Logs computations of this derived. */ - log(): IObservable; + log(): IObservableWithChange; /** * Makes sure this value is computed eagerly. @@ -98,7 +105,7 @@ export interface IReader { /** * Reads the value of an observable and subscribes to it. */ - readObservable(observable: IObservable): T; + readObservable(observable: IObservableWithChange): T; } /** @@ -143,7 +150,7 @@ export interface IObserver { * * @param change Indicates how or why the value changed. */ - handleChange(observable: IObservable, change: TChange): void; + handleChange(observable: IObservableWithChange, change: TChange): void; } export interface ISettable { @@ -162,7 +169,7 @@ export interface ITransaction { * Calls {@link Observer.beginUpdate} immediately * and {@link Observer.endUpdate} when the transaction ends. */ - updateObserver(observer: IObserver, observable: IObservable): void; + updateObserver(observer: IObserver, observable: IObservableWithChange): void; } let _recomputeInitiallyAndOnChange: typeof recomputeInitiallyAndOnChange; @@ -185,7 +192,7 @@ export function _setDerivedOpts(derived: typeof _derived) { _derived = derived; } -export abstract class ConvenientObservable implements IObservable { +export abstract class ConvenientObservable implements IObservableWithChange { get TChange(): TChange { return null!; } public abstract get(): T; @@ -239,7 +246,7 @@ export abstract class ConvenientObservable implements IObservable { + public log(): IObservableWithChange { logObservable(this); return this; } @@ -248,7 +255,7 @@ export abstract class ConvenientObservable implements IObservable(this: IObservable>): IObservable { + public flatten(this: IObservable>): IObservable { return _derived( { owner: undefined, @@ -390,7 +397,7 @@ export class TransactionImpl implements ITransaction { /** * A settable observable. */ -export interface ISettableObservable extends IObservable, ISettable { +export interface ISettableObservable extends IObservableWithChange, ISettable { } /** @@ -505,11 +512,11 @@ export interface IChangeTracker { } export interface IChangeContext { - readonly changedObservable: IObservable; + readonly changedObservable: IObservableWithChange; readonly change: unknown; /** * Returns if the given observable caused the change. */ - didChange(observable: IObservable): this is { change: TChange }; + didChange(observable: IObservableWithChange): this is { change: TChange }; } diff --git a/src/vs/base/common/observableInternal/derived.ts b/src/vs/base/common/observableInternal/derived.ts index ba018041799..54a1e99296d 100644 --- a/src/vs/base/common/observableInternal/derived.ts +++ b/src/vs/base/common/observableInternal/derived.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BaseObservable, IChangeContext, IObservable, IObserver, IReader, ISettableObservable, ITransaction, _setDerivedOpts, } from './base.js'; +import { BaseObservable, IChangeContext, IObservable, IObservableWithChange, IObserver, IReader, ISettableObservable, ITransaction, _setDerivedOpts, } from './base.js'; import { DebugNameData, DebugOwner, IDebugNameData } from './debugName.js'; import { BugIndicatingError, DisposableStore, EqualityComparer, IDisposable, assertFn, onBugIndicatingError, strictEquals } from './commonFacade/deps.js'; import { getLogger } from './logging.js'; @@ -220,6 +220,7 @@ export class Derived extends BaseObservable im */ this.state = DerivedState.initial; this.value = undefined; + getLogger()?.handleDerivedCleared(this); for (const d of this.dependencies) { d.removeObserver(this); } @@ -386,7 +387,7 @@ export class Derived extends BaseObservable im assertFn(() => this.updateCount >= 0); } - public handlePossibleChange(observable: IObservable): void { + public handlePossibleChange(observable: IObservable): void { // In all other states, observers already know that we might have changed. if (this.state === DerivedState.upToDate && this.dependencies.has(observable) && !this.dependenciesToBeRemoved.has(observable)) { this.state = DerivedState.dependenciesMightHaveChanged; @@ -396,7 +397,7 @@ export class Derived extends BaseObservable im } } - public handleChange(observable: IObservable, change: TChange): void { + public handleChange(observable: IObservableWithChange, change: TChange): void { if (this.dependencies.has(observable) && !this.dependenciesToBeRemoved.has(observable)) { let shouldReact = false; try { @@ -460,7 +461,7 @@ export class Derived extends BaseObservable im super.removeObserver(observer); } - public override log(): IObservable { + public override log(): IObservableWithChange { if (!getLogger()) { super.log(); getLogger()?.handleDerivedCreated(this); diff --git a/src/vs/base/common/observableInternal/index.ts b/src/vs/base/common/observableInternal/index.ts index 9295f2697d5..873cf946171 100644 --- a/src/vs/base/common/observableInternal/index.ts +++ b/src/vs/base/common/observableInternal/index.ts @@ -7,7 +7,7 @@ export { observableValueOpts } from './api.js'; export { autorun, autorunDelta, autorunHandleChanges, autorunOpts, autorunWithStore, autorunWithStoreHandleChanges } from './autorun.js'; -export { asyncTransaction, disposableObservableValue, globalTransaction, observableValue, subtransaction, transaction, TransactionImpl, type IChangeContext, type IChangeTracker, type IObservable, type IObserver, type IReader, type ISettable, type ISettableObservable, type ITransaction, } from './base.js'; +export { asyncTransaction, disposableObservableValue, globalTransaction, observableValue, subtransaction, transaction, TransactionImpl, type IChangeContext, type IChangeTracker, type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type ISettableObservable, type ITransaction, } from './base.js'; export { derived, derivedDisposable, derivedHandleChanges, derivedOpts, derivedWithSetter, derivedWithStore } from './derived.js'; export { ObservableLazy, ObservableLazyPromise, ObservablePromise, PromiseResult, } from './promise.js'; export { derivedWithCancellationToken, waitForState } from './utilsCancellation.js'; diff --git a/src/vs/base/common/observableInternal/logging.ts b/src/vs/base/common/observableInternal/logging.ts index a6a5bb78a06..f0bc82170d8 100644 --- a/src/vs/base/common/observableInternal/logging.ts +++ b/src/vs/base/common/observableInternal/logging.ts @@ -39,7 +39,7 @@ interface IChangeInformation { } export interface IObservableLogger { - handleObservableChanged(observable: IObservable, info: IChangeInformation): void; + handleObservableChanged(observable: IObservable, info: IChangeInformation): void; handleFromEventObservableTriggered(observable: FromEventObservable, info: IChangeInformation): void; handleAutorunCreated(autorun: AutorunObserver): void; @@ -48,6 +48,7 @@ export interface IObservableLogger { handleDerivedCreated(observable: Derived): void; handleDerivedRecomputed(observable: Derived, info: IChangeInformation): void; + handleDerivedCleared(observable: Derived): void; handleBeginTransaction(transaction: TransactionImpl): void; handleEndTransaction(): void; @@ -101,7 +102,7 @@ export class ConsoleObservableLogger implements IObservableLogger { : [normalText(` (unchanged)`)]; } - handleObservableChanged(observable: IObservable, info: IChangeInformation): void { + handleObservableChanged(observable: IObservable, info: IChangeInformation): void { if (!this._isIncluded(observable)) { return; } console.log(...this.textToConsoleArgs([ formatKind('observable value changed'), @@ -110,9 +111,9 @@ export class ConsoleObservableLogger implements IObservableLogger { ])); } - private readonly changedObservablesSets = new WeakMap>>(); + private readonly changedObservablesSets = new WeakMap>>(); - formatChanges(changes: Set>): ConsoleText | undefined { + formatChanges(changes: Set>): ConsoleText | undefined { if (changes.size === 0) { return undefined; } @@ -170,6 +171,15 @@ export class ConsoleObservableLogger implements IObservableLogger { changedObservables.clear(); } + handleDerivedCleared(derived: Derived): void { + if (!this._isIncluded(derived)) { return; } + + console.log(...this.textToConsoleArgs([ + formatKind('derived cleared'), + styled(derived.debugName, { color: 'BlueViolet' }), + ])); + } + handleFromEventObservableTriggered(observable: FromEventObservable, info: IChangeInformation): void { if (!this._isIncluded(observable)) { return; } diff --git a/src/vs/base/common/observableInternal/utils.ts b/src/vs/base/common/observableInternal/utils.ts index 2fb57d1a42a..c42f12f7b8e 100644 --- a/src/vs/base/common/observableInternal/utils.ts +++ b/src/vs/base/common/observableInternal/utils.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { autorun, autorunOpts, autorunWithStoreHandleChanges } from './autorun.js'; -import { BaseObservable, ConvenientObservable, IObservable, IObserver, IReader, ITransaction, _setKeepObserved, _setRecomputeInitiallyAndOnChange, observableValue, subtransaction, transaction } from './base.js'; +import { BaseObservable, ConvenientObservable, IObservable, IObservableWithChange, IObserver, IReader, ITransaction, _setKeepObserved, _setRecomputeInitiallyAndOnChange, observableValue, subtransaction, transaction } from './base.js'; import { DebugNameData, DebugOwner, IDebugNameData, getDebugName, } from './debugName.js'; import { BugIndicatingError, DisposableStore, EqualityComparer, Event, IDisposable, IValueWithChangeEvent, strictEquals, toDisposable } from './commonFacade/deps.js'; import { derived, derivedOpts } from './derived.js'; @@ -259,7 +259,7 @@ export function observableSignal(debugNameOrOwner: string | objec } } -export interface IObservableSignal extends IObservable { +export interface IObservableSignal extends IObservableWithChange { trigger(tx: ITransaction | undefined, change: TChange): void; } @@ -434,11 +434,11 @@ export class KeepAliveObserver implements IObserver { private readonly _handleValue: ((value: any) => void) | undefined, ) { } - beginUpdate(observable: IObservable): void { + beginUpdate(observable: IObservable): void { this._counter++; } - endUpdate(observable: IObservable): void { + endUpdate(observable: IObservable): void { this._counter--; if (this._counter === 0 && this._forceRecompute) { if (this._handleValue) { @@ -449,11 +449,11 @@ export class KeepAliveObserver implements IObserver { } } - handlePossibleChange(observable: IObservable): void { + handlePossibleChange(observable: IObservable): void { // NO OP } - handleChange(observable: IObservable, change: TChange): void { + handleChange(observable: IObservableWithChange, change: TChange): void { // NO OP } } @@ -625,7 +625,7 @@ export function derivedConstOnceDefined(owner: DebugOwner, fn: (reader: IRead type RemoveUndefined = T extends undefined ? never : T; -export function runOnChange(observable: IObservable, cb: (value: T, previousValue: undefined | T, deltas: RemoveUndefined[]) => void): IDisposable { +export function runOnChange(observable: IObservableWithChange, cb: (value: T, previousValue: undefined | T, deltas: RemoveUndefined[]) => void): IDisposable { let _previousValue: T | undefined; return autorunWithStoreHandleChanges({ createEmptyChangeSummary: () => ({ deltas: [] as RemoveUndefined[], didChange: false }), @@ -649,7 +649,7 @@ export function runOnChange(observable: IObservable, cb: }); } -export function runOnChangeWithStore(observable: IObservable, cb: (value: T, previousValue: undefined | T, deltas: RemoveUndefined[], store: DisposableStore) => void): IDisposable { +export function runOnChangeWithStore(observable: IObservableWithChange, cb: (value: T, previousValue: undefined | T, deltas: RemoveUndefined[], store: DisposableStore) => void): IDisposable { const store = new DisposableStore(); const disposable = runOnChange(observable, (value, previousValue: undefined | T, deltas) => { store.clear(); diff --git a/src/vs/base/common/uuid.ts b/src/vs/base/common/uuid.ts index 0bd0c937cae..d1d08064f5a 100644 --- a/src/vs/base/common/uuid.ts +++ b/src/vs/base/common/uuid.ts @@ -10,72 +10,4 @@ export function isUUID(value: string): boolean { return _UUIDPattern.test(value); } -declare const crypto: undefined | { - //https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues#browser_compatibility - getRandomValues?(data: Uint8Array): Uint8Array; - //https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID#browser_compatibility - randomUUID?(): string; -}; - -export const generateUuid = (function (): () => string { - - // use `randomUUID` if possible - if (typeof crypto === 'object' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID.bind(crypto); - } - - // use `randomValues` if possible - let getRandomValues: (bucket: Uint8Array) => Uint8Array; - if (typeof crypto === 'object' && typeof crypto.getRandomValues === 'function') { - getRandomValues = crypto.getRandomValues.bind(crypto); - - } else { - getRandomValues = function (bucket: Uint8Array): Uint8Array { - for (let i = 0; i < bucket.length; i++) { - bucket[i] = Math.floor(Math.random() * 256); - } - return bucket; - }; - } - - // prep-work - const _data = new Uint8Array(16); - const _hex: string[] = []; - for (let i = 0; i < 256; i++) { - _hex.push(i.toString(16).padStart(2, '0')); - } - - return function generateUuid(): string { - // get data - getRandomValues(_data); - - // set version bits - _data[6] = (_data[6] & 0x0f) | 0x40; - _data[8] = (_data[8] & 0x3f) | 0x80; - - // print as string - let i = 0; - let result = ''; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - return result; - }; -})(); +export const generateUuid: () => string = crypto.randomUUID.bind(crypto); diff --git a/src/vs/base/node/extpath.ts b/src/vs/base/node/extpath.ts index 4fa8d7a0b2b..60fbdd6e4c7 100644 --- a/src/vs/base/node/extpath.ts +++ b/src/vs/base/node/extpath.ts @@ -8,7 +8,7 @@ import { CancellationToken } from '../common/cancellation.js'; import { basename, dirname, join, normalize, sep } from '../common/path.js'; import { isLinux } from '../common/platform.js'; import { rtrim } from '../common/strings.js'; -import { Promises, readdirSync } from './pfs.js'; +import { Promises } from './pfs.js'; /** * Copied from: https://github.com/microsoft/vscode-node-debug/blob/master/src/node/pathUtilities.ts#L83 @@ -17,48 +17,8 @@ import { Promises, readdirSync } from './pfs.js'; * On a case insensitive file system, the returned path might differ from the original path by character casing. * On a case sensitive file system, the returned path will always be identical to the original path. * In case of errors, null is returned. But you cannot use this function to verify that a path exists. - * realcaseSync does not handle '..' or '.' path segments and it does not take the locale into account. + * realcase does not handle '..' or '.' path segments and it does not take the locale into account. */ -export function realcaseSync(path: string): string | null { - if (isLinux) { - // This method is unsupported on OS that have case sensitive - // file system where the same path can exist in different forms - // (see also https://github.com/microsoft/vscode/issues/139709) - return path; - } - - const dir = dirname(path); - if (path === dir) { // end recursion - return path; - } - - const name = (basename(path) /* can be '' for windows drive letters */ || path).toLowerCase(); - try { - const entries = readdirSync(dir); - const found = entries.filter(e => e.toLowerCase() === name); // use a case insensitive search - if (found.length === 1) { - // on a case sensitive filesystem we cannot determine here, whether the file exists or not, hence we need the 'file exists' precondition - const prefix = realcaseSync(dir); // recurse - if (prefix) { - return join(prefix, found[0]); - } - } else if (found.length > 1) { - // must be a case sensitive $filesystem - const ix = found.indexOf(name); - if (ix >= 0) { // case sensitive - const prefix = realcaseSync(dir); // recurse - if (prefix) { - return join(prefix, found[ix]); - } - } - } - } catch (error) { - // silently ignore error - } - - return null; -} - export async function realcase(path: string, token?: CancellationToken): Promise { if (isLinux) { // This method is unsupported on OS that have case sensitive diff --git a/src/vs/base/test/common/color.test.ts b/src/vs/base/test/common/color.test.ts index c0f439d744e..116ebba4119 100644 --- a/src/vs/base/test/common/color.test.ts +++ b/src/vs/base/test/common/color.test.ts @@ -118,40 +118,40 @@ suite('Color', () => { }); }); - suite('toNumber24Bit', () => { + suite('toNumber32Bit', () => { test('alpha channel', () => { - assert.deepStrictEqual(Color.fromHex('#00000000').toNumber24Bit(), 0x00000000); - assert.deepStrictEqual(Color.fromHex('#00000080').toNumber24Bit(), 0x00000080); - assert.deepStrictEqual(Color.fromHex('#000000FF').toNumber24Bit(), 0x000000FF); + assert.deepStrictEqual(Color.fromHex('#00000000').toNumber32Bit(), 0x00000000); + assert.deepStrictEqual(Color.fromHex('#00000080').toNumber32Bit(), 0x00000080); + assert.deepStrictEqual(Color.fromHex('#000000FF').toNumber32Bit(), 0x000000FF); }); test('opaque', () => { - assert.deepStrictEqual(Color.fromHex('#000000').toNumber24Bit(), 0x000000FF); - assert.deepStrictEqual(Color.fromHex('#FFFFFF').toNumber24Bit(), 0xFFFFFFFF); - assert.deepStrictEqual(Color.fromHex('#FF0000').toNumber24Bit(), 0xFF0000FF); - assert.deepStrictEqual(Color.fromHex('#00FF00').toNumber24Bit(), 0x00FF00FF); - assert.deepStrictEqual(Color.fromHex('#0000FF').toNumber24Bit(), 0x0000FFFF); - assert.deepStrictEqual(Color.fromHex('#FFFF00').toNumber24Bit(), 0xFFFF00FF); - assert.deepStrictEqual(Color.fromHex('#00FFFF').toNumber24Bit(), 0x00FFFFFF); - assert.deepStrictEqual(Color.fromHex('#FF00FF').toNumber24Bit(), 0xFF00FFFF); - assert.deepStrictEqual(Color.fromHex('#C0C0C0').toNumber24Bit(), 0xC0C0C0FF); - assert.deepStrictEqual(Color.fromHex('#808080').toNumber24Bit(), 0x808080FF); - assert.deepStrictEqual(Color.fromHex('#800000').toNumber24Bit(), 0x800000FF); - assert.deepStrictEqual(Color.fromHex('#808000').toNumber24Bit(), 0x808000FF); - assert.deepStrictEqual(Color.fromHex('#008000').toNumber24Bit(), 0x008000FF); - assert.deepStrictEqual(Color.fromHex('#800080').toNumber24Bit(), 0x800080FF); - assert.deepStrictEqual(Color.fromHex('#008080').toNumber24Bit(), 0x008080FF); - assert.deepStrictEqual(Color.fromHex('#000080').toNumber24Bit(), 0x000080FF); - assert.deepStrictEqual(Color.fromHex('#010203').toNumber24Bit(), 0x010203FF); - assert.deepStrictEqual(Color.fromHex('#040506').toNumber24Bit(), 0x040506FF); - assert.deepStrictEqual(Color.fromHex('#070809').toNumber24Bit(), 0x070809FF); - assert.deepStrictEqual(Color.fromHex('#0a0A0a').toNumber24Bit(), 0x0a0A0aFF); - assert.deepStrictEqual(Color.fromHex('#0b0B0b').toNumber24Bit(), 0x0b0B0bFF); - assert.deepStrictEqual(Color.fromHex('#0c0C0c').toNumber24Bit(), 0x0c0C0cFF); - assert.deepStrictEqual(Color.fromHex('#0d0D0d').toNumber24Bit(), 0x0d0D0dFF); - assert.deepStrictEqual(Color.fromHex('#0e0E0e').toNumber24Bit(), 0x0e0E0eFF); - assert.deepStrictEqual(Color.fromHex('#0f0F0f').toNumber24Bit(), 0x0f0F0fFF); - assert.deepStrictEqual(Color.fromHex('#a0A0a0').toNumber24Bit(), 0xa0A0a0FF); + assert.deepStrictEqual(Color.fromHex('#000000').toNumber32Bit(), 0x000000FF); + assert.deepStrictEqual(Color.fromHex('#FFFFFF').toNumber32Bit(), 0xFFFFFFFF); + assert.deepStrictEqual(Color.fromHex('#FF0000').toNumber32Bit(), 0xFF0000FF); + assert.deepStrictEqual(Color.fromHex('#00FF00').toNumber32Bit(), 0x00FF00FF); + assert.deepStrictEqual(Color.fromHex('#0000FF').toNumber32Bit(), 0x0000FFFF); + assert.deepStrictEqual(Color.fromHex('#FFFF00').toNumber32Bit(), 0xFFFF00FF); + assert.deepStrictEqual(Color.fromHex('#00FFFF').toNumber32Bit(), 0x00FFFFFF); + assert.deepStrictEqual(Color.fromHex('#FF00FF').toNumber32Bit(), 0xFF00FFFF); + assert.deepStrictEqual(Color.fromHex('#C0C0C0').toNumber32Bit(), 0xC0C0C0FF); + assert.deepStrictEqual(Color.fromHex('#808080').toNumber32Bit(), 0x808080FF); + assert.deepStrictEqual(Color.fromHex('#800000').toNumber32Bit(), 0x800000FF); + assert.deepStrictEqual(Color.fromHex('#808000').toNumber32Bit(), 0x808000FF); + assert.deepStrictEqual(Color.fromHex('#008000').toNumber32Bit(), 0x008000FF); + assert.deepStrictEqual(Color.fromHex('#800080').toNumber32Bit(), 0x800080FF); + assert.deepStrictEqual(Color.fromHex('#008080').toNumber32Bit(), 0x008080FF); + assert.deepStrictEqual(Color.fromHex('#000080').toNumber32Bit(), 0x000080FF); + assert.deepStrictEqual(Color.fromHex('#010203').toNumber32Bit(), 0x010203FF); + assert.deepStrictEqual(Color.fromHex('#040506').toNumber32Bit(), 0x040506FF); + assert.deepStrictEqual(Color.fromHex('#070809').toNumber32Bit(), 0x070809FF); + assert.deepStrictEqual(Color.fromHex('#0a0A0a').toNumber32Bit(), 0x0a0A0aFF); + assert.deepStrictEqual(Color.fromHex('#0b0B0b').toNumber32Bit(), 0x0b0B0bFF); + assert.deepStrictEqual(Color.fromHex('#0c0C0c').toNumber32Bit(), 0x0c0C0cFF); + assert.deepStrictEqual(Color.fromHex('#0d0D0d').toNumber32Bit(), 0x0d0D0dFF); + assert.deepStrictEqual(Color.fromHex('#0e0E0e').toNumber32Bit(), 0x0e0E0eFF); + assert.deepStrictEqual(Color.fromHex('#0f0F0f').toNumber32Bit(), 0x0f0F0fFF); + assert.deepStrictEqual(Color.fromHex('#a0A0a0').toNumber32Bit(), 0xa0A0a0FF); }); }); diff --git a/src/vs/base/test/common/observable.test.ts b/src/vs/base/test/common/observable.test.ts index 3d7ed472fcd..5d33ac30a9c 100644 --- a/src/vs/base/test/common/observable.test.ts +++ b/src/vs/base/test/common/observable.test.ts @@ -8,7 +8,7 @@ import { setUnexpectedErrorHandler } from '../../common/errors.js'; import { Emitter, Event } from '../../common/event.js'; import { DisposableStore } from '../../common/lifecycle.js'; import { autorun, autorunHandleChanges, derived, derivedDisposable, IObservable, IObserver, ISettableObservable, ITransaction, keepObserved, observableFromEvent, observableSignal, observableValue, transaction, waitForState } from '../../common/observable.js'; -import { BaseObservable } from '../../common/observableInternal/base.js'; +import { BaseObservable, IObservableWithChange } from '../../common/observableInternal/base.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; suite('observables', () => { @@ -1486,18 +1486,18 @@ export class LoggingObserver implements IObserver { constructor(public readonly debugName: string, private readonly log: Log) { } - beginUpdate(observable: IObservable): void { + beginUpdate(observable: IObservable): void { this.count++; this.log.log(`${this.debugName}.beginUpdate (count ${this.count})`); } - endUpdate(observable: IObservable): void { + endUpdate(observable: IObservable): void { this.log.log(`${this.debugName}.endUpdate (count ${this.count})`); this.count--; } - handleChange(observable: IObservable, change: TChange): void { + handleChange(observable: IObservableWithChange, change: TChange): void { this.log.log(`${this.debugName}.handleChange (count ${this.count})`); } - handlePossibleChange(observable: IObservable): void { + handlePossibleChange(observable: IObservable): void { this.log.log(`${this.debugName}.handlePossibleChange`); } } diff --git a/src/vs/base/test/node/extpath.test.ts b/src/vs/base/test/node/extpath.test.ts index 9dbdf1ac87e..c86f3cea0d1 100644 --- a/src/vs/base/test/node/extpath.test.ts +++ b/src/vs/base/test/node/extpath.test.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import assert from 'assert'; import { tmpdir } from 'os'; -import { realcase, realcaseSync, realpath, realpathSync } from '../../node/extpath.js'; +import { realcase, realpath, realpathSync } from '../../node/extpath.js'; import { Promises } from '../../node/pfs.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js'; import { flakySuite, getRandomTestPath } from './testUtils.js'; @@ -24,30 +24,6 @@ flakySuite('Extpath', () => { return Promises.rm(testDir); }); - test('realcaseSync', async () => { - - // assume case insensitive file system - if (process.platform === 'win32' || process.platform === 'darwin') { - const upper = testDir.toUpperCase(); - const real = realcaseSync(upper); - - if (real) { // can be null in case of permission errors - assert.notStrictEqual(real, upper); - assert.strictEqual(real.toUpperCase(), upper); - assert.strictEqual(real, testDir); - } - } - - // linux, unix, etc. -> assume case sensitive file system - else { - let real = realcaseSync(testDir); - assert.strictEqual(real, testDir); - - real = realcaseSync(testDir.toUpperCase()); - assert.strictEqual(real, testDir.toUpperCase()); - } - }); - test('realcase', async () => { // assume case insensitive file system diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 11ca66e8767..47481c0de3f 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -83,7 +83,7 @@ import { NativeURLService } from '../../platform/url/common/urlService.js'; import { ElectronURLListener } from '../../platform/url/electron-main/electronUrlListener.js'; import { IWebviewManagerService } from '../../platform/webview/common/webviewManagerService.js'; import { WebviewMainService } from '../../platform/webview/electron-main/webviewMainService.js'; -import { isFolderToOpen, isWorkspaceToOpen, IWindowOpenable } from '../../platform/window/common/window.js'; +import { isFolderToOpen, isWorkspaceToOpen, IWindowOpenable, TitlebarStyle, overrideDefaultTitlebarStyle } from '../../platform/window/common/window.js'; import { IWindowsMainService, OpenContext } from '../../platform/windows/electron-main/windows.js'; import { ICodeWindow } from '../../platform/window/electron-main/window.js'; import { WindowsMainService } from '../../platform/windows/electron-main/windowsMainService.js'; @@ -593,6 +593,14 @@ export class CodeApplication extends Disposable { // Services const appInstantiationService = await this.initServices(machineId, sqmId, devDeviceId, sharedProcessReady); + // Linux (stable only): custom title default style override + if (isLinux && this.productService.quality === 'stable') { + const titleBarDefaultStyleOverride = this.stateService.getItem('window.titleBarStyleOverride'); + if (titleBarDefaultStyleOverride === TitlebarStyle.CUSTOM) { + overrideDefaultTitlebarStyle(titleBarDefaultStyleOverride); + } + } + // Auth Handler appInstantiationService.invokeFunction(accessor => accessor.get(IProxyAuthService)); @@ -605,7 +613,7 @@ export class CodeApplication extends Disposable { // Setup Protocol URL Handlers const initialProtocolUrls = await appInstantiationService.invokeFunction(accessor => this.setupProtocolUrlHandlers(accessor, mainProcessElectronServer)); - // Setup vscode-remote-resource protocol handler. + // Setup vscode-remote-resource protocol handler this.setupManagedRemoteResourceUrlHandler(mainProcessElectronServer); // Signal phase: ready - before opening first window diff --git a/src/vs/editor/browser/editorDom.ts b/src/vs/editor/browser/editorDom.ts index 38100ab8407..e42959d81b6 100644 --- a/src/vs/editor/browser/editorDom.ts +++ b/src/vs/editor/browser/editorDom.ts @@ -150,13 +150,13 @@ export class EditorMouseEventFactory { } public onContextMenu(target: HTMLElement, callback: (e: EditorMouseEvent) => void): IDisposable { - return dom.addDisposableListener(target, 'contextmenu', (e: MouseEvent) => { + return dom.addDisposableListener(target, dom.EventType.CONTEXT_MENU, (e: MouseEvent) => { callback(this._create(e)); }); } public onMouseUp(target: HTMLElement, callback: (e: EditorMouseEvent) => void): IDisposable { - return dom.addDisposableListener(target, 'mouseup', (e: MouseEvent) => { + return dom.addDisposableListener(target, dom.EventType.MOUSE_UP, (e: MouseEvent) => { callback(this._create(e)); }); } @@ -180,7 +180,7 @@ export class EditorMouseEventFactory { } public onMouseMove(target: HTMLElement, callback: (e: EditorMouseEvent) => void): IDisposable { - return dom.addDisposableListener(target, 'mousemove', (e) => callback(this._create(e))); + return dom.addDisposableListener(target, dom.EventType.MOUSE_MOVE, (e) => callback(this._create(e))); } } diff --git a/src/vs/editor/browser/gpu/atlas/atlas.ts b/src/vs/editor/browser/gpu/atlas/atlas.ts index f4d87f2ae2a..42d1eacaa89 100644 --- a/src/vs/editor/browser/gpu/atlas/atlas.ts +++ b/src/vs/editor/browser/gpu/atlas/atlas.ts @@ -106,4 +106,4 @@ export const enum UsagePreviewColors { Restricted = '#FF000088', } -export type GlyphMap = FourKeyMap; +export type GlyphMap = FourKeyMap; diff --git a/src/vs/editor/browser/gpu/atlas/textureAtlas.ts b/src/vs/editor/browser/gpu/atlas/textureAtlas.ts index 9f527081e44..472f39e6709 100644 --- a/src/vs/editor/browser/gpu/atlas/textureAtlas.ts +++ b/src/vs/editor/browser/gpu/atlas/textureAtlas.ts @@ -110,7 +110,7 @@ export class TextureAtlas extends Disposable { this._onDidDeleteGlyphs.fire(); } - getGlyph(rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, charMetadata: number): Readonly { + getGlyph(rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, decorationStyleSetId: number): Readonly { // TODO: Encode font size and family into key // Ignore metadata that doesn't affect the glyph tokenMetadata &= ~(MetadataConsts.LANGUAGEID_MASK | MetadataConsts.TOKEN_TYPE_MASK | MetadataConsts.BALANCED_BRACKETS_MASK); @@ -122,27 +122,27 @@ export class TextureAtlas extends Disposable { } // Try get the glyph, overflowing to a new page if necessary - return this._tryGetGlyph(this._glyphPageIndex.get(chars, tokenMetadata, charMetadata, rasterizer.cacheKey) ?? 0, rasterizer, chars, tokenMetadata, charMetadata); + return this._tryGetGlyph(this._glyphPageIndex.get(chars, tokenMetadata, decorationStyleSetId, rasterizer.cacheKey) ?? 0, rasterizer, chars, tokenMetadata, decorationStyleSetId); } - private _tryGetGlyph(pageIndex: number, rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, charMetadata: number): Readonly { - this._glyphPageIndex.set(chars, tokenMetadata, charMetadata, rasterizer.cacheKey, pageIndex); + private _tryGetGlyph(pageIndex: number, rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, decorationStyleSetId: number): Readonly { + this._glyphPageIndex.set(chars, tokenMetadata, decorationStyleSetId, rasterizer.cacheKey, pageIndex); return ( - this._pages[pageIndex].getGlyph(rasterizer, chars, tokenMetadata, charMetadata) + this._pages[pageIndex].getGlyph(rasterizer, chars, tokenMetadata, decorationStyleSetId) ?? (pageIndex + 1 < this._pages.length - ? this._tryGetGlyph(pageIndex + 1, rasterizer, chars, tokenMetadata, charMetadata) + ? this._tryGetGlyph(pageIndex + 1, rasterizer, chars, tokenMetadata, decorationStyleSetId) : undefined) - ?? this._getGlyphFromNewPage(rasterizer, chars, tokenMetadata, charMetadata) + ?? this._getGlyphFromNewPage(rasterizer, chars, tokenMetadata, decorationStyleSetId) ); } - private _getGlyphFromNewPage(rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, charMetadata: number): Readonly { + private _getGlyphFromNewPage(rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, decorationStyleSetId: number): Readonly { if (this._pages.length >= TextureAtlas.maximumPageCount) { throw new Error(`Attempt to create a texture atlas page past the limit ${TextureAtlas.maximumPageCount}`); } this._pages.push(this._instantiationService.createInstance(TextureAtlasPage, this._pages.length, this.pageSize, this._allocatorType)); - this._glyphPageIndex.set(chars, tokenMetadata, charMetadata, rasterizer.cacheKey, this._pages.length - 1); - return this._pages[this._pages.length - 1].getGlyph(rasterizer, chars, tokenMetadata, charMetadata)!; + this._glyphPageIndex.set(chars, tokenMetadata, decorationStyleSetId, rasterizer.cacheKey, this._pages.length - 1); + return this._pages[this._pages.length - 1].getGlyph(rasterizer, chars, tokenMetadata, decorationStyleSetId)!; } public getUsagePreview(): Promise { diff --git a/src/vs/editor/browser/gpu/atlas/textureAtlasPage.ts b/src/vs/editor/browser/gpu/atlas/textureAtlasPage.ts index 9c09181751a..4c48a3f70e2 100644 --- a/src/vs/editor/browser/gpu/atlas/textureAtlasPage.ts +++ b/src/vs/editor/browser/gpu/atlas/textureAtlasPage.ts @@ -65,20 +65,20 @@ export class TextureAtlasPage extends Disposable implements IReadableTextureAtla })); } - public getGlyph(rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, charMetadata: number): Readonly | undefined { + public getGlyph(rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, decorationStyleSetId: number): Readonly | undefined { // IMPORTANT: There are intentionally no intermediate variables here to aid in runtime // optimization as it's a very hot function - return this._glyphMap.get(chars, tokenMetadata, charMetadata, rasterizer.cacheKey) ?? this._createGlyph(rasterizer, chars, tokenMetadata, charMetadata); + return this._glyphMap.get(chars, tokenMetadata, decorationStyleSetId, rasterizer.cacheKey) ?? this._createGlyph(rasterizer, chars, tokenMetadata, decorationStyleSetId); } - private _createGlyph(rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, charMetadata: number): Readonly | undefined { + private _createGlyph(rasterizer: IGlyphRasterizer, chars: string, tokenMetadata: number, decorationStyleSetId: number): Readonly | undefined { // Ensure the glyph can fit on the page if (this._glyphInOrderSet.size >= TextureAtlasPage.maximumGlyphCount) { return undefined; } // Rasterize and allocate the glyph - const rasterizedGlyph = rasterizer.rasterizeGlyph(chars, tokenMetadata, charMetadata, this._colorMap); + const rasterizedGlyph = rasterizer.rasterizeGlyph(chars, tokenMetadata, decorationStyleSetId, this._colorMap); const glyph = this._allocator.allocate(rasterizedGlyph); // Ensure the glyph was allocated @@ -89,7 +89,7 @@ export class TextureAtlasPage extends Disposable implements IReadableTextureAtla } // Save the glyph - this._glyphMap.set(chars, tokenMetadata, charMetadata, rasterizer.cacheKey, glyph); + this._glyphMap.set(chars, tokenMetadata, decorationStyleSetId, rasterizer.cacheKey, glyph); this._glyphInOrderSet.add(glyph); // Update page version and it's tracked used area @@ -101,7 +101,7 @@ export class TextureAtlasPage extends Disposable implements IReadableTextureAtla this._logService.trace('New glyph', { chars, tokenMetadata, - charMetadata, + decorationStyleSetId, rasterizedGlyph, glyph }); diff --git a/src/vs/editor/browser/gpu/decorationCssRuleExtractor.ts b/src/vs/editor/browser/gpu/css/decorationCssRuleExtractor.ts similarity index 94% rename from src/vs/editor/browser/gpu/decorationCssRuleExtractor.ts rename to src/vs/editor/browser/gpu/css/decorationCssRuleExtractor.ts index ae36165b2e6..43dbd85c87a 100644 --- a/src/vs/editor/browser/gpu/decorationCssRuleExtractor.ts +++ b/src/vs/editor/browser/gpu/css/decorationCssRuleExtractor.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, getActiveDocument } from '../../../base/browser/dom.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { $, getActiveDocument } from '../../../../base/browser/dom.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import './media/decorationCssRuleExtractor.css'; /** diff --git a/src/vs/editor/browser/gpu/css/decorationStyleCache.ts b/src/vs/editor/browser/gpu/css/decorationStyleCache.ts new file mode 100644 index 00000000000..5e023e84078 --- /dev/null +++ b/src/vs/editor/browser/gpu/css/decorationStyleCache.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IDecorationStyleSet { + /** + * A 24-bit number representing `color`. + */ + color: number | undefined; + /** + * Whether the text should be rendered in bold. + */ + bold: boolean | undefined; +} + +export interface IDecorationStyleCacheEntry extends IDecorationStyleSet { + /** + * A unique identifier for this set of styles. + */ + id: number; +} + +export class DecorationStyleCache { + + private _nextId = 1; + + private readonly _cache = new Map(); + + getOrCreateEntry(color: number | undefined, bold: boolean | undefined): number { + if (color === undefined && bold === undefined) { + return 0; + } + const id = this._nextId++; + const entry = { + id, + color, + bold, + }; + this._cache.set(id, entry); + return id; + } + + getStyleSet(id: number): IDecorationStyleSet | undefined { + if (id === 0) { + return undefined; + } + return this._cache.get(id); + } +} diff --git a/src/vs/editor/browser/gpu/media/decorationCssRuleExtractor.css b/src/vs/editor/browser/gpu/css/media/decorationCssRuleExtractor.css similarity index 100% rename from src/vs/editor/browser/gpu/media/decorationCssRuleExtractor.css rename to src/vs/editor/browser/gpu/css/media/decorationCssRuleExtractor.css diff --git a/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts b/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts index a1d5358123e..0d597760bc8 100644 --- a/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts +++ b/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts @@ -256,7 +256,8 @@ export class FullFileRenderStrategy extends ViewEventHandler implements IGpuRend let tokenEndIndex = 0; let tokenMetadata = 0; - let charMetadata = 0; + let decorationStyleSetBold: boolean | undefined; + let decorationStyleSetColor: number | undefined; let lineData: ViewLineRenderingData; let decoration: InlineDecoration; @@ -360,7 +361,8 @@ export class FullFileRenderStrategy extends ViewEventHandler implements IGpuRend break; } chars = content.charAt(x); - charMetadata = 0; + decorationStyleSetColor = undefined; + decorationStyleSetBold = undefined; // Apply supported inline decoration styles to the cell metadata for (decoration of lineData.inlineDecorations) { @@ -386,7 +388,18 @@ export class FullFileRenderStrategy extends ViewEventHandler implements IGpuRend if (!parsedColor) { throw new BugIndicatingError('Invalid color format ' + value); } - charMetadata = parsedColor.toNumber24Bit(); + decorationStyleSetColor = parsedColor.toNumber32Bit(); + break; + } + case 'font-weight': { + const parsedValue = parseCssFontWeight(value); + if (parsedValue >= 400) { + decorationStyleSetBold = true; + // TODO: Set bold (https://github.com/microsoft/vscode/issues/237584) + } else { + decorationStyleSetBold = false; + // TODO: Set normal (https://github.com/microsoft/vscode/issues/237584) + } break; } default: throw new BugIndicatingError('Unexpected inline decoration style'); @@ -406,7 +419,8 @@ export class FullFileRenderStrategy extends ViewEventHandler implements IGpuRend continue; } - glyph = this._viewGpuContext.atlas.getGlyph(this._glyphRasterizer.value, chars, tokenMetadata, charMetadata); + const decorationStyleSetId = ViewGpuContext.decorationStyleCache.getOrCreateEntry(decorationStyleSetColor, decorationStyleSetBold); + glyph = this._viewGpuContext.atlas.getGlyph(this._glyphRasterizer.value, chars, tokenMetadata, decorationStyleSetId); // TODO: Support non-standard character widths absoluteOffsetX = Math.round((x + xOffset) * viewLineOptions.spaceWidth * dpr); @@ -485,3 +499,13 @@ export class FullFileRenderStrategy extends ViewEventHandler implements IGpuRend this._queuedBufferUpdates[1].push(e); } } + +function parseCssFontWeight(value: string) { + switch (value) { + case 'lighter': + case 'normal': return 400; + case 'bolder': + case 'bold': return 700; + } + return parseInt(value); +} diff --git a/src/vs/editor/browser/gpu/raster/glyphRasterizer.ts b/src/vs/editor/browser/gpu/raster/glyphRasterizer.ts index 159c3ad46c9..0375d305f15 100644 --- a/src/vs/editor/browser/gpu/raster/glyphRasterizer.ts +++ b/src/vs/editor/browser/gpu/raster/glyphRasterizer.ts @@ -8,6 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { StringBuilder } from '../../../common/core/stringBuilder.js'; import { FontStyle, TokenMetadata } from '../../../common/encodedTokenAttributes.js'; import { ensureNonNullable } from '../gpuUtils.js'; +import { ViewGpuContext } from '../viewGpuContext.js'; import { type IBoundingBox, type IGlyphRasterizer, type IRasterizedGlyph } from './raster.js'; let nextId = 0; @@ -40,7 +41,7 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer { fontBoundingBoxAscent: 0, fontBoundingBoxDescent: 0, }; - private _workGlyphConfig: { chars: string | undefined; tokenMetadata: number; charMetadata: number } = { chars: undefined, tokenMetadata: 0, charMetadata: 0 }; + private _workGlyphConfig: { chars: string | undefined; tokenMetadata: number; decorationStyleSetId: number } = { chars: undefined, tokenMetadata: 0, decorationStyleSetId: 0 }; constructor( readonly fontSize: number, @@ -68,7 +69,7 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer { public rasterizeGlyph( chars: string, tokenMetadata: number, - charMetadata: number, + decorationStyleSetId: number, colorMap: string[], ): Readonly { if (chars === '') { @@ -83,19 +84,19 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer { // Check if the last glyph matches the config, reuse if so. This helps avoid unnecessary // work when the rasterizer is called multiple times like when the glyph doesn't fit into a // page. - if (this._workGlyphConfig.chars === chars && this._workGlyphConfig.tokenMetadata === tokenMetadata && this._workGlyphConfig.charMetadata === charMetadata) { + if (this._workGlyphConfig.chars === chars && this._workGlyphConfig.tokenMetadata === tokenMetadata && this._workGlyphConfig.decorationStyleSetId === decorationStyleSetId) { return this._workGlyph; } this._workGlyphConfig.chars = chars; this._workGlyphConfig.tokenMetadata = tokenMetadata; - this._workGlyphConfig.charMetadata = charMetadata; - return this._rasterizeGlyph(chars, tokenMetadata, charMetadata, colorMap); + this._workGlyphConfig.decorationStyleSetId = decorationStyleSetId; + return this._rasterizeGlyph(chars, tokenMetadata, decorationStyleSetId, colorMap); } public _rasterizeGlyph( chars: string, - metadata: number, - charMetadata: number, + tokenMetadata: number, + decorationStyleSetId: number, colorMap: string[], ): Readonly { const devicePixelFontSize = Math.ceil(this.fontSize * this.devicePixelRatio); @@ -105,15 +106,21 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer { this._canvas.height = canvasDim; } + const decorationStyleSet = ViewGpuContext.decorationStyleCache.getStyleSet(decorationStyleSetId); + // TODO: Support workbench.fontAliasing this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); const fontSb = new StringBuilder(200); - const fontStyle = TokenMetadata.getFontStyle(metadata); + const fontStyle = TokenMetadata.getFontStyle(tokenMetadata); if (fontStyle & FontStyle.Italic) { fontSb.appendString('italic '); } - if (fontStyle & FontStyle.Bold) { + if (decorationStyleSet?.bold !== undefined) { + if (decorationStyleSet.bold) { + fontSb.appendString('bold '); + } + } else if (fontStyle & FontStyle.Bold) { fontSb.appendString('bold '); } fontSb.appendString(`${devicePixelFontSize}px ${this.fontFamily}`); @@ -125,10 +132,10 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer { const originX = devicePixelFontSize; const originY = devicePixelFontSize; - if (charMetadata) { - this._ctx.fillStyle = `#${charMetadata.toString(16).padStart(8, '0')}`; + if (decorationStyleSet?.color !== undefined) { + this._ctx.fillStyle = `#${decorationStyleSet.color.toString(16).padStart(8, '0')}`; } else { - this._ctx.fillStyle = colorMap[TokenMetadata.getForeground(metadata)]; + this._ctx.fillStyle = colorMap[TokenMetadata.getForeground(tokenMetadata)]; } this._ctx.textBaseline = 'top'; diff --git a/src/vs/editor/browser/gpu/raster/raster.ts b/src/vs/editor/browser/gpu/raster/raster.ts index 989a4656415..b93d8748978 100644 --- a/src/vs/editor/browser/gpu/raster/raster.ts +++ b/src/vs/editor/browser/gpu/raster/raster.ts @@ -23,13 +23,13 @@ export interface IGlyphRasterizer { * emoji, etc. * @param tokenMetadata The token metadata of the glyph to rasterize. See {@link MetadataConsts} * for how this works. - * @param charMetadata The chracter metadata of the glyph to rasterize. + * @param decorationStyleSetId The id of the decoration style sheet. Zero means no decoration. * @param colorMap A theme's color map. */ rasterizeGlyph( chars: string, tokenMetadata: number, - charMetadata: number, + decorationStyleSetId: number, colorMap: string[], ): Readonly; } diff --git a/src/vs/editor/browser/gpu/rectangleRenderer.ts b/src/vs/editor/browser/gpu/rectangleRenderer.ts index d0e087dbbc8..5be1db2f162 100644 --- a/src/vs/editor/browser/gpu/rectangleRenderer.ts +++ b/src/vs/editor/browser/gpu/rectangleRenderer.ts @@ -6,6 +6,7 @@ import { getActiveWindow } from '../../../base/browser/dom.js'; import { Event } from '../../../base/common/event.js'; import { IReference, MutableDisposable } from '../../../base/common/lifecycle.js'; +import type { IObservable } from '../../../base/common/observable.js'; import { EditorOption } from '../../common/config/editorOptions.js'; import { ViewEventHandler } from '../../common/viewEventHandler.js'; import type { ViewScrollChangedEvent } from '../../common/viewEvents.js'; @@ -56,6 +57,8 @@ export class RectangleRenderer extends ViewEventHandler { constructor( private readonly _context: ViewContext, + private readonly _contentLeft: IObservable, + private readonly _devicePixelRatio: IObservable, private readonly _canvas: HTMLCanvasElement, private readonly _ctx: GPUCanvasContext, device: Promise, @@ -281,6 +284,10 @@ export class RectangleRenderer extends ViewEventHandler { pass.setVertexBuffer(0, this._vertexBuffer); pass.setBindGroup(0, this._bindGroup); + // Only draw the content area + const contentLeft = Math.ceil(this._contentLeft.get() * this._devicePixelRatio.get()); + pass.setScissorRect(contentLeft, 0, this._canvas.width - contentLeft, this._canvas.height); + pass.draw(quadVertices.length / 2, this._shapeCollection.entryCount); pass.end(); diff --git a/src/vs/editor/browser/gpu/viewGpuContext.ts b/src/vs/editor/browser/gpu/viewGpuContext.ts index 54e4a4892bd..7c0985a376b 100644 --- a/src/vs/editor/browser/gpu/viewGpuContext.ts +++ b/src/vs/editor/browser/gpu/viewGpuContext.ts @@ -19,10 +19,11 @@ import { GPULifecycle } from './gpuDisposable.js'; import { ensureNonNullable, observeDevicePixelDimensions } from './gpuUtils.js'; import { RectangleRenderer } from './rectangleRenderer.js'; import type { ViewContext } from '../../common/viewModel/viewContext.js'; -import { DecorationCssRuleExtractor } from './decorationCssRuleExtractor.js'; +import { DecorationCssRuleExtractor } from './css/decorationCssRuleExtractor.js'; import { Event } from '../../../base/common/event.js'; -import type { IEditorOptions } from '../../common/config/editorOptions.js'; +import { EditorOption, type IEditorOptions } from '../../common/config/editorOptions.js'; import { InlineDecorationType } from '../../common/viewModel.js'; +import { DecorationStyleCache } from './css/decorationStyleCache.js'; const enum GpuRenderLimits { maxGpuLines = 3000, @@ -54,6 +55,11 @@ export class ViewGpuContext extends Disposable { return ViewGpuContext._decorationCssRuleExtractor; } + private static readonly _decorationStyleCache = new DecorationStyleCache(); + static get decorationStyleCache(): DecorationStyleCache { + return ViewGpuContext._decorationStyleCache; + } + private static _atlas: TextureAtlas | undefined; /** @@ -79,6 +85,7 @@ export class ViewGpuContext extends Disposable { readonly canvasDevicePixelDimensions: IObservable<{ width: number; height: number }>; readonly devicePixelRatio: IObservable; + readonly contentLeft: IObservable; constructor( context: ViewContext, @@ -115,8 +122,6 @@ export class ViewGpuContext extends Disposable { } }); - this.rectangleRenderer = this._instantiationService.createInstance(RectangleRenderer, context, this.canvas.domNode, this.ctx, this.device); - const dprObs = observableValue(this, getActiveWindow().devicePixelRatio); this._register(addDisposableListener(getActiveWindow(), 'resize', () => { dprObs.set(getActiveWindow().devicePixelRatio, undefined); @@ -135,6 +140,15 @@ export class ViewGpuContext extends Disposable { } )); this.canvasDevicePixelDimensions = canvasDevicePixelDimensions; + + const contentLeft = observableValue(this, 0); + this._register(this.configurationService.onDidChangeConfiguration(e => { + contentLeft.set(context.configuration.options.get(EditorOption.layoutInfo).contentLeft, undefined); + })); + this.contentLeft = contentLeft; + + + this.rectangleRenderer = this._instantiationService.createInstance(RectangleRenderer, context, this.contentLeft, this.devicePixelRatio, this.canvas.domNode, this.ctx, this.device); } /** @@ -170,7 +184,7 @@ export class ViewGpuContext extends Disposable { return false; } for (const r of rule.style) { - if (!gpuSupportedDecorationCssRules.includes(r)) { + if (!supportsCssRule(r, rule.style)) { return false; } } @@ -220,8 +234,8 @@ export class ViewGpuContext extends Disposable { return false; } for (const r of rule.style) { - if (!gpuSupportedDecorationCssRules.includes(r)) { - problemRules.push(r); + if (!supportsCssRule(r, rule.style)) { + problemRules.push(`${r}: ${rule.style[r as any]}`); return false; } } @@ -249,8 +263,19 @@ export class ViewGpuContext extends Disposable { } /** - * A list of fully supported decoration CSS rules that can be used in the GPU renderer. + * A list of supported decoration CSS rules that can be used in the GPU renderer. */ const gpuSupportedDecorationCssRules = [ 'color', + 'font-weight', ]; + +function supportsCssRule(rule: string, style: CSSStyleDeclaration) { + if (!gpuSupportedDecorationCssRules.includes(rule)) { + return false; + } + // Check for values that aren't supported + switch (rule) { + default: return true; + } +} diff --git a/src/vs/editor/browser/observableCodeEditor.ts b/src/vs/editor/browser/observableCodeEditor.ts index 3633d4cac45..ac1ab182fa4 100644 --- a/src/vs/editor/browser/observableCodeEditor.ts +++ b/src/vs/editor/browser/observableCodeEditor.ts @@ -5,7 +5,7 @@ import { equalsIfDefined, itemsEquals } from '../../base/common/equals.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; -import { IObservable, ITransaction, TransactionImpl, autorun, autorunOpts, derived, derivedOpts, derivedWithSetter, observableFromEvent, observableSignal, observableValue, observableValueOpts } from '../../base/common/observable.js'; +import { IObservable, IObservableWithChange, ITransaction, TransactionImpl, autorun, autorunOpts, derived, derivedOpts, derivedWithSetter, observableFromEvent, observableSignal, observableValue, observableValueOpts } from '../../base/common/observable.js'; import { EditorOption, FindComputedEditorOptionValueById } from '../common/config/editorOptions.js'; import { LineRange } from '../common/core/lineRange.js'; import { OffsetRange } from '../common/core/offsetRange.js'; @@ -155,13 +155,13 @@ export class ObservableCodeEditor extends Disposable { public readonly isReadonly = observableFromEvent(this, this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.readOnly)); private readonly _versionId = observableValueOpts({ owner: this, lazy: true }, this.editor.getModel()?.getVersionId() ?? null); - public readonly versionId: IObservable = this._versionId; + public readonly versionId: IObservableWithChange = this._versionId; private readonly _selections = observableValueOpts( { owner: this, equalsFn: equalsIfDefined(itemsEquals(Selection.selectionsEqual)), lazy: true }, this.editor.getSelections() ?? null ); - public readonly selections: IObservable = this._selections; + public readonly selections: IObservableWithChange = this._selections; public readonly positions = derivedOpts( diff --git a/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts b/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts index 678dec6ab99..7be81a4dc99 100644 --- a/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts +++ b/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts @@ -5,7 +5,7 @@ import { getActiveWindow } from '../../../../base/browser/dom.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; -import { autorun, observableValue, runOnChange } from '../../../../base/common/observable.js'; +import { autorun, runOnChange } from '../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; @@ -61,8 +61,6 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { private _renderStrategy!: IGpuRenderStrategy; - private _contentLeftObs = observableValue('contentLeft', 0); - constructor( context: ViewContext, private readonly _viewGpuContext: ViewGpuContext, @@ -160,7 +158,7 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { this._register(runOnChange(this._viewGpuContext.canvasDevicePixelDimensions, ({ width, height }) => { this._device.queue.writeBuffer(layoutInfoUniformBuffer, 0, updateBufferValues(width, height)); })); - this._register(runOnChange(this._contentLeftObs, () => { + this._register(runOnChange(this._viewGpuContext.contentLeft, () => { this._device.queue.writeBuffer(layoutInfoUniformBuffer, 0, updateBufferValues()); })); } @@ -381,6 +379,7 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { // from that side. Luckily rendering is cheap, it's only when uploaded data changes does it // start to cost. + override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean { return true; } override onCursorStateChanged(e: viewEvents.ViewCursorStateChangedEvent): boolean { return true; } override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean { return true; } override onFlushed(e: viewEvents.ViewFlushedEvent): boolean { return true; } @@ -394,11 +393,6 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { override onThemeChanged(e: viewEvents.ViewThemeChangedEvent): boolean { return true; } override onZonesChanged(e: viewEvents.ViewZonesChangedEvent): boolean { return true; } - override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean { - this._contentLeftObs.set(this._context.configuration.options.get(EditorOption.layoutInfo).contentLeft, undefined); - return true; - } - // #endregion public renderText(viewportData: ViewportData): void { @@ -427,7 +421,7 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { pass.setVertexBuffer(0, this._vertexBuffer); // Only draw the content area - const contentLeft = Math.ceil(this._contentLeftObs.get() * this._viewGpuContext.devicePixelRatio.get()); + const contentLeft = Math.ceil(this._viewGpuContext.contentLeft.get() * this._viewGpuContext.devicePixelRatio.get()); pass.setScissorRect(contentLeft, 0, this.canvas.width - contentLeft, this.canvas.height); pass.setBindGroup(0, this._bindGroup); diff --git a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index ed20f95580a..3bd2f0a903e 100644 --- a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -290,6 +290,11 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE })); this._contextKeyService = this._register(contextKeyService.createScoped(this._domElement)); + if (codeEditorWidgetOptions.contextKeyValues) { + for (const [key, value] of Object.entries(codeEditorWidgetOptions.contextKeyValues)) { + this._contextKeyService.createKey(key, value); + } + } this._notificationService = notificationService; this._codeEditorService = codeEditorService; this._commandService = commandService; @@ -1988,6 +1993,12 @@ export interface ICodeEditorWidgetOptions { * Defaults to MenuId.SimpleEditorContext or MenuId.EditorContext depending on whether the widget is simple. */ contextMenuId?: MenuId; + + /** + * Define extra context keys that will be defined in the context service + * for the editor. + */ + contextKeyValues?: Record; } class ModelData { diff --git a/src/vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget.ts index b716aa61109..3852374d394 100644 --- a/src/vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget.ts @@ -13,7 +13,7 @@ import { ILanguageFeaturesService } from '../../../common/services/languageFeatu import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; @@ -61,3 +61,11 @@ export class EmbeddedCodeEditorWidget extends CodeEditorWidget { super.updateOptions(this._overwriteOptions); } } + +export function getOuterEditor(accessor: ServicesAccessor): ICodeEditor | null { + const editor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); + if (editor instanceof EmbeddedCodeEditorWidget) { + return editor.getParentEditor(); + } + return editor; +} diff --git a/src/vs/editor/browser/widget/diffEditor/commands.ts b/src/vs/editor/browser/widget/diffEditor/commands.ts index 9e04b2aad05..0d0676fea62 100644 --- a/src/vs/editor/browser/widget/diffEditor/commands.ts +++ b/src/vs/editor/browser/widget/diffEditor/commands.ts @@ -20,6 +20,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import './registrations.contribution.js'; import { DiffEditorSelectionHunkToolbarContext } from './features/gutterFeature.js'; import { URI } from '../../../../base/common/uri.js'; +import { EditorOption } from '../../../common/config/editorOptions.js'; export class ToggleCollapseUnchangedRegions extends Action2 { constructor() { @@ -255,7 +256,7 @@ export function findFocusedDiffEditor(accessor: ServicesAccessor): IDiffEditor | if (activeElement) { for (const d of diffEditors) { const container = d.getContainerDomNode(); - if (isElementOrParentOf(container, activeElement)) { + if (container.contains(activeElement)) { return d; } } @@ -264,13 +265,24 @@ export function findFocusedDiffEditor(accessor: ServicesAccessor): IDiffEditor | return null; } -function isElementOrParentOf(elementOrParent: Element, element: Element): boolean { - let e: Element | null = element; - while (e) { - if (e === elementOrParent) { - return true; - } - e = e.parentElement; + +/** + * If `editor` is the original or modified editor of a diff editor, it returns it. + * It returns null otherwise. + */ +export function findDiffEditorContainingCodeEditor(accessor: ServicesAccessor, editor: ICodeEditor): IDiffEditor | null { + if (!editor.getOption(EditorOption.inDiffEditor)) { + return null; } - return false; + + const codeEditorService = accessor.get(ICodeEditorService); + + for (const diffEditor of codeEditorService.listDiffEditors()) { + const originalEditor = diffEditor.getOriginalEditor(); + const modifiedEditor = diffEditor.getModifiedEditor(); + if (originalEditor === editor || modifiedEditor === editor) { + return diffEditor; + } + } + return null; } diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorSash.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorSash.ts index 8abaddcceb9..468e6323c7f 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorSash.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorSash.ts @@ -62,7 +62,7 @@ export class DiffEditorSash extends Disposable { private readonly _domNode: HTMLElement, private readonly _dimensions: { height: IObservable; width: IObservable }, private readonly _enabled: IObservable, - private readonly _boundarySashes: IObservable, + private readonly _boundarySashes: IObservable, public readonly sashLeft: ISettableObservable, private readonly _resetSash: () => void, ) { diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts b/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts index 0c1a533681f..3d2e483ca15 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IObservable, ISettableObservable, derived, derivedConstOnceDefined, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { IObservable, IObservableWithChange, ISettableObservable, derived, derivedConstOnceDefined, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { Constants } from '../../../../base/common/uint.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { diffEditorDefaultOptions } from '../../../common/config/diffEditor.js'; @@ -15,7 +15,7 @@ import { DiffEditorViewModel, DiffState } from './diffEditorViewModel.js'; export class DiffEditorOptions { private readonly _options: ISettableObservable, { changedOptions: IDiffEditorOptions }>; - public get editorOptions(): IObservable { return this._options; } + public get editorOptions(): IObservableWithChange { return this._options; } private readonly _diffEditorWidth = observableValue(this, 0); diff --git a/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts index 556378aebf3..17ff41896b4 100644 --- a/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts +++ b/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts @@ -50,7 +50,7 @@ export class DiffEditorGutter extends Disposable { private readonly _editors: DiffEditorEditors, private readonly _options: DiffEditorOptions, private readonly _sashLayout: SashLayout, - private readonly _boundarySashes: IObservable, + private readonly _boundarySashes: IObservable, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IMenuService private readonly _menuService: IMenuService, diff --git a/src/vs/editor/browser/widget/diffEditor/utils.ts b/src/vs/editor/browser/widget/diffEditor/utils.ts index d34d60fd457..47faca2f8cf 100644 --- a/src/vs/editor/browser/widget/diffEditor/utils.ts +++ b/src/vs/editor/browser/widget/diffEditor/utils.ts @@ -7,7 +7,7 @@ import { IDimension } from '../../../../base/browser/dom.js'; import { findLast } from '../../../../base/common/arraysFind.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, ISettableObservable, autorun, autorunHandleChanges, autorunOpts, autorunWithStore, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, IObservableWithChange, ISettableObservable, autorun, autorunHandleChanges, autorunOpts, autorunWithStore, observableValue, transaction } from '../../../../base/common/observable.js'; import { ElementSizeObserver } from '../../config/elementSizeObserver.js'; import { ICodeEditor, IOverlayWidget, IViewZone } from '../../editorBrowser.js'; import { Position } from '../../../common/core/position.js'; @@ -126,7 +126,7 @@ export class ObservableElementSizeObserver extends Disposable { } } -export function animatedObservable(targetWindow: Window, base: IObservable, store: DisposableStore): IObservable { +export function animatedObservable(targetWindow: Window, base: IObservableWithChange, store: DisposableStore): IObservable { let targetVal = base.get(); let startVal = targetVal; let curVal = targetVal; diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index e355a960db9..908dcac26bd 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -4200,7 +4200,6 @@ export interface IInlineSuggestOptions { useWordInsertionView?: 'never' | 'whenPossible'; useWordReplacementView?: 'never' | 'whenPossible'; - onlyShowWhenCloseToCursor?: boolean; useGutterIndicator?: boolean; }; }; @@ -4233,10 +4232,9 @@ class InlineEditorSuggest extends BaseEditorOption { @@ -832,6 +834,8 @@ export interface InlineCompletionsProvider { + displayName?: string; provideInlineEdit(model: model.ITextModel, context: IInlineEditContext, token: CancellationToken): ProviderResult; freeInlineEdit(edit: T): void; } diff --git a/src/vs/editor/common/languages/languageConfigurationRegistry.ts b/src/vs/editor/common/languages/languageConfigurationRegistry.ts index bf0516d531b..f42b06d0b74 100644 --- a/src/vs/editor/common/languages/languageConfigurationRegistry.ts +++ b/src/vs/editor/common/languages/languageConfigurationRegistry.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, markAsSingleton, toDisposable } from '../../../base/common/lifecycle.js'; import * as strings from '../../../base/common/strings.js'; import { ITextModel } from '../model.js'; import { DEFAULT_WORD_REGEXP, ensureValidWordDefinition } from '../core/wordHelper.js'; @@ -202,7 +202,7 @@ class ComposedLanguageConfiguration { ); this._entries.push(entry); this._resolved = null; - return toDisposable(() => { + return markAsSingleton(toDisposable(() => { for (let i = 0; i < this._entries.length; i++) { if (this._entries[i] === entry) { this._entries.splice(i, 1); @@ -210,7 +210,7 @@ class ComposedLanguageConfiguration { break; } } - }); + })); } public getResolvedConfiguration(): ResolvedLanguageConfiguration | null { @@ -332,10 +332,10 @@ export class LanguageConfigurationRegistry extends Disposable { const disposable = entries.register(configuration, priority); this._onDidChange.fire(new LanguageConfigurationChangeEvent(languageId)); - return toDisposable(() => { + return markAsSingleton(toDisposable(() => { disposable.dispose(); this._onDidChange.fire(new LanguageConfigurationChangeEvent(languageId)); - }); + })); } public getLanguageConfiguration(languageId: string): ResolvedLanguageConfiguration | null { diff --git a/src/vs/editor/common/model/tokenizationTextModelPart.ts b/src/vs/editor/common/model/tokenizationTextModelPart.ts index f51bf1b98bc..704c128b992 100644 --- a/src/vs/editor/common/model/tokenizationTextModelPart.ts +++ b/src/vs/editor/common/model/tokenizationTextModelPart.ts @@ -23,7 +23,6 @@ import { TextModelPart } from './textModelPart.js'; import { DefaultBackgroundTokenizer, TokenizerWithStateStoreAndTextModel, TrackingTokenizationStateStore } from './textModelTokens.js'; import { AbstractTokens, AttachedViewHandler, AttachedViews } from './tokens.js'; import { TreeSitterTokens } from './treeSitterTokens.js'; -import { ITreeSitterParserService } from '../services/treeSitterParserService.js'; import { IModelContentChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelTokensChangedEvent } from '../textModelEvents.js'; import { BackgroundTokenizationState, ITokenizationTextModelPart } from '../tokenizationTextModelPart.js'; import { ContiguousMultilineTokens } from '../tokens/contiguousMultilineTokens.js'; @@ -32,6 +31,7 @@ import { ContiguousTokensStore } from '../tokens/contiguousTokensStore.js'; import { LineTokens } from '../tokens/lineTokens.js'; import { SparseMultilineTokens } from '../tokens/sparseMultilineTokens.js'; import { SparseTokensStore } from '../tokens/sparseTokensStore.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; export class TokenizationTextModelPart extends TextModelPart implements ITokenizationTextModelPart { private readonly _semanticTokens: SparseTokensStore = new SparseTokensStore(this._languageService.languageIdCodec); @@ -55,7 +55,7 @@ export class TokenizationTextModelPart extends TextModelPart implements ITokeniz private readonly _attachedViews: AttachedViews, @ILanguageService private readonly _languageService: ILanguageService, @ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService, - @ITreeSitterParserService private readonly _treeSitterService: ITreeSitterParserService, + @IInstantiationService private readonly _instantiationService: IInstantiationService ) { super(); @@ -73,7 +73,7 @@ export class TokenizationTextModelPart extends TextModelPart implements ITokeniz } private createTreeSitterTokens(): AbstractTokens { - return this._register(new TreeSitterTokens(this._treeSitterService, this._languageService.languageIdCodec, this._textModel, () => this._languageId)); + return this._register(this._instantiationService.createInstance(TreeSitterTokens, this._languageService.languageIdCodec, this._textModel, () => this._languageId)); } private createTokens(useTreeSitter: boolean): void { diff --git a/src/vs/editor/common/model/treeSitterTokens.ts b/src/vs/editor/common/model/treeSitterTokens.ts index f4077388ef0..7f8f91bb276 100644 --- a/src/vs/editor/common/model/treeSitterTokens.ts +++ b/src/vs/editor/common/model/treeSitterTokens.ts @@ -17,10 +17,10 @@ export class TreeSitterTokens extends AbstractTokens { private _lastLanguageId: string | undefined; private readonly _tokensChangedListener: MutableDisposable = this._register(new MutableDisposable()); - constructor(private readonly _treeSitterService: ITreeSitterParserService, - languageIdCodec: ILanguageIdCodec, + constructor(languageIdCodec: ILanguageIdCodec, textModel: TextModel, - languageId: () => string) { + languageId: () => string, + @ITreeSitterParserService private readonly _treeSitterService: ITreeSitterParserService) { super(languageIdCodec, textModel, languageId); this._initialize(); diff --git a/src/vs/editor/common/standaloneStrings.ts b/src/vs/editor/common/standaloneStrings.ts index a90add96865..f2edfd33f60 100644 --- a/src/vs/editor/common/standaloneStrings.ts +++ b/src/vs/editor/common/standaloneStrings.ts @@ -34,6 +34,9 @@ export namespace AccessibilityHelpNLS { export const setBreakpoint = nls.localize('debugConsole.setBreakpoint', "The Debug: Inline Breakpoint command{0} will set or unset a breakpoint at the current cursor position in the active editor.", ''); export const addToWatch = nls.localize('debugConsole.addToWatch', "The Debug: Add to Watch command{0} will add the selected text to the watch view.", ''); export const debugExecuteSelection = nls.localize('debugConsole.executeSelection', "The Debug: Execute Selection command{0} will execute the selected text in the debug console.", ''); + export const chatEditorModification = nls.localize('chatEditorModification', "The editor contains pending modifications that have been made by chat."); + export const chatEditorRequestInProgress = nls.localize('chatEditorRequestInProgress', "The editor is currently waiting for modifications to be made by chat."); + export const chatEditActions = nls.localize('chatEditing.navigation', 'Navigate between edits in the editor with navigate previous{0} and next{1} and accept{3} and reject the current change{4}.', '', '', '', ''); } export namespace InspectTokensNLS { diff --git a/src/vs/editor/contrib/codelens/browser/codeLensCache.ts b/src/vs/editor/contrib/codelens/browser/codeLensCache.ts index 5f479695091..6dcde99c369 100644 --- a/src/vs/editor/contrib/codelens/browser/codeLensCache.ts +++ b/src/vs/editor/contrib/codelens/browser/codeLensCache.ts @@ -77,7 +77,7 @@ export class CodeLensCache implements ICodeLensCache { }; }); const copyModel = new CodeLensModel(); - copyModel.add({ lenses: copyItems, dispose: () => { } }, this._fakeProvider); + copyModel.add({ lenses: copyItems }, this._fakeProvider); const item = new CacheItem(model.getLineCount(), copyModel); this._cache.set(model.uri.toString(), item); diff --git a/src/vs/editor/contrib/codelens/browser/codelens.ts b/src/vs/editor/contrib/codelens/browser/codelens.ts index 0a777339b04..be24cae08b0 100644 --- a/src/vs/editor/contrib/codelens/browser/codelens.ts +++ b/src/vs/editor/contrib/codelens/browser/codelens.ts @@ -5,7 +5,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { illegalArgument, onUnexpectedExternalError } from '../../../../base/common/errors.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, isDisposable } from '../../../../base/common/lifecycle.js'; import { assertType } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { ITextModel } from '../../../common/model.js'; @@ -24,18 +24,21 @@ export class CodeLensModel { lenses: CodeLensItem[] = []; - private readonly _disposables = new DisposableStore(); + private _store: DisposableStore | undefined; dispose(): void { - this._disposables.dispose(); + this._store?.dispose(); } get isDisposed(): boolean { - return this._disposables.isDisposed; + return this._store?.isDisposed ?? false; } add(list: CodeLensList, provider: CodeLensProvider): void { - this._disposables.add(list); + if (isDisposable(list)) { + this._store ??= new DisposableStore(); + this._store.add(list); + } for (const symbol of list.lenses) { this.lenses.push({ symbol, provider }); } diff --git a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesController.ts b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesController.ts index 175e4c8b49c..48ba268a699 100644 --- a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesController.ts +++ b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesController.ts @@ -14,7 +14,8 @@ import { Position } from '../../../../common/core/position.js'; import { Range } from '../../../../common/core/range.js'; import { IEditorContribution } from '../../../../common/editorCommon.js'; import { Location } from '../../../../common/languages.js'; -import { getOuterEditor, PeekContext } from '../../../peekView/browser/peekView.js'; +import { PeekContext } from '../../../peekView/browser/peekView.js'; +import { getOuterEditor } from '../../../../browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import * as nls from '../../../../../nls.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; diff --git a/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts b/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts index 81ccfcbc60b..a80bdb7966f 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts @@ -273,6 +273,7 @@ class RenderedContentHoverParts extends Disposable { ...hoverContext }; const disposables = new DisposableStore(); + disposables.add(statusBar); for (const participant of participants) { const renderedHoverParts = this._renderHoverPartsForParticipant(hoverParts, participant, hoverRenderingContext); disposables.add(renderedHoverParts); @@ -294,7 +295,7 @@ class RenderedContentHoverParts extends Disposable { actions: renderedStatusBar.actions, }); } - return toDisposable(() => { disposables.dispose(); }); + return disposables; } private _renderHoverPartsForParticipant(hoverParts: IHoverPart[], participant: IEditorHoverParticipant, hoverRenderingContext: IEditorHoverRenderContext): IRenderedHoverParts { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts index f183f9f896f..2fe48fc4eb9 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts @@ -11,7 +11,8 @@ import { Action2, MenuId } from '../../../../../platform/actions/common/actions. import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; import { ICodeEditor } from '../../../../browser/editorBrowser.js'; import { EditorAction, EditorCommand, ServicesAccessor } from '../../../../browser/editorExtensions.js'; import { EditorContextKeys } from '../../../../common/editorContextKeys.js'; @@ -19,7 +20,6 @@ import { Context as SuggestContext } from '../../../suggest/browser/suggest.js'; import { inlineSuggestCommitId, showNextInlineSuggestionActionId, showPreviousInlineSuggestionActionId } from './commandIds.js'; import { InlineCompletionContextKeys } from './inlineCompletionContextKeys.js'; import { InlineCompletionsController } from './inlineCompletionsController.js'; -import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; export class ShowNextInlineSuggestionAction extends EditorAction { public static ID = showNextInlineSuggestionActionId; @@ -211,14 +211,21 @@ export class AcceptInlineCompletion extends EditorAction { }); } - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - const controller = InlineCompletionsController.get(editor); + public async run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { + const controller = InlineCompletionsController.getInFocusedEditorOrParent(accessor); if (controller) { controller.model.get()?.accept(controller.editor); controller.editor.focus(); } } } +KeybindingsRegistry.registerKeybindingRule({ + id: inlineSuggestCommitId, + weight: 202, // greater than jump + primary: KeyCode.Tab, + when: ContextKeyExpr.and(InlineCompletionContextKeys.inInlineEditsPreviewEditor) +}); + export class JumpToNextInlineEdit extends EditorAction { constructor() { @@ -276,14 +283,23 @@ export class HideInlineCompletion extends EditorAction { }); } - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - const controller = InlineCompletionsController.get(editor); + public async run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { + const controller = InlineCompletionsController.getInFocusedEditorOrParent(accessor); transaction(tx => { controller?.model.get()?.stop('explicitCancel', tx); }); + controller?.editor.focus(); } } +KeybindingsRegistry.registerKeybindingRule({ + id: HideInlineCompletion.ID, + weight: -1, // very weak + primary: KeyCode.Escape, + secondary: [KeyMod.Shift | KeyCode.Escape], + when: ContextKeyExpr.and(InlineCompletionContextKeys.inInlineEditsPreviewEditor) +}); + export class ToggleAlwaysShowInlineSuggestionToolbar extends Action2 { public static ID = 'editor.action.inlineSuggest.toggleAlwaysShowToolbar'; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionContextKeys.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionContextKeys.ts index 7fbd4dc19fc..834adac93ca 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionContextKeys.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionContextKeys.ts @@ -5,6 +5,7 @@ import { RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { localize } from '../../../../../nls.js'; +import * as nls from '../../../../../nls.js'; export abstract class InlineCompletionContextKeys { @@ -19,4 +20,6 @@ export abstract class InlineCompletionContextKeys { public static readonly inlineEditVisible = new RawContextKey('inlineEditIsVisible', false, localize('inlineEditVisible', "Whether an inline edit is visible")); public static readonly tabShouldJumpToInlineEdit = new RawContextKey('tabShouldJumpToInlineEdit', false, localize('tabShouldJumpToInlineEdit', "Whether tab should jump to an inline edit.")); public static readonly tabShouldAcceptInlineEdit = new RawContextKey('tabShouldAcceptInlineEdit', false, localize('tabShouldAcceptInlineEdit', "Whether tab should accept the inline edit.")); + + public static readonly inInlineEditsPreviewEditor = new RawContextKey('inInlineEditsPreviewEditor', true, nls.localize('inInlineEditsPreviewEditor', "Whether the current code editor is showing an inline edits preview")); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts index 79fdfb85afd..a0fbec72920 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts @@ -8,7 +8,7 @@ import { timeout } from '../../../../../base/common/async.js'; import { cancelOnDispose } from '../../../../../base/common/cancellation.js'; import { createHotClass } from '../../../../../base/common/hotReloadHelpers.js'; import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { ITransaction, autorun, derived, derivedDisposable, derivedObservableWithCache, observableFromEvent, observableSignal, runOnChange, runOnChangeWithStore, transaction, waitForState } from '../../../../../base/common/observable.js'; +import { ITransaction, autorun, derived, derivedDisposable, derivedObservableWithCache, observableFromEvent, observableSignal, observableValue, runOnChange, runOnChangeWithStore, transaction, waitForState } from '../../../../../base/common/observable.js'; import { isUndefined } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; @@ -16,7 +16,7 @@ import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../.. import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { hotClassGetOriginalInstance } from '../../../../../platform/observable/common/wrapInHotClass.js'; import { CoreEditingCommands } from '../../../../browser/coreCommands.js'; @@ -36,6 +36,7 @@ import { ObservableContextKeyService } from '../utils.js'; import { inlineSuggestCommitId } from './commandIds.js'; import { InlineCompletionContextKeys } from './inlineCompletionContextKeys.js'; import { InlineCompletionsView } from '../view/inlineCompletionsView.js'; +import { getOuterEditor } from '../../../../browser/widget/codeEditor/embeddedCodeEditorWidget.js'; export class InlineCompletionsController extends Disposable { private static readonly _instances = new Set(); @@ -43,6 +44,17 @@ export class InlineCompletionsController extends Disposable { public static hot = createHotClass(InlineCompletionsController); public static ID = 'editor.contrib.inlineCompletionsController'; + /** + * Find the controller in the focused editor or in the outer editor (if applicable) + */ + public static getInFocusedEditorOrParent(accessor: ServicesAccessor): InlineCompletionsController | null { + const outerEditor = getOuterEditor(accessor); + if (!outerEditor) { + return null; + } + return InlineCompletionsController.get(outerEditor); + } + public static get(editor: ICodeEditor): InlineCompletionsController | null { return hotClassGetOriginalInstance(editor.getContribution(InlineCompletionsController.ID)); } @@ -70,6 +82,13 @@ export class InlineCompletionsController extends Disposable { { min: 50, max: 50 } ); + private readonly _focusIsInMenu = observableValue(this, false); + private readonly _focusIsInEditorOrMenu = derived(this, reader => { + const editorHasFocus = this._editorObs.isFocused.read(reader); + const menuHasFocus = this._focusIsInMenu.read(reader); + return editorHasFocus || menuHasFocus; + }); + private readonly _cursorIsInIndentation = derived(this, reader => { const cursorPos = this._editorObs.cursorPosition.read(reader); if (cursorPos === null) { return false; } @@ -102,7 +121,7 @@ export class InlineCompletionsController extends Disposable { private readonly _hideInlineEditOnSelectionChange = this._editorObs.getOption(EditorOption.inlineSuggest).map(val => true); - protected readonly _view = this._register(new InlineCompletionsView(this.editor, this.model, this._instantiationService)); + protected readonly _view = this._register(new InlineCompletionsView(this.editor, this.model, this._focusIsInMenu, this._instantiationService)); constructor( public readonly editor: ICodeEditor, @@ -178,7 +197,12 @@ export class InlineCompletionsController extends Disposable { } })); - this._register(this.editor.onDidBlurEditorWidget(() => { + this._register(autorun(reader => { + const isFocused = this._focusIsInEditorOrMenu.read(reader); + if (isFocused) { + return; + } + // This is a hidden setting very useful for debugging if (this._contextKeyService.getContextKeyValue('accessibleViewIsShown') || this._configurationService.getValue('editor.inlineSuggest.keepOnBlur') diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index a15005e75fa..111013977f6 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { mapFindFirst } from '../../../../../base/common/arraysFind.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; import { itemsEquals } from '../../../../../base/common/equals.js'; import { BugIndicatingError, onUnexpectedError, onUnexpectedExternalError } from '../../../../../base/common/errors.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable, IReader, ITransaction, autorun, derived, derivedHandleChanges, derivedOpts, observableSignal, observableValue, recomputeInitiallyAndOnChange, subtransaction, transaction } from '../../../../../base/common/observable.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, IObservableWithChange, IReader, ITransaction, autorun, derived, derivedHandleChanges, derivedOpts, observableSignal, observableValue, recomputeInitiallyAndOnChange, subtransaction, transaction } from '../../../../../base/common/observable.js'; import { commonPrefixLength, firstNonWhitespaceIndex } from '../../../../../base/common/strings.js'; import { isDefined } from '../../../../../base/common/types.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -26,11 +27,11 @@ import { TextLength } from '../../../../common/core/textLength.js'; import { ScrollType } from '../../../../common/editorCommon.js'; import { Command, InlineCompletion, InlineCompletionContext, InlineCompletionTriggerKind, PartialAcceptTriggerKind } from '../../../../common/languages.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; -import { EndOfLinePreference, ITextModel } from '../../../../common/model.js'; +import { EndOfLinePreference, IModelDecorationOptions, ITextModel, TrackedRangeStickiness } from '../../../../common/model.js'; import { IFeatureDebounceInformation } from '../../../../common/services/languageFeatureDebounce.js'; import { IModelContentChangedEvent } from '../../../../common/textModelEvents.js'; import { SnippetController2 } from '../../../snippet/browser/snippetController2.js'; -import { addPositions, getEndPositionsAfterApplying, substringPos, subtractPositions } from '../utils.js'; +import { addPositions, getEndPositionsAfterApplying, getModifiedRangesAfterApplying, substringPos, subtractPositions } from '../utils.js'; import { computeGhostText } from './computeGhostText.js'; import { GhostText, GhostTextOrReplacement, ghostTextOrReplacementEquals, ghostTextsOrReplacementsEqual } from './ghostText.js'; import { InlineCompletionWithUpdatedRange, InlineCompletionsSource } from './inlineCompletionsSource.js'; @@ -54,7 +55,13 @@ export class InlineCompletionsModel extends Disposable { private readonly _editorObs = observableCodeEditor(this._editor); - private readonly _onlyShowWhenCloseToCursor = this._editorObs.getOption(EditorOption.inlineSuggest).map(v => !!v.edits.experimental.onlyShowWhenCloseToCursor); + private readonly _acceptCompletionDecorationTimer = this._register(new MutableDisposable()); + private readonly _acceptCompletionDecoration: IModelDecorationOptions = { + description: 'inline-completion-accepted', + className: 'inlineCompletionAccepted', + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges + }; + private readonly _suggestPreviewEnabled = this._editorObs.getOption(EditorOption.suggest).map(v => v.preview); private readonly _suggestPreviewMode = this._editorObs.getOption(EditorOption.suggest).map(v => v.previewMode); private readonly _inlineSuggestMode = this._editorObs.getOption(EditorOption.inlineSuggest).map(v => v.mode); @@ -63,7 +70,7 @@ export class InlineCompletionsModel extends Disposable { constructor( public readonly textModel: ITextModel, private readonly _selectedSuggestItem: IObservable, - public readonly _textModelVersionId: IObservable, + public readonly _textModelVersionId: IObservableWithChange, private readonly _positions: IObservable, private readonly _debounceValue: IFeatureDebounceInformation, private readonly _enabled: IObservable, @@ -358,12 +365,13 @@ export class InlineCompletionsModel extends Disposable { const cursorPos = this.primaryPosition.read(reader); const cursorAtInlineEdit = LineRange.fromRangeInclusive(edit.range).addMargin(1, 1).contains(cursorPos.lineNumber); + const cursorInsideShowRange = cursorAtInlineEdit || (item.inlineEdit.inlineCompletion.cursorShowRange?.containsPosition(cursorPos) ?? true); - const cursorDist = LineRange.fromRange(edit.range).distanceToLine(this.primaryPosition.read(reader).lineNumber); - - if (this._onlyShowWhenCloseToCursor.read(reader) && cursorDist > 3 && !item.inlineEdit.request.isExplicitRequest && !this._inAcceptFlow.read(reader)) { + if (!cursorInsideShowRange && !this._inAcceptFlow.read(reader)) { return undefined; } + + const cursorDist = LineRange.fromRange(edit.range).distanceToLine(this.primaryPosition.read(reader).lineNumber); const disableCollapsing = true; const currentItemIsCollapsed = !disableCollapsing && (cursorDist > 1 && this._collapsedInlineEditId.read(reader) === item.inlineEdit.semanticId); @@ -573,6 +581,8 @@ export class InlineCompletionsModel extends Disposable { completion.source.addRef(); } + this._acceptCompletionDecorationTimer.clear(); + editor.pushUndoStop(); if (completion.snippetInfo) { editor.executeEdits( @@ -586,12 +596,18 @@ export class InlineCompletionsModel extends Disposable { SnippetController2.get(editor)?.insert(completion.snippetInfo.snippet, { undoStopBefore: false }); } else { const edits = state.edits; - const selections = getEndPositionsAfterApplying(edits).map(p => Selection.fromPositions(p)); + const modifiedRanges = getModifiedRangesAfterApplying(edits); + const selections = modifiedRanges.map(r => Selection.fromPositions(r.getEndPosition())); editor.executeEdits('inlineSuggestion.accept', [ ...edits.map(edit => EditOperation.replace(edit.range, edit.text)), ...completion.additionalTextEdits ]); editor.setSelections(selections, 'inlineCompletionAccept'); + + if (state.kind === 'inlineEdit') { + const acceptEditsDecorations = editor.createDecorationsCollection(modifiedRanges.map(r => ({ range: r, options: this._acceptCompletionDecoration }))); + this._acceptCompletionDecorationTimer.value = disposableTimeout(() => acceptEditsDecorations.clear(), 2500); + } } // Reset before invoking the command, as the command might cause a follow up trigger (which we don't want to reset). diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEditsAdapter.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEditsAdapter.ts index 8d651a32a85..cf034658647 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEditsAdapter.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEditsAdapter.ts @@ -58,6 +58,7 @@ export class InlineEditsAdapter extends Disposable { const inlineEdits = await Promise.all(allInlineEditProvider.map(async provider => { const result = await provider.provideInlineEdit(model, { triggerKind: InlineEditTriggerKind.Automatic, + requestUuid: context.requestUuid }, token); if (!result) { return undefined; } return { result, provider }; @@ -69,6 +70,7 @@ export class InlineEditsAdapter extends Disposable { items: definedEdits.map(e => { return { range: e.result.range, + showRange: e.result.showRange, insertText: e.result.text, command: e.result.accepted, shownCommand: e.result.shown, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts index a240f67595d..d6b357f0b14 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts @@ -330,6 +330,7 @@ export class InlineCompletionItem { range, insertText, snippetInfo, + Range.lift(inlineCompletion.showRange) ?? undefined, inlineCompletion.additionalTextEdits || getReadonlyEmptyArray(), inlineCompletion, source, @@ -345,6 +346,7 @@ export class InlineCompletionItem { readonly range: Range, readonly insertText: string, readonly snippetInfo: SnippetInfo | undefined, + readonly cursorShowRange: Range | undefined, readonly additionalTextEdits: readonly ISingleEditOperation[], @@ -380,6 +382,7 @@ export class InlineCompletionItem { updatedRange, this.insertText, this.snippetInfo, + this.cursorShowRange, this.additionalTextEdits, this.sourceInlineCompletion, this.source, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts index 0f37508a9a2..4541e90d9fa 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts @@ -53,11 +53,15 @@ export function substringPos(text: string, pos: Position): string { return text.substring(offset); } -export function getEndPositionsAfterApplying(edits: readonly SingleTextEdit[]): Position[] { +export function getModifiedRangesAfterApplying(edits: readonly SingleTextEdit[]): Range[] { const sortPerm = Permutation.createSortPermutation(edits, compareBy(e => e.range, Range.compareRangesUsingStarts)); const edit = new TextEdit(sortPerm.apply(edits)); const sortedNewRanges = edit.getNewRanges(); - const newRanges = sortPerm.inverse().apply(sortedNewRanges); + return sortPerm.inverse().apply(sortedNewRanges); +} + +export function getEndPositionsAfterApplying(edits: readonly SingleTextEdit[]): Position[] { + const newRanges = getModifiedRangesAfterApplying(edits); return newRanges.map(range => range.getEndPosition()); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts index baaf3c08205..faa3c671e12 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts @@ -6,7 +6,7 @@ import { createStyleSheetFromObservable } from '../../../../../base/browser/domObservable.js'; import { readHotReloadableExport } from '../../../../../base/common/hotReloadHelpers.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { derived, mapObservableArrayCached, derivedDisposable, constObservable, derivedObservableWithCache, IObservable } from '../../../../../base/common/observable.js'; +import { derived, mapObservableArrayCached, derivedDisposable, constObservable, derivedObservableWithCache, IObservable, ISettableObservable } from '../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor } from '../../../../browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../browser/observableCodeEditor.js'; @@ -38,7 +38,7 @@ export class InlineCompletionsView extends Disposable { if (!this._everHadInlineEdit.read(reader)) { return undefined; } - return this._instantiationService.createInstance(InlineEditsViewAndDiffProducer.hot.read(reader), this._editor, this._inlineEdit, this._model); + return this._instantiationService.createInstance(InlineEditsViewAndDiffProducer.hot.read(reader), this._editor, this._inlineEdit, this._model, this._focusIsInMenu); }) .recomputeInitiallyAndOnChange(this._store); @@ -48,7 +48,8 @@ export class InlineCompletionsView extends Disposable { constructor( private readonly _editor: ICodeEditor, private readonly _model: IObservable, - @IInstantiationService private readonly _instantiationService: IInstantiationService + private readonly _focusIsInMenu: ISettableObservable, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/gutterIndicatorMenu.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/gutterIndicatorMenu.ts new file mode 100644 index 00000000000..afbcc2b58d5 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/gutterIndicatorMenu.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { KeybindingLabel, unthemedKeybindingLabelOptions } from '../../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ResolvedKeybinding } from '../../../../../../base/common/keybindings.js'; +import { IObservable, autorun, constObservable, derived, derivedWithStore, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; +import { OS } from '../../../../../../base/common/platform.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { Command } from '../../../../../common/languages.js'; +import { AcceptInlineCompletion, HideInlineCompletion, JumpToNextInlineEdit } from '../../controller/commands.js'; +import { ChildNode, FirstFnArg, LiveElement, n } from './utils.js'; + +export class GutterIndicatorMenuContent { + constructor( + private readonly _menuTitle: IObservable, + private readonly _selectionOverride: IObservable<'jump' | 'accept' | undefined>, + private readonly _close: (focusEditor: boolean) => void, + private readonly _extensionCommands: IObservable, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @ICommandService private readonly _commandService: ICommandService, + ) { + } + + public toDisposableLiveElement(): LiveElement { + return this._createHoverContent().toDisposableLiveElement(); + } + + private _createHoverContent() { + const activeElement = observableValue('active', undefined); + const activeElementOrDefault = derived(reader => this._selectionOverride.read(reader) ?? activeElement.read(reader)); + + const createOptionArgs = (options: { id: string; title: string; icon: ThemeIcon; commandId: string; commandArgs?: unknown[] }): FirstFnArg => { + return { + title: options.title, + icon: options.icon, + keybinding: this._getKeybinding(options.commandArgs ? undefined : options.commandId), + isActive: activeElementOrDefault.map(v => v === options.id), + onHoverChange: v => activeElement.set(v ? options.id : undefined, undefined), + onAction: () => { + this._close(true); + return this._commandService.executeCommand(options.commandId, ...(options.commandArgs ?? [])); + }, + }; + }; + + // TODO make this menu contributable! + return hoverContent([ + header(this._menuTitle), + option(createOptionArgs({ id: 'jump', title: localize('goto', "Go To"), icon: Codicon.arrowRight, commandId: new JumpToNextInlineEdit().id })), + option(createOptionArgs({ id: 'accept', title: localize('accept', "Accept"), icon: Codicon.check, commandId: new AcceptInlineCompletion().id })), + option(createOptionArgs({ id: 'reject', title: localize('reject', "Reject"), icon: Codicon.close, commandId: new HideInlineCompletion().id })), + separator(), + this._extensionCommands?.map(c => c && c.length > 0 ? [ + ...c.map(c => option(createOptionArgs({ id: c.id, title: c.title, icon: Codicon.symbolEvent, commandId: c.id, commandArgs: c.arguments }))), + separator() + ] : []), + option(createOptionArgs({ id: 'settings', title: localize('settings', "Settings"), icon: Codicon.gear, commandId: 'workbench.action.openSettings', commandArgs: ['inlineSuggest.edits'] })), + ]); + } + + private _getKeybinding(commandId: string | undefined) { + if (!commandId) { + return constObservable(undefined); + } + return observableFromEvent(this._contextKeyService.onDidChangeContext, () => this._keybindingService.lookupKeybinding(commandId)); // TODO: use contextkeyservice to use different renderings + } +} + +function hoverContent(content: ChildNode) { + return n.div({ + class: 'content', + style: { + margin: 4, + minWidth: 150, + } + }, content); +} + +function header(title: string | IObservable) { + return n.div({ + class: 'header', + style: { + color: 'var(--vscode-descriptionForeground)', + fontSize: '12px', + fontWeight: '600', + padding: '0 10px', + lineHeight: 26, + } + }, [title]); +} + +function option(props: { + title: string; + icon: ThemeIcon; + keybinding: IObservable; + isActive?: IObservable; + onHoverChange?: (isHovered: boolean) => void; + onAction?: () => void; +}) { + return derivedWithStore((_reader, store) => n.div({ + class: ['monaco-menu-option', props.isActive?.map(v => v && 'active')], + onmouseenter: () => props.onHoverChange?.(true), + onmouseleave: () => props.onHoverChange?.(false), + onclick: props.onAction, + onkeydown: e => { + if (e.key === 'Enter') { + props.onAction?.(); + } + }, + tabIndex: 0, + }, [ + n.elem('span', { + style: { + fontSize: 16, + display: 'flex', + } + }, [renderIcon(props.icon)]), + n.elem('span', {}, [props.title]), + n.div({ + style: { marginLeft: 'auto', opacity: '0.6' }, + ref: elem => { + const keybindingLabel = store.add(new KeybindingLabel(elem, OS, { disableTitle: true, ...unthemedKeybindingLabelOptions })); + store.add(autorun(reader => { + keybindingLabel.set(props.keybinding.read(reader)); + })); + } + }) + ])); +} + +function separator() { + return n.div({ + class: 'menu-separator', + style: { + color: 'var(--vscode-editorActionList-foreground)', + padding: '2px 0', + } + }, n.div({ + style: { + borderBottom: '1px solid var(--vscode-editorHoverWidget-border)', + } + })); +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/gutterIndicatorView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/gutterIndicatorView.ts index 739f12b4feb..847f3a87ede 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/gutterIndicatorView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/gutterIndicatorView.ts @@ -5,27 +5,57 @@ import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { IObservable, constObservable, derived, observableFromEvent } from '../../../../../../base/common/observable.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IObservable, ISettableObservable, autorun, constObservable, derived, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { buttonBackground, buttonForeground, buttonSecondaryBackground, buttonSecondaryForeground } from '../../../../../../platform/theme/common/colorRegistry.js'; import { registerColor, transparent } from '../../../../../../platform/theme/common/colorUtils.js'; import { ObservableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; import { Rect } from '../../../../../browser/rect.js'; +import { HoverService } from '../../../../../browser/services/hoverService/hoverService.js'; +import { HoverWidget } from '../../../../../browser/services/hoverService/hoverWidget.js'; import { EditorOption } from '../../../../../common/config/editorOptions.js'; import { LineRange } from '../../../../../common/core/lineRange.js'; import { OffsetRange } from '../../../../../common/core/offsetRange.js'; import { StickyScrollController } from '../../../../stickyScroll/browser/stickyScrollController.js'; import { InlineCompletionsModel } from '../../model/inlineCompletionsModel.js'; +import { GutterIndicatorMenuContent } from './gutterIndicatorMenu.js'; import { mapOutFalsy, n, rectToProps } from './utils.js'; +import { localize } from '../../../../../../nls.js'; +import { trackFocus } from '../../../../../../base/browser/dom.js'; +export const inlineEditIndicatorPrimaryForeground = registerColor( + 'inlineEdit.gutterIndicator.primaryForeground', + buttonForeground, + localize('inlineEdit.gutterIndicator.primaryForeground', 'Foreground color for the primary inline edit gutter indicator.') +); +export const inlineEditIndicatorPrimaryBackground = registerColor( + 'inlineEdit.gutterIndicator.primaryBackground', + buttonBackground, + localize('inlineEdit.gutterIndicator.primaryBackground', 'Background color for the primary inline edit gutter indicator.') +); -export const inlineEditIndicatorPrimaryForeground = registerColor('inlineEdit.gutterIndicator.primaryForeground', buttonForeground, 'Foreground color for the primary inline edit gutter indicator.'); -export const inlineEditIndicatorPrimaryBackground = registerColor('inlineEdit.gutterIndicator.primaryBackground', buttonBackground, 'Background color for the primary inline edit gutter indicator.'); +export const inlineEditIndicatorSecondaryForeground = registerColor( + 'inlineEdit.gutterIndicator.secondaryForeground', + buttonSecondaryForeground, + localize('inlineEdit.gutterIndicator.secondaryForeground', 'Foreground color for the secondary inline edit gutter indicator.') +); +export const inlineEditIndicatorSecondaryBackground = registerColor( + 'inlineEdit.gutterIndicator.secondaryBackground', + buttonSecondaryBackground, + localize('inlineEdit.gutterIndicator.secondaryBackground', 'Background color for the secondary inline edit gutter indicator.') +); -export const inlineEditIndicatorSecondaryForeground = registerColor('inlineEdit.gutterIndicator.secondaryForeground', buttonSecondaryForeground, 'Foreground color for the secondary inline edit gutter indicator.'); -export const inlineEditIndicatorSecondaryBackground = registerColor('inlineEdit.gutterIndicator.secondaryBackground', buttonSecondaryBackground, 'Background color for the secondary inline edit gutter indicator.'); - -export const inlineEditIndicatorsuccessfulForeground = registerColor('inlineEdit.gutterIndicator.successfulForeground', buttonForeground, 'Foreground color for the successful inline edit gutter indicator.'); -export const inlineEditIndicatorsuccessfulBackground = registerColor('inlineEdit.gutterIndicator.successfulBackground', { light: '#2e825c', dark: '#2e825c', hcLight: '#2e825c', hcDark: '#2e825c' }, 'Background color for the successful inline edit gutter indicator.'); +export const inlineEditIndicatorsuccessfulForeground = registerColor( + 'inlineEdit.gutterIndicator.successfulForeground', + buttonForeground, + localize('inlineEdit.gutterIndicator.successfulForeground', 'Foreground color for the successful inline edit gutter indicator.') +); +export const inlineEditIndicatorsuccessfulBackground = registerColor( + 'inlineEdit.gutterIndicator.successfulBackground', + { light: '#2e825c', dark: '#2e825c', hcLight: '#2e825c', hcDark: '#2e825c' }, + localize('inlineEdit.gutterIndicator.successfulBackground', 'Background color for the successful inline edit gutter indicator.') +); export const inlineEditIndicatorBackground = registerColor( 'inlineEdit.gutterIndicator.background', @@ -35,15 +65,18 @@ export const inlineEditIndicatorBackground = registerColor( dark: transparent('tab.inactiveBackground', 0.5), light: '#5f5f5f18', }, - 'Background color for the inline edit gutter indicator.' + localize('inlineEdit.gutterIndicator.background', 'Background color for the inline edit gutter indicator.') ); - export class InlineEditsGutterIndicator extends Disposable { constructor( private readonly _editorObs: ObservableCodeEditor, private readonly _originalRange: IObservable, private readonly _model: IObservable, + private readonly _shouldShowHover: IObservable, + private readonly _focusIsInMenu: ISettableObservable, + @IHoverService private readonly _hoverService: HoverService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -53,6 +86,14 @@ export class InlineEditsGutterIndicator extends Disposable { allowEditorOverflow: false, minContentWidthInPx: constObservable(0), })); + + this._register(autorun(reader => { + if (this._shouldShowHover.read(reader)) { + this._showHover(); + } else { + this._hoverService.hideHover(); + } + })); } private readonly _originalRangeObs = mapOutFalsy(this._originalRange); @@ -77,7 +118,8 @@ export class InlineEditsGutterIndicator extends Disposable { const layout = this._editorObs.layoutInfo.read(reader); - const fullViewPort = Rect.fromLeftTopRightBottom(0, 0, layout.width, layout.height); + const bottomPadding = 1; + const fullViewPort = Rect.fromLeftTopRightBottom(0, 0, layout.width, layout.height - bottomPadding); const viewPortWithStickyScroll = fullViewPort.withTop(this._stickyScrollHeight.read(reader)); const targetVertRange = s.lineOffsetRange.read(reader); @@ -108,44 +150,112 @@ export class InlineEditsGutterIndicator extends Disposable { private readonly _tabAction = derived(this, reader => { const m = this._model.read(reader); - if (m && m.tabShouldJumpToInlineEdit.read(reader)) { return 'jump' as const; } - if (m && m.tabShouldAcceptInlineEdit.read(reader)) { return 'accept' as const; } + if (this._editorObs.isFocused.read(reader)) { + if (m && m.tabShouldJumpToInlineEdit.read(reader)) { return 'jump' as const; } + if (m && m.tabShouldAcceptInlineEdit.read(reader)) { return 'accept' as const; } + } return 'inactive' as const; }); private readonly _onClickAction = derived(this, reader => { if (this._layout.map(d => d && d.docked).read(reader)) { return { - label: 'Click to accept inline edit', - action: () => { this._model.get()?.accept(); } + selectionOverride: 'accept' as const, + action: () => { + this._editorObs.editor.focus(); + this._model.get()?.accept(); + } }; } else { return { - label: 'Click to jump to inline edit', - action: () => { this._model.get()?.jump(); } + selectionOverride: 'jump' as const, + action: () => { + this._editorObs.editor.focus(); + this._model.get()?.jump(); + } }; } }); + private readonly _iconRef = n.ref(); + private _hoverVisible: boolean = false; + private readonly _isHoveredOverIcon = observableValue(this, false); + private readonly _hoverSelectionOverride = derived(this, reader => this._isHoveredOverIcon.read(reader) ? this._onClickAction.read(reader).selectionOverride : undefined); + + private _showHover(): void { + if (this._hoverVisible) { + return; + } + + const displayName = derived(this, reader => { + const state = this._model.read(reader)?.inlineEditState; + const item = state?.read(reader); + const completionSource = item?.inlineCompletion?.inlineCompletion.source; + // TODO: expose the provider (typed) and expose the provider the edit belongs totyping and get correct edit + const displayName = (completionSource?.inlineCompletions as any).edits[0]?.provider?.displayName ?? localize('inlineEdit', "Inline Edit"); + return displayName; + }); + + const disposableStore = new DisposableStore(); + const content = disposableStore.add(this._instantiationService.createInstance( + GutterIndicatorMenuContent, + displayName, + this._hoverSelectionOverride, + (focusEditor) => { + if (focusEditor) { + this._editorObs.editor.focus(); + } + h?.dispose(); + }, + this._model.map((m, r) => m?.state.read(r)?.inlineCompletion?.inlineCompletion.source.inlineCompletions.commands), + ).toDisposableLiveElement()); + + const focusTracker = disposableStore.add(trackFocus(content.element)); + disposableStore.add(focusTracker.onDidBlur(() => this._focusIsInMenu.set(false, undefined))); + disposableStore.add(focusTracker.onDidFocus(() => this._focusIsInMenu.set(true, undefined))); + disposableStore.add(toDisposable(() => this._focusIsInMenu.set(false, undefined))); + + const h = this._hoverService.showHover({ + target: this._iconRef.element, + content: content.element, + }) as HoverWidget | undefined; + if (h) { + this._hoverVisible = true; + h.onDispose(() => { // TODO:@hediet fix leak + disposableStore.dispose(); + this._hoverVisible = false; + }); + } else { + disposableStore.dispose(); + } + } + private readonly _indicator = n.div({ class: 'inline-edits-view-gutter-indicator', onclick: () => this._onClickAction.get().action(), - title: this._onClickAction.map(a => a.label), + tabIndex: 0, style: { position: 'absolute', overflow: 'visible', }, - }, mapOutFalsy(this._layout).map(l => !l ? [] : [ + }, mapOutFalsy(this._layout).map(layout => !layout ? [] : [ n.div({ style: { position: 'absolute', background: 'var(--vscode-inlineEdit-gutterIndicator-background)', borderRadius: '4px', - ...rectToProps(reader => l.read(reader).rect), + ...rectToProps(reader => layout.read(reader).rect), } }), n.div({ class: 'icon', + ref: this._iconRef, + onmouseenter: () => { + // TODO show hover when hovering ghost text etc. + this._isHoveredOverIcon.set(true, undefined); + this._showHover(); + }, + onmouseleave: () => { this._isHoveredOverIcon.set(false, undefined); }, style: { cursor: 'pointer', zIndex: '1000', @@ -168,12 +278,12 @@ export class InlineEditsGutterIndicator extends Disposable { display: 'flex', justifyContent: 'center', transition: 'background-color 0.2s ease-in-out', - ...rectToProps(reader => l.read(reader).iconRect), + ...rectToProps(reader => layout.read(reader).iconRect), } }, [ n.div({ style: { - rotate: l.map(l => { + rotate: layout.map(l => { switch (l.arrowDirection) { case 'right': return '0deg'; case 'bottom': return '90deg'; @@ -183,7 +293,7 @@ export class InlineEditsGutterIndicator extends Disposable { transition: 'rotate 0.2s ease-in-out', } }, [ - renderIcon(Codicon.arrowRight), + renderIcon(Codicon.arrowRight) ]) ]), ])).keepUpdated(this._store); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/indicatorView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/indicatorView.ts index f5845663658..00b129bbbfb 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/indicatorView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/indicatorView.ts @@ -13,16 +13,15 @@ import { registerColor } from '../../../../../../platform/theme/common/colorUtil import { ObservableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; import { OffsetRange } from '../../../../../common/core/offsetRange.js'; import { InlineCompletionsModel } from '../../model/inlineCompletionsModel.js'; +import { localize } from '../../../../../../nls.js'; export interface IInlineEditsIndicatorState { editTop: number; showAlways: boolean; } - - -export const inlineEditIndicatorForeground = registerColor('inlineEdit.indicator.foreground', buttonForeground, ''); -export const inlineEditIndicatorBackground = registerColor('inlineEdit.indicator.background', buttonBackground, ''); -export const inlineEditIndicatorBorder = registerColor('inlineEdit.indicator.border', buttonSeparator, ''); +export const inlineEditIndicatorForeground = registerColor('inlineEdit.indicator.foreground', buttonForeground, localize('inlineEdit.indicator.foreground', 'Foreground color for the inline edit indicator.')); +export const inlineEditIndicatorBackground = registerColor('inlineEdit.indicator.background', buttonBackground, localize('inlineEdit.indicator.background', 'Background color for the inline edit indicator.')); +export const inlineEditIndicatorBorder = registerColor('inlineEdit.indicator.border', buttonSeparator, localize('inlineEdit.indicator.border', 'Border color for the inline edit indicator.')); export class InlineEditsIndicator extends Disposable { private readonly _indicator = h('div.inline-edits-view-indicator', { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/sideBySideDiff.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/sideBySideDiff.ts index b34dc44ac1d..67c8dfb38fa 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/sideBySideDiff.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/sideBySideDiff.ts @@ -13,7 +13,7 @@ import { MenuId, MenuItemAction } from '../../../../../../platform/actions/commo import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { diffInserted, diffRemoved } from '../../../../../../platform/theme/common/colorRegistry.js'; -import { darken, lighten, registerColor } from '../../../../../../platform/theme/common/colorUtils.js'; +import { darken, lighten, registerColor, transparent } from '../../../../../../platform/theme/common/colorUtils.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { ICodeEditor } from '../../../../../browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; @@ -28,52 +28,52 @@ import { Range } from '../../../../../common/core/range.js'; import { Command } from '../../../../../common/languages.js'; import { ITextModel } from '../../../../../common/model.js'; import { StickyScrollController } from '../../../../stickyScroll/browser/stickyScrollController.js'; +import { InlineCompletionContextKeys } from '../../controller/inlineCompletionContextKeys.js'; import { CustomizedMenuWorkbenchToolBar } from '../../hintsWidget/inlineCompletionsHintsWidget.js'; import { PathBuilder, StatusBarViewItem, getOffsetForPos, mapOutFalsy, maxContentWidthInRange, n } from './utils.js'; import { InlineEditWithChanges } from './viewAndDiffProducer.js'; - +import { localize } from '../../../../../../nls.js'; export const originalBackgroundColor = registerColor( 'inlineEdit.originalBackground', Color.transparent, - '', + localize('inlineEdit.originalBackground', 'Background color for the original text in inline edits.'), true ); export const modifiedBackgroundColor = registerColor( 'inlineEdit.modifiedBackground', Color.transparent, - '', + localize('inlineEdit.modifiedBackground', 'Background color for the modified text in inline edits.'), true ); export const originalChangedLineBackgroundColor = registerColor( 'inlineEdit.originalChangedLineBackground', Color.transparent, - '', + localize('inlineEdit.originalChangedLineBackground', 'Background color for the changed lines in the original text of inline edits.'), true ); export const originalChangedTextOverlayColor = registerColor( 'inlineEdit.originalChangedTextBackground', diffRemoved, - '', + localize('inlineEdit.originalChangedTextBackground', 'Overlay color for the changed text in the original text of inline edits.'), true ); export const modifiedChangedLineBackgroundColor = registerColor( 'inlineEdit.modifiedChangedLineBackground', Color.transparent, - '', + localize('inlineEdit.modifiedChangedLineBackground', 'Background color for the changed lines in the modified text of inline edits.'), true ); export const modifiedChangedTextOverlayColor = registerColor( 'inlineEdit.modifiedChangedTextBackground', diffInserted, - '', + localize('inlineEdit.modifiedChangedTextBackground', 'Overlay color for the changed text in the modified text of inline edits.'), true ); - export const originalBorder = registerColor( 'inlineEdit.originalBorder', { @@ -82,7 +82,7 @@ export const originalBorder = registerColor( hcDark: editorLineHighlightBorder, hcLight: editorLineHighlightBorder }, - '' + localize('inlineEdit.originalBorder', 'Border color for the original text in inline edits.') ); export const modifiedBorder = registerColor( @@ -93,7 +93,19 @@ export const modifiedBorder = registerColor( hcDark: editorLineHighlightBorder, hcLight: editorLineHighlightBorder }, - '' + localize('inlineEdit.modifiedBorder', 'Border color for the modified text in inline edits.') +); + +export const acceptedDecorationBackgroundColor = registerColor( + 'inlineEdit.acceptedBackground', + { + light: transparent(modifiedChangedTextOverlayColor, 0.5), + dark: transparent(modifiedChangedTextOverlayColor, 0.5), + hcDark: modifiedChangedTextOverlayColor, + hcLight: modifiedChangedTextOverlayColor + }, + localize('inlineEdit.acceptedBackground', 'Background color for the accepted text after appying an inline edit.'), + true ); export class InlineEditsSideBySideDiff extends Disposable { @@ -147,9 +159,11 @@ export class InlineEditsSideBySideDiff extends Disposable { return; } - this.previewEditor.layout({ height: layoutInfo.editHeight, width: layoutInfo.previewEditorWidth }); - const topEdit = layoutInfo.edit1; + const bottomEdit = layoutInfo.edit2; + + this.previewEditor.layout({ height: bottomEdit.y - topEdit.y, width: layoutInfo.previewEditorWidth }); + this.previewEditor.updateOptions({ padding: { top: layoutInfo.padding, bottom: layoutInfo.padding } }); this._editorContainer.element.style.top = `${topEdit.y}px`; this._editorContainer.element.style.left = `${topEdit.x}px`; })); @@ -180,13 +194,15 @@ export class InlineEditsSideBySideDiff extends Disposable { private readonly _editorContainerTopLeft = observableValue | undefined>(this, undefined); private readonly _editorContainer = n.div({ - class: 'editorContainer', + class: ['editorContainer', this._editorObs.getOption(EditorOption.inlineSuggest).map(v => !v.edits.experimental.useGutterIndicator && 'showHover')], style: { position: 'absolute' }, }, [ n.div({ class: 'preview', style: {}, ref: this.previewRef }), n.div({ class: 'toolbar', style: {}, ref: this.toolbarRef }), ]).keepUpdated(this._store); + public readonly isHovered = this._editorContainer.getIsHovered(this._store); + protected readonly _toolbar = this._register(this._instantiationService.createInstance(CustomizedMenuWorkbenchToolBar, this.toolbarRef.element, MenuId.InlineEditsActions, { menuOptions: { renderShortTitle: true }, toolbarOptions: { @@ -259,6 +275,7 @@ export class InlineEditsSideBySideDiff extends Disposable { overviewRulerLanes: 0, lineDecorationsWidth: 0, lineNumbersMinChars: 0, + revealHorizontalRightPadding: 0, bracketPairColorization: { enabled: true, independentColorPoolPerBracketType: false }, scrollBeyondLastLine: false, scrollbar: { @@ -271,7 +288,12 @@ export class InlineEditsSideBySideDiff extends Disposable { wordWrapOverride1: 'off', wordWrapOverride2: 'off', }, - { contributions: [], }, + { + contextKeyValues: { + [InlineCompletionContextKeys.inInlineEditsPreviewEditor.key]: true, + }, + contributions: [], + }, this._editor )); @@ -378,8 +400,8 @@ export class InlineEditsSideBySideDiff extends Disposable { const editorContentAreaWidth = editorLayout.contentWidth - editorLayout.verticalScrollbarWidth; const editorBoundingClientRect = this._editor.getContainerDomNode().getBoundingClientRect(); const clientContentAreaRight = editorLayout.contentLeft + editorLayout.contentWidth + editorBoundingClientRect.left; - const remainingWidthRightOfContent = getWindow(this._editor.getContainerDomNode()).outerWidth - clientContentAreaRight; - const remainingWidthRightOfEditor = getWindow(this._editor.getContainerDomNode()).outerWidth - editorBoundingClientRect.right; + const remainingWidthRightOfContent = getWindow(this._editor.getContainerDomNode()).innerWidth - clientContentAreaRight; + const remainingWidthRightOfEditor = getWindow(this._editor.getContainerDomNode()).innerWidth - editorBoundingClientRect.right; const desiredMinimumWidth = Math.min(editorLayout.contentWidth * 0.3, previewContentWidth, 100); const IN_EDITOR_DISPLACEMENT = 0; const maximumAvailableWidth = IN_EDITOR_DISPLACEMENT + remainingWidthRightOfContent; @@ -433,8 +455,10 @@ export class InlineEditsSideBySideDiff extends Disposable { const previewEditorWidth = Math.min(previewContentWidth, remainingWidthRightOfEditor + editorLayout.width - editorLayout.contentLeft - codeEditDist); + const PADDING = 4; + const edit1 = new Point(left + codeEditDist, selectionTop); - const edit2 = new Point(left + codeEditDist, selectionTop + editHeight); + const edit2 = new Point(left + codeEditDist, selectionTop + editHeight + PADDING * 2); return { code1, @@ -449,7 +473,9 @@ export class InlineEditsSideBySideDiff extends Disposable { maxContentWidth, shouldShowShadow: clipped, desiredPreviewEditorScrollLeft, - previewEditorWidth + previewEditorWidth, + padding: PADDING, + borderRadius: PADDING }; }); @@ -477,13 +503,28 @@ export class InlineEditsSideBySideDiff extends Disposable { private readonly _extendedModifiedPath = derived(reader => { const layoutInfo = this._previewEditorLayoutInfo.read(reader); if (!layoutInfo) { return undefined; } - const width = layoutInfo.previewEditorWidth; + const width = layoutInfo.previewEditorWidth + layoutInfo.padding; + + + const topLeft = layoutInfo.edit1; + const topRight = layoutInfo.edit1.deltaX(width); + const topRightBefore = topRight.deltaX(-layoutInfo.borderRadius); + const topRightAfter = topRight.deltaY(layoutInfo.borderRadius); + + const bottomLeft = layoutInfo.edit2; + const bottomRight = bottomLeft.deltaX(width); + const bottomRightBefore = bottomRight.deltaY(-layoutInfo.borderRadius); + const bottomRightAfter = bottomRight.deltaX(-layoutInfo.borderRadius); + const extendedModifiedPathBuilder = new PathBuilder() .moveTo(layoutInfo.code1) - .lineTo(layoutInfo.edit1) - .lineTo(layoutInfo.edit1.deltaX(width)) - .lineTo(layoutInfo.edit2.deltaX(width)) - .lineTo(layoutInfo.edit2); + .lineTo(topLeft) + .lineTo(topRightBefore) + .curveTo(topRight, topRightAfter) + .lineTo(bottomRightBefore) + .curveTo(bottomRight, bottomRightAfter) + .lineTo(bottomLeft); + if (layoutInfo.edit2.y !== layoutInfo.code2.y) { extendedModifiedPathBuilder.curveTo2(layoutInfo.edit2.deltaX(-20), layoutInfo.code2.deltaX(20), layoutInfo.code2.deltaX(0)); } @@ -538,6 +579,19 @@ export class InlineEditsSideBySideDiff extends Disposable { }), ]).keepUpdated(this._store); + private readonly _middleBorderWithShadow = n.div({ + class: ['middleBorderWithShadow'], + style: { + position: 'absolute', + display: this._previewEditorLayoutInfo.map(i => i?.shouldShowShadow ? 'block' : 'none'), + width: '6px', + boxShadow: 'var(--vscode-scrollbar-shadow) -6px 0 6px -6px inset', + left: this._previewEditorLayoutInfo.map(i => i ? i.code1.x - 6 : 0), + top: this._previewEditorLayoutInfo.map(i => i ? i.code1.y : 0), + height: this._previewEditorLayoutInfo.map(i => i ? i.code2.y - i.code1.y : 0), + }, + }, []).keepUpdated(this._store); + private readonly _foregroundSvg = n.svg({ transform: 'translate(-0.5 -0.5)', style: { overflow: 'visible', pointerEvents: 'none', position: 'absolute' }, @@ -545,7 +599,6 @@ export class InlineEditsSideBySideDiff extends Disposable { const layoutInfoObs = mapOutFalsy(this._previewEditorLayoutInfo).read(reader); if (!layoutInfoObs) { return undefined; } - const shadowWidth = 6; return [ n.svgElem('path', { class: 'originalOverlay', @@ -572,46 +625,19 @@ export class InlineEditsSideBySideDiff extends Disposable { strokeWidth: '1px', } }), - - ...(!layoutInfoObs.map(i => i.shouldShowShadow).read(reader) - ? [ - n.svgElem('path', { - class: 'middleBorder', - d: layoutInfoObs.map(layoutInfo => new PathBuilder() - .moveTo(layoutInfo.code1) - .lineTo(layoutInfo.code2) - .build() - ), - style: { - stroke: 'var(--vscode-inlineEdit-modifiedBorder)', - strokeWidth: '1px' - } - }) - ] - : [ - n.svgElem('defs', {}, [ - n.svgElem('linearGradient', { id: 'gradient', x1: '0%', x2: '100%', }, [ - n.svgElem('stop', { - offset: '0%', - style: { stopColor: 'var(--vscode-inlineEdit-modifiedBorder)', stopOpacity: '0', } - }), - n.svgElem('stop', { - offset: '100%', - style: { stopColor: 'var(--vscode-inlineEdit-modifiedBorder)', stopOpacity: '1', } - }) - ]) - ]), - n.svgElem('rect', { - class: 'middleBorderWithShadow', - x: layoutInfoObs.map(layoutInfo => layoutInfo.code1.x - shadowWidth), - y: layoutInfoObs.map(layoutInfo => layoutInfo.code1.y), - width: shadowWidth, - height: layoutInfoObs.map(layoutInfo => layoutInfo.code2.y - layoutInfo.code1.y), - fill: 'url(#gradient)', - style: { strokeWidth: '0', stroke: 'transparent', } - }) - ] - ) + n.svgElem('path', { + class: 'middleBorder', + d: layoutInfoObs.map(layoutInfo => new PathBuilder() + .moveTo(layoutInfo.code1) + .lineTo(layoutInfo.code2) + .build() + ), + style: { + display: layoutInfoObs.map(i => i.shouldShowShadow ? 'none' : 'block'), + stroke: 'var(--vscode-inlineEdit-modifiedBorder)', + strokeWidth: '1px' + } + }) ]; })).keepUpdated(this._store); @@ -627,7 +653,7 @@ export class InlineEditsSideBySideDiff extends Disposable { }, }, [ this._backgroundSvg, - derived(this, reader => this._shouldOverflow.read(reader) ? [] : [this._foregroundBackgroundSvg, this._editorContainer, this._foregroundSvg]), + derived(this, reader => this._shouldOverflow.read(reader) ? [] : [this._foregroundBackgroundSvg, this._editorContainer, this._foregroundSvg, this._middleBorderWithShadow]), ]).keepUpdated(this._store); private readonly _overflowView = n.div({ @@ -638,6 +664,6 @@ export class InlineEditsSideBySideDiff extends Disposable { display: this._display, }, }, [ - derived(this, reader => this._shouldOverflow.read(reader) ? [this._foregroundBackgroundSvg, this._editorContainer, this._foregroundSvg] : []), + derived(this, reader => this._shouldOverflow.read(reader) ? [this._foregroundBackgroundSvg, this._editorContainer, this._foregroundSvg, this._middleBorderWithShadow] : []), ]).keepUpdated(this._store); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/utils.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/utils.ts index a70a0721491..a3b822d875d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/utils.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { getDomNodePagePosition, h, isSVGElement } from '../../../../../../base/browser/dom.js'; +import { addDisposableListener, getDomNodePagePosition, h, isSVGElement } from '../../../../../../base/browser/dom.js'; import { KeybindingLabel, unthemedKeybindingLabelOptions } from '../../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { numberComparator } from '../../../../../../base/common/arrays.js'; import { findFirstMin } from '../../../../../../base/common/arraysFind.js'; import { BugIndicatingError } from '../../../../../../base/common/errors.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { derived, derivedObservableWithCache, IObservable, IReader, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { OS } from '../../../../../../base/common/platform.js'; import { getIndentationLength, splitLines } from '../../../../../../base/common/strings.js'; @@ -164,7 +164,7 @@ export class PathBuilder { } type Value = T | IObservable; -type ValueOrList = Value | Value[]; +type ValueOrList = Value | ValueOrList[]; type ValueOrList2 = ValueOrList | ValueOrList>; type Element = HTMLElement | SVGElement; @@ -203,16 +203,18 @@ type SVGElementTagNameMap2 = { type DomTagCreateFn> = ( tag: TTag, - attributes: ElementAttributeKeys & { class?: Value; ref?: IRef }, - children?: ValueOrList2, + attributes: ElementAttributeKeys & { class?: ValueOrList; ref?: IRef }, + children?: ChildNode, ) => ObserverNode; type DomCreateFn = ( - attributes: ElementAttributeKeys & { class?: Value; ref?: IRef }, - children?: ValueOrList2, + attributes: ElementAttributeKeys & { class?: ValueOrList; ref?: IRef }, + children?: ChildNode, ) => ObserverNode; +export type ChildNode = ValueOrList2; + export namespace n { function nodeNs>(elementNs: string | undefined = undefined): DomTagCreateFn { return (tag, attributes, children) => { @@ -233,35 +235,37 @@ export namespace n { } export const div: DomCreateFn = node('div'); + + export const elem = nodeNs(undefined); + export const svg: DomCreateFn = node('svg', 'http://www.w3.org/2000/svg'); export const svgElem = nodeNs('http://www.w3.org/2000/svg'); - export function ref(): Ref { - return new Ref(); + export function ref(): IRefWithVal { + let value: T | undefined = undefined; + const result: IRef = function (val: T) { + value = val; + }; + Object.defineProperty(result, 'element', { + get() { + if (!value) { + throw new BugIndicatingError('Make sure the ref is set before accessing the element. Maybe wrong initialization order?'); + } + return value; + } + }); + return result as any; } } -export interface IRef { - setValue(value: T): void; +export type IRef = (value: T) => void; + +export interface IRefWithVal extends IRef { + readonly element: T; } -export class Ref implements IRef { - private _value: T | undefined = undefined; - - public setValue(value: T): void { - this._value = value; - } - - public get element(): T { - if (!this._value) { - throw new BugIndicatingError('Make sure the ref is set before accessing the element. Maybe wrong initialization order?'); - } - return this._value; - } -} - -export abstract class ObserverNode extends Disposable { +export abstract class ObserverNode { private readonly _deriveds: (IObservable)[] = []; protected readonly _element: T; @@ -270,50 +274,25 @@ export abstract class ObserverNode extends Disposab tag: string, ref: IRef | undefined, ns: string | undefined, - className: Value | undefined, + className: ValueOrList | undefined, attributes: ElementAttributeKeys, - children: ValueOrList2 | undefined, + children: ChildNode, ) { - super(); - this._element = (ns ? document.createElementNS(ns, tag) : document.createElement(tag)) as unknown as T; if (ref) { - ref.setValue(this._element); - } - - function setClassName(domNode: Element, className: string | string[]) { - if (isSVGElement(domNode)) { - if (Array.isArray(className)) { - domNode.setAttribute('class', className.join(' ')); - } else { - domNode.setAttribute('class', className); - } - } else { - if (Array.isArray(className)) { - domNode.className = className.join(' '); - } else { - domNode.className = className; - } - } + ref(this._element); } if (className) { - if (isObservable(className)) { + if (hasObservable(className)) { this._deriveds.push(derived(this, reader => { - setClassName(this._element, className.read(reader)); + setClassName(this._element, getClassName(className, reader)); })); } else { - setClassName(this._element, className); + setClassName(this._element, getClassName(className, undefined)); } } - function convertCssValue(value: any): string { - if (typeof value === 'number') { - return value + 'px'; - } - return value; - } - for (const [key, value] of Object.entries(attributes)) { if (key === 'style') { for (const [cssKey, cssValue] of Object.entries(value)) { @@ -347,36 +326,26 @@ export abstract class ObserverNode extends Disposab } } - function getChildren(reader: IReader | undefined, children: ValueOrList2): (Element | string)[] { - if (isObservable(children)) { - return getChildren(reader, children.read(reader)); - } - if (Array.isArray(children)) { - return children.flatMap(c => getChildren(reader, c)); - } - if (children instanceof ObserverNode) { - if (reader) { - children.readEffect(reader); - } - return [children._element]; - } - if (children) { - return [children]; - } - return []; - } - - function childrenIsObservable(children: ValueOrList2): boolean { - if (isObservable(children)) { - return true; - } - if (Array.isArray(children)) { - return children.some(c => childrenIsObservable(c)); - } - return false; - } - if (children) { + function getChildren(reader: IReader | undefined, children: ValueOrList2): (Element | string)[] { + if (isObservable(children)) { + return getChildren(reader, children.read(reader)); + } + if (Array.isArray(children)) { + return children.flatMap(c => getChildren(reader, c)); + } + if (children instanceof ObserverNode) { + if (reader) { + children.readEffect(reader); + } + return [children._element]; + } + if (children) { + return [children]; + } + return []; + } + const d = derived(this, reader => { this._element.replaceChildren(...getChildren(reader, children)); }); @@ -399,12 +368,104 @@ export abstract class ObserverNode extends Disposab }).recomputeInitiallyAndOnChange(store); return this as unknown as ObserverNodeWithElement; } + + /** + * Creates a live element that will keep the element updated as long as the returned object is not disposed. + */ + toDisposableLiveElement() { + const store = new DisposableStore(); + this.keepUpdated(store); + return new LiveElement(this._element, store); + } +} + + + +function setClassName(domNode: Element, className: string) { + if (isSVGElement(domNode)) { + domNode.setAttribute('class', className); + } else { + domNode.className = className; + } +} + +function resolve(value: ValueOrList, reader: IReader | undefined, cb: (val: T) => void): void { + if (isObservable(value)) { + cb(value.read(reader)); + return; + } + if (Array.isArray(value)) { + for (const v of value) { + resolve(v, reader, cb); + } + return; + } + cb(value as any); +} + +function getClassName(className: ValueOrList | undefined, reader: IReader | undefined): string { + let result = ''; + resolve(className, reader, val => { + if (val) { + if (result.length === 0) { + result = val; + } else { + result += ' ' + val; + } + } + }); + return result; +} + +function hasObservable(value: ValueOrList): boolean { + if (isObservable(value)) { + return true; + } + if (Array.isArray(value)) { + return value.some(v => hasObservable(v)); + } + return false; +} +function convertCssValue(value: any): string { + if (typeof value === 'number') { + return value + 'px'; + } + return value; +} + + +function childrenIsObservable(children: ValueOrList2): boolean { + if (isObservable(children)) { + return true; + } + if (Array.isArray(children)) { + return children.some(c => childrenIsObservable(c)); + } + return false; +} + +export class LiveElement { + constructor( + public readonly element: T, + private readonly _disposable: IDisposable, + ) { } + + dispose() { + this._disposable.dispose(); + } } export class ObserverNodeWithElement extends ObserverNode { public get element() { return this._element; } + + public getIsHovered(store: DisposableStore): IObservable { + const hovered = observableValue('hovered', false); + store.add(addDisposableListener(this._element, 'mouseenter', () => hovered.set(true, undefined))); + store.add(addDisposableListener(this._element, 'mouseleave', () => hovered.set(false, undefined))); + return hovered; + } } function setOrRemoveAttribute(element: Element, key: string, value: unknown) { @@ -479,3 +540,5 @@ export function rectToProps(fn: (reader: IReader) => Rect) { height: derived(reader => fn(reader).bottom - fn(reader).top), }; } + +export type FirstFnArg = T extends (arg: infer U) => any ? U : never; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css index e5531dc7b4b..05fc3ffa137 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css @@ -77,7 +77,7 @@ } .inline-edits-view { - &.toolbarDropdownVisible, .editorContainer:hover { + &.toolbarDropdownVisible, .editorContainer.showHover:hover { .toolbar { display: block; } @@ -165,3 +165,26 @@ border-left: solid var(--vscode-inlineEdit-modifiedChangedTextBackground) 3px; } } + +.monaco-menu-option { + color: var(--vscode-editorActionList-foreground); + font-size: 13px; + padding: 0 10px; + line-height: 26px; + display: flex; + gap: 8px; + align-items: center; + border-radius: 4px; + cursor: pointer; + + &.active { + background: var(--vscode-editorActionList-focusBackground); + color: var(--vscode-editorActionList-focusForeground); + outline: 1px solid var(--vscode-menu-selectionBorder, transparent); + outline-offset: -1px; + } +} + +.monaco-editor .inlineCompletionAccepted { + background-color: var(--vscode-inlineEdit-acceptedBackground); +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.ts index dfb11c8bebf..8e01c35c5bc 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { autorunWithStore, derived, IObservable, IReader, mapObservableArrayCached } from '../../../../../../base/common/observable.js'; +import { autorunWithStore, derived, IObservable, IReader, ISettableObservable, mapObservableArrayCached } from '../../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor } from '../../../../../browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../../browser/observableCodeEditor.js'; @@ -37,6 +37,7 @@ export class InlineEditsView extends Disposable { private readonly _editor: ICodeEditor, private readonly _edit: IObservable, private readonly _model: IObservable, + private readonly _focusIsInMenu: ISettableObservable, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -137,10 +138,13 @@ export class InlineEditsView extends Disposable { protected readonly _indicator = this._register(autorunWithStore((reader, store) => { if (this._useGutterIndicator.read(reader)) { - store.add(new InlineEditsGutterIndicator( + store.add(this._instantiationService.createInstance( + InlineEditsGutterIndicator, this._editorObs, this._uiState.map(s => s && s.originalDisplayRange), this._model, + this._sideBySide.isHovered, + this._focusIsInMenu, )); } else { store.add(new InlineEditsIndicator( diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/viewAndDiffProducer.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/viewAndDiffProducer.ts index f56b4d2c986..a0e9d4d1268 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/viewAndDiffProducer.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/viewAndDiffProducer.ts @@ -8,7 +8,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { equalsIfDefined, itemEquals } from '../../../../../../base/common/equals.js'; import { createHotClass } from '../../../../../../base/common/hotReloadHelpers.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { derivedDisposable, ObservablePromise, derived, IObservable, derivedOpts } from '../../../../../../base/common/observable.js'; +import { derivedDisposable, ObservablePromise, derived, IObservable, derivedOpts, ISettableObservable } from '../../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor } from '../../../../../browser/editorBrowser.js'; import { IDiffProviderFactoryService } from '../../../../../browser/widget/diffEditor/diffProviderFactoryService.js'; @@ -99,13 +99,14 @@ export class InlineEditsViewAndDiffProducer extends Disposable { private readonly _editor: ICodeEditor, private readonly _edit: IObservable, private readonly _model: IObservable, + private readonly _focusIsInMenu: ISettableObservable, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IDiffProviderFactoryService private readonly _diffProviderFactoryService: IDiffProviderFactoryService, @IModelService private readonly _modelService: IModelService ) { super(); - this._register(this._instantiationService.createInstance(InlineEditsView, this._editor, this._inlineEdit, this._model)); + this._register(this._instantiationService.createInstance(InlineEditsView, this._editor, this._inlineEdit, this._model, this._focusIsInMenu)); } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/wordReplacementView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/wordReplacementView.ts index 6e6f213d38a..9797502d7b0 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/wordReplacementView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/wordReplacementView.ts @@ -19,16 +19,16 @@ import { ILanguageService } from '../../../../../common/languages/language.js'; import { LineTokens } from '../../../../../common/tokens/lineTokens.js'; import { TokenArray } from '../../../../../common/tokens/tokenArray.js'; import { mapOutFalsy, n, rectToProps } from './utils.js'; - +import { localize } from '../../../../../../nls.js'; export const transparentHoverBackground = registerColor( 'inlineEdit.wordReplacementView.background', { light: transparent(editorHoverStatusBarBackground, 0.1), - dark: transparent(editorHoverStatusBarBackground, 0.5), + dark: transparent(editorHoverStatusBarBackground, 0.1), hcLight: transparent(editorHoverStatusBarBackground, 0.1), hcDark: transparent(editorHoverStatusBarBackground, 0.1), }, - 'Background color for the inline edit word replacement view.' + localize('inlineEdit.wordReplacementView.background', 'Background color for the inline edit word replacement view.') ); export class WordReplacementView extends Disposable { diff --git a/src/vs/editor/contrib/links/browser/getLinks.ts b/src/vs/editor/contrib/links/browser/getLinks.ts index cd2e19c756e..be8a31af119 100644 --- a/src/vs/editor/contrib/links/browser/getLinks.ts +++ b/src/vs/editor/contrib/links/browser/getLinks.ts @@ -70,6 +70,8 @@ export class Link implements ILink { export class LinksList { + static readonly Empty = new LinksList([]); + readonly links: Link[]; private readonly _disposables = new DisposableStore(); @@ -137,27 +139,31 @@ export class LinksList { } -export function getLinks(providers: LanguageFeatureRegistry, model: ITextModel, token: CancellationToken): Promise { - +export async function getLinks(providers: LanguageFeatureRegistry, model: ITextModel, token: CancellationToken): Promise { const lists: [ILinksList, LinkProvider][] = []; // ask all providers for links in parallel - const promises = providers.ordered(model).reverse().map((provider, i) => { - return Promise.resolve(provider.provideLinks(model, token)).then(result => { + const promises = providers.ordered(model).reverse().map(async (provider, i) => { + try { + const result = await provider.provideLinks(model, token); if (result) { lists[i] = [result, provider]; } - }, onUnexpectedExternalError); + } catch (err) { + onUnexpectedExternalError(err); + } }); - return Promise.all(promises).then(() => { - const result = new LinksList(coalesce(lists)); - if (!token.isCancellationRequested) { - return result; - } - result.dispose(); - return new LinksList([]); - }); + await Promise.all(promises); + + let res = new LinksList(coalesce(lists)); + + if (token.isCancellationRequested) { + res.dispose(); + res = LinksList.Empty; + } + + return res; } diff --git a/src/vs/editor/contrib/links/browser/links.ts b/src/vs/editor/contrib/links/browser/links.ts index ef1b67addd1..a07122f53b4 100644 --- a/src/vs/editor/contrib/links/browser/links.ts +++ b/src/vs/editor/contrib/links/browser/links.ts @@ -287,6 +287,10 @@ export class LinkDetector extends Disposable implements IEditorContribution { return null; } + public getAllLinkOccurrences(): LinkOccurrence[] { + return Object.values(this.currentOccurrences); + } + private isEnabled(mouseEvent: ClickLinkMouseEvent, withKey?: ClickLinkKeyboardEvent | null): boolean { return Boolean( (mouseEvent.target.type === MouseTargetType.CONTENT_TEXT) diff --git a/src/vs/editor/contrib/peekView/browser/peekView.ts b/src/vs/editor/contrib/peekView/browser/peekView.ts index 17084e7ae64..c774617241f 100644 --- a/src/vs/editor/contrib/peekView/browser/peekView.ts +++ b/src/vs/editor/contrib/peekView/browser/peekView.ts @@ -16,7 +16,6 @@ import * as objects from '../../../../base/common/objects.js'; import './media/peekViewWidget.css'; import { ICodeEditor } from '../../../browser/editorBrowser.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../browser/editorExtensions.js'; -import { ICodeEditorService } from '../../../browser/services/codeEditorService.js'; import { EmbeddedCodeEditorWidget } from '../../../browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; import { IEditorContribution } from '../../../common/editorCommon.js'; @@ -25,7 +24,7 @@ import * as nls from '../../../../nls.js'; import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator, IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { activeContrastBorder, contrastBorder, editorForeground, editorInfoForeground, registerColor } from '../../../../platform/theme/common/colorRegistry.js'; export const IPeekViewService = createDecorator('IPeekViewService'); @@ -79,14 +78,6 @@ class PeekContextController implements IEditorContribution { registerEditorContribution(PeekContextController.ID, PeekContextController, EditorContributionInstantiation.Eager); // eager because it needs to define a context key -export function getOuterEditor(accessor: ServicesAccessor): ICodeEditor | null { - const editor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); - if (editor instanceof EmbeddedCodeEditorWidget) { - return editor.getParentEditor(); - } - return editor; -} - export interface IPeekViewStyles extends IStyles { headerBackgroundColor?: Color; primaryHeadingColor?: Color; diff --git a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts index e1bf843a994..0ad604159e1 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts @@ -570,7 +570,7 @@ export class SuggestWidget implements IDisposable { try { this._list.splice(0, this._list.length, this._completionModel.items); this._setState(isFrozen ? State.Frozen : State.Open); - this._list.reveal(selectionIndex, 0); + this._list.reveal(selectionIndex, 0, selectionIndex === 0 ? 0 : this.getLayoutInfo().itemHeight * 0.33); this._list.setFocus(noFocus ? [] : [selectionIndex]); } finally { this._onDidFocus.resume(); diff --git a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts index 51988af7d17..a38f1639eea 100644 --- a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts +++ b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts @@ -29,7 +29,6 @@ export interface IOptions { frameColor?: Color | string; arrowColor?: Color; keepEditorSelection?: boolean; - allowUnlimitedHeight?: boolean; ordinal?: number; showInHiddenAreas?: boolean; } @@ -376,6 +375,11 @@ export abstract class ZoneWidget implements IHorizontalSashLayoutProvider { return result; } + /** Gets the maximum widget height in lines. */ + protected _getMaximumHeightInLines(): number | undefined { + return Math.max(12, (this.editor.getLayoutInfo().height / this.editor.getOption(EditorOption.lineHeight)) * 0.8); + } + private _showImpl(where: Range, heightInLines: number): void { const position = where.getStartPosition(); const layoutInfo = this.editor.getLayoutInfo(); @@ -389,8 +393,8 @@ export abstract class ZoneWidget implements IHorizontalSashLayoutProvider { const lineHeight = this.editor.getOption(EditorOption.lineHeight); // adjust heightInLines to viewport - if (!this.options.allowUnlimitedHeight) { - const maxHeightInLines = Math.max(12, (this.editor.getLayoutInfo().height / lineHeight) * 0.8); + const maxHeightInLines = this._getMaximumHeightInLines(); + if (maxHeightInLines !== undefined) { heightInLines = Math.min(heightInLines, maxHeightInLines); } @@ -493,7 +497,9 @@ export abstract class ZoneWidget implements IHorizontalSashLayoutProvider { // implement in subclass } - protected _relayout(newHeightInLines: number): void { + protected _relayout(_newHeightInLines: number): void { + const maxHeightInLines = this._getMaximumHeightInLines(); + const newHeightInLines = maxHeightInLines === undefined ? _newHeightInLines : Math.min(maxHeightInLines, _newHeightInLines); if (this._viewZone && this._viewZone.heightInLines !== newHeightInLines) { this.editor.changeViewZones(accessor => { if (this._viewZone) { diff --git a/src/vs/editor/test/browser/gpu/decorationCssRulerExtractor.test.ts b/src/vs/editor/test/browser/gpu/decorationCssRulerExtractor.test.ts index 5828d479ab7..c36d8ddadc1 100644 --- a/src/vs/editor/test/browser/gpu/decorationCssRulerExtractor.test.ts +++ b/src/vs/editor/test/browser/gpu/decorationCssRulerExtractor.test.ts @@ -5,7 +5,7 @@ import { deepStrictEqual } from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { DecorationCssRuleExtractor } from '../../../browser/gpu/decorationCssRuleExtractor.js'; +import { DecorationCssRuleExtractor } from '../../../browser/gpu/css/decorationCssRuleExtractor.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { $, getActiveDocument } from '../../../../base/browser/dom.js'; diff --git a/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts b/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts index d9144896afe..e8406d868f8 100644 --- a/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts +++ b/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts @@ -112,7 +112,7 @@ suite("CodeEditorWidget", () => { })); test("listener interaction (unforced)", () => { - let derived: IObservable; + let derived: IObservable; let log: Log; withEditorSetupTestFixture( (editor, disposables) => { @@ -143,7 +143,7 @@ suite("CodeEditorWidget", () => { }); test("listener interaction ()", () => { - let derived: IObservable; + let derived: IObservable; let log: Log; withEditorSetupTestFixture( (editor, disposables) => { diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index c1df5f99ffe..461852b5196 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4607,7 +4607,6 @@ declare namespace monaco.editor { useInterleavedLinesDiff?: 'never' | 'always' | 'afterJump'; useWordInsertionView?: 'never' | 'whenPossible'; useWordReplacementView?: 'never' | 'whenPossible'; - onlyShowWhenCloseToCursor?: boolean; useGutterIndicator?: boolean; }; }; @@ -7277,6 +7276,7 @@ declare namespace monaco.languages { */ readonly completeBracketPairs?: boolean; readonly isInlineEdit?: boolean; + readonly showRange?: IRange; } export interface InlineCompletions { @@ -7320,6 +7320,7 @@ declare namespace monaco.languages { * The current provider is only requested for completions if no provider with a preferred group id returned a result. */ yieldsToGroupIds?: InlineCompletionProviderGroupId[]; + displayName?: string; toString?(): string; } @@ -8060,7 +8061,7 @@ declare namespace monaco.languages { export interface CodeLensList { lenses: CodeLens[]; - dispose(): void; + dispose?(): void; } export interface CodeLensProvider { @@ -8164,6 +8165,7 @@ declare namespace monaco.languages { export interface IInlineEdit { text: string; range: IRange; + showRange?: IRange; accepted?: Command; rejected?: Command; shown?: Command; @@ -8180,6 +8182,7 @@ declare namespace monaco.languages { } export interface InlineEditProvider { + displayName?: string; provideInlineEdit(model: editor.ITextModel, context: IInlineEditContext, token: CancellationToken): ProviderResult; freeInlineEdit(edit: T): void; } diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 2b784b5bf5e..9f1452f4b65 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -5,7 +5,7 @@ import { IAction, SubmenuAction } from '../../../base/common/actions.js'; import { Event, MicrotaskEmitter } from '../../../base/common/event.js'; -import { DisposableStore, dispose, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { DisposableStore, dispose, IDisposable, markAsSingleton, toDisposable } from '../../../base/common/lifecycle.js'; import { LinkedList } from '../../../base/common/linkedList.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { ICommandAction, ICommandActionTitle, Icon, ILocalizedString } from '../../action/common/action.js'; @@ -397,11 +397,11 @@ export const MenuRegistry: IMenuRegistry = new class implements IMenuRegistry { this._commands.set(command.id, command); this._onDidChangeMenu.fire(MenuRegistryChangeEvent.for(MenuId.CommandPalette)); - return toDisposable(() => { + return markAsSingleton(toDisposable(() => { if (this._commands.delete(command.id)) { this._onDidChangeMenu.fire(MenuRegistryChangeEvent.for(MenuId.CommandPalette)); } - }); + })); } getCommand(id: string): ICommandAction | undefined { @@ -422,10 +422,10 @@ export const MenuRegistry: IMenuRegistry = new class implements IMenuRegistry { } const rm = list.push(item); this._onDidChangeMenu.fire(MenuRegistryChangeEvent.for(id)); - return toDisposable(() => { + return markAsSingleton(toDisposable(() => { rm(); this._onDidChangeMenu.fire(MenuRegistryChangeEvent.for(id)); - }); + })); } appendMenuItems(items: Iterable<{ id: MenuId; item: IMenuItem | ISubmenuItem }>): IDisposable { diff --git a/src/vs/platform/commands/common/commands.ts b/src/vs/platform/commands/common/commands.ts index 53e32c4842e..b6e7bc6fe50 100644 --- a/src/vs/platform/commands/common/commands.ts +++ b/src/vs/platform/commands/common/commands.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Iterable } from '../../../base/common/iterator.js'; import { IJSONSchema } from '../../../base/common/jsonSchema.js'; -import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { IDisposable, markAsSingleton, toDisposable } from '../../../base/common/lifecycle.js'; import { LinkedList } from '../../../base/common/linkedList.js'; import { TypeConstraint, validateConstraints } from '../../../base/common/types.js'; import { ILocalizedString } from '../../action/common/action.js'; @@ -121,7 +121,7 @@ export const CommandsRegistry: ICommandRegistry = new class implements ICommandR // tell the world about this command this._onDidRegisterCommand.fire(id); - return ret; + return markAsSingleton(ret); } registerCommandAlias(oldId: string, newId: string): IDisposable { diff --git a/src/vs/platform/configuration/common/configurationModels.ts b/src/vs/platform/configuration/common/configurationModels.ts index 400ae1033cb..468614153ed 100644 --- a/src/vs/platform/configuration/common/configurationModels.ts +++ b/src/vs/platform/configuration/common/configurationModels.ts @@ -727,7 +727,7 @@ export class Configuration { } getValue(section: string | undefined, overrides: IConfigurationOverrides, workspace: Workspace | undefined): any { - const consolidateConfigurationModel = this.getConsolidatedConfigurationModel(section, overrides, workspace); + const consolidateConfigurationModel = this.getConsolidatedConfigurationModel(overrides, workspace); return consolidateConfigurationModel.getValue(section); } @@ -755,7 +755,7 @@ export class Configuration { } inspect(key: string, overrides: IConfigurationOverrides, workspace: Workspace | undefined): IConfigurationValue { - const consolidateConfigurationModel = this.getConsolidatedConfigurationModel(key, overrides, workspace); + const consolidateConfigurationModel = this.getConsolidatedConfigurationModel(overrides, workspace); const folderConfigurationModel = this.getFolderConfigurationModelForResource(overrides.resource, workspace); const memoryConfigurationModel = overrides.resource ? this._memoryConfigurationByResource.get(overrides.resource) || this._memoryConfiguration : this._memoryConfiguration; const overrideIdentifiers = new Set(); @@ -965,13 +965,17 @@ export class Configuration { return this._folderConfigurations; } - private getConsolidatedConfigurationModel(section: string | undefined, overrides: IConfigurationOverrides, workspace: Workspace | undefined): ConfigurationModel { + private getConsolidatedConfigurationModel(overrides: IConfigurationOverrides, workspace: Workspace | undefined): ConfigurationModel { let configurationModel = this.getConsolidatedConfigurationModelForResource(overrides, workspace); if (overrides.overrideIdentifier) { configurationModel = configurationModel.override(overrides.overrideIdentifier); } - if (!this._policyConfiguration.isEmpty() && this._policyConfiguration.getValue(section) !== undefined) { - configurationModel = configurationModel.merge(this._policyConfiguration); + if (!this._policyConfiguration.isEmpty()) { + // clone by merging + configurationModel = configurationModel.merge(); + for (const key of this._policyConfiguration.keys) { + configurationModel.setValue(key, this._policyConfiguration.getValue(key)); + } } return configurationModel; } diff --git a/src/vs/platform/configuration/test/common/testConfigurationService.ts b/src/vs/platform/configuration/test/common/testConfigurationService.ts index c2e63fa4dfb..13d6c0b622a 100644 --- a/src/vs/platform/configuration/test/common/testConfigurationService.ts +++ b/src/vs/platform/configuration/test/common/testConfigurationService.ts @@ -64,12 +64,12 @@ export class TestConfigurationService implements IConfigurationService { } public inspect(key: string, overrides?: IConfigurationOverrides): IConfigurationValue { - const config = this.getValue(undefined, overrides); + const value = this.getValue(key, overrides); return { - value: getConfigurationValue(config, key), - defaultValue: getConfigurationValue(config, key), - userValue: getConfigurationValue(config, key), + value, + defaultValue: undefined, + userValue: value, overrideIdentifiers: this.overrideIdentifiers.get(key) }; } diff --git a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts index 6b854b0bd87..4bef58907b7 100644 --- a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts +++ b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts @@ -24,7 +24,7 @@ import { ExtensionSignatureVerificationCode, IAllowedExtensionsService } from './extensionManagement.js'; -import { areSameExtensions, ExtensionKey, getGalleryExtensionId, getGalleryExtensionTelemetryData, getLocalExtensionTelemetryData } from './extensionManagementUtil.js'; +import { areSameExtensions, ExtensionKey, getGalleryExtensionId, getGalleryExtensionTelemetryData, getLocalExtensionTelemetryData, isMalicious } from './extensionManagementUtil.js'; import { ExtensionType, IExtensionManifest, isApplicationScopedExtension, TargetPlatform } from '../../extensions/common/extensions.js'; import { areApiProposalsCompatible } from '../../extensions/common/extensionValidator.js'; import { ILogService } from '../../log/common/log.js'; @@ -639,7 +639,7 @@ export abstract class AbstractExtensionManagementService extends CommontExtensio let compatibleExtension: IGalleryExtension | null; const extensionsControlManifest = await this.getExtensionsControlManifest(); - if (extensionsControlManifest.malicious.some(identifier => areSameExtensions(extension.identifier, identifier))) { + if (isMalicious(extension.identifier, extensionsControlManifest)) { throw new ExtensionManagementError(nls.localize('malicious extension', "Can't install '{0}' extension since it was reported to be problematic.", extension.identifier.id), ExtensionManagementErrorCode.Malicious); } diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index 9a9157545ce..1262c42d93a 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -10,12 +10,12 @@ import { CancellationError, getErrorMessage, isCancellationError } from '../../. import { IPager } from '../../../base/common/paging.js'; import { isWeb, platform } from '../../../base/common/platform.js'; import { arch } from '../../../base/common/process.js'; -import { isBoolean } from '../../../base/common/types.js'; +import { isBoolean, isString } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { IHeaders, IRequestContext, IRequestOptions, isOfflineError } from '../../../base/parts/request/common/request.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; -import { getTargetPlatform, IExtensionGalleryService, IExtensionIdentifier, IExtensionInfo, IGalleryExtension, IGalleryExtensionAsset, IGalleryExtensionAssets, IGalleryExtensionVersion, InstallOperation, IQueryOptions, IExtensionsControlManifest, isNotWebExtensionInWebTargetPlatform, isTargetPlatformCompatible, ITranslation, SortBy, SortOrder, StatisticType, toTargetPlatform, WEB_EXTENSION_TAG, IExtensionQueryOptions, IDeprecationInfo, ISearchPrefferedResults, ExtensionGalleryError, ExtensionGalleryErrorCode, IProductVersion, UseUnpkgResourceApiConfigKey, IAllowedExtensionsService } from './extensionManagement.js'; +import { getTargetPlatform, IExtensionGalleryService, IExtensionIdentifier, IExtensionInfo, IGalleryExtension, IGalleryExtensionAsset, IGalleryExtensionAssets, IGalleryExtensionVersion, InstallOperation, IQueryOptions, IExtensionsControlManifest, isNotWebExtensionInWebTargetPlatform, isTargetPlatformCompatible, ITranslation, SortBy, SortOrder, StatisticType, toTargetPlatform, WEB_EXTENSION_TAG, IExtensionQueryOptions, IDeprecationInfo, ISearchPrefferedResults, ExtensionGalleryError, ExtensionGalleryErrorCode, IProductVersion, UseUnpkgResourceApiConfigKey, IAllowedExtensionsService, EXTENSION_IDENTIFIER_REGEX } from './extensionManagement.js'; import { adoptToGalleryExtensionId, areSameExtensions, getGalleryExtensionId, getGalleryExtensionTelemetryData } from './extensionManagementUtil.js'; import { IExtensionManifest, TargetPlatform } from '../../extensions/common/extensions.js'; import { areApiProposalsCompatible, isEngineValid } from '../../extensions/common/extensionValidator.js'; @@ -297,14 +297,27 @@ type GalleryServiceAdditionalQueryEvent = { readonly count: number; }; -interface IExtensionCriteria { +type ExtensionsCriteria = { readonly productVersion: IProductVersion; readonly targetPlatform: TargetPlatform; readonly compatible: boolean; readonly includePreRelease: boolean | (IExtensionIdentifier & { includePreRelease: boolean })[]; readonly versions?: (IExtensionIdentifier & { version: string })[]; +}; + +const enum VersionKind { + Release, + Prerelease, + Latest } +type ExtensionVersionCriteria = { + readonly productVersion: IProductVersion; + readonly targetPlatform: TargetPlatform; + readonly compatible: boolean; + readonly version: VersionKind | string; +}; + class Query { constructor(private state = DefaultQueryState) { } @@ -742,25 +755,35 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi return; } + if (!EXTENSION_IDENTIFIER_REGEX.test(extensionInfo.id)) { + toQuery.push(extensionInfo); + return; + } + try { const rawGalleryExtension = await this.getLatestRawGalleryExtension(extensionInfo.id, token); if (!rawGalleryExtension) { - toQuery.push(extensionInfo); + if (extensionInfo.uuid) { + toQuery.push(extensionInfo); + } return; } - const extension = await this.toGalleryExtensionWithCriteria(rawGalleryExtension, { - targetPlatform: options.targetPlatform ?? CURRENT_TARGET_PLATFORM, - includePreRelease: !!extensionInfo.preRelease, - compatible: !!options.compatible, - productVersion: options.productVersion ?? { - version: this.productService.version, - date: this.productService.date - } - }); + const allTargetPlatforms = getAllTargetPlatforms(rawGalleryExtension); + const rawGalleryExtensionVersion = await this.getRawGalleryExtensionVersion( + rawGalleryExtension, + { + targetPlatform: options.targetPlatform ?? CURRENT_TARGET_PLATFORM, + compatible: !!options.compatible, + productVersion: options.productVersion ?? { + version: this.productService.version, + date: this.productService.date + }, + version: extensionInfo.preRelease ? VersionKind.Prerelease : VersionKind.Release + }, allTargetPlatforms); - if (extension) { - result.push(extension); + if (rawGalleryExtensionVersion) { + result.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms)); } // report telemetry @@ -861,13 +884,30 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi return areApiProposalsCompatible(enabledApiProposals); } - private async isValidVersion(extension: string, rawGalleryExtensionVersion: IRawGalleryExtensionVersion, publisherDisplayName: string, versionType: 'release' | 'prerelease' | 'any', compatible: boolean, allTargetPlatforms: TargetPlatform[], targetPlatform: TargetPlatform, productVersion: IProductVersion = { version: this.productService.version, date: this.productService.date }): Promise { - const targetPlatformForExtension = getTargetPlatformForExtensionVersion(rawGalleryExtensionVersion); - if (!isTargetPlatformCompatible(targetPlatformForExtension, allTargetPlatforms, targetPlatform)) { - return false; + private async isValidVersion( + extension: string, + rawGalleryExtensionVersion: IRawGalleryExtensionVersion, + { targetPlatform, compatible, productVersion, version }: ExtensionVersionCriteria, + publisherDisplayName: string, + allTargetPlatforms: TargetPlatform[] + ): Promise { + + // Specific version + if (isString(version)) { + if (rawGalleryExtensionVersion.version !== version) { + return false; + } } - if (versionType !== 'any' && isPreReleaseVersion(rawGalleryExtensionVersion) !== (versionType === 'prerelease')) { + // Prerelease or release version kind + else if (version === VersionKind.Release || version === VersionKind.Prerelease) { + if (isPreReleaseVersion(rawGalleryExtensionVersion) !== (version === VersionKind.Prerelease)) { + return false; + } + } + + const targetPlatformForExtension = getTargetPlatformForExtensionVersion(rawGalleryExtensionVersion); + if (!isTargetPlatformCompatible(targetPlatformForExtension, allTargetPlatforms, targetPlatform)) { return false; } @@ -956,7 +996,7 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi return { firstPage: extensions, total, pageSize: query.pageSize, getPage }; } - private async queryGalleryExtensions(query: Query, criteria: IExtensionCriteria, token: CancellationToken): Promise<{ extensions: IGalleryExtension[]; total: number }> { + private async queryGalleryExtensions(query: Query, criteria: ExtensionsCriteria, token: CancellationToken): Promise<{ extensions: IGalleryExtension[]; total: number }> { const flags = query.flags; /** @@ -990,9 +1030,21 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi if (hasAllVersions) { const extensions: IGalleryExtension[] = []; for (const rawGalleryExtension of rawGalleryExtensions) { - const extension = await this.toGalleryExtensionWithCriteria(rawGalleryExtension, criteria, context); - if (extension) { - extensions.push(extension); + const allTargetPlatforms = getAllTargetPlatforms(rawGalleryExtension); + const extensionIdentifier = { id: getGalleryExtensionId(rawGalleryExtension.publisher.publisherName, rawGalleryExtension.extensionName), uuid: rawGalleryExtension.extensionId }; + const rawGalleryExtensionVersion = await this.getRawGalleryExtensionVersion( + rawGalleryExtension, + { + compatible: criteria.compatible, + targetPlatform: criteria.targetPlatform, + productVersion: criteria.productVersion, + version: criteria.versions?.find(extensionIdentifierWithVersion => areSameExtensions(extensionIdentifierWithVersion, extensionIdentifier))?.version + ?? (criteria.includePreRelease ? VersionKind.Latest : VersionKind.Release) + }, + allTargetPlatforms + ); + if (rawGalleryExtensionVersion) { + extensions.push(toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, context)); } } return { extensions, total }; @@ -1004,22 +1056,29 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi const rawGalleryExtension = rawGalleryExtensions[index]; const extensionIdentifier = { id: getGalleryExtensionId(rawGalleryExtension.publisher.publisherName, rawGalleryExtension.extensionName), uuid: rawGalleryExtension.extensionId }; const includePreRelease = isBoolean(criteria.includePreRelease) ? criteria.includePreRelease : !!criteria.includePreRelease.find(extensionIdentifierWithPreRelease => areSameExtensions(extensionIdentifierWithPreRelease, extensionIdentifier))?.includePreRelease; + const allTargetPlatforms = getAllTargetPlatforms(rawGalleryExtension); if (criteria.compatible) { - /** Skip if requested for a web-compatible extension and it is not a web extension. - * All versions are not needed in this case - */ - if (isNotWebExtensionInWebTargetPlatform(getAllTargetPlatforms(rawGalleryExtension), criteria.targetPlatform)) { + // Skip looking for all versions if requested for a web-compatible extension and it is not a web extension. + if (isNotWebExtensionInWebTargetPlatform(allTargetPlatforms, criteria.targetPlatform)) { continue; } - /** - * Skip if the extension is not allowed. - * All versions are not needed in this case - */ + // Skip looking for all versions if the extension is not allowed. if (this.allowedExtensionsService.isAllowed({ id: extensionIdentifier.id, publisherDisplayName: rawGalleryExtension.publisher.displayName }) !== true) { continue; } } - const extension = await this.toGalleryExtensionWithCriteria(rawGalleryExtension, criteria, context); + const rawGalleryExtensionVersion = await this.getRawGalleryExtensionVersion( + rawGalleryExtension, + { + compatible: criteria.compatible, + targetPlatform: criteria.targetPlatform, + productVersion: criteria.productVersion, + version: criteria.versions?.find(extensionIdentifierWithVersion => areSameExtensions(extensionIdentifierWithVersion, extensionIdentifier))?.version + ?? (criteria.includePreRelease ? VersionKind.Latest : VersionKind.Release) + }, + allTargetPlatforms + ); + const extension = rawGalleryExtensionVersion ? toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, context) : null; if (!extension /** Need all versions if the extension is a pre-release version but * - the query is to look for a release version or @@ -1060,38 +1119,29 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi return { extensions: result.sort((a, b) => a[0] - b[0]).map(([, extension]) => extension), total }; } - private async toGalleryExtensionWithCriteria(rawGalleryExtension: IRawGalleryExtension, criteria: IExtensionCriteria, queryContext?: IStringDictionary): Promise { - + private async getRawGalleryExtensionVersion(rawGalleryExtension: IRawGalleryExtension, criteria: ExtensionVersionCriteria, allTargetPlatforms: TargetPlatform[]): Promise { const extensionIdentifier = { id: getGalleryExtensionId(rawGalleryExtension.publisher.publisherName, rawGalleryExtension.extensionName), uuid: rawGalleryExtension.extensionId }; - const version = criteria.versions?.find(extensionIdentifierWithVersion => areSameExtensions(extensionIdentifierWithVersion, extensionIdentifier))?.version; - const includePreRelease = isBoolean(criteria.includePreRelease) ? criteria.includePreRelease : !!criteria.includePreRelease.find(extensionIdentifierWithPreRelease => areSameExtensions(extensionIdentifierWithPreRelease, extensionIdentifier))?.includePreRelease; - const allTargetPlatforms = getAllTargetPlatforms(rawGalleryExtension); const rawGalleryExtensionVersions = sortExtensionVersions(rawGalleryExtension.versions, criteria.targetPlatform); if (criteria.compatible && isNotWebExtensionInWebTargetPlatform(allTargetPlatforms, criteria.targetPlatform)) { return null; } + const version = isString(criteria.version) ? criteria.version : undefined; + for (let index = 0; index < rawGalleryExtensionVersions.length; index++) { const rawGalleryExtensionVersion = rawGalleryExtensionVersions[index]; - if (version && rawGalleryExtensionVersion.version !== version) { - continue; - } - // Allow any version if includePreRelease flag is set otherwise only release versions are allowed if (await this.isValidVersion( extensionIdentifier.id, rawGalleryExtensionVersion, + criteria, rawGalleryExtension.publisher.displayName, - includePreRelease ? 'any' : 'release', - criteria.compatible, - allTargetPlatforms, - criteria.targetPlatform, - criteria.productVersion) + allTargetPlatforms) ) { if (criteria.compatible && !this.areApiProposalsCompatible(extensionIdentifier, getEnabledApiProposals(rawGalleryExtensionVersion))) { continue; } - return toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, queryContext); + return rawGalleryExtensionVersion; } if (version && rawGalleryExtensionVersion.version === version) { return null; @@ -1106,7 +1156,7 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi * Fallback: Return the latest version * This can happen when the extension does not have a release version or does not have a version compatible with the given target platform. */ - return toExtension(rawGalleryExtension, rawGalleryExtension.versions[0], allTargetPlatforms); + return rawGalleryExtension.versions[0]; } private async queryRawGalleryExtensions(query: Query, token: CancellationToken): Promise { @@ -1190,16 +1240,12 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi } } - private async getLatestRawGalleryExtension(extensionId: string, token: CancellationToken): Promise { + private async getLatestRawGalleryExtension(extensionId: string, token: CancellationToken): Promise { let errorCode: string | undefined; const stopWatch = new StopWatch(); try { const [publisher, name] = extensionId.split('.'); - if (!publisher || !name) { - errorCode = 'InvalidExtensionId'; - return undefined; - } const uri = URI.parse(format2(this.extensionUrlTemplate!, { publisher, name })); const commonHeaders = await this.commonHeadersPromise; const headers = { @@ -1216,20 +1262,21 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi timeout: 10000 /*10s*/ }, token); + if (context.res.statusCode === 404) { + errorCode = 'NotFound'; + return null; + } + if (context.res.statusCode && context.res.statusCode !== 200) { errorCode = `GalleryServiceError:` + context.res.statusCode; - this.logService.warn('Error getting latest version of the extension', extensionId, context.res.statusCode); - return undefined; + throw new Error('Unexpected HTTP response: ' + context.res.statusCode); } const result = await asJson(context); - if (result) { - return result; + if (!result) { + errorCode = 'NoData'; } - - errorCode = 'NoData'; - this.logService.warn('Error getting latest version of the extension', extensionId, errorCode); - + return result; } catch (error) { @@ -1243,6 +1290,7 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi ? ExtensionGalleryErrorCode.Timeout : ExtensionGalleryErrorCode.Failed; } + throw error; } finally { @@ -1260,8 +1308,6 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi }; this.telemetryService.publicLog2('galleryService:getLatest', { extension: extensionId, duration: stopWatch.elapsed(), errorCode }); } - - return undefined; } async reportStatistic(publisher: string, name: string, version: string, type: StatisticType): Promise { @@ -1412,17 +1458,21 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi } const validVersions: IRawGalleryExtensionVersion[] = []; + const productVersion = { version: this.productService.version, date: this.productService.date }; await Promise.all(galleryExtensions[0].versions.map(async (version) => { try { if ( (await this.isValidVersion( extensionIdentifier.id, version, + { + compatible: true, + productVersion, + targetPlatform, + version: includePreRelease ? VersionKind.Latest : VersionKind.Release + }, galleryExtensions[0].publisher.displayName, - includePreRelease ? 'any' : 'release', - true, - allTargetPlatforms, - targetPlatform)) + allTargetPlatforms)) && this.areApiProposalsCompatible(extensionIdentifier, getEnabledApiProposals(version)) ) { validVersions.push(version); @@ -1527,13 +1577,17 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi } const result = await asJson(context); - const malicious: IExtensionIdentifier[] = []; + const malicious: Array = []; const deprecated: IStringDictionary = {}; const search: ISearchPrefferedResults[] = []; const extensionsEnabledWithPreRelease: string[] = []; if (result) { for (const id of result.malicious) { - malicious.push({ id }); + if (EXTENSION_IDENTIFIER_REGEX.test(id)) { + malicious.push({ id }); + } else { + malicious.push(id); + } } if (result.migrateToPreRelease) { for (const [unsupportedPreReleaseExtensionId, preReleaseExtensionInfo] of Object.entries(result.migrateToPreRelease)) { diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index f08b46ae65b..dd719f089e9 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -328,7 +328,7 @@ export interface ISearchPrefferedResults { } export interface IExtensionsControlManifest { - readonly malicious: IExtensionIdentifier[]; + readonly malicious: ReadonlyArray; readonly deprecated: IStringDictionary; readonly search: ISearchPrefferedResults[]; readonly extensionsEnabledWithPreRelease?: string[]; diff --git a/src/vs/platform/extensionManagement/common/extensionManagementUtil.ts b/src/vs/platform/extensionManagement/common/extensionManagementUtil.ts index 39d008ba8be..7a2cd54e865 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementUtil.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementUtil.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { compareIgnoreCase } from '../../../base/common/strings.js'; -import { IExtensionIdentifier, IGalleryExtension, ILocalExtension, getTargetPlatform } from './extensionManagement.js'; +import { IExtensionIdentifier, IExtensionsControlManifest, IGalleryExtension, ILocalExtension, getTargetPlatform } from './extensionManagement.js'; import { ExtensionIdentifier, IExtension, TargetPlatform, UNDEFINED_PUBLISHER } from '../../extensions/common/extensions.js'; import { IFileService } from '../../files/common/files.js'; import { isLinux, platform } from '../../../base/common/platform.js'; @@ -13,6 +13,7 @@ import { getErrorMessage } from '../../../base/common/errors.js'; import { ILogService } from '../../log/common/log.js'; import { arch } from '../../../base/common/process.js'; import { TelemetryTrustedValue } from '../../telemetry/common/telemetryUtils.js'; +import { isString } from '../../../base/common/types.js'; export function areSameExtensions(a: IExtensionIdentifier, b: IExtensionIdentifier): boolean { if (a.uuid && b.uuid) { @@ -196,3 +197,12 @@ export async function computeTargetPlatform(fileService: IFileService, logServic logService.debug('ComputeTargetPlatform:', targetPlatform); return targetPlatform; } + +export function isMalicious(identifier: IExtensionIdentifier, controlManifest: IExtensionsControlManifest): boolean { + return controlManifest.malicious.some(publisherOrIdentifier => { + if (isString(publisherOrIdentifier)) { + return compareIgnoreCase(identifier.id.split('.')[0], publisherOrIdentifier) === 0; + } + return areSameExtensions(identifier, publisherOrIdentifier); + }); +} diff --git a/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts b/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts index ced131827be..162f11b5c96 100644 --- a/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts +++ b/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts @@ -85,7 +85,7 @@ export interface IExtensionsProfileScannerService { scanProfileExtensions(profileLocation: URI, options?: IProfileExtensionsScanOptions): Promise; addExtensionsToProfile(extensions: [IExtension, Metadata | undefined][], profileLocation: URI, keepExistingVersions?: boolean): Promise; updateMetadata(extensions: [IExtension, Metadata | undefined][], profileLocation: URI): Promise; - removeExtensionFromProfile(extension: IExtension, profileLocation: URI): Promise; + removeExtensionsFromProfile(extensions: IExtensionIdentifier[], profileLocation: URI): Promise; } export abstract class AbstractExtensionsProfileScannerService extends Disposable implements IExtensionsProfileScannerService { @@ -193,13 +193,13 @@ export abstract class AbstractExtensionsProfileScannerService extends Disposable return updatedExtensions; } - async removeExtensionFromProfile(extension: IExtension, profileLocation: URI): Promise { + async removeExtensionsFromProfile(extensions: IExtensionIdentifier[], profileLocation: URI): Promise { const extensionsToRemove: IScannedProfileExtension[] = []; try { await this.withProfileExtensions(profileLocation, profileExtensions => { const result: IScannedProfileExtension[] = []; for (const e of profileExtensions) { - if (areSameExtensions(e.identifier, extension.identifier)) { + if (extensions.some(extension => areSameExtensions(e.identifier, extension))) { extensionsToRemove.push(e); } else { result.push(e); diff --git a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts index a186b6fa045..c613e0669b4 100644 --- a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts +++ b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts @@ -100,16 +100,24 @@ interface IBuiltInExtensionControl { [name: string]: 'marketplace' | 'disabled' | string; } -export type ScanOptions = { - readonly profileLocation?: URI; - readonly includeInvalid?: boolean; - readonly includeAllVersions?: boolean; +export type SystemExtensionsScanOptions = { readonly checkControlFile?: boolean; readonly language?: string; +}; + +export type UserExtensionsScanOptions = { + readonly profileLocation: URI; + readonly includeInvalid?: boolean; + readonly language?: string; readonly useCache?: boolean; readonly productVersion?: IProductVersion; }; +export type ScanOptions = { + readonly includeInvalid?: boolean; + readonly language?: string; +}; + export const IExtensionsScannerService = createDecorator('IExtensionsScannerService'); export interface IExtensionsScannerService { readonly _serviceBrand: undefined; @@ -118,17 +126,16 @@ export interface IExtensionsScannerService { readonly userExtensionsLocation: URI; readonly onDidChangeCache: Event; - getTargetPlatform(): Promise; + scanAllExtensions(systemScanOptions: SystemExtensionsScanOptions, userScanOptions: UserExtensionsScanOptions): Promise; + scanSystemExtensions(scanOptions: SystemExtensionsScanOptions): Promise; + scanUserExtensions(scanOptions: UserExtensionsScanOptions): Promise; + scanAllUserExtensions(): Promise; - scanAllExtensions(systemScanOptions: ScanOptions, userScanOptions: ScanOptions, includeExtensionsUnderDev: boolean): Promise; - scanSystemExtensions(scanOptions: ScanOptions): Promise; - scanUserExtensions(scanOptions: ScanOptions): Promise; - scanExtensionsUnderDevelopment(scanOptions: ScanOptions, existingExtensions: IScannedExtension[]): Promise; + scanExtensionsUnderDevelopment(existingExtensions: IScannedExtension[], scanOptions: ScanOptions): Promise; scanExistingExtension(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise; - scanOneOrMultipleExtensions(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise; scanMultipleExtensions(extensionLocations: URI[], extensionType: ExtensionType, scanOptions: ScanOptions): Promise; + scanOneOrMultipleExtensions(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise; - scanMetadata(extensionLocation: URI): Promise; updateMetadata(extensionLocation: URI, metadata: Partial): Promise; initializeDefaultProfileExtensions(): Promise; } @@ -167,35 +174,33 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem } private _targetPlatformPromise: Promise | undefined; - getTargetPlatform(): Promise { + private getTargetPlatform(): Promise { if (!this._targetPlatformPromise) { this._targetPlatformPromise = computeTargetPlatform(this.fileService, this.logService); } return this._targetPlatformPromise; } - async scanAllExtensions(systemScanOptions: ScanOptions, userScanOptions: ScanOptions, includeExtensionsUnderDev: boolean): Promise { + async scanAllExtensions(systemScanOptions: SystemExtensionsScanOptions, userScanOptions: UserExtensionsScanOptions): Promise { const [system, user] = await Promise.all([ this.scanSystemExtensions(systemScanOptions), this.scanUserExtensions(userScanOptions), ]); - const development = includeExtensionsUnderDev ? await this.scanExtensionsUnderDevelopment(systemScanOptions, [...system, ...user]) : []; - return this.dedupExtensions(system, user, development, await this.getTargetPlatform(), true); + return this.dedupExtensions(system, user, [], await this.getTargetPlatform(), true); } - async scanSystemExtensions(scanOptions: ScanOptions): Promise { + async scanSystemExtensions(scanOptions: SystemExtensionsScanOptions): Promise { const promises: Promise[] = []; - promises.push(this.scanDefaultSystemExtensions(!!scanOptions.useCache, scanOptions.language)); + promises.push(this.scanDefaultSystemExtensions(scanOptions.language)); promises.push(this.scanDevSystemExtensions(scanOptions.language, !!scanOptions.checkControlFile)); const [defaultSystemExtensions, devSystemExtensions] = await Promise.all(promises); - return this.applyScanOptions([...defaultSystemExtensions, ...devSystemExtensions], ExtensionType.System, scanOptions, false); + return this.applyScanOptions([...defaultSystemExtensions, ...devSystemExtensions], ExtensionType.System, { pickLatest: false }); } - async scanUserExtensions(scanOptions: ScanOptions): Promise { - const location = scanOptions.profileLocation ?? this.userExtensionsLocation; - this.logService.trace('Started scanning user extensions', location); + async scanUserExtensions(scanOptions: UserExtensionsScanOptions): Promise { + this.logService.trace('Started scanning user extensions', scanOptions.profileLocation); const profileScanOptions: IProfileExtensionsScanOptions | undefined = this.uriIdentityService.extUri.isEqual(scanOptions.profileLocation, this.userDataProfilesService.defaultProfile.extensionsResource) ? { bailOutWhenFileNotFound: true } : undefined; - const extensionsScannerInput = await this.createExtensionScannerInput(location, !!scanOptions.profileLocation, ExtensionType.User, scanOptions.language, true, profileScanOptions, scanOptions.productVersion ?? this.getProductVersion()); + const extensionsScannerInput = await this.createExtensionScannerInput(scanOptions.profileLocation, true, ExtensionType.User, scanOptions.language, true, profileScanOptions, scanOptions.productVersion ?? this.getProductVersion()); const extensionsScanner = scanOptions.useCache && !extensionsScannerInput.devMode ? this.userExtensionsCachedScanner : this.extensionsScanner; let extensions: IRelaxedScannedExtension[]; try { @@ -208,16 +213,22 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem throw error; } } - extensions = await this.applyScanOptions(extensions, ExtensionType.User, scanOptions, true); + extensions = await this.applyScanOptions(extensions, ExtensionType.User, { includeInvalid: scanOptions.includeInvalid, pickLatest: true }); this.logService.trace('Scanned user extensions:', extensions.length); return extensions; } - async scanExtensionsUnderDevelopment(scanOptions: ScanOptions, existingExtensions: IScannedExtension[]): Promise { + async scanAllUserExtensions(scanOptions: { includeAllVersions?: boolean; includeInvalid: boolean } = { includeInvalid: true, includeAllVersions: true }): Promise { + const extensionsScannerInput = await this.createExtensionScannerInput(this.userExtensionsLocation, false, ExtensionType.User, undefined, true, undefined, this.getProductVersion()); + const extensions = await this.extensionsScanner.scanExtensions(extensionsScannerInput); + return this.applyScanOptions(extensions, ExtensionType.User, { includeAllVersions: scanOptions.includeAllVersions, includeInvalid: scanOptions.includeInvalid }); + } + + async scanExtensionsUnderDevelopment(existingExtensions: IScannedExtension[], scanOptions: ScanOptions): Promise { if (this.environmentService.isExtensionDevelopment && this.environmentService.extensionDevelopmentLocationURI) { const extensions = (await Promise.all(this.environmentService.extensionDevelopmentLocationURI.filter(extLoc => extLoc.scheme === Schemas.file) .map(async extensionDevelopmentLocationURI => { - const input = await this.createExtensionScannerInput(extensionDevelopmentLocationURI, false, ExtensionType.User, scanOptions.language, false /* do not validate */, undefined, scanOptions.productVersion ?? this.getProductVersion()); + const input = await this.createExtensionScannerInput(extensionDevelopmentLocationURI, false, ExtensionType.User, scanOptions.language, false /* do not validate */, undefined, this.getProductVersion()); const extensions = await this.extensionsScanner.scanOneOrMultipleExtensions(input); return extensions.map(extension => { // Override the extension type from the existing extensions @@ -227,13 +238,13 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem }); }))) .flat(); - return this.applyScanOptions(extensions, 'development', scanOptions, true); + return this.applyScanOptions(extensions, 'development', { includeInvalid: scanOptions.includeInvalid, pickLatest: true }); } return []; } async scanExistingExtension(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise { - const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, scanOptions.language, true, undefined, scanOptions.productVersion ?? this.getProductVersion()); + const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, scanOptions.language, true, undefined, this.getProductVersion()); const extension = await this.extensionsScanner.scanExtension(extensionsScannerInput); if (!extension) { return null; @@ -245,9 +256,9 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem } async scanOneOrMultipleExtensions(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise { - const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, scanOptions.language, true, undefined, scanOptions.productVersion ?? this.getProductVersion()); + const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, scanOptions.language, true, undefined, this.getProductVersion()); const extensions = await this.extensionsScanner.scanOneOrMultipleExtensions(extensionsScannerInput); - return this.applyScanOptions(extensions, extensionType, scanOptions, true); + return this.applyScanOptions(extensions, extensionType, { includeInvalid: scanOptions.includeInvalid, pickLatest: true }); } async scanMultipleExtensions(extensionLocations: URI[], extensionType: ExtensionType, scanOptions: ScanOptions): Promise { @@ -256,14 +267,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem const scannedExtensions = await this.scanOneOrMultipleExtensions(extensionLocation, extensionType, scanOptions); extensions.push(...scannedExtensions); })); - return this.applyScanOptions(extensions, extensionType, scanOptions, true); - } - - async scanMetadata(extensionLocation: URI): Promise { - const manifestLocation = joinPath(extensionLocation, 'package.json'); - const content = (await this.fileService.readFile(manifestLocation)).value.toString(); - const manifest: IScannedExtensionManifest = JSON.parse(content); - return manifest.__metadata; + return this.applyScanOptions(extensions, extensionType, { includeInvalid: scanOptions.includeInvalid, pickLatest: true }); } async updateMetadata(extensionLocation: URI, metaData: Partial): Promise { @@ -301,7 +305,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem this.initializeDefaultProfileExtensionsPromise = (async () => { try { this.logService.info('Started initializing default profile extensions in extensions installation folder.', this.userExtensionsLocation.toString()); - const userExtensions = await this.scanUserExtensions({ includeInvalid: true }); + const userExtensions = await this.scanAllUserExtensions({ includeInvalid: true }); if (userExtensions.length) { await this.extensionsProfileScannerService.addExtensionsToProfile(userExtensions.map(e => [e, e.metadata]), this.userDataProfilesService.defaultProfile.extensionsResource); } else { @@ -324,9 +328,9 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem return this.initializeDefaultProfileExtensionsPromise; } - private async applyScanOptions(extensions: IRelaxedScannedExtension[], type: ExtensionType | 'development', scanOptions: ScanOptions, pickLatest: boolean): Promise { + private async applyScanOptions(extensions: IRelaxedScannedExtension[], type: ExtensionType | 'development', scanOptions: { includeAllVersions?: boolean; includeInvalid?: boolean; pickLatest?: boolean } = {}): Promise { if (!scanOptions.includeAllVersions) { - extensions = this.dedupExtensions(type === ExtensionType.System ? extensions : undefined, type === ExtensionType.User ? extensions : undefined, type === 'development' ? extensions : undefined, await this.getTargetPlatform(), pickLatest); + extensions = this.dedupExtensions(type === ExtensionType.System ? extensions : undefined, type === ExtensionType.User ? extensions : undefined, type === 'development' ? extensions : undefined, await this.getTargetPlatform(), !!scanOptions.pickLatest); } if (!scanOptions.includeInvalid) { extensions = extensions.filter(extension => extension.isValid); @@ -399,10 +403,10 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem return [...result.values()]; } - private async scanDefaultSystemExtensions(useCache: boolean, language: string | undefined): Promise { + private async scanDefaultSystemExtensions(language: string | undefined): Promise { this.logService.trace('Started scanning system extensions'); const extensionsScannerInput = await this.createExtensionScannerInput(this.systemExtensionsLocation, false, ExtensionType.System, language, true, undefined, this.getProductVersion()); - const extensionsScanner = useCache && !extensionsScannerInput.devMode ? this.systemExtensionsCachedScanner : this.extensionsScanner; + const extensionsScanner = extensionsScannerInput.devMode ? this.extensionsScanner : this.systemExtensionsCachedScanner; const result = await extensionsScanner.scanExtensions(extensionsScannerInput); this.logService.trace('Scanned system extensions:', result.length); return result; @@ -609,7 +613,7 @@ class ExtensionsScanner extends Disposable { scannedProfileExtensions.map(async extensionInfo => { if (filter(extensionInfo)) { const extensionScannerInput = new ExtensionScannerInput(extensionInfo.location, input.mtime, input.applicationExtensionslocation, input.applicationExtensionslocationMtime, input.profile, input.profileScanOptions, input.type, input.validate, input.productVersion, input.productDate, input.productCommit, input.devMode, input.language, input.translations); - return this.scanExtension(extensionScannerInput, extensionInfo.metadata); + return this.scanExtension(extensionScannerInput, extensionInfo); } return null; })); @@ -630,56 +634,76 @@ class ExtensionsScanner extends Disposable { } } - async scanExtension(input: ExtensionScannerInput, metadata?: Metadata): Promise { + async scanExtension(input: ExtensionScannerInput): Promise; + async scanExtension(input: ExtensionScannerInput, scannedProfileExtension: IScannedProfileExtension): Promise; + async scanExtension(input: ExtensionScannerInput, scannedProfileExtension?: IScannedProfileExtension): Promise { + const validations: [Severity, string][] = []; + let isValid = true; + let manifest: IScannedExtensionManifest; try { - let manifest = await this.scanExtensionManifest(input.location); - if (manifest) { - // allow publisher to be undefined to make the initial extension authoring experience smoother - if (!manifest.publisher) { - manifest.publisher = UNDEFINED_PUBLISHER; - } - metadata = metadata ?? manifest.__metadata; - if (metadata && !metadata?.size && manifest.__metadata?.size) { - metadata.size = manifest.__metadata?.size; - } - delete manifest.__metadata; - const id = getGalleryExtensionId(manifest.publisher, manifest.name); - const identifier = metadata?.id ? { id, uuid: metadata.id } : { id }; - const type = metadata?.isSystem ? ExtensionType.System : input.type; - const isBuiltin = type === ExtensionType.System || !!metadata?.isBuiltin; - manifest = await this.translateManifest(input.location, manifest, ExtensionScannerInput.createNlsConfiguration(input)); - let extension: IRelaxedScannedExtension = { - type, - identifier, - manifest, - location: input.location, - isBuiltin, - targetPlatform: metadata?.targetPlatform ?? TargetPlatform.UNDEFINED, - publisherDisplayName: metadata?.publisherDisplayName, - metadata, - isValid: true, - validations: [], - preRelease: !!metadata?.preRelease, - }; - if (input.validate) { - extension = this.validate(extension, input); - } - if (manifest.enabledApiProposals && (!this.environmentService.isBuilt || this.extensionsEnabledWithApiProposalVersion.includes(id.toLowerCase()))) { - manifest.originalEnabledApiProposals = manifest.enabledApiProposals; - manifest.enabledApiProposals = parseEnabledApiProposalNames([...manifest.enabledApiProposals]); - } - return extension; - } + manifest = await this.scanExtensionManifest(input.location); } catch (e) { - if (input.type !== ExtensionType.System) { - this.logService.error(e); + if (scannedProfileExtension) { + validations.push([Severity.Error, getErrorMessage(e)]); + isValid = false; + const [publisher, name] = scannedProfileExtension.identifier.id.split('.'); + manifest = { + name, + publisher, + version: scannedProfileExtension.version, + engines: { vscode: '' } + }; + } else { + if (input.type !== ExtensionType.System) { + this.logService.error(e); + } + return null; } } - return null; + + // allow publisher to be undefined to make the initial extension authoring experience smoother + if (!manifest.publisher) { + manifest.publisher = UNDEFINED_PUBLISHER; + } + const metadata = scannedProfileExtension?.metadata ?? manifest.__metadata; + if (metadata && !metadata?.size && manifest.__metadata?.size) { + metadata.size = manifest.__metadata?.size; + } + delete manifest.__metadata; + const id = getGalleryExtensionId(manifest.publisher, manifest.name); + const identifier = metadata?.id ? { id, uuid: metadata.id } : { id }; + const type = metadata?.isSystem ? ExtensionType.System : input.type; + const isBuiltin = type === ExtensionType.System || !!metadata?.isBuiltin; + try { + manifest = await this.translateManifest(input.location, manifest, ExtensionScannerInput.createNlsConfiguration(input)); + } catch (error) { + this.logService.warn('Failed to translate manifest', getErrorMessage(error)); + } + let extension: IRelaxedScannedExtension = { + type, + identifier, + manifest, + location: input.location, + isBuiltin, + targetPlatform: metadata?.targetPlatform ?? TargetPlatform.UNDEFINED, + publisherDisplayName: metadata?.publisherDisplayName, + metadata, + isValid, + validations, + preRelease: !!metadata?.preRelease, + }; + if (input.validate) { + extension = this.validate(extension, input); + } + if (manifest.enabledApiProposals && (!this.environmentService.isBuilt || this.extensionsEnabledWithApiProposalVersion.includes(id.toLowerCase()))) { + manifest.originalEnabledApiProposals = manifest.enabledApiProposals; + manifest.enabledApiProposals = parseEnabledApiProposalNames([...manifest.enabledApiProposals]); + } + return extension; } validate(extension: IRelaxedScannedExtension, input: ExtensionScannerInput): IRelaxedScannedExtension { - let isValid = true; + let isValid = extension.isValid; const validateApiVersion = this.environmentService.isBuilt && this.extensionsEnabledWithApiProposalVersion.includes(extension.identifier.id.toLowerCase()); const validations = validateExtensionManifest(input.productVersion, input.productDate, input.location, extension.manifest, extension.isBuiltin, validateApiVersion); for (const [severity, message] of validations) { @@ -689,11 +713,11 @@ class ExtensionsScanner extends Disposable { } } extension.isValid = isValid; - extension.validations = validations; + extension.validations = [...extension.validations, ...validations]; return extension; } - private async scanExtensionManifest(extensionLocation: URI): Promise { + private async scanExtensionManifest(extensionLocation: URI): Promise { const manifestLocation = joinPath(extensionLocation, 'package.json'); let content; try { @@ -702,7 +726,7 @@ class ExtensionsScanner extends Disposable { if (toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) { this.logService.error(this.formatMessage(extensionLocation, localize('fileReadFail', "Cannot read file {0}: {1}.", manifestLocation.path, error.message))); } - return null; + throw error; } let manifest: IScannedExtensionManifest; try { @@ -714,11 +738,12 @@ class ExtensionsScanner extends Disposable { for (const e of errors) { this.logService.error(this.formatMessage(extensionLocation, localize('jsonParseFail', "Failed to parse {0}: [{1}, {2}] {3}.", manifestLocation.path, e.offset, e.length, getParseErrorMessage(e.error)))); } - return null; + throw err; } if (getNodeType(manifest) !== 'object') { - this.logService.error(this.formatMessage(extensionLocation, localize('jsonParseInvalidType', "Invalid manifest file {0}: Not a JSON object.", manifestLocation.path))); - return null; + const errorMessage = this.formatMessage(extensionLocation, localize('jsonParseInvalidType', "Invalid manifest file {0}: Not a JSON object.", manifestLocation.path)); + this.logService.error(errorMessage); + throw new Error(errorMessage); } return manifest; } diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 762da10967f..e954abf073b 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -17,7 +17,7 @@ import { Schemas } from '../../../base/common/network.js'; import * as path from '../../../base/common/path.js'; import { joinPath } from '../../../base/common/resources.js'; import * as semver from '../../../base/common/semver/semver.js'; -import { isBoolean } from '../../../base/common/types.js'; +import { isBoolean, isDefined, isUndefined } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as pfs from '../../../base/node/pfs.js'; @@ -37,7 +37,7 @@ import { } from '../common/extensionManagement.js'; import { areSameExtensions, computeTargetPlatform, ExtensionKey, getGalleryExtensionId, groupByExtension } from '../common/extensionManagementUtil.js'; import { IExtensionsProfileScannerService, IScannedProfileExtension } from '../common/extensionsProfileScannerService.js'; -import { IExtensionsScannerService, IScannedExtension, ScanOptions } from '../common/extensionsScannerService.js'; +import { IExtensionsScannerService, IScannedExtension, UserExtensionsScanOptions } from '../common/extensionsScannerService.js'; import { ExtensionsDownloader } from './extensionDownloader.js'; import { ExtensionsLifecycle } from './extensionLifecycle.js'; import { fromExtractError, getManifest } from './extensionManagementUtil.js'; @@ -45,7 +45,7 @@ import { ExtensionsManifestCache } from './extensionsManifestCache.js'; import { DidChangeProfileExtensionsEvent, ExtensionsWatcher } from './extensionsWatcher.js'; import { ExtensionType, IExtension, IExtensionManifest, TargetPlatform } from '../../extensions/common/extensions.js'; import { isEngineValid } from '../../extensions/common/extensionValidator.js'; -import { FileChangesEvent, FileChangeType, FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js'; +import { FileChangesEvent, FileChangeType, FileOperationResult, IFileService, IFileStat, toFileOperationResult } from '../../files/common/files.js'; import { IInstantiationService, refineServiceDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; @@ -132,7 +132,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi } scanAllUserInstalledExtensions(): Promise { - return this.extensionsScanner.scanAllUserExtensions(false); + return this.extensionsScanner.scanAllUserExtensions(); } scanInstalledExtensionAtLocation(location: URI): Promise { @@ -298,23 +298,6 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi const local = await this.extensionsScanner.extractUserExtension( extensionKey, location.fsPath, - { - id: gallery.identifier.uuid, - publisherId: gallery.publisherId, - publisherDisplayName: gallery.publisherDisplayName, - targetPlatform: gallery.properties.targetPlatform, - isApplicationScoped: options.isApplicationScoped, - isMachineScoped: options.isMachineScoped, - isBuiltin: options.isBuiltin, - isPreReleaseVersion: gallery.properties.isPreReleaseVersion, - hasPreReleaseVersion: gallery.properties.isPreReleaseVersion, - installedTimestamp: Date.now(), - pinned: options.installGivenVersion ? true : !!options.pinned, - preRelease: isBoolean(options.preRelease) - ? options.preRelease - : options.installPreReleaseVersion || gallery.properties.isPreReleaseVersion, - source: 'gallery', - }, false, token); @@ -382,14 +365,6 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi const local = await this.extensionsScanner.extractUserExtension( extensionKey, path.resolve(location.fsPath), - { - isApplicationScoped: options.isApplicationScoped, - isMachineScoped: options.isMachineScoped, - isBuiltin: options.isBuiltin, - installedTimestamp: Date.now(), - pinned: options.installGivenVersion ? true : !!options.pinned, - source: 'vsix', - }, isBoolean(options.keepExisting) ? !options.keepExisting : true, token); return { local }; @@ -561,17 +536,18 @@ export class ExtensionsScanner extends Disposable { async cleanUp(): Promise { await this.removeTemporarilyDeletedFolders(); await this.deleteExtensionsMarkedForRemoval(); - await this.initializeMetadata(); + //TODO: Remove this initiialization after coupe of releases + await this.initializeExtensionSize(); } async scanExtensions(type: ExtensionType | null, profileLocation: URI, productVersion: IProductVersion): Promise { try { - const userScanOptions: ScanOptions = { includeInvalid: true, profileLocation, productVersion }; + const userScanOptions: UserExtensionsScanOptions = { includeInvalid: true, profileLocation, productVersion }; let scannedExtensions: IScannedExtension[] = []; if (type === null || type === ExtensionType.System) { let scanAllExtensionsPromise = this.scanAllExtensionPromise.get(profileLocation); if (!scanAllExtensionsPromise) { - scanAllExtensionsPromise = this.extensionsScannerService.scanAllExtensions({ includeInvalid: true, useCache: true }, userScanOptions, false) + scanAllExtensionsPromise = this.extensionsScannerService.scanAllExtensions({}, userScanOptions) .finally(() => this.scanAllExtensionPromise.delete(profileLocation)); this.scanAllExtensionPromise.set(profileLocation, scanAllExtensionsPromise); } @@ -592,9 +568,9 @@ export class ExtensionsScanner extends Disposable { } } - async scanAllUserExtensions(excludeOutdated: boolean): Promise { + async scanAllUserExtensions(): Promise { try { - const scannedExtensions = await this.extensionsScannerService.scanUserExtensions({ includeAllVersions: !excludeOutdated, includeInvalid: true }); + const scannedExtensions = await this.extensionsScannerService.scanAllUserExtensions(); return await Promise.all(scannedExtensions.map(extension => this.toLocalExtension(extension))); } catch (error) { throw toExtensionManagementError(error, ExtensionManagementErrorCode.Scanning); @@ -613,7 +589,7 @@ export class ExtensionsScanner extends Disposable { return null; } - async extractUserExtension(extensionKey: ExtensionKey, zipPath: string, metadata: Metadata, removeIfExists: boolean, token: CancellationToken): Promise { + async extractUserExtension(extensionKey: ExtensionKey, zipPath: string, removeIfExists: boolean, token: CancellationToken): Promise { const folderName = extensionKey.toString(); const tempLocation = URI.file(path.join(this.extensionsScannerService.userExtensionsLocation.fsPath, `.${generateUuid()}`)); const extensionLocation = URI.file(path.join(this.extensionsScannerService.userExtensionsLocation.fsPath, folderName)); @@ -648,6 +624,7 @@ export class ExtensionsScanner extends Disposable { throw fromExtractError(e); } + const metadata: Metadata = { installedTimestamp: Date.now() }; try { metadata.size = await computeSize(tempLocation, this.fileService); } catch (error) { @@ -691,13 +668,9 @@ export class ExtensionsScanner extends Disposable { return this.scanLocalExtension(extensionLocation, ExtensionType.User); } - async scanMetadata(local: ILocalExtension, profileLocation?: URI): Promise { - if (profileLocation) { - const extension = await this.getScannedExtension(local, profileLocation); - return extension?.metadata; - } else { - return this.extensionsScannerService.scanMetadata(local.location); - } + async scanMetadata(local: ILocalExtension, profileLocation: URI): Promise { + const extension = await this.getScannedExtension(local, profileLocation); + return extension?.metadata; } private async getScannedExtension(local: ILocalExtension, profileLocation: URI): Promise { @@ -763,7 +736,7 @@ export class ExtensionsScanner extends Disposable { await this.extensionsProfileScannerService.updateMetadata([[extension, { ...target.metadata, ...metadata }]], toProfileLocation); } else { const targetExtension = await this.scanLocalExtension(target.location, extension.type, toProfileLocation); - await this.extensionsProfileScannerService.removeExtensionFromProfile(targetExtension, toProfileLocation); + await this.extensionsProfileScannerService.removeExtensionsFromProfile([targetExtension.identifier], toProfileLocation); await this.extensionsProfileScannerService.addExtensionsToProfile([[extension, { ...target.metadata, ...metadata }]], toProfileLocation); } } else { @@ -856,10 +829,14 @@ export class ExtensionsScanner extends Disposable { } private async toLocalExtension(extension: IScannedExtension): Promise { - const stat = await this.fileService.resolve(extension.location); + let stat: IFileStat | undefined; + try { + stat = await this.fileService.resolve(extension.location); + } catch (error) {/* ignore */ } + let readmeUrl: URI | undefined; let changelogUrl: URI | undefined; - if (stat.children) { + if (stat?.children) { readmeUrl = stat.children.find(({ name }) => /^readme(\.txt|\.md|)$/i.test(name))?.resource; changelogUrl = stat.children.find(({ name }) => /^changelog(\.txt|\.md|)$/i.test(name))?.resource; } @@ -890,11 +867,11 @@ export class ExtensionsScanner extends Disposable { }; } - private async initializeMetadata(): Promise { - const extensions = await this.extensionsScannerService.scanUserExtensions({ includeInvalid: true }); + private async initializeExtensionSize(): Promise { + const extensions = await this.extensionsScannerService.scanAllUserExtensions(); await Promise.all(extensions.map(async extension => { // set size if not set before - if (!extension.metadata?.size && extension.metadata?.source !== 'resource') { + if (isDefined(extension.metadata?.installedTimestamp) && isUndefined(extension.metadata?.size)) { const size = await computeSize(extension.location, this.fileService); await this.extensionsScannerService.updateMetadata(extension.location, { size }); } @@ -916,7 +893,7 @@ export class ExtensionsScanner extends Disposable { this.logService.debug(`Deleting extensions marked as removed:`, Object.keys(removed)); - const extensions = await this.extensionsScannerService.scanUserExtensions({ includeAllVersions: true, includeInvalid: true }); // All user extensions + const extensions = await this.scanAllUserExtensions(); const installed: Set = new Set(); for (const e of extensions) { if (!removed[ExtensionKey.create(e).toString()]) { @@ -930,14 +907,14 @@ export class ExtensionsScanner extends Disposable { await Promises.settled(byExtension.map(async e => { const latest = e.sort((a, b) => semver.rcompare(a.manifest.version, b.manifest.version))[0]; if (!installed.has(latest.identifier.id.toLowerCase())) { - await this.beforeRemovingExtension(await this.toLocalExtension(latest)); + await this.beforeRemovingExtension(latest); } })); } catch (error) { this.logService.error(error); } - const toRemove = extensions.filter(e => e.metadata /* Installed by System */ && removed[ExtensionKey.create(e).toString()]); + const toRemove = extensions.filter(e => e.installedTimestamp /* Installed by System */ && removed[ExtensionKey.create(e).toString()]); await Promise.allSettled(toRemove.map(e => this.deleteExtension(e, 'marked for removal'))); } @@ -1115,7 +1092,7 @@ class InstallExtensionInProfileTask extends AbstractExtensionTask ExtensionKey.create(i).equals(extensionKey)); } return undefined; @@ -1155,7 +1132,7 @@ class UninstallExtensionInProfileTask extends AbstractExtensionTask implem } protected doRun(token: CancellationToken): Promise { - return this.extensionsProfileScannerService.removeExtensionFromProfile(this.extension, this.options.profileLocation); + return this.extensionsProfileScannerService.removeExtensionsFromProfile([this.extension.identifier], this.options.profileLocation); } } diff --git a/src/vs/platform/extensionManagement/test/common/extensionsProfileScannerService.test.ts b/src/vs/platform/extensionManagement/test/common/extensionsProfileScannerService.test.ts index 0329b90263b..a21b64a95a5 100644 --- a/src/vs/platform/extensionManagement/test/common/extensionsProfileScannerService.test.ts +++ b/src/vs/platform/extensionManagement/test/common/extensionsProfileScannerService.test.ts @@ -356,7 +356,7 @@ suite('ExtensionsProfileScannerService', () => { assert.deepStrictEqual(((target2.args[0][0])).extensions[0].location.toString(), extension.location.toString()); }); - test('remove extension trigger events', async () => { + test('remove extensions trigger events', async () => { const testObject = disposables.add(instantiationService.createInstance(TestObject, extensionsLocation)); const target1 = sinon.stub(); const target2 = sinon.stub(); @@ -364,26 +364,33 @@ suite('ExtensionsProfileScannerService', () => { disposables.add(testObject.onDidRemoveExtensions(target2)); const extensionsManifest = joinPath(extensionsLocation, 'extensions.json'); - const extension = aExtension('pub.a', joinPath(ROOT, 'foo', 'pub.a-1.0.0')); - await testObject.addExtensionsToProfile([[extension, undefined]], extensionsManifest); - await testObject.removeExtensionFromProfile(extension, extensionsManifest); + const extension1 = aExtension('pub.a', joinPath(ROOT, 'foo', 'pub.a-1.0.0')); + const extension2 = aExtension('pub.b', joinPath(ROOT, 'foo', 'pub.b-1.0.0')); + await testObject.addExtensionsToProfile([[extension1, undefined], [extension2, undefined]], extensionsManifest); + await testObject.removeExtensionsFromProfile([extension1.identifier, extension2.identifier], extensionsManifest); const actual = await testObject.scanProfileExtensions(extensionsManifest); assert.deepStrictEqual(actual.length, 0); assert.ok(target1.calledOnce); assert.deepStrictEqual(((target1.args[0][0])).profileLocation.toString(), extensionsManifest.toString()); - assert.deepStrictEqual(((target1.args[0][0])).extensions.length, 1); - assert.deepStrictEqual(((target1.args[0][0])).extensions[0].identifier, extension.identifier); - assert.deepStrictEqual(((target1.args[0][0])).extensions[0].version, extension.manifest.version); - assert.deepStrictEqual(((target1.args[0][0])).extensions[0].location.toString(), extension.location.toString()); + assert.deepStrictEqual(((target1.args[0][0])).extensions.length, 2); + assert.deepStrictEqual(((target1.args[0][0])).extensions[0].identifier, extension1.identifier); + assert.deepStrictEqual(((target1.args[0][0])).extensions[0].version, extension1.manifest.version); + assert.deepStrictEqual(((target1.args[0][0])).extensions[0].location.toString(), extension1.location.toString()); + assert.deepStrictEqual(((target1.args[0][0])).extensions[1].identifier, extension2.identifier); + assert.deepStrictEqual(((target1.args[0][0])).extensions[1].version, extension2.manifest.version); + assert.deepStrictEqual(((target1.args[0][0])).extensions[1].location.toString(), extension2.location.toString()); assert.ok(target2.calledOnce); assert.deepStrictEqual(((target2.args[0][0])).profileLocation.toString(), extensionsManifest.toString()); - assert.deepStrictEqual(((target2.args[0][0])).extensions.length, 1); - assert.deepStrictEqual(((target2.args[0][0])).extensions[0].identifier, extension.identifier); - assert.deepStrictEqual(((target2.args[0][0])).extensions[0].version, extension.manifest.version); - assert.deepStrictEqual(((target2.args[0][0])).extensions[0].location.toString(), extension.location.toString()); + assert.deepStrictEqual(((target2.args[0][0])).extensions.length, 2); + assert.deepStrictEqual(((target2.args[0][0])).extensions[0].identifier, extension1.identifier); + assert.deepStrictEqual(((target2.args[0][0])).extensions[0].version, extension1.manifest.version); + assert.deepStrictEqual(((target2.args[0][0])).extensions[0].location.toString(), extension1.location.toString()); + assert.deepStrictEqual(((target2.args[0][0])).extensions[1].identifier, extension2.identifier); + assert.deepStrictEqual(((target2.args[0][0])).extensions[1].version, extension2.manifest.version); + assert.deepStrictEqual(((target2.args[0][0])).extensions[1].location.toString(), extension2.location.toString()); }); test('add extension with same id but different version', async () => { diff --git a/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts b/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts index 551ba576d44..ea4108b2c17 100644 --- a/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts +++ b/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts @@ -105,12 +105,12 @@ suite('NativeExtensionsScanerService Test', () => { assert.deepStrictEqual(actual[0].manifest, manifest); }); - test('scan user extension', async () => { + test('scan user extensions', async () => { const manifest: Partial = anExtensionManifest({ 'name': 'name', 'publisher': 'pub', __metadata: { id: 'uuid' } }); const extensionLocation = await aUserExtension(manifest); const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions(); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name', uuid: 'uuid' }); @@ -175,24 +175,24 @@ suite('NativeExtensionsScanerService Test', () => { assert.deepStrictEqual(actual[1].identifier, { id: 'pub.name2' }); }); - test('scan user extension with different versions', async () => { + test('scan all user extensions with different versions', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.1' })); await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.2' })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false }); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); assert.deepStrictEqual(actual[0].manifest.version, '1.0.2'); }); - test('scan user extension include all versions', async () => { + test('scan all user extensions include all versions', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.1' })); await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.2' })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({ includeAllVersions: true }); + const actual = await testObject.scanAllUserExtensions(); assert.deepStrictEqual(actual.length, 2); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); @@ -201,35 +201,35 @@ suite('NativeExtensionsScanerService Test', () => { assert.deepStrictEqual(actual[1].manifest.version, '1.0.2'); }); - test('scan user extension with different versions and higher version is not compatible', async () => { + test('scan all user extensions with different versions and higher version is not compatible', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.1' })); await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.2', engines: { vscode: '^1.67.0' } })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false }); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); assert.deepStrictEqual(actual[0].manifest.version, '1.0.1'); }); - test('scan exclude invalid extensions', async () => { + test('scan all user extensions exclude invalid extensions', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub' })); await aUserExtension(anExtensionManifest({ 'name': 'name2', 'publisher': 'pub', engines: { vscode: '^1.67.0' } })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false }); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); }); - test('scan include invalid extensions', async () => { + test('scan all user extensions include invalid extensions', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub' })); await aUserExtension(anExtensionManifest({ 'name': 'name2', 'publisher': 'pub', engines: { vscode: '^1.67.0' } })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({ includeInvalid: true }); + const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: true }); assert.deepStrictEqual(actual.length, 2); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); @@ -257,12 +257,12 @@ suite('NativeExtensionsScanerService Test', () => { assert.deepStrictEqual(actual[0].manifest.version, '1.0.0'); }); - test('scan extension with default nls replacements', async () => { + test('scan all user extensions with default nls replacements', async () => { const extensionLocation = await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', displayName: '%displayName%' })); await instantiationService.get(IFileService).writeFile(joinPath(extensionLocation, 'package.nls.json'), VSBuffer.fromString(JSON.stringify({ displayName: 'Hello World' }))); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions(); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); @@ -277,11 +277,11 @@ suite('NativeExtensionsScanerService Test', () => { const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); translations = { 'pub.name': nlsLocation.fsPath }; - const actual = await testObject.scanUserExtensions({ language: 'en' }); + const actual = await testObject.scanExistingExtension(extensionLocation, ExtensionType.User, { language: 'en' }); - assert.deepStrictEqual(actual.length, 1); - assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); - assert.deepStrictEqual(actual[0].manifest.displayName, 'Hello World EN'); + assert.ok(actual !== null); + assert.deepStrictEqual(actual!.identifier, { id: 'pub.name' }); + assert.deepStrictEqual(actual!.manifest.displayName, 'Hello World EN'); }); test('scan extension falls back to default nls replacements', async () => { @@ -292,11 +292,11 @@ suite('NativeExtensionsScanerService Test', () => { const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); translations = { 'pub.name2': nlsLocation.fsPath }; - const actual = await testObject.scanUserExtensions({ language: 'en' }); + const actual = await testObject.scanExistingExtension(extensionLocation, ExtensionType.User, { language: 'en' }); - assert.deepStrictEqual(actual.length, 1); - assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); - assert.deepStrictEqual(actual[0].manifest.displayName, 'Hello World'); + assert.ok(actual !== null); + assert.deepStrictEqual(actual!.identifier, { id: 'pub.name' }); + assert.deepStrictEqual(actual!.manifest.displayName, 'Hello World'); }); async function aUserExtension(manifest: Partial): Promise { diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index bd35500287e..0ca98611fb8 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -38,6 +38,9 @@ const _allApiProposals = { chatProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatProvider.d.ts', }, + chatReadonlyPromptReference: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatReadonlyPromptReference.d.ts', + }, chatReferenceBinaryData: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatReferenceBinaryData.d.ts', }, @@ -165,9 +168,6 @@ const _allApiProposals = { documentFiltersExclusive: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.documentFiltersExclusive.d.ts', }, - documentPaste: { - proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.documentPaste.d.ts', - }, editSessionIdentityProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.editSessionIdentityProvider.d.ts', }, @@ -352,6 +352,9 @@ const _allApiProposals = { terminalSelection: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalSelection.d.ts', }, + terminalShellEnv: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalShellEnv.d.ts', + }, testObserver: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testObserver.d.ts', }, diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts index 0c60edd91a9..6b40b9d4ba3 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts @@ -9,7 +9,6 @@ import { BaseWatcher } from '../baseWatcher.js'; import { isLinux } from '../../../../../base/common/platform.js'; import { INonRecursiveWatchRequest, INonRecursiveWatcher, IRecursiveWatcherWithSubscribe } from '../../../common/watcher.js'; import { NodeJSFileWatcherLibrary } from './nodejsWatcherLib.js'; -import { isEqual } from '../../../../../base/common/extpath.js'; export interface INodeJSWatcherInstance { @@ -28,7 +27,8 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { readonly onDidError = Event.None; - readonly watchers = new Set(); + private readonly _watchers = new Map(); + get watchers() { return this._watchers.values(); } constructor(protected readonly recursiveWatcher: IRecursiveWatcherWithSubscribe | undefined) { super(); @@ -43,7 +43,7 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { const requestsToStart: INonRecursiveWatchRequest[] = []; const watchersToStop = new Set(Array.from(this.watchers)); for (const request of requests) { - const watcher = this.findWatcher(request); + const watcher = this._watchers.get(this.requestToWatcherKey(request)); if (watcher && patternsEquals(watcher.request.excludes, request.excludes) && patternsEquals(watcher.request.includes, request.includes)) { watchersToStop.delete(watcher); // keep watcher } else { @@ -72,25 +72,12 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { } } - private findWatcher(request: INonRecursiveWatchRequest): INodeJSWatcherInstance | undefined { - for (const watcher of this.watchers) { + private requestToWatcherKey(request: INonRecursiveWatchRequest): string | number { + return typeof request.correlationId === 'number' ? request.correlationId : this.pathToWatcherKey(request.path); + } - // Requests or watchers with correlation always match on that - if (typeof request.correlationId === 'number' || typeof watcher.request.correlationId === 'number') { - if (watcher.request.correlationId === request.correlationId) { - return watcher; - } - } - - // Non-correlated requests or watchers match on path - else { - if (isEqual(watcher.request.path, request.path, !isLinux /* ignorecase */)) { - return watcher; - } - } - } - - return undefined; + private pathToWatcherKey(path: string): string { + return isLinux ? path : path.toLowerCase() /* ignore path casing */; } private startWatching(request: INonRecursiveWatchRequest): void { @@ -100,7 +87,7 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { // Remember as watcher instance const watcher: INodeJSWatcherInstance = { request, instance }; - this.watchers.add(watcher); + this._watchers.set(this.requestToWatcherKey(request), watcher); } override async stop(): Promise { @@ -114,7 +101,7 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { private stopWatching(watcher: INodeJSWatcherInstance): void { this.trace(`stopping file watcher`, watcher); - this.watchers.delete(watcher); + this._watchers.delete(this.requestToWatcherKey(watcher.request)); watcher.instance.dispose(); } @@ -124,7 +111,6 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { // Ignore requests for the same paths that have the same correlation for (const request of requests) { - const path = isLinux ? request.path : request.path.toLowerCase(); // adjust for case sensitivity let requestsForCorrelation = mapCorrelationtoRequests.get(request.correlationId); if (!requestsForCorrelation) { @@ -132,6 +118,7 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { mapCorrelationtoRequests.set(request.correlationId, requestsForCorrelation); } + const path = this.pathToWatcherKey(request.path); if (requestsForCorrelation.has(path)) { this.trace(`ignoring a request for watching who's path is already watched: ${this.requestToString(request)}`); } diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts index a7c89ceb00e..b016dde0a9f 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts @@ -6,17 +6,18 @@ import { watch, promises } from 'fs'; import { RunOnceWorker, ThrottledWorker } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { isEqualOrParent } from '../../../../../base/common/extpath.js'; +import { isEqual, isEqualOrParent } from '../../../../../base/common/extpath.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { normalizeNFC } from '../../../../../base/common/normalization.js'; import { basename, dirname, join } from '../../../../../base/common/path.js'; import { isLinux, isMacintosh } from '../../../../../base/common/platform.js'; import { joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; -import { realcase } from '../../../../../base/node/extpath.js'; +import { realpath } from '../../../../../base/node/extpath.js'; import { Promises } from '../../../../../base/node/pfs.js'; import { FileChangeType, IFileChange } from '../../../common/files.js'; import { ILogMessage, coalesceEvents, INonRecursiveWatchRequest, parseWatcherPatterns, IRecursiveWatcherWithSubscribe, isFiltered, isWatchRequestWithCorrelation } from '../../../common/watcher.js'; +import { Lazy } from '../../../../../base/common/lazy.js'; export class NodeJSFileWatcherLibrary extends Disposable { @@ -55,6 +56,28 @@ export class NodeJSFileWatcherLibrary extends Disposable { private readonly cts = new CancellationTokenSource(); + private readonly realPath = new Lazy(async () => { + + // This property is intentionally `Lazy` and not using `realcase()` as the counterpart + // in the recursive watcher because of the amount of paths this watcher is dealing with. + // We try as much as possible to avoid even needing `realpath()` if we can because even + // that method does an `lstat()` per segment of the path. + + let result = this.request.path; + + try { + result = await realpath(this.request.path); + + if (this.request.path !== result) { + this.trace(`correcting a path to watch that seems to be a symbolic link (original: ${this.request.path}, real: ${result})`); + } + } catch (error) { + // ignore + } + + return result; + }); + readonly ready = this.watch(); private _isReusingRecursiveWatcher = false; @@ -76,19 +99,13 @@ export class NodeJSFileWatcherLibrary extends Disposable { private async watch(): Promise { try { - const realPath = await this.normalizePath(this.request); + const stat = await promises.stat(this.request.path); if (this.cts.token.isCancellationRequested) { return; } - const stat = await promises.stat(realPath); - - if (this.cts.token.isCancellationRequested) { - return; - } - - this._register(await this.doWatch(realPath, stat.isDirectory())); + this._register(await this.doWatch(stat.isDirectory())); } catch (error) { if (error.code !== 'ENOENT') { this.error(error); @@ -106,46 +123,21 @@ export class NodeJSFileWatcherLibrary extends Disposable { this.onDidWatchFail?.(); } - private async normalizePath(request: INonRecursiveWatchRequest): Promise { - let realPath = request.path; - - try { - - // First check for symbolic link - realPath = await Promises.realpath(request.path); - - // Second check for casing difference - // Note: this will be a no-op on Linux platforms - if (request.path === realPath) { - realPath = await realcase(request.path, this.cts.token) ?? request.path; - } - - // Correct watch path as needed - if (request.path !== realPath) { - this.trace(`correcting a path to watch that seems to be a symbolic link or wrong casing (original: ${request.path}, real: ${realPath})`); - } - } catch (error) { - // ignore - } - - return realPath; - } - - private async doWatch(realPath: string, isDirectory: boolean): Promise { + private async doWatch(isDirectory: boolean): Promise { const disposables = new DisposableStore(); - if (this.doWatchWithExistingWatcher(realPath, isDirectory, disposables)) { + if (this.doWatchWithExistingWatcher(isDirectory, disposables)) { this.trace(`reusing an existing recursive watcher for ${this.request.path}`); this._isReusingRecursiveWatcher = true; } else { this._isReusingRecursiveWatcher = false; - await this.doWatchWithNodeJS(realPath, isDirectory, disposables); + await this.doWatchWithNodeJS(isDirectory, disposables); } return disposables; } - private doWatchWithExistingWatcher(realPath: string, isDirectory: boolean, disposables: DisposableStore): boolean { + private doWatchWithExistingWatcher(isDirectory: boolean, disposables: DisposableStore): boolean { if (isDirectory) { // Recursive watcher re-use is currently not enabled for when // folders are watched. this is because the dispatching in the @@ -162,7 +154,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { } if (error) { - const watchDisposable = await this.doWatch(realPath, isDirectory); + const watchDisposable = await this.doWatch(isDirectory); if (!disposables.isDisposed) { disposables.add(watchDisposable); } else { @@ -188,7 +180,8 @@ export class NodeJSFileWatcherLibrary extends Disposable { return false; } - private async doWatchWithNodeJS(realPath: string, isDirectory: boolean, disposables: DisposableStore): Promise { + private async doWatchWithNodeJS(isDirectory: boolean, disposables: DisposableStore): Promise { + const realPath = await this.realPath.value; // macOS: watching samba shares can crash VSCode so we do // a simple check for the file path pointing to /Volumes @@ -311,7 +304,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { // file watching specifically we want to handle // the atomic-write cases where the file is being // deleted and recreated with different contents. - if (changedFileName === pathBasename && !await Promises.exists(realPath)) { + if (isEqual(changedFileName, pathBasename, !isLinux) && !await Promises.exists(realPath)) { this.onWatchedPathDeleted(requestResource); return; @@ -374,7 +367,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { else { // File added/deleted - if (type === 'rename' || changedFileName !== pathBasename) { + if (type === 'rename' || !isEqual(changedFileName, pathBasename, !isLinux)) { // Depending on the OS the watcher runs on, there // is different behaviour for when the watched @@ -407,7 +400,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { if (fileExists) { this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */); - watcherDisposables.add(await this.doWatch(realPath, false)); + watcherDisposables.add(await this.doWatch(false)); } // File seems to be really gone, so emit a deleted and failed event diff --git a/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts b/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts index 3b47a55d691..c778b998140 100644 --- a/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts +++ b/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as parcelWatcher from '@parcel/watcher'; -import { statSync, unlinkSync } from 'fs'; +import parcelWatcher from '@parcel/watcher'; +import { promises } from 'fs'; import { tmpdir, homedir } from 'os'; import { URI } from '../../../../../base/common/uri.js'; import { DeferredPromise, RunOnceScheduler, RunOnceWorker, ThrottledWorker } from '../../../../../base/common/async.js'; @@ -18,7 +18,7 @@ import { TernarySearchTree } from '../../../../../base/common/ternarySearchTree. import { normalizeNFC } from '../../../../../base/common/normalization.js'; import { normalize, join } from '../../../../../base/common/path.js'; import { isLinux, isMacintosh, isWindows } from '../../../../../base/common/platform.js'; -import { realcaseSync, realpathSync } from '../../../../../base/node/extpath.js'; +import { realcase, realpath } from '../../../../../base/node/extpath.js'; import { FileChangeType, IFileChange } from '../../../common/files.js'; import { coalesceEvents, IRecursiveWatchRequest, parseWatcherPatterns, IRecursiveWatcherWithSubscribe, isFiltered, IWatcherErrorEvent } from '../../../common/watcher.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; @@ -157,7 +157,8 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS private readonly _onDidError = this._register(new Emitter()); readonly onDidError = this._onDidError.event; - readonly watchers = new Set(); + private readonly _watchers = new Map(); + get watchers() { return this._watchers.values(); } // A delay for collecting file changes from Parcel // before collecting them for coalescing and emitting. @@ -200,13 +201,13 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS protected override async doWatch(requests: IRecursiveWatchRequest[]): Promise { // Figure out duplicates to remove from the requests - requests = this.removeDuplicateRequests(requests); + requests = await this.removeDuplicateRequests(requests); // Figure out which watchers to start and which to stop const requestsToStart: IRecursiveWatchRequest[] = []; const watchersToStop = new Set(Array.from(this.watchers)); for (const request of requests) { - const watcher = this.findWatcher(request); + const watcher = this._watchers.get(this.requestToWatcherKey(request)); if (watcher && patternsEquals(watcher.request.excludes, request.excludes) && patternsEquals(watcher.request.includes, request.includes) && watcher.request.pollingInterval === request.pollingInterval) { watchersToStop.delete(watcher); // keep watcher } else { @@ -231,35 +232,22 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS // Start watching as instructed for (const request of requestsToStart) { if (request.pollingInterval) { - this.startPolling(request, request.pollingInterval); + await this.startPolling(request, request.pollingInterval); } else { await this.startWatching(request); } } } - private findWatcher(request: IRecursiveWatchRequest): ParcelWatcherInstance | undefined { - for (const watcher of this.watchers) { - - // Requests or watchers with correlation always match on that - if (this.isCorrelated(request) || this.isCorrelated(watcher.request)) { - if (watcher.request.correlationId === request.correlationId) { - return watcher; - } - } - - // Non-correlated requests or watchers match on path - else { - if (isEqual(watcher.request.path, request.path, !isLinux /* ignorecase */)) { - return watcher; - } - } - } - - return undefined; + private requestToWatcherKey(request: IRecursiveWatchRequest): string | number { + return typeof request.correlationId === 'number' ? request.correlationId : this.pathToWatcherKey(request.path); } - private startPolling(request: IRecursiveWatchRequest, pollingInterval: number, restarts = 0): void { + private pathToWatcherKey(path: string): string { + return isLinux ? path : path.toLowerCase() /* ignore path casing */; + } + + private async startPolling(request: IRecursiveWatchRequest, pollingInterval: number, restarts = 0): Promise { const cts = new CancellationTokenSource(); const instance = new DeferredPromise(); @@ -280,13 +268,13 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS watcher.worker.dispose(); pollingWatcher.dispose(); - unlinkSync(snapshotFile); + await promises.unlink(snapshotFile); } ); - this.watchers.add(watcher); + this._watchers.set(this.requestToWatcherKey(request), watcher); // Path checks for symbolic links / wrong casing - const { realPath, realPathDiffers, realPathLength } = this.normalizePath(request); + const { realPath, realPathDiffers, realPathLength } = await this.normalizePath(request); this.trace(`Started watching: '${realPath}' with polling interval '${pollingInterval}'`); @@ -352,10 +340,10 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS await watcherInstance?.unsubscribe(); } ); - this.watchers.add(watcher); + this._watchers.set(this.requestToWatcherKey(request), watcher); // Path checks for symbolic links / wrong casing - const { realPath, realPathDiffers, realPathLength } = this.normalizePath(request); + const { realPath, realPathDiffers, realPathLength } = await this.normalizePath(request); try { const parcelWatcherLib = parcelWatcher; @@ -483,7 +471,7 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS } } - private normalizePath(request: IRecursiveWatchRequest): { realPath: string; realPathDiffers: boolean; realPathLength: number } { + private async normalizePath(request: IRecursiveWatchRequest): Promise<{ realPath: string; realPathDiffers: boolean; realPathLength: number }> { let realPath = request.path; let realPathDiffers = false; let realPathLength = request.path.length; @@ -491,12 +479,12 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS try { // First check for symbolic link - realPath = realpathSync(request.path); + realPath = await realpath(request.path); // Second check for casing difference // Note: this will be a no-op on Linux platforms if (request.path === realPath) { - realPath = realcaseSync(request.path) ?? request.path; + realPath = await realcase(request.path) ?? request.path; } // Correct watch path as needed @@ -627,7 +615,7 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS // Start watcher again counting the restarts if (watcher.request.pollingInterval) { - this.startPolling(watcher.request, watcher.request.pollingInterval, watcher.restarts + 1); + await this.startPolling(watcher.request, watcher.request.pollingInterval, watcher.restarts + 1); } else { await this.startWatching(watcher.request, watcher.restarts + 1); } @@ -643,7 +631,7 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS private async stopWatching(watcher: ParcelWatcherInstance, joinRestart?: Promise): Promise { this.trace(`stopping file watcher`, watcher); - this.watchers.delete(watcher); + this._watchers.delete(this.requestToWatcherKey(watcher.request)); try { await watcher.stop(joinRestart); @@ -652,7 +640,7 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS } } - protected removeDuplicateRequests(requests: IRecursiveWatchRequest[], validatePaths = true): IRecursiveWatchRequest[] { + protected async removeDuplicateRequests(requests: IRecursiveWatchRequest[], validatePaths = true): Promise { // Sort requests by path length to have shortest first // to have a way to prevent children to be watched if @@ -666,7 +654,6 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS continue; // path is ignored entirely (via `**` glob exclude) } - const path = isLinux ? request.path : request.path.toLowerCase(); // adjust for case sensitivity let requestsForCorrelation = mapCorrelationtoRequests.get(request.correlationId); if (!requestsForCorrelation) { @@ -674,6 +661,7 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS mapCorrelationtoRequests.set(request.correlationId, requestsForCorrelation); } + const path = this.pathToWatcherKey(request.path); if (requestsForCorrelation.has(path)) { this.trace(`ignoring a request for watching who's path is already watched: ${this.requestToString(request)}`); } @@ -698,26 +686,29 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS for (const request of requestsForCorrelation.values()) { - // Check for overlapping requests + // Check for overlapping request paths (but preserve symbolic links) if (requestTrie.findSubstr(request.path)) { - try { - const realpath = realpathSync(request.path); - if (realpath === request.path) { - this.trace(`ignoring a request for watching who's parent is already watched: ${this.requestToString(request)}`); + if (requestTrie.has(request.path)) { + this.trace(`ignoring a request for watching who's path is already watched: ${this.requestToString(request)}`); + } else { + try { + if (!(await promises.lstat(request.path)).isSymbolicLink()) { + this.trace(`ignoring a request for watching who's parent is already watched: ${this.requestToString(request)}`); + + continue; + } + } catch (error) { + this.trace(`ignoring a request for watching who's lstat failed to resolve: ${this.requestToString(request)} (error: ${error})`); + + this._onDidWatchFail.fire(request); continue; } - } catch (error) { - this.trace(`ignoring a request for watching who's realpath failed to resolve: ${this.requestToString(request)} (error: ${error})`); - - this._onDidWatchFail.fire(request); - - continue; } } // Check for invalid paths - if (validatePaths && !this.isPathValid(request.path)) { + if (validatePaths && !(await this.isPathValid(request.path))) { this._onDidWatchFail.fire(request); continue; @@ -732,9 +723,9 @@ export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithS return normalizedRequests; } - private isPathValid(path: string): boolean { + private async isPathValid(path: string): Promise { try { - const stat = statSync(path); + const stat = await promises.stat(path); if (!stat.isDirectory()) { this.trace(`ignoring a path for watching that is a file and not a folder: ${path}`); diff --git a/src/vs/platform/files/node/watcher/watcherStats.ts b/src/vs/platform/files/node/watcher/watcherStats.ts index b13a2187fb4..eca99eddef9 100644 --- a/src/vs/platform/files/node/watcher/watcherStats.ts +++ b/src/vs/platform/files/node/watcher/watcherStats.ts @@ -34,8 +34,8 @@ export function computeStats( lines.push('[Summary]'); lines.push(`- Recursive Requests: total: ${allRecursiveRequests.length}, suspended: ${recursiveRequestsStatus.suspended}, polling: ${recursiveRequestsStatus.polling}, failed: ${failedRecursiveRequests}`); lines.push(`- Non-Recursive Requests: total: ${allNonRecursiveRequests.length}, suspended: ${nonRecursiveRequestsStatus.suspended}, polling: ${nonRecursiveRequestsStatus.polling}`); - lines.push(`- Recursive Watchers: total: ${recursiveWatcher.watchers.size}, active: ${recursiveWatcherStatus.active}, failed: ${recursiveWatcherStatus.failed}, stopped: ${recursiveWatcherStatus.stopped}`); - lines.push(`- Non-Recursive Watchers: total: ${nonRecursiveWatcher.watchers.size}, active: ${nonRecursiveWatcherStatus.active}, failed: ${nonRecursiveWatcherStatus.failed}, reusing: ${nonRecursiveWatcherStatus.reusing}`); + lines.push(`- Recursive Watchers: total: ${Array.from(recursiveWatcher.watchers).length}, active: ${recursiveWatcherStatus.active}, failed: ${recursiveWatcherStatus.failed}, stopped: ${recursiveWatcherStatus.stopped}`); + lines.push(`- Non-Recursive Watchers: total: ${Array.from(nonRecursiveWatcher.watchers).length}, active: ${nonRecursiveWatcherStatus.active}, failed: ${nonRecursiveWatcherStatus.failed}, reusing: ${nonRecursiveWatcherStatus.reusing}`); lines.push(`- I/O Handles Impact: total: ${recursiveRequestsStatus.polling + nonRecursiveRequestsStatus.polling + recursiveWatcherStatus.active + nonRecursiveWatcherStatus.active}`); lines.push(`\n[Recursive Requests (${allRecursiveRequests.length}, suspended: ${recursiveRequestsStatus.suspended}, polling: ${recursiveRequestsStatus.polling})]:`); @@ -106,7 +106,7 @@ function computeRecursiveWatchStatus(recursiveWatcher: ParcelWatcher): { active: let failed = 0; let stopped = 0; - for (const watcher of recursiveWatcher.watchers.values()) { + for (const watcher of recursiveWatcher.watchers) { if (!watcher.failed && !watcher.stopped) { active++; } @@ -189,7 +189,7 @@ function requestDetailsToString(request: IUniversalWatchRequest): string { } function fillRecursiveWatcherStats(lines: string[], recursiveWatcher: ParcelWatcher): void { - const watchers = sortByPathPrefix(Array.from(recursiveWatcher.watchers.values())); + const watchers = sortByPathPrefix(Array.from(recursiveWatcher.watchers)); const { active, failed, stopped } = computeRecursiveWatchStatus(recursiveWatcher); lines.push(`\n[Recursive Watchers (${watchers.length}, active: ${active}, failed: ${failed}, stopped: ${stopped})]:`); @@ -213,7 +213,7 @@ function fillRecursiveWatcherStats(lines: string[], recursiveWatcher: ParcelWatc } function fillNonRecursiveWatcherStats(lines: string[], nonRecursiveWatcher: NodeJSWatcher): void { - const allWatchers = sortByPathPrefix(Array.from(nonRecursiveWatcher.watchers.values())); + const allWatchers = sortByPathPrefix(Array.from(nonRecursiveWatcher.watchers)); const activeWatchers = allWatchers.filter(watcher => !watcher.instance.failed && !watcher.instance.isReusingRecursiveWatcher); const failedWatchers = allWatchers.filter(watcher => watcher.instance.failed); const reusingWatchers = allWatchers.filter(watcher => watcher.instance.isReusingRecursiveWatcher); diff --git a/src/vs/platform/files/test/node/nodejsWatcher.test.ts b/src/vs/platform/files/test/node/nodejsWatcher.test.ts index a46279aaf50..2e748a7c532 100644 --- a/src/vs/platform/files/test/node/nodejsWatcher.test.ts +++ b/src/vs/platform/files/test/node/nodejsWatcher.test.ts @@ -72,7 +72,11 @@ suite.skip('File Watcher (node.js)', function () { setup(async () => { await createWatcher(undefined); - testDir = URI.file(getRandomTestPath(tmpdir(), 'vsctests', 'filewatcher')).fsPath; + // Rule out strange testing conditions by using the realpath + // here. for example, on macOS the tmp dir is potentially a + // symlink in some of the root folders, which is a rather + // unrealisic case for the file watcher. + testDir = URI.file(getRandomTestPath(fs.realpathSync(tmpdir()), 'vsctests', 'filewatcher')).fsPath; const sourceDir = FileAccess.asFileUri('vs/platform/files/test/node/fixtures/service').fsPath; @@ -630,7 +634,7 @@ suite.skip('File Watcher (node.js)', function () { await basicCrudTest(filePath, undefined, null, undefined, true); }); - test('watch requests support suspend/resume (folder, does not exist in beginning)', async function () { + (isWindows /* Windows: does not seem to report this */ ? test.skip : test)('watch requests support suspend/resume (folder, does not exist in beginning)', async function () { let onDidWatchFail = Event.toPromise(watcher.onWatchFail); const folderPath = join(testDir, 'not-found'); diff --git a/src/vs/platform/files/test/node/parcelWatcher.test.ts b/src/vs/platform/files/test/node/parcelWatcher.test.ts index 558b81e19a9..e2038a17f50 100644 --- a/src/vs/platform/files/test/node/parcelWatcher.test.ts +++ b/src/vs/platform/files/test/node/parcelWatcher.test.ts @@ -32,14 +32,14 @@ export class TestParcelWatcher extends ParcelWatcher { readonly onWatchFail = this._onDidWatchFail.event; - testRemoveDuplicateRequests(paths: string[], excludes: string[] = []): string[] { + async testRemoveDuplicateRequests(paths: string[], excludes: string[] = []): Promise { // Work with strings as paths to simplify testing const requests: IRecursiveWatchRequest[] = paths.map(path => { return { path, excludes, recursive: true }; }); - return this.removeDuplicateRequests(requests, false /* validate paths skipped for tests */).map(request => request.path); + return (await this.removeDuplicateRequests(requests, false /* validate paths skipped for tests */)).map(request => request.path); } protected override getUpdateWatchersDelay(): number { @@ -97,7 +97,11 @@ suite.skip('File Watcher (parcel)', function () { } }); - testDir = URI.file(getRandomTestPath(tmpdir(), 'vsctests', 'filewatcher')).fsPath; + // Rule out strange testing conditions by using the realpath + // here. for example, on macOS the tmp dir is potentially a + // symlink in some of the root folders, which is a rather + // unrealisic case for the file watcher. + testDir = URI.file(getRandomTestPath(realpathSync(tmpdir()), 'vsctests', 'filewatcher')).fsPath; const sourceDir = FileAccess.asFileUri('vs/platform/files/test/node/fixtures/service').fsPath; @@ -105,7 +109,7 @@ suite.skip('File Watcher (parcel)', function () { }); teardown(async () => { - const watchers = watcher.watchers.size; + const watchers = Array.from(watcher.watchers).length; let stoppedInstances = 0; for (const instance of watcher.watchers) { Event.once(instance.onDidStop)(() => { @@ -190,7 +194,6 @@ suite.skip('File Watcher (parcel)', function () { test('basics', async function () { const request = { path: testDir, excludes: [], recursive: true }; await watcher.watch([request]); - assert.strictEqual(watcher.watchers.size, watcher.watchers.size); const instance = Array.from(watcher.watchers)[0]; assert.strictEqual(request, instance.request); @@ -637,34 +640,34 @@ suite.skip('File Watcher (parcel)', function () { return basicCrudTest(join(testDir, 'newFile.txt'), correlationId); }); - test('should not exclude roots that do not overlap', () => { + test('should not exclude roots that do not overlap', async () => { if (isWindows) { - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a']), ['C:\\a']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\b', 'C:\\c\\d\\e']), ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['C:\\a']), ['C:\\a']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\b', 'C:\\c\\d\\e']), ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']); } else { - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a']), ['/a']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/b']), ['/a', '/b']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/b', '/c/d/e']), ['/a', '/b', '/c/d/e']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['/a']), ['/a']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['/a', '/b']), ['/a', '/b']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['/a', '/b', '/c/d/e']), ['/a', '/b', '/c/d/e']); } }); - test('should remove sub-folders of other paths', () => { + test('should remove sub-folders of other paths', async () => { if (isWindows) { - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\a\\b']), ['C:\\a']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d']), ['C:\\a']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\a\\b']), ['C:\\a']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d']), ['C:\\a']); } else { - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/a/b']), ['/a']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/b', '/a/b']), ['/a', '/b']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/b/a', '/a', '/b', '/a/b']), ['/a', '/b']); - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/a/b', '/a/c/d']), ['/a']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['/a', '/a/b']), ['/a']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['/a', '/b', '/a/b']), ['/a', '/b']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['/b/a', '/a', '/b', '/a/b']), ['/a', '/b']); + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['/a', '/a/b', '/a/c/d']), ['/a']); } }); - test('should ignore when everything excluded', () => { - assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/foo/bar', '/bar'], ['**', 'something']), []); + test('should ignore when everything excluded', async () => { + assert.deepStrictEqual(await watcher.testRemoveDuplicateRequests(['/foo/bar', '/bar'], ['**', 'something']), []); }); test('watching same or overlapping paths supported when correlation is applied', async () => { @@ -787,17 +790,19 @@ suite.skip('File Watcher (parcel)', function () { const filePath = join(folderPath, 'newFile.txt'); await basicCrudTest(filePath); - onDidWatchFail = Event.toPromise(watcher.onWatchFail); - await Promises.rm(folderPath); - await onDidWatchFail; + if (!reuseExistingWatcher) { + onDidWatchFail = Event.toPromise(watcher.onWatchFail); + await Promises.rm(folderPath); + await onDidWatchFail; - changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED); - onDidWatch = Event.toPromise(watcher.onDidWatch); - await promises.mkdir(folderPath); - await changeFuture; - await onDidWatch; + changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED); + onDidWatch = Event.toPromise(watcher.onDidWatch); + await promises.mkdir(folderPath); + await changeFuture; + await onDidWatch; - await basicCrudTest(filePath); + await basicCrudTest(filePath); + } } (isWindows /* Windows: times out for some reason */ ? test.skip : test)('watch requests support suspend/resume (folder, exist in beginning, not reusing watcher)', async () => { @@ -821,17 +826,19 @@ suite.skip('File Watcher (parcel)', function () { const filePath = join(folderPath, 'newFile.txt'); await basicCrudTest(filePath); - const onDidWatchFail = Event.toPromise(watcher.onWatchFail); - await Promises.rm(folderPath); - await onDidWatchFail; + if (!reuseExistingWatcher) { + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + await Promises.rm(folderPath); + await onDidWatchFail; - const changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED); - const onDidWatch = Event.toPromise(watcher.onDidWatch); - await promises.mkdir(folderPath); - await changeFuture; - await onDidWatch; + const changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED); + const onDidWatch = Event.toPromise(watcher.onDidWatch); + await promises.mkdir(folderPath); + await changeFuture; + await onDidWatch; - await basicCrudTest(filePath); + await basicCrudTest(filePath); + } } test('watch request reuses another recursive watcher even when requests are coming in at the same time', async function () { diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 2fdfb63e0b5..13141d89063 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -113,6 +113,9 @@ export interface ICommonNativeHostService { */ focusWindow(options?: INativeHostOptions & { force?: boolean }): Promise; + // Titlebar default style override + overrideDefaultTitlebarStyle(style: 'custom' | undefined): Promise; + // Dialogs showMessageBox(options: MessageBoxOptions & INativeHostOptions): Promise; showSaveDialog(options: SaveDialogOptions & INativeHostOptions): Promise; @@ -143,10 +146,6 @@ export interface ICommonNativeHostService { hasWSLFeatureInstalled(): Promise; // Screenshots - - /** - * Gets a screenshot of the currently active Electron window. - */ getScreenshot(): Promise; // Process @@ -199,7 +198,7 @@ export interface ICommonNativeHostService { loadCertificates(): Promise; findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride?: number): Promise; - // Registry (windows only) + // Registry (Windows only) windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise; } diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 794c5aed575..030b6a27506 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -48,6 +48,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; import { randomPath } from '../../../base/common/extpath.js'; +import { IStateService } from '../../state/node/state.js'; export interface INativeHostMainService extends AddFirstParameterToFunctions /* only methods, not events */, number | undefined /* window ID */> { } @@ -70,7 +71,8 @@ export class NativeHostMainService extends Disposable implements INativeHostMain @IConfigurationService private readonly configurationService: IConfigurationService, @IRequestService private readonly requestService: IRequestService, @IProxyAuthService private readonly proxyAuthService: IProxyAuthService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStateService private readonly stateService: IStateService ) { super(); } @@ -324,6 +326,14 @@ export class NativeHostMainService extends Disposable implements INativeHostMain this.themeMainService.saveWindowSplash(windowId, splash); } + async overrideDefaultTitlebarStyle(windowId: number | undefined, style: 'custom' | undefined): Promise { + if (style === 'custom') { + this.stateService.setItem('window.titleBarStyleOverride', style); + } else { + this.stateService.removeItem('window.titleBarStyleOverride'); + } + } + //#endregion @@ -697,6 +707,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain async getScreenshot(windowId: number | undefined, options?: INativeHostOptions): Promise { const window = this.windowById(options?.targetWindowId, windowId); const captured = await window?.win?.webContents.capturePage(); + return captured?.toJPEG(95); } diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 9108ee9ae80..24394ac42cb 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -8,12 +8,12 @@ width: 600px; z-index: 2550; left: 50%; - margin-left: -300px; -webkit-app-region: no-drag; border-radius: 6px; } .quick-input-titlebar { + cursor: grab; display: flex; align-items: center; border-top-right-radius: 5px; @@ -26,11 +26,18 @@ flex: 1; } -.quick-input-inline-action-bar { - margin: 2px 0 0 5px; +/* give some space between input and action bar */ +.quick-input-inline-action-bar > .actions-container > .action-item:first-child { + margin-left: 5px; +} + +/* center horizontally */ +.quick-input-inline-action-bar > .actions-container > .action-item { + margin-top: 2px; } .quick-input-title { + cursor: grab; padding: 3px 0px; text-align: center; text-overflow: ellipsis; @@ -63,6 +70,7 @@ } .quick-input-header { + cursor: grab; display: flex; padding: 8px 6px 2px 6px; } @@ -356,3 +364,27 @@ .quick-input-list .monaco-tl-twistie { display: none !important; } + +/* Quick input snap lines visible while DnD */ +.quick-input-widget-snapline { + position: absolute; + z-index: 2549; +} + +.quick-input-widget-snapline.hidden { + display: none; +} + +.quick-input-widget-snapline.horizontal { + border-top: 1px dashed var(--vscode-editorRuler-foreground); + height: 0; + width: 100%; + left: 0; +} + +.quick-input-widget-snapline.vertical { + border-left: 1px dashed var(--vscode-editorRuler-foreground); + height: 100%; + width: 0; + top: 0; +} diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 7b77f077c03..783dde2dd5b 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -26,9 +26,19 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { QuickInputTree } from './quickInputTree.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; import './quickInputActions.js'; +import { autorun, observableValue } from '../../../base/common/observable.js'; +import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; const $ = dom.$; +const VIEWSTATE_STORAGE_KEY = 'workbench.quickInput.viewState'; + +type QuickInputViewState = { + readonly top?: number; + readonly left?: number; +}; + export class QuickInputController extends Disposable { private static readonly MAX_WIDTH = 600; // Max total width of quick input widget @@ -58,6 +68,9 @@ export class QuickInputController extends Disposable { private previousFocusElement?: HTMLElement; + private viewState: QuickInputViewState | undefined; + private dndController: QuickInputDragAndDropController | undefined; + private readonly inQuickInputContext = InQuickInputContextKey.bindTo(this.contextKeyService); private readonly quickInputTypeContext = QuickInputTypeContextKey.bindTo(this.contextKeyService); private readonly endOfQuickInputBoxContext = EndOfQuickInputBoxContextKey.bindTo(this.contextKeyService); @@ -66,7 +79,8 @@ export class QuickInputController extends Disposable { private options: IQuickInputOptions, @ILayoutService private readonly layoutService: ILayoutService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService ) { super(); this.idPrefix = options.idPrefix; @@ -83,6 +97,7 @@ export class QuickInputController extends Disposable { this.layout(this.layoutService.mainContainerDimension, this.layoutService.mainContainerOffset.quickPickTop); } })); + this.viewState = this.loadViewState(); } private registerKeyModsListeners(window: Window, disposables: DisposableStore): void { @@ -314,6 +329,36 @@ export class QuickInputController extends Disposable { } })); + // Drag and Drop support + this.dndController = this._register(this.instantiationService.createInstance( + QuickInputDragAndDropController, this._container, container, [titleBar, title, headerContainer])); + + // DnD update layout + this._register(autorun(reader => { + const dndViewState = this.dndController?.dndViewState.read(reader); + if (!dndViewState) { + return; + } + + if (dndViewState.top !== undefined && dndViewState.left !== undefined) { + this.viewState = { + ...this.viewState, + top: dndViewState.top, + left: dndViewState.left + }; + } else { + // Reset position/size + this.viewState = undefined; + } + + this.updateLayout(); + + // Save position + if (dndViewState.done) { + this.saveViewState(this.viewState); + } + })); + this.ui = { container, styleSheet, @@ -360,6 +405,7 @@ export class QuickInputController extends Disposable { if (this.ui) { this._container = container; dom.append(this._container, this.ui.container); + this.dndController?.reparentUI(this._container); } } @@ -729,12 +775,13 @@ export class QuickInputController extends Disposable { private updateLayout() { if (this.ui && this.isVisible()) { - this.ui.container.style.top = `${this.titleBarOffset}px`; - const style = this.ui.container.style; const width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); style.width = width + 'px'; - style.marginLeft = '-' + (width / 2) + 'px'; + + // Position + style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`; + style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`; this.ui.inputBox.layout(); this.ui.list.layout(this.dimension && this.dimension.height * 0.4); @@ -800,6 +847,164 @@ export class QuickInputController extends Disposable { } } } + + private loadViewState(): QuickInputViewState | undefined { + try { + const data = JSON.parse(this.storageService.get(VIEWSTATE_STORAGE_KEY, StorageScope.APPLICATION, '{}')); + if (data.top !== undefined || data.left !== undefined) { + return data; + } + } catch { } + + return undefined; + } + + private saveViewState(viewState: QuickInputViewState | undefined): void { + const isMainWindow = this.layoutService.activeContainer === this.layoutService.mainContainer; + if (!isMainWindow) { + return; + } + + if (viewState !== undefined) { + this.storageService.store(VIEWSTATE_STORAGE_KEY, JSON.stringify(viewState), StorageScope.APPLICATION, StorageTarget.MACHINE); + } else { + this.storageService.remove(VIEWSTATE_STORAGE_KEY, StorageScope.APPLICATION); + } + } } + export interface IQuickInputControllerHost extends ILayoutService { } +class QuickInputDragAndDropController extends Disposable { + readonly dndViewState = observableValue<{ top?: number; left?: number; done: boolean } | undefined>(this, undefined); + + private readonly _snapThreshold = 20; + private readonly _snapLineHorizontalRatio = 0.15; + private readonly _snapLineHorizontal: HTMLElement; + private readonly _snapLineVertical1: HTMLElement; + private readonly _snapLineVertical2: HTMLElement; + + constructor( + private _container: HTMLElement, + private readonly _quickInputContainer: HTMLElement, + private _quickInputDragAreas: HTMLElement[], + @ILayoutService private readonly _layoutService: ILayoutService + ) { + super(); + + this._snapLineHorizontal = dom.append(this._container, $('.quick-input-widget-snapline.horizontal.hidden')); + this._snapLineVertical1 = dom.append(this._container, $('.quick-input-widget-snapline.vertical.hidden')); + this._snapLineVertical2 = dom.append(this._container, $('.quick-input-widget-snapline.vertical.hidden')); + + this.registerMouseListeners(); + } + + reparentUI(container: HTMLElement): void { + this._container = container; + this._snapLineHorizontal.remove(); + this._snapLineVertical1.remove(); + this._snapLineVertical2.remove(); + dom.append(this._container, this._snapLineHorizontal); + dom.append(this._container, this._snapLineVertical1); + dom.append(this._container, this._snapLineVertical2); + } + + private registerMouseListeners(): void { + for (const dragArea of this._quickInputDragAreas) { + let top: number | undefined; + let left: number | undefined; + + // Double click + this._register(dom.addDisposableGenericMouseUpListener(dragArea, (event: MouseEvent) => { + const originEvent = new StandardMouseEvent(dom.getWindow(dragArea), event); + + // Ignore event if the target is not the drag area + if (originEvent.target !== dragArea) { + return; + } + + if (originEvent.detail === 2) { + top = undefined; + left = undefined; + + this.dndViewState.set({ top, left, done: true }, undefined); + } + })); + + // Mouse down + this._register(dom.addDisposableGenericMouseDownListener(dragArea, (e: MouseEvent) => { + const activeWindow = dom.getWindow(this._layoutService.activeContainer); + const originEvent = new StandardMouseEvent(activeWindow, e); + + // Ignore event if the target is not the drag area + if (originEvent.target !== dragArea) { + return; + } + + // Mouse position offset relative to dragArea + const dragAreaRect = this._quickInputContainer.getBoundingClientRect(); + const dragOffsetX = originEvent.browserEvent.clientX - dragAreaRect.left; + const dragOffsetY = originEvent.browserEvent.clientY - dragAreaRect.top; + + // Snap lines + let snapLinesVisible = false; + const snapCoordinateYTop = this._layoutService.activeContainerOffset.quickPickTop; + const snapCoordinateY = Math.round(this._container.clientHeight * this._snapLineHorizontalRatio); + const snapCoordinateX = Math.round(this._container.clientWidth / 2) - Math.round(this._quickInputContainer.clientWidth / 2); + + // Mouse move + const mouseMoveListener = dom.addDisposableGenericMouseMoveListener(activeWindow, (e: MouseEvent) => { + const mouseMoveEvent = new StandardMouseEvent(activeWindow, e); + mouseMoveEvent.preventDefault(); + + if (!snapLinesVisible) { + this._showSnapLines(snapCoordinateY, snapCoordinateX); + snapLinesVisible = true; + } + + let topCoordinate = e.clientY - dragOffsetY; + topCoordinate = Math.max(0, Math.min(topCoordinate, this._container.clientHeight - this._quickInputContainer.clientHeight)); + topCoordinate = Math.abs(topCoordinate - snapCoordinateYTop) < this._snapThreshold ? snapCoordinateYTop : topCoordinate; + topCoordinate = Math.abs(topCoordinate - snapCoordinateY) < this._snapThreshold ? snapCoordinateY : topCoordinate; + top = topCoordinate / this._container.clientHeight; + + let leftCoordinate = e.clientX - dragOffsetX; + leftCoordinate = Math.max(0, Math.min(leftCoordinate, this._container.clientWidth - this._quickInputContainer.clientWidth)); + leftCoordinate = Math.abs(leftCoordinate - snapCoordinateX) < this._snapThreshold ? snapCoordinateX : leftCoordinate; + left = (leftCoordinate + (this._quickInputContainer.clientWidth / 2)) / this._container.clientWidth; + + this.dndViewState.set({ top, left, done: false }, undefined); + }); + + // Mouse up + const mouseUpListener = dom.addDisposableGenericMouseUpListener(activeWindow, (e: MouseEvent) => { + // Hide snaplines + this._hideSnapLines(); + + // Save position + this.dndViewState.set({ top, left, done: true }, undefined); + + // Dispose listeners + mouseMoveListener.dispose(); + mouseUpListener.dispose(); + }); + })); + } + } + + private _showSnapLines(horizontal: number, vertical: number) { + this._snapLineHorizontal.style.top = `${horizontal}px`; + this._snapLineVertical1.style.left = `${vertical}px`; + this._snapLineVertical2.style.left = `${vertical + this._quickInputContainer.clientWidth}px`; + + this._snapLineHorizontal.classList.remove('hidden'); + this._snapLineVertical1.classList.remove('hidden'); + this._snapLineVertical2.classList.remove('hidden'); + } + + private _hideSnapLines() { + this._snapLineHorizontal.classList.add('hidden'); + this._snapLineVertical1.classList.add('hidden'); + this._snapLineVertical2.classList.add('hidden'); + } +} diff --git a/src/vs/platform/terminal/common/capabilities/capabilities.ts b/src/vs/platform/terminal/common/capabilities/capabilities.ts index 6de15be87fa..50f39332a43 100644 --- a/src/vs/platform/terminal/common/capabilities/capabilities.ts +++ b/src/vs/platform/terminal/common/capabilities/capabilities.ts @@ -70,7 +70,13 @@ export const enum TerminalCapability { * the request (task, debug, etc) provides an ID, optional marker, hoverMessage, and hidden property. When * hidden is not provided, a generic decoration is added to the buffer and overview ruler. */ - BufferMarkDetection + BufferMarkDetection, + + /** + * The terminal can detect the latest environment of user's current shell. + */ + ShellEnvDetection, + } /** @@ -133,6 +139,7 @@ export interface ITerminalCapabilityImplMap { [TerminalCapability.NaiveCwdDetection]: INaiveCwdDetectionCapability; [TerminalCapability.PartialCommandDetection]: IPartialCommandDetectionCapability; [TerminalCapability.BufferMarkDetection]: IBufferMarkCapability; + [TerminalCapability.ShellEnvDetection]: IShellEnvDetectionCapability; } export interface ICwdDetectionCapability { @@ -143,6 +150,13 @@ export interface ICwdDetectionCapability { updateCwd(cwd: string): void; } +export interface IShellEnvDetectionCapability { + readonly type: TerminalCapability.ShellEnvDetection; + readonly onDidChangeEnv: Event>; + get env(): Map; + setEnvironment(envs: { [key: string]: string | undefined } | undefined, isTrusted: boolean): void; +} + export const enum CommandInvalidationReason { Windows = 'windows', NoProblemsReported = 'noProblemsReported' diff --git a/src/vs/platform/terminal/common/capabilities/shellEnvDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/shellEnvDetectionCapability.ts new file mode 100644 index 00000000000..95e1d827dc4 --- /dev/null +++ b/src/vs/platform/terminal/common/capabilities/shellEnvDetectionCapability.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 { IShellEnvDetectionCapability, TerminalCapability } from './capabilities.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { equals } from '../../../../base/common/objects.js'; + +export class ShellEnvDetectionCapability extends Disposable implements IShellEnvDetectionCapability { + readonly type = TerminalCapability.ShellEnvDetection; + + private readonly _env: Map = new Map(); + get env(): Map { return this._env; } + + private readonly _onDidChangeEnv = this._register(new Emitter>()); + readonly onDidChangeEnv = this._onDidChangeEnv.event; + + setEnvironment(env: { [key: string]: string | undefined }, isTrusted: boolean): void { + if (!isTrusted) { + return; + } + + if (equals(this._env, env)) { + return; + } + + this._env.clear(); + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + this._env.set(key, value); + } + } + + // Convert to event and fire event + this._onDidChangeEnv.fire(this._env); + } +} diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 8235e8a0ab4..2accdc4b612 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -103,6 +103,7 @@ export const enum TerminalSettingId { EnablePersistentSessions = 'terminal.integrated.enablePersistentSessions', PersistentSessionReviveProcess = 'terminal.integrated.persistentSessionReviveProcess', HideOnStartup = 'terminal.integrated.hideOnStartup', + HideOnLastClosed = 'terminal.integrated.hideOnLastClosed', CustomGlyphs = 'terminal.integrated.customGlyphs', RescaleOverlappingGlyphs = 'terminal.integrated.rescaleOverlappingGlyphs', PersistentSessionScrollback = 'terminal.integrated.persistentSessionScrollback', @@ -116,7 +117,9 @@ export const enum TerminalSettingId { SmoothScrolling = 'terminal.integrated.smoothScrolling', IgnoreBracketedPasteMode = 'terminal.integrated.ignoreBracketedPasteMode', FocusAfterRun = 'terminal.integrated.focusAfterRun', - FontLigatures = 'terminal.integrated.fontLigatures', + FontLigaturesEnabled = 'terminal.integrated.fontLigatures.enabled', + FontLigaturesFeatureSettings = 'terminal.integrated.fontLigatures.featureSettings', + FontLigaturesFallbackLigatures = 'terminal.integrated.fontLigatures.fallbackLigatures', // Debug settings that are hidden from user @@ -871,7 +874,6 @@ export interface ITerminalProfile { overrideName?: boolean; color?: string; icon?: ThemeIcon | URI | { light: URI; dark: URI }; - isAutomationShell?: boolean; } export interface ITerminalDimensionsOverride extends Readonly { diff --git a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts index 8221ad5c282..ee7dbff267e 100644 --- a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts +++ b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts @@ -8,7 +8,7 @@ import { Disposable, dispose, IDisposable, toDisposable } from '../../../../base import { TerminalCapabilityStore } from '../capabilities/terminalCapabilityStore.js'; import { CommandDetectionCapability } from '../capabilities/commandDetectionCapability.js'; import { CwdDetectionCapability } from '../capabilities/cwdDetectionCapability.js'; -import { IBufferMarkCapability, ICommandDetectionCapability, ICwdDetectionCapability, ISerializedCommandDetectionCapability, TerminalCapability } from '../capabilities/capabilities.js'; +import { IBufferMarkCapability, ICommandDetectionCapability, ICwdDetectionCapability, ISerializedCommandDetectionCapability, IShellEnvDetectionCapability, TerminalCapability } from '../capabilities/capabilities.js'; import { PartialCommandDetectionCapability } from '../capabilities/partialCommandDetectionCapability.js'; import { ILogService } from '../../../log/common/log.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; @@ -18,6 +18,7 @@ import type { ITerminalAddon, Terminal } from '@xterm/headless'; import { URI } from '../../../../base/common/uri.js'; import { sanitizeCwd } from '../terminalEnvironment.js'; import { removeAnsiEscapeCodesFromPrompt } from '../../../../base/common/strings.js'; +import { ShellEnvDetectionCapability } from '../capabilities/shellEnvDetectionCapability.js'; /** @@ -224,6 +225,20 @@ const enum VSCodeOscPt { * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ SetMark = 'SetMark', + + /** + * Sends the shell's complete environment in JSON format. + * + * Format: `OSC 633 ; EnvJson ; ; ` + * + * - `Environment` - A stringified JSON object containing the shell's complete environment. The + * variables and values use the same encoding rules as the {@link CommandLine} sequence. + * - `Nonce` - An _mandatory_ nonce to ensure the sequence is not malicious. + * + * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. + */ + EnvJson = 'EnvJson', + } /** @@ -418,6 +433,19 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati this._createOrGetCommandDetection(this._terminal).handleContinuationEnd(); return true; } + case VSCodeOscPt.EnvJson: { + const arg0 = args[0]; + const arg1 = args[1]; + if (arg0 !== undefined) { + try { + const env = JSON.parse(deserializeMessage(arg0)); + this._createOrGetShellEnvDetection().setEnvironment(env, arg1 === this._nonce); + } catch (e) { + this._logService.warn('Failed to parse environment from shell integration sequence', arg0); + } + } + return true; + } case VSCodeOscPt.RightPromptStart: { this._createOrGetCommandDetection(this._terminal).handleRightPromptStart(); return true; @@ -614,6 +642,15 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati } return bufferMarkDetection; } + + protected _createOrGetShellEnvDetection(): IShellEnvDetectionCapability { + let shellEnvDetection = this.capabilities.get(TerminalCapability.ShellEnvDetection); + if (!shellEnvDetection) { + shellEnvDetection = this._register(new ShellEnvDetectionCapability()); + this.capabilities.add(TerminalCapability.ShellEnvDetection, shellEnvDetection); + } + return shellEnvDetection; + } } export function deserializeMessage(message: string): string { diff --git a/src/vs/platform/window/common/window.ts b/src/vs/platform/window/common/window.ts index 3a03481e55a..cec025d6632 100644 --- a/src/vs/platform/window/common/window.ts +++ b/src/vs/platform/window/common/window.ts @@ -187,10 +187,14 @@ export const enum CustomTitleBarVisibility { NEVER = 'never', } +export let titlebarStyleDefaultOverride: 'custom' | undefined = undefined; +export function overrideDefaultTitlebarStyle(style: 'custom'): void { + titlebarStyleDefaultOverride = style; +} + export function hasCustomTitlebar(configurationService: IConfigurationService, titleBarStyle?: TitlebarStyle): boolean { // Returns if it possible to have a custom title bar in the curren session // Does not imply that the title bar is visible - return true; } @@ -198,6 +202,7 @@ export function hasNativeTitlebar(configurationService: IConfigurationService, t if (!titleBarStyle) { titleBarStyle = getTitleBarStyle(configurationService); } + return titleBarStyle === TitlebarStyle.NATIVE; } @@ -224,6 +229,10 @@ export function getTitleBarStyle(configurationService: IConfigurationService): T } } + if (titlebarStyleDefaultOverride === 'custom') { + return TitlebarStyle.CUSTOM; + } + return isLinux && product.quality === 'stable' ? TitlebarStyle.NATIVE : TitlebarStyle.CUSTOM; // default to custom on all OS except Linux stable (for now) } @@ -395,6 +404,7 @@ export interface INativeWindowConfiguration extends IWindowConfiguration, Native autoDetectHighContrast?: boolean; autoDetectColorScheme?: boolean; isCustomZoomLevel?: boolean; + overrideDefaultTitlebarStyle?: 'custom'; perfMarks: PerformanceMark[]; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index 8db5f04b7e3..b47f1a17cb4 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -37,7 +37,7 @@ import product from '../../product/common/product.js'; import { IProtocolMainService } from '../../protocol/electron-main/protocol.js'; import { getRemoteAuthority } from '../../remote/common/remoteHosts.js'; import { IStateService } from '../../state/node/state.js'; -import { IAddRemoveFoldersRequest, INativeOpenFileRequest, INativeWindowConfiguration, IOpenEmptyWindowOptions, IPath, IPathsToWaitFor, isFileToOpen, isFolderToOpen, isWorkspaceToOpen, IWindowOpenable, IWindowSettings } from '../../window/common/window.js'; +import { IAddRemoveFoldersRequest, INativeOpenFileRequest, INativeWindowConfiguration, IOpenEmptyWindowOptions, IPath, IPathsToWaitFor, isFileToOpen, isFolderToOpen, isWorkspaceToOpen, IWindowOpenable, IWindowSettings, titlebarStyleDefaultOverride } from '../../window/common/window.js'; import { CodeWindow } from './windowImpl.js'; import { IOpenConfiguration, IOpenEmptyConfiguration, IWindowsCountChangedEvent, IWindowsMainService, OpenContext, getLastFocused } from './windows.js'; import { findWindowOnExtensionDevelopmentPath, findWindowOnFile, findWindowOnWorkspaceOrFolder } from './windowsFinder.js'; @@ -160,6 +160,8 @@ interface IPathToOpen extends IPath { label?: string; } +const EMPTY_WINDOW: IPathToOpen = Object.create(null); + interface IWorkspacePathToOpen extends IPathToOpen { readonly workspace: IWorkspaceIdentifier; } @@ -304,7 +306,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic const emptyWindowsWithBackupsToRestore: IEmptyWindowBackupInfo[] = []; let filesToOpen: IFilesToOpen | undefined; - let openOneEmptyWindow = false; + let maybeOpenEmptyWindow = false; // Identify things to open from open config const pathsToOpen = await this.getPathsToOpen(openConfig); @@ -332,7 +334,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic } else if (path.backupPath) { emptyWindowsWithBackupsToRestore.push({ backupFolder: basename(path.backupPath), remoteAuthority: path.remoteAuthority }); } else { - openOneEmptyWindow = true; + maybeOpenEmptyWindow = true; // depends on other parameters such as `forceEmpty` and how many windows have opened already } } @@ -368,9 +370,9 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic } // Open based on config - const { windows: usedWindows, filesOpenedInWindow } = await this.doOpen(openConfig, workspacesToOpen, foldersToOpen, emptyWindowsWithBackupsToRestore, openOneEmptyWindow, filesToOpen, foldersToAdd, foldersToRemove); + const { windows: usedWindows, filesOpenedInWindow } = await this.doOpen(openConfig, workspacesToOpen, foldersToOpen, emptyWindowsWithBackupsToRestore, maybeOpenEmptyWindow, filesToOpen, foldersToAdd, foldersToRemove); - this.logService.trace(`windowsManager#open used window count ${usedWindows.length} (workspacesToOpen: ${workspacesToOpen.length}, foldersToOpen: ${foldersToOpen.length}, emptyToRestore: ${emptyWindowsWithBackupsToRestore.length}, openOneEmptyWindow: ${openOneEmptyWindow})`); + this.logService.trace(`windowsManager#open used window count ${usedWindows.length} (workspacesToOpen: ${workspacesToOpen.length}, foldersToOpen: ${foldersToOpen.length}, emptyToRestore: ${emptyWindowsWithBackupsToRestore.length}, maybeOpenEmptyWindow: ${maybeOpenEmptyWindow})`); // Make sure to pass focus to the most relevant of the windows if we open multiple if (usedWindows.length > 1) { @@ -469,7 +471,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic workspacesToOpen: IWorkspacePathToOpen[], foldersToOpen: ISingleFolderWorkspacePathToOpen[], emptyToRestore: IEmptyWindowBackupInfo[], - openOneEmptyWindow: boolean, + maybeOpenEmptyWindow: boolean, filesToOpen: IFilesToOpen | undefined, foldersToAdd: ISingleFolderWorkspacePathToOpen[], foldersToRemove: ISingleFolderWorkspacePathToOpen[] @@ -639,8 +641,11 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic } } - // Open empty window either if enforced or when files still have to open - if (filesToOpen || openOneEmptyWindow) { + // Finally, open an empty window if + // - we still have files to open + // - user forces an empty window (e.g. via command line) + // - no window has opened yet + if (filesToOpen || (maybeOpenEmptyWindow && (openConfig.forceEmpty || usedWindows.length === 0))) { const remoteAuthority = filesToOpen ? filesToOpen.remoteAuthority : openConfig.remoteAuthority; addUsedWindow(await this.doOpenEmpty(openConfig, openFolderInNewWindow, remoteAuthority, filesToOpen), !!filesToOpen); @@ -749,14 +754,14 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic // Check for force empty else if (openConfig.forceEmpty) { - pathsToOpen = [Object.create(null)]; + pathsToOpen = [EMPTY_WINDOW]; } // Extract paths: from CLI else if (openConfig.cli._.length || openConfig.cli['folder-uri'] || openConfig.cli['file-uri']) { pathsToOpen = await this.doExtractPathsFromCLI(openConfig.cli); if (pathsToOpen.length === 0) { - pathsToOpen.push(Object.create(null)); // add an empty window if we did not have windows to open from command line + pathsToOpen.push(EMPTY_WINDOW); // add an empty window if we did not have windows to open from command line } isCommandLineOrAPICall = true; @@ -766,7 +771,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic else { pathsToOpen = await this.doGetPathsFromLastSession(); if (pathsToOpen.length === 0) { - pathsToOpen.push(Object.create(null)); // add an empty window if we did not have windows to restore + pathsToOpen.push(EMPTY_WINDOW); // add an empty window if we did not have windows to restore } isRestoringPaths = true; @@ -1502,6 +1507,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic autoDetectHighContrast: windowConfig?.autoDetectHighContrast ?? true, autoDetectColorScheme: windowConfig?.autoDetectColorScheme ?? false, + overrideDefaultTitlebarStyle: titlebarStyleDefaultOverride, accessibilitySupport: app.accessibilitySupportEnabled, colorScheme: this.themeMainService.getColorScheme(), policiesData: this.policyService.serialize(), diff --git a/src/vs/server/node/remoteExtensionHostAgentServer.ts b/src/vs/server/node/remoteExtensionHostAgentServer.ts index 77b124f9700..8323d0f13d0 100644 --- a/src/vs/server/node/remoteExtensionHostAgentServer.ts +++ b/src/vs/server/node/remoteExtensionHostAgentServer.ts @@ -14,7 +14,7 @@ import { CharCode } from '../../base/common/charCode.js'; import { isSigPipeError, onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; import { isEqualOrParent } from '../../base/common/extpath.js'; import { Disposable, DisposableStore } from '../../base/common/lifecycle.js'; -import { connectionTokenQueryName, FileAccess, getServerRootPath, Schemas } from '../../base/common/network.js'; +import { connectionTokenQueryName, FileAccess, getServerProductSegment, Schemas } from '../../base/common/network.js'; import { dirname, join } from '../../base/common/path.js'; import * as perf from '../../base/common/performance.js'; import * as platform from '../../base/common/platform.js'; @@ -66,7 +66,8 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { private readonly _webClientServer: WebClientServer | null; private readonly _webEndpointOriginChecker = WebEndpointOriginChecker.create(this._productService); - private readonly _serverRootPath: string; + private readonly _serverBasePath: string | undefined; + private readonly _serverProductPath: string; private shutdownTimer: NodeJS.Timeout | undefined; @@ -83,13 +84,18 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { ) { super(); - this._serverRootPath = getServerRootPath(_productService, serverBasePath); + if (serverBasePath !== undefined && serverBasePath.charCodeAt(serverBasePath.length - 1) === CharCode.Slash) { + // Remove trailing slash from base path + serverBasePath = serverBasePath.substring(0, serverBasePath.length - 1); + } + this._serverBasePath = serverBasePath; // undefined or starts with a slash + this._serverProductPath = `/${getServerProductSegment(_productService)}`; // starts with a slash this._extHostConnections = Object.create(null); this._managementConnections = Object.create(null); this._allReconnectionTokens = new Set(); this._webClientServer = ( hasWebClient - ? this._instantiationService.createInstance(WebClientServer, this._connectionToken, serverBasePath ?? '/', this._serverRootPath) + ? this._instantiationService.createInstance(WebClientServer, this._connectionToken, serverBasePath ?? '/', this._serverProductPath) : null ); this._logService.info(`Extension host agent started.`); @@ -114,9 +120,13 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { return serveError(req, res, 400, `Bad request.`); } - // for now accept all paths, with or without server root path - if (pathname.startsWith(this._serverRootPath) && pathname.charCodeAt(this._serverRootPath.length) === CharCode.Slash) { - pathname = pathname.substring(this._serverRootPath.length); + // Serve from both '/' and serverBasePath + if (this._serverBasePath !== undefined && pathname.startsWith(this._serverBasePath)) { + pathname = pathname.substring(this._serverBasePath.length) || '/'; + } + // for now accept all paths, with or without server product path + if (pathname.startsWith(this._serverProductPath) && pathname.charCodeAt(this._serverProductPath.length) === CharCode.Slash) { + pathname = pathname.substring(this._serverProductPath.length); } // Version @@ -172,7 +182,7 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { // workbench web UI if (this._webClientServer) { - this._webClientServer.handle(req, res, parsedUrl); + this._webClientServer.handle(req, res, parsedUrl, pathname); return; } diff --git a/src/vs/server/node/remoteExtensionsScanner.ts b/src/vs/server/node/remoteExtensionsScanner.ts index ee94de1090d..3855e37ce94 100644 --- a/src/vs/server/node/remoteExtensionsScanner.ts +++ b/src/vs/server/node/remoteExtensionsScanner.ts @@ -139,7 +139,7 @@ export class RemoteExtensionsScannerService implements IRemoteExtensionsScannerS } private async _scanBuiltinExtensions(language: string): Promise { - const scannedExtensions = await this._extensionsScannerService.scanSystemExtensions({ language, useCache: true }); + const scannedExtensions = await this._extensionsScannerService.scanSystemExtensions({ language }); return scannedExtensions.map(e => toExtensionDescription(e, false)); } diff --git a/src/vs/server/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index 7c6430e2380..5ce490dc139 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -12,9 +12,9 @@ import * as crypto from 'crypto'; import { isEqualOrParent } from '../../base/common/extpath.js'; import { getMediaMime } from '../../base/common/mime.js'; import { isLinux } from '../../base/common/platform.js'; -import { ILogService } from '../../platform/log/common/log.js'; +import { ILogService, LogLevel } from '../../platform/log/common/log.js'; import { IServerEnvironmentService } from './serverEnvironmentService.js'; -import { extname, dirname, join, normalize } from '../../base/common/path.js'; +import { extname, dirname, join, normalize, posix } from '../../base/common/path.js'; import { FileAccess, connectionTokenCookieName, connectionTokenQueryName, Schemas, builtinExtensionsPath } from '../../base/common/network.js'; import { generateUuid } from '../../base/common/uuid.js'; import { IProductService } from '../../platform/product/common/productService.js'; @@ -93,18 +93,18 @@ export async function serveFile(filePath: string, cacheControl: CacheControl, lo const APP_ROOT = dirname(FileAccess.asFileUri('').fsPath); +const STATIC_PATH = `/static`; +const CALLBACK_PATH = `/callback`; +const WEB_EXTENSION_PATH = `/web-extension-resource`; + export class WebClientServer { private readonly _webExtensionResourceUrlTemplate: URI | undefined; - private readonly _staticRoute: string; - private readonly _callbackRoute: string; - private readonly _webExtensionRoute: string; - constructor( private readonly _connectionToken: ServerConnectionToken, private readonly _basePath: string, - readonly serverRootPath: string, + private readonly _productPath: string, @IServerEnvironmentService private readonly _environmentService: IServerEnvironmentService, @ILogService private readonly _logService: ILogService, @IRequestService private readonly _requestService: IRequestService, @@ -112,34 +112,30 @@ export class WebClientServer { @ICSSDevelopmentService private readonly _cssDevService: ICSSDevelopmentService ) { this._webExtensionResourceUrlTemplate = this._productService.extensionsGallery?.resourceUrlTemplate ? URI.parse(this._productService.extensionsGallery.resourceUrlTemplate) : undefined; - - this._staticRoute = `${serverRootPath}/static`; - this._callbackRoute = `${serverRootPath}/callback`; - this._webExtensionRoute = `${serverRootPath}/web-extension-resource`; } /** * Handle web resources (i.e. only needed by the web client). * **NOTE**: This method is only invoked when the server has web bits. * **NOTE**: This method is only invoked after the connection token has been validated. + * @param parsedUrl The URL to handle, including base and product path + * @param pathname The pathname of the URL, without base and product path */ - async handle(req: http.IncomingMessage, res: http.ServerResponse, parsedUrl: url.UrlWithParsedQuery): Promise { + async handle(req: http.IncomingMessage, res: http.ServerResponse, parsedUrl: url.UrlWithParsedQuery, pathname: string): Promise { try { - const pathname = parsedUrl.pathname!; - - if (pathname.startsWith(this._staticRoute) && pathname.charCodeAt(this._staticRoute.length) === CharCode.Slash) { - return this._handleStatic(req, res, parsedUrl); + if (pathname.startsWith(STATIC_PATH) && pathname.charCodeAt(STATIC_PATH.length) === CharCode.Slash) { + return this._handleStatic(req, res, pathname.substring(STATIC_PATH.length)); } - if (pathname === this._basePath) { + if (pathname === '/') { return this._handleRoot(req, res, parsedUrl); } - if (pathname === this._callbackRoute) { + if (pathname === CALLBACK_PATH) { // callback support return this._handleCallback(res); } - if (pathname.startsWith(this._webExtensionRoute) && pathname.charCodeAt(this._webExtensionRoute.length) === CharCode.Slash) { + if (pathname.startsWith(WEB_EXTENSION_PATH) && pathname.charCodeAt(WEB_EXTENSION_PATH.length) === CharCode.Slash) { // extension resource support - return this._handleWebExtensionResource(req, res, parsedUrl); + return this._handleWebExtensionResource(req, res, pathname.substring(WEB_EXTENSION_PATH.length)); } return serveError(req, res, 404, 'Not found.'); @@ -152,15 +148,15 @@ export class WebClientServer { } /** * Handle HTTP requests for /static/* + * @param resourcePath The path after /static/ */ - private async _handleStatic(req: http.IncomingMessage, res: http.ServerResponse, parsedUrl: url.UrlWithParsedQuery): Promise { + private async _handleStatic(req: http.IncomingMessage, res: http.ServerResponse, resourcePath: string): Promise { const headers: Record = Object.create(null); // Strip the this._staticRoute from the path - const normalizedPathname = decodeURIComponent(parsedUrl.pathname!); // support paths that are uri-encoded (e.g. spaces => %20) - const relativeFilePath = normalizedPathname.substring(this._staticRoute.length + 1); + const normalizedPathname = decodeURIComponent(resourcePath); // support paths that are uri-encoded (e.g. spaces => %20) - const filePath = join(APP_ROOT, relativeFilePath); // join also normalizes the path + const filePath = join(APP_ROOT, normalizedPathname); // join also normalizes the path if (!isEqualOrParent(filePath, APP_ROOT, !isLinux)) { return serveError(req, res, 400, `Bad request.`); } @@ -175,15 +171,15 @@ export class WebClientServer { /** * Handle extension resources + * @param resourcePath The path after /web-extension-resource/ */ - private async _handleWebExtensionResource(req: http.IncomingMessage, res: http.ServerResponse, parsedUrl: url.UrlWithParsedQuery): Promise { + private async _handleWebExtensionResource(req: http.IncomingMessage, res: http.ServerResponse, resourcePath: string): Promise { if (!this._webExtensionResourceUrlTemplate) { return serveError(req, res, 500, 'No extension gallery service configured.'); } - // Strip `/web-extension-resource/` from the path - const normalizedPathname = decodeURIComponent(parsedUrl.pathname!); // support paths that are uri-encoded (e.g. spaces => %20) - const path = normalize(normalizedPathname.substring(this._webExtensionRoute.length + 1)); + const normalizedPathname = decodeURIComponent(resourcePath); // support paths that are uri-encoded (e.g. spaces => %20) + const path = normalize(normalizedPathname); const uri = URI.parse(path).with({ scheme: this._webExtensionResourceUrlTemplate.scheme, authority: path.substring(0, path.indexOf('/')), @@ -243,7 +239,6 @@ export class WebClientServer { * Handle HTTP requests for / */ private async _handleRoot(req: http.IncomingMessage, res: http.ServerResponse, parsedUrl: url.UrlWithParsedQuery): Promise { - const queryConnectionToken = parsedUrl.query[connectionTokenQueryName]; if (typeof queryConnectionToken === 'string') { // We got a connection token as a query parameter. @@ -276,8 +271,17 @@ export class WebClientServer { return Array.isArray(val) ? val[0] : val; }; + const replacePort = (host: string, port: string) => { + const index = host?.indexOf(':'); + if (index !== -1) { + host = host?.substring(0, index); + } + host += `:${port}`; + return host; + }; + const useTestResolver = (!this._environmentService.isBuilt && this._environmentService.args['use-test-resolver']); - const remoteAuthority = ( + let remoteAuthority = ( useTestResolver ? 'test+test' : (getFirstHeader('x-original-host') || getFirstHeader('x-forwarded-host') || req.headers.host) @@ -285,6 +289,10 @@ export class WebClientServer { if (!remoteAuthority) { return serveError(req, res, 400, `Bad request.`); } + const forwardedPort = getFirstHeader('x-forwarded-port'); + if (forwardedPort) { + remoteAuthority = replacePort(remoteAuthority, forwardedPort); + } function asJSON(value: unknown): string { return JSON.stringify(value).replace(/"/g, '"'); @@ -297,6 +305,23 @@ export class WebClientServer { _wrapWebWorkerExtHostInIframe = false; } + // Prefix routes with basePath for clients + const basePath = getFirstHeader('x-forwarded-prefix') || this._basePath; + + if (this._logService.getLevel() === LogLevel.Trace) { + ['x-original-host', 'x-forwarded-host', 'x-forwarded-port', 'host'].forEach(header => { + const value = getFirstHeader(header); + if (value) { + this._logService.trace(`[WebClientServer] ${header}: ${value}`); + } + }); + this._logService.trace(`[WebClientServer] Request URL: ${req.url}, basePath: ${basePath}, remoteAuthority: ${remoteAuthority}`); + } + + const staticRoute = posix.join(basePath, this._productPath, STATIC_PATH); + const callbackRoute = posix.join(basePath, this._productPath, CALLBACK_PATH); + const webExtensionRoute = posix.join(basePath, this._productPath, WEB_EXTENSION_PATH); + const resolveWorkspaceURI = (defaultLocation?: string) => defaultLocation && URI.file(path.resolve(defaultLocation)).with({ scheme: Schemas.vscodeRemote, authority: remoteAuthority }); const filePath = FileAccess.asFileUri(`vs/code/browser/workbench/workbench${this._environmentService.isBuilt ? '' : '-dev'}.html`).fsPath; @@ -314,7 +339,7 @@ export class WebClientServer { resourceUrlTemplate: this._webExtensionResourceUrlTemplate.with({ scheme: 'http', authority: remoteAuthority, - path: `${this._webExtensionRoute}/${this._webExtensionResourceUrlTemplate.authority}${this._webExtensionResourceUrlTemplate.path}` + path: `${webExtensionRoute}/${this._webExtensionResourceUrlTemplate.authority}${this._webExtensionResourceUrlTemplate.path}` }).toString(true) } : undefined } satisfies Partial; @@ -328,7 +353,7 @@ export class WebClientServer { const workbenchWebConfiguration = { remoteAuthority, - serverBasePath: this._basePath, + serverBasePath: basePath, _wrapWebWorkerExtHostInIframe, developmentOptions: { enableSmokeTestDriver: this._environmentService.args['enable-smoke-test-driver'] ? true : undefined, logLevel: this._logService.getLevel() }, settingsSyncOptions: !this._environmentService.isBuilt && this._environmentService.args['enable-sync'] ? { enabled: true } : undefined, @@ -336,7 +361,7 @@ export class WebClientServer { folderUri: resolveWorkspaceURI(this._environmentService.args['default-folder']), workspaceUri: resolveWorkspaceURI(this._environmentService.args['default-workspace']), productConfiguration, - callbackRoute: this._callbackRoute + callbackRoute: callbackRoute }; const cookies = cookie.parse(req.headers.cookie || ''); @@ -353,9 +378,9 @@ export class WebClientServer { const values: { [key: string]: string } = { WORKBENCH_WEB_CONFIGURATION: asJSON(workbenchWebConfiguration), WORKBENCH_AUTH_SESSION: authSessionInfo ? asJSON(authSessionInfo) : '', - WORKBENCH_WEB_BASE_URL: this._staticRoute, + WORKBENCH_WEB_BASE_URL: staticRoute, WORKBENCH_NLS_URL, - WORKBENCH_NLS_FALLBACK_URL: `${this._staticRoute}/out/nls.messages.js` + WORKBENCH_NLS_FALLBACK_URL: `${staticRoute}/out/nls.messages.js` }; // DEV --------------------------------------------------------------------------------------- diff --git a/src/vs/workbench/api/browser/mainThreadChatCodeMapper.ts b/src/vs/workbench/api/browser/mainThreadChatCodeMapper.ts index abacb824c24..eea3e819b97 100644 --- a/src/vs/workbench/api/browser/mainThreadChatCodeMapper.ts +++ b/src/vs/workbench/api/browser/mainThreadChatCodeMapper.ts @@ -25,15 +25,15 @@ export class MainThreadChatCodemapper extends Disposable implements MainThreadCo this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostCodeMapper); } - $registerCodeMapperProvider(handle: number): void { + $registerCodeMapperProvider(handle: number, displayName: string): void { const impl: ICodeMapperProvider = { + displayName, mapCode: async (uiRequest: ICodeMapperRequest, response: ICodeMapperResponse, token: CancellationToken) => { const requestId = String(MainThreadChatCodemapper._requestHandlePool++); this._responseMap.set(requestId, response); const extHostRequest: ICodeMapperRequestDto = { requestId, - codeBlocks: uiRequest.codeBlocks, - conversation: uiRequest.conversation + codeBlocks: uiRequest.codeBlocks }; try { return await this._proxy.$mapCode(handle, extHostRequest, token).then((result) => result ?? undefined); diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 3d2c8205057..98faa3e3814 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -644,8 +644,9 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread this._registrations.set(handle, this._languageFeaturesService.inlineCompletionsProvider.register(selector, provider)); } - $registerInlineEditProvider(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier): void { + $registerInlineEditProvider(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier, displayName: string): void { const provider: languages.InlineEditProvider = { + displayName, provideInlineEdit: async (model: ITextModel, context: languages.IInlineEditContext, token: CancellationToken): Promise => { return this._proxy.$provideInlineEdit(handle, model.uri, context, token); }, diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts index 659f026e78d..1b1cb20d72b 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts @@ -20,6 +20,7 @@ import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticati import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { ExtHostContext, ExtHostLanguageModelsShape, MainContext, MainThreadLanguageModelsShape } from '../common/extHost.protocol.js'; +import { LanguageModelError } from '../common/extHostTypes.js'; @extHostNamedCustomer(MainContext.MainThreadLanguageModels) export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { @@ -97,7 +98,7 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { if (data) { this._pendingProgress.delete(requestId); if (err) { - const error = transformErrorFromSerialization(err); + const error = LanguageModelError.tryDeserialize(err) ?? transformErrorFromSerialization(err); data.stream.reject(error); data.defer.error(error); } else { diff --git a/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts b/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts index 6181cd4c637..bfe720fc087 100644 --- a/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts +++ b/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts @@ -33,7 +33,18 @@ export class MainThreadTerminalShellIntegration extends Disposable implements Ma } })); - // onDidChangeTerminalShellIntegration + // onDidchangeTerminalShellIntegration initial state + for (const terminal of this._terminalService.instances) { + if (terminal.capabilities.has(TerminalCapability.CommandDetection)) { + this._proxy.$shellIntegrationChange(terminal.instanceId); + const cwdDetection = terminal.capabilities.get(TerminalCapability.CwdDetection); + if (cwdDetection) { + this._proxy.$cwdChange(terminal.instanceId, this._convertCwdToUri(cwdDetection.getCwd())); + } + } + } + + // onDidChangeTerminalShellIntegration via command detection const onDidAddCommandDetection = this._store.add(this._terminalService.createOnInstanceEvent(instance => { return Event.map( Event.filter(instance.capabilities.onDidAddCapabilityType, e => { @@ -43,6 +54,20 @@ export class MainThreadTerminalShellIntegration extends Disposable implements Ma })).event; this._store.add(onDidAddCommandDetection(e => this._proxy.$shellIntegrationChange(e.instanceId))); + // onDidChangeTerminalShellIntegration via cwd + const cwdChangeEvent = this._store.add(this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CwdDetection, e => e.onDidChangeCwd)); + this._store.add(cwdChangeEvent.event(e => { + this._proxy.$cwdChange(e.instance.instanceId, this._convertCwdToUri(e.data)); + })); + + // onDidChangeTerminalShellIntegration via env + const envChangeEvent = this._store.add(this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.ShellEnvDetection, e => e.onDidChangeEnv)); + this._store.add(envChangeEvent.event(e => { + const keysArr = Array.from(e.data.keys()); + const valuesArr = Array.from(e.data.values()); + this._proxy.$shellEnvChange(e.instance.instanceId, keysArr, valuesArr); + })); + // onDidStartTerminalShellExecution const commandDetectionStartEvent = this._store.add(this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CommandDetection, e => e.onCommandExecuted)); let currentCommand: ITerminalCommand | undefined; @@ -75,12 +100,6 @@ export class MainThreadTerminalShellIntegration extends Disposable implements Ma }); })); - // onDidChangeTerminalShellIntegration via cwd - const cwdChangeEvent = this._store.add(this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CwdDetection, e => e.onDidChangeCwd)); - this._store.add(cwdChangeEvent.event(e => { - this._proxy.$cwdChange(e.instance.instanceId, this._convertCwdToUri(e.data)); - })); - // Clean up after dispose this._store.add(this._terminalService.onDidDisposeInstance(e => this._proxy.$closeTerminal(e.instanceId))); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 868906908ea..c1919e71363 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -545,7 +545,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostLanguageFeatures.registerCodeActionProvider(extension, checkSelector(selector), provider, metadata); }, registerDocumentPasteEditProvider(selector: vscode.DocumentSelector, provider: vscode.DocumentPasteEditProvider, metadata: vscode.DocumentPasteProviderMetadata): vscode.Disposable { - checkProposedApiEnabled(extension, 'documentPaste'); return extHostLanguageFeatures.registerDocumentPasteEditProvider(extension, checkSelector(selector), provider, metadata); }, registerCodeLensProvider(selector: vscode.DocumentSelector, provider: vscode.CodeLensProvider): vscode.Disposable { @@ -669,7 +668,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostLanguages.createLanguageStatusItem(extension, id, selector); }, registerDocumentDropEditProvider(selector: vscode.DocumentSelector, provider: vscode.DocumentDropEditProvider, metadata?: vscode.DocumentDropEditProviderMetadata): vscode.Disposable { - return extHostLanguageFeatures.registerDocumentOnDropEditProvider(extension, selector, provider, isProposedApiEnabled(extension, 'documentPaste') ? metadata : undefined); + return extHostLanguageFeatures.registerDocumentOnDropEditProvider(extension, selector, provider, metadata); } }; @@ -1500,10 +1499,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostLanguageModelTools.registerTool(extension, name, tool); }, invokeTool(name: string, parameters: vscode.LanguageModelToolInvocationOptions, token?: vscode.CancellationToken) { - return extHostLanguageModelTools.invokeTool(name, parameters, token); + return extHostLanguageModelTools.invokeTool(extension, name, parameters, token); }, get tools() { - return extHostLanguageModelTools.tools; + return extHostLanguageModelTools.getTools(extension); }, fileIsIgnored(uri: vscode.Uri, token: vscode.CancellationToken) { return extHostLanguageModels.fileIsIgnored(extension, uri, token); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 36e385c3e6e..e8de9400531 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -455,7 +455,7 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable { $registerDocumentRangeSemanticTokensProvider(handle: number, selector: IDocumentFilterDto[], legend: languages.SemanticTokensLegend): void; $registerCompletionsProvider(handle: number, selector: IDocumentFilterDto[], triggerCharacters: string[], supportsResolveDetails: boolean, extensionId: ExtensionIdentifier): void; $registerInlineCompletionsSupport(handle: number, selector: IDocumentFilterDto[], supportsHandleDidShowCompletionItem: boolean, extensionId: string, yieldsToExtensionIds: string[]): void; - $registerInlineEditProvider(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier): void; + $registerInlineEditProvider(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier, displayName: string): void; $registerSignatureHelpProvider(handle: number, selector: IDocumentFilterDto[], metadata: ISignatureHelpProviderMetadataDto): void; $registerInlayHintsProvider(handle: number, selector: IDocumentFilterDto[], supportsResolve: boolean, eventHandle: number | undefined, displayName: string | undefined): void; $emitInlayHintsEvent(eventHandle: number): void; @@ -1299,7 +1299,7 @@ export interface ICodeMapperTextEdit { export type ICodeMapperProgressDto = Dto; export interface MainThreadCodeMapperShape extends IDisposable { - $registerCodeMapperProvider(handle: number): void; + $registerCodeMapperProvider(handle: number, displayName: string): void; $unregisterCodeMapperProvider(handle: number): void; $handleProgress(requestId: string, data: ICodeMapperProgressDto): Promise; } @@ -1606,6 +1606,7 @@ export interface SCMHistoryItemDto { readonly message: string; readonly displayId?: string; readonly author?: string; + readonly authorEmail?: string; readonly timestamp?: number; readonly statistics?: { readonly files: number; @@ -2438,6 +2439,7 @@ export interface ExtHostTerminalShellIntegrationShape { $shellExecutionStart(instanceId: number, commandLineValue: string, commandLineConfidence: TerminalShellExecutionCommandLineConfidence, isTrusted: boolean, cwd: UriComponents | undefined): void; $shellExecutionEnd(instanceId: number, commandLineValue: string, commandLineConfidence: TerminalShellExecutionCommandLineConfidence, isTrusted: boolean, exitCode: number | undefined): void; $shellExecutionData(instanceId: number, data: string): void; + $shellEnvChange(instanceId: number, shellEnvKeys: string[], shellEnvValues: string[]): void; $cwdChange(instanceId: number, cwd: UriComponents | undefined): void; $closeTerminal(instanceId: number): void; } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 13a6572cba1..45b7fd7fd0b 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -18,7 +18,7 @@ import { assertType } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { Location } from '../../../editor/common/languages.js'; -import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { ExtensionIdentifier, IExtensionDescription, IRelaxedExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { ChatAgentLocation, IChatAgentRequest, IChatAgentResult, IChatAgentResultTimings, IChatWelcomeMessageContent } from '../../contrib/chat/common/chatAgents.js'; import { ChatAgentVoteDirection, IChatContentReference, IChatFollowup, IChatResponseErrorDetails, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService.js'; @@ -387,15 +387,15 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } async $detectChatParticipant(handle: number, requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { location: ChatAgentLocation; participants?: vscode.ChatParticipantMetadata[] }, token: CancellationToken): Promise { - const { request, location, history } = await this._createRequest(requestDto, context); - const detector = this._participantDetectionProviders.get(handle); if (!detector) { return undefined; } + const { request, location, history } = await this._createRequest(requestDto, context, detector.extension); + const model = await this.getModelForRequest(request, detector.extension); - const extRequest = typeConvert.ChatAgentRequest.to(request, location, model); + const extRequest = typeConvert.ChatAgentRequest.to(request, location, model, isProposedApiEnabled(detector.extension, 'chatReadonlyPromptReference')); return detector.provider.provideParticipantDetection( extRequest, @@ -405,9 +405,9 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS ); } - private async _createRequest(requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }) { + private async _createRequest(requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }, extension: IExtensionDescription) { const request = revive(requestDto); - const convertedHistory = await this.prepareHistoryTurns(request.agentId, context); + const convertedHistory = await this.prepareHistoryTurns(extension, request.agentId, context); // in-place converting for location-data let location: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined; @@ -452,7 +452,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS let stream: ChatAgentResponseStream | undefined; try { - const { request, location, history } = await this._createRequest(requestDto, context); + const { request, location, history } = await this._createRequest(requestDto, context, agent.extension); // Init session disposables let sessionDisposables = this._sessionDisposables.get(request.sessionId); @@ -464,7 +464,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS stream = new ChatAgentResponseStream(agent.extension, request, this._proxy, this._commands.converter, sessionDisposables); const model = await this.getModelForRequest(request, agent.extension); - const extRequest = typeConvert.ChatAgentRequest.to(request, location, model); + const extRequest = typeConvert.ChatAgentRequest.to(request, location, model, isProposedApiEnabled(agent.extension, 'chatReadonlyPromptReference')); const task = agent.invoke( extRequest, @@ -511,7 +511,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } } - private async prepareHistoryTurns(agentId: string, context: { history: IChatAgentHistoryEntryDto[] }): Promise<(vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]> { + private async prepareHistoryTurns(extension: Readonly, agentId: string, context: { history: IChatAgentHistoryEntryDto[] }): Promise<(vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]> { const res: (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[] = []; for (const h of context.history) { @@ -521,9 +521,10 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS { ...ehResult, metadata: undefined }; // REQUEST turn + const hasReadonlyProposal = isProposedApiEnabled(extension, 'chatReadonlyPromptReference'); const varsWithoutTools = h.request.variables.variables .filter(v => !v.isTool) - .map(typeConvert.ChatPromptReference.to); + .map(v => typeConvert.ChatPromptReference.to(v, hasReadonlyProposal)); const toolReferences = h.request.variables.variables .filter(v => v.isTool) .map(typeConvert.ChatLanguageModelToolReference.to); @@ -549,7 +550,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } const request = revive(requestDto); - const convertedHistory = await this.prepareHistoryTurns(agent.id, context); + const convertedHistory = await this.prepareHistoryTurns(agent.extension, agent.id, context); const ehResult = typeConvert.ChatAgentResult.to(result); return (await agent.provideFollowups(ehResult, { history: convertedHistory }, token)) @@ -642,7 +643,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return; } - const history = await this.prepareHistoryTurns(agent.id, { history: context }); + const history = await this.prepareHistoryTurns(agent.extension, agent.id, { history: context }); return await agent.provideTitle({ history }, token); } diff --git a/src/vs/workbench/api/common/extHostCodeMapper.ts b/src/vs/workbench/api/common/extHostCodeMapper.ts index 0f6b9d12922..4a421e6f23d 100644 --- a/src/vs/workbench/api/common/extHostCodeMapper.ts +++ b/src/vs/workbench/api/common/extHostCodeMapper.ts @@ -8,7 +8,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ICodeMapperResult } from '../../contrib/chat/common/chatCodeMapperService.js'; import * as extHostProtocol from './extHost.protocol.js'; -import { ChatAgentResult, DocumentContextItem, TextEdit } from './extHostTypeConverters.js'; +import { TextEdit } from './extHostTypeConverters.js'; import { URI } from '../../../base/common/uri.js'; export class ExtHostCodeMapper implements extHostProtocol.ExtHostCodeMapperShape { @@ -48,21 +48,6 @@ export class ExtHostCodeMapper implements extHostProtocol.ExtHostCodeMapperShape resource: URI.revive(block.resource), markdownBeforeBlock: block.markdownBeforeBlock }; - }), - conversation: internalRequest.conversation.map(item => { - if (item.type === 'request') { - return { - type: 'request', - message: item.message - } satisfies vscode.ConversationRequest; - } else { - return { - type: 'response', - message: item.message, - result: item.result ? ChatAgentResult.to(item.result) : undefined, - references: item.references?.map(DocumentContextItem.to) - } satisfies vscode.ConversationResponse; - } }) }; @@ -72,7 +57,7 @@ export class ExtHostCodeMapper implements extHostProtocol.ExtHostCodeMapperShape registerMappedEditsProvider(extension: IExtensionDescription, provider: vscode.MappedEditsProvider2): vscode.Disposable { const handle = ExtHostCodeMapper._providerHandlePool++; - this._proxy.$registerCodeMapperProvider(handle); + this._proxy.$registerCodeMapperProvider(handle, extension.displayName ?? extension.name); this.providers.set(handle, provider); return { dispose: () => { diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 9783b16e880..1951cbf1c6f 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -1526,7 +1526,8 @@ class InlineEditAdapter { async provideInlineEdits(uri: URI, context: languages.IInlineEditContext, token: CancellationToken): Promise { const doc = this._documents.getDocument(uri); const result = await this._provider.provideInlineEdit(doc, { - triggerKind: this.languageTriggerKindToVSCodeTriggerKind[context.triggerKind] + triggerKind: this.languageTriggerKindToVSCodeTriggerKind[context.triggerKind], + requestUuid: context.requestUuid, }, token); if (!result) { @@ -1577,6 +1578,7 @@ class InlineEditAdapter { pid, text: result.text, range: typeConvert.Range.from(result.range), + showRange: typeConvert.Range.from(result.showRange), accepted: acceptCommand, rejected: rejectCommand, shown: shownCommand, @@ -2727,7 +2729,7 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF registerInlineEditProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.InlineEditProvider): vscode.Disposable { const adapter = new InlineEditAdapter(extension, this._documents, provider, this._commands.converter); const handle = this._addNewAdapter(adapter, extension); - this._proxy.$registerInlineEditProvider(handle, this._transformDocumentSelector(selector, extension), extension.identifier); + this._proxy.$registerInlineEditProvider(handle, this._transformDocumentSelector(selector, extension), extension.identifier, provider.displayName || extension.name); return this._createDisposable(handle); } @@ -2914,7 +2916,7 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF const handle = this._nextHandle(); this._adapter.set(handle, new AdapterData(new DocumentDropEditAdapter(this._proxy, this._documents, provider, handle, extension), extension)); - this._proxy.$registerDocumentOnDropEditProvider(handle, this._transformDocumentSelector(selector, extension), isProposedApiEnabled(extension, 'documentPaste') && metadata ? { + this._proxy.$registerDocumentOnDropEditProvider(handle, this._transformDocumentSelector(selector, extension), metadata ? { supportsResolve: !!provider.resolveDocumentDropEdit, dropMimeTypes: metadata.dropMimeTypes, providedDropKinds: metadata.providedDropEditKinds?.map(x => x.value), diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index f078897e24a..b2f25fb152f 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -14,6 +14,7 @@ import { IExtensionDescription } from '../../../platform/extensions/common/exten import { IPreparedToolInvocation, isToolInvocationContext, IToolInvocation, IToolInvocationContext, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; import { ExtHostLanguageModelToolsShape, IMainContext, IToolDataDto, MainContext, MainThreadLanguageModelToolsShape } from './extHost.protocol.js'; import * as typeConvert from './extHostTypeConverters.js'; +import { isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; @@ -45,17 +46,22 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape return await fn(input, token); } - async invokeTool(toolId: string, options: vscode.LanguageModelToolInvocationOptions, token?: CancellationToken): Promise { + async invokeTool(extension: IExtensionDescription, toolId: string, options: vscode.LanguageModelToolInvocationOptions, token?: CancellationToken): Promise { const callId = generateUuid(); if (options.tokenizationOptions) { this._tokenCountFuncs.set(callId, options.tokenizationOptions.countTokens); } - if (options.toolInvocationToken && !isToolInvocationContext(options.toolInvocationToken)) { - throw new Error(`Invalid tool invocation token`); - } - try { + if (options.toolInvocationToken && !isToolInvocationContext(options.toolInvocationToken)) { + throw new Error(`Invalid tool invocation token`); + } + + const tool = this._allTools.get(toolId); + if (tool?.tags?.includes('vscode_editing') && !isProposedApiEnabled(extension, 'chatParticipantPrivate')) { + throw new Error(`Invalid tool: ${toolId}`); + } + // Making the round trip here because not all tools were necessarily registered in this EH const result = await this._proxy.$invokeTool({ toolId, @@ -77,9 +83,16 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape } } - get tools(): vscode.LanguageModelToolInformation[] { + getTools(extension: IExtensionDescription): vscode.LanguageModelToolInformation[] { return Array.from(this._allTools.values()) - .map(tool => typeConvert.LanguageModelToolDescription.to(tool)); + .map(tool => typeConvert.LanguageModelToolDescription.to(tool)) + .filter(tool => { + if (tool.tags.includes('vscode_editing')) { + return isProposedApiEnabled(extension, 'chatParticipantPrivate'); + } + + return true; + }); } async $invokeTool(dto: IToolInvocation, token: CancellationToken): Promise { diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index b5267b761c7..9264a42d289 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -434,7 +434,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { if (error) { // we error the stream because that's the only way to signal // that the request has failed - data.res.reject(transformErrorFromSerialization(error)); + data.res.reject(extHostTypes.LanguageModelError.tryDeserialize(error) ?? transformErrorFromSerialization(error)); } else { data.res.resolve(); } diff --git a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts index 7a1c24029d6..076f1b725cc 100644 --- a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts +++ b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts @@ -131,6 +131,10 @@ export class ExtHostTerminalShellIntegration extends Disposable implements IExtH this._activeShellIntegrations.get(instanceId)?.emitData(data); } + public $shellEnvChange(instanceId: number, shellEnvKeys: string[], shellEnvValues: string[]): void { + this._activeShellIntegrations.get(instanceId)?.setEnv(shellEnvKeys, shellEnvValues); + } + public $cwdChange(instanceId: number, cwd: UriComponents | undefined): void { this._activeShellIntegrations.get(instanceId)?.setCwd(URI.revive(cwd)); } @@ -147,6 +151,7 @@ class InternalTerminalShellIntegration extends Disposable { get currentExecution(): InternalTerminalShellExecution | undefined { return this._currentExecution; } private _ignoreNextExecution: boolean = false; + private _env: { [key: string]: string | undefined } | undefined; private _cwd: URI | undefined; readonly store: DisposableStore = this._register(new DisposableStore()); @@ -171,6 +176,9 @@ class InternalTerminalShellIntegration extends Disposable { get cwd(): URI | undefined { return that._cwd; }, + get env(): { [key: string]: string | undefined } | undefined { + return that._env; + }, // executeCommand(commandLine: string): vscode.TerminalShellExecution; // executeCommand(executable: string, args: string[]): vscode.TerminalShellExecution; executeCommand(commandLineOrExecutable: string, args?: string[]): vscode.TerminalShellExecution { @@ -232,6 +240,15 @@ class InternalTerminalShellIntegration extends Disposable { } } + setEnv(keys: string[], values: string[]): void { + const env: { [key: string]: string | undefined } = {}; + for (let i = 0; i < keys.length; i++) { + env[keys[i]] = values[i]; + } + this._env = env; + this._fireChangeEvent(); + } + setCwd(cwd: URI | undefined): void { let wasChanged = false; if (URI.isUri(this._cwd)) { @@ -241,9 +258,13 @@ class InternalTerminalShellIntegration extends Disposable { } if (wasChanged) { this._cwd = cwd; - this._onDidRequestChangeShellIntegration.fire({ terminal: this._terminal, shellIntegration: this.value }); + this._fireChangeEvent(); } } + + private _fireChangeEvent() { + this._onDidRequestChangeShellIntegration.fire({ terminal: this._terminal, shellIntegration: this.value }); + } } class InternalTerminalShellExecution { diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index b7f3d7e8b5e..203ebfae86d 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -2799,7 +2799,7 @@ export namespace ChatResponsePart { } export namespace ChatAgentRequest { - export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat): vscode.ChatRequest { + export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat, hasReadonlyProposal: boolean): vscode.ChatRequest { const toolReferences = request.variables.variables.filter(v => v.isTool); const variableReferences = request.variables.variables.filter(v => !v.isTool); return { @@ -2808,7 +2808,7 @@ export namespace ChatAgentRequest { attempt: request.attempt ?? 0, enableCommandDetection: request.enableCommandDetection ?? true, isParticipantDetected: request.isParticipantDetected ?? false, - references: variableReferences.map(ChatPromptReference.to), + references: variableReferences.map(v => ChatPromptReference.to(v, hasReadonlyProposal)), toolReferences: toolReferences.map(ChatLanguageModelToolReference.to), location: ChatLocation.to(request.location), acceptedConfirmationData: request.acceptedConfirmationData, @@ -2852,7 +2852,7 @@ export namespace ChatLocation { } export namespace ChatPromptReference { - export function to(variable: IChatRequestVariableEntry): vscode.ChatPromptReference { + export function to(variable: IChatRequestVariableEntry, hasReadonlyProposal: boolean): vscode.ChatPromptReference { const value = variable.value; if (!value) { throw new Error('Invalid value reference'); @@ -2865,7 +2865,8 @@ export namespace ChatPromptReference { value: isUriComponents(value) ? URI.revive(value) : value && typeof value === 'object' && 'uri' in value && 'range' in value && isUriComponents(value.uri) ? Location.to(revive(value)) : variable.isImage ? new types.ChatReferenceBinaryData(variable.mimeType ?? 'image/png', () => Promise.resolve(new Uint8Array(Object.values(value)))) : value, - modelDescription: variable.modelDescription + modelDescription: variable.modelDescription, + isReadonly: hasReadonlyProposal ? variable.isMarkedReadonly : undefined, }; } } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 90f82a69ce0..abb6a3e737b 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -7,7 +7,7 @@ import type * as vscode from 'vscode'; import { asArray, coalesceInPlace, equals } from '../../../base/common/arrays.js'; -import { illegalArgument } from '../../../base/common/errors.js'; +import { illegalArgument, SerializedError } from '../../../base/common/errors.js'; import { IRelativePattern } from '../../../base/common/glob.js'; import { MarkdownString as BaseMarkdownString, MarkdownStringTrustedOptions } from '../../../base/common/htmlContent.js'; import { ResourceMap } from '../../../base/common/map.js'; @@ -2343,7 +2343,9 @@ export class ShellExecution implements vscode.ShellExecution { throw illegalArgument('command'); } this._command = arg0; - this._args = arg1 as (string | vscode.ShellQuotedString)[]; + if (arg1) { + this._args = arg1; + } this._options = arg2; } else { if (typeof arg0 !== 'string') { @@ -2380,7 +2382,7 @@ export class ShellExecution implements vscode.ShellExecution { return this._args; } - set args(value: (string | vscode.ShellQuotedString)[]) { + set args(value: (string | vscode.ShellQuotedString)[] | undefined) { this._args = value || []; } @@ -2948,6 +2950,7 @@ export enum DocumentPasteTriggerKind { export class DocumentDropOrPasteEditKind { static Empty: DocumentDropOrPasteEditKind; static Text: DocumentDropOrPasteEditKind; + static TextUpdateImports: DocumentDropOrPasteEditKind; private static sep = '.'; @@ -2969,6 +2972,7 @@ export class DocumentDropOrPasteEditKind { } DocumentDropOrPasteEditKind.Empty = new DocumentDropOrPasteEditKind(''); DocumentDropOrPasteEditKind.Text = new DocumentDropOrPasteEditKind('text'); +DocumentDropOrPasteEditKind.TextUpdateImports = DocumentDropOrPasteEditKind.Text.append('updateImports'); export class DocumentPasteEdit { @@ -4843,6 +4847,8 @@ export class LanguageModelChatAssistantMessage { export class LanguageModelError extends Error { + static readonly #name = 'LanguageModelError'; + static NotFound(message?: string): LanguageModelError { return new LanguageModelError(message, LanguageModelError.NotFound.name); } @@ -4855,11 +4861,18 @@ export class LanguageModelError extends Error { return new LanguageModelError(message, LanguageModelError.Blocked.name); } + static tryDeserialize(data: SerializedError): LanguageModelError | undefined { + if (data.name !== LanguageModelError.#name) { + return undefined; + } + return new LanguageModelError(data.message, data.code, data.cause); + } + readonly code: string; constructor(message?: string, code?: string, cause?: Error) { super(message, { cause }); - this.name = 'LanguageModelError'; + this.name = LanguageModelError.#name; this.code = code ?? ''; } diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index b490bf7a242..52e1743b990 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -479,7 +479,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac } - findFiles2(filePatterns: vscode.GlobPattern[], + findFiles2(filePatterns: readonly vscode.GlobPattern[], options: vscode.FindFiles2Options = {}, extensionId: ExtensionIdentifier, token: vscode.CancellationToken = CancellationToken.None): Promise { @@ -490,7 +490,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac private async _findFilesImpl( // the old `findFiles` used `include` to query, but the new `findFiles2` uses `filePattern` to query. // `filePattern` is the proper way to handle this, since it takes less precedence than the ignore files. - query: { type: 'include'; value: vscode.GlobPattern | undefined } | { type: 'filePatterns'; value: vscode.GlobPattern[] }, + query: { readonly type: 'include'; readonly value: vscode.GlobPattern | undefined } | { readonly type: 'filePatterns'; readonly value: readonly vscode.GlobPattern[] }, options: vscode.FindFiles2Options, token: vscode.CancellationToken ): Promise { @@ -500,7 +500,8 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac const filePatternsToUse = query.type === 'include' ? [query.value] : query.value ?? []; if (!Array.isArray(filePatternsToUse)) { - throw new Error(`Invalid file pattern provided ${filePatternsToUse}`); + console.error('Invalid file pattern provided', filePatternsToUse); + throw new Error(`Invalid file pattern provided ${JSON.stringify(filePatternsToUse)}`); } const queryOptions: QueryOptions[] = filePatternsToUse.map(filePattern => { diff --git a/src/vs/workbench/browser/actions/layoutActions.ts b/src/vs/workbench/browser/actions/layoutActions.ts index 75253e235de..aed2fe31f74 100644 --- a/src/vs/workbench/browser/actions/layoutActions.ts +++ b/src/vs/workbench/browser/actions/layoutActions.ts @@ -181,17 +181,6 @@ MenuRegistry.appendMenuItems([{ when: ContextKeyExpr.and(ContextKeyExpr.notEquals('config.workbench.sideBar.location', 'right'), ContextKeyExpr.equals('viewContainerLocation', ViewContainerLocationToString(ViewContainerLocation.Sidebar))), order: 1 } -}, { - id: MenuId.ViewTitleContext, - item: { - group: '3_workbench_layout_move', - command: { - id: ToggleSidebarPositionAction.ID, - title: localize('move sidebar right', "Move Primary Side Bar Right") - }, - when: ContextKeyExpr.and(ContextKeyExpr.notEquals('config.workbench.sideBar.location', 'right'), ContextKeyExpr.equals('viewLocation', ViewContainerLocationToString(ViewContainerLocation.Sidebar))), - order: 1 - } }, { id: MenuId.ViewContainerTitleContext, item: { @@ -204,36 +193,25 @@ MenuRegistry.appendMenuItems([{ order: 1 } }, { - id: MenuId.ViewTitleContext, - item: { - group: '3_workbench_layout_move', - command: { - id: ToggleSidebarPositionAction.ID, - title: localize('move sidebar left', "Move Primary Side Bar Left") - }, - when: ContextKeyExpr.and(ContextKeyExpr.equals('config.workbench.sideBar.location', 'right'), ContextKeyExpr.equals('viewLocation', ViewContainerLocationToString(ViewContainerLocation.Sidebar))), - order: 1 - } -}, { - id: MenuId.ViewTitleContext, + id: MenuId.ViewContainerTitleContext, item: { group: '3_workbench_layout_move', command: { id: ToggleSidebarPositionAction.ID, title: localize('move second sidebar left', "Move Secondary Side Bar Left") }, - when: ContextKeyExpr.and(ContextKeyExpr.notEquals('config.workbench.sideBar.location', 'right'), ContextKeyExpr.equals('viewLocation', ViewContainerLocationToString(ViewContainerLocation.AuxiliaryBar))), + when: ContextKeyExpr.and(ContextKeyExpr.notEquals('config.workbench.sideBar.location', 'right'), ContextKeyExpr.equals('viewContainerLocation', ViewContainerLocationToString(ViewContainerLocation.AuxiliaryBar))), order: 1 } }, { - id: MenuId.ViewTitleContext, + id: MenuId.ViewContainerTitleContext, item: { group: '3_workbench_layout_move', command: { id: ToggleSidebarPositionAction.ID, title: localize('move second sidebar right', "Move Secondary Side Bar Right") }, - when: ContextKeyExpr.and(ContextKeyExpr.equals('config.workbench.sideBar.location', 'right'), ContextKeyExpr.equals('viewLocation', ViewContainerLocationToString(ViewContainerLocation.AuxiliaryBar))), + when: ContextKeyExpr.and(ContextKeyExpr.equals('config.workbench.sideBar.location', 'right'), ContextKeyExpr.equals('viewContainerLocation', ViewContainerLocationToString(ViewContainerLocation.AuxiliaryBar))), order: 1 } }]); @@ -291,9 +269,10 @@ MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, { // Toggle Sidebar Visibility -class ToggleSidebarVisibilityAction extends Action2 { +export class ToggleSidebarVisibilityAction extends Action2 { static readonly ID = 'workbench.action.toggleSidebarVisibility'; + static readonly LABEL = localize('compositePart.hideSideBarLabel', "Hide Primary Side Bar"); constructor() { super({ @@ -349,17 +328,6 @@ MenuRegistry.appendMenuItems([ when: ContextKeyExpr.and(SideBarVisibleContext, ContextKeyExpr.equals('viewContainerLocation', ViewContainerLocationToString(ViewContainerLocation.Sidebar))), order: 2 } - }, { - id: MenuId.ViewTitleContext, - item: { - group: '3_workbench_layout_move', - command: { - id: ToggleSidebarVisibilityAction.ID, - title: localize('compositePart.hideSideBarLabel', "Hide Primary Side Bar"), - }, - when: ContextKeyExpr.and(SideBarVisibleContext, ContextKeyExpr.equals('viewLocation', ViewContainerLocationToString(ViewContainerLocation.Sidebar))), - order: 2 - } }, { id: MenuId.LayoutControlMenu, item: { diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index 0935255446a..f2af5fb3a2f 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -11,7 +11,7 @@ import { Part } from '../../part.js'; import { ActivityBarPosition, IWorkbenchLayoutService, LayoutSettings, Parts, Position } from '../../../services/layout/browser/layoutService.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { ToggleSidebarPositionAction } from '../../actions/layoutActions.js'; +import { ToggleSidebarPositionAction, ToggleSidebarVisibilityAction } from '../../actions/layoutActions.js'; import { IThemeService, IColorTheme, registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; import { ACTIVITY_BAR_BACKGROUND, ACTIVITY_BAR_BORDER, ACTIVITY_BAR_FOREGROUND, ACTIVITY_BAR_ACTIVE_BORDER, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_INACTIVE_FOREGROUND, ACTIVITY_BAR_ACTIVE_BACKGROUND, ACTIVITY_BAR_DRAG_AND_DROP_BORDER, ACTIVITY_BAR_ACTIVE_FOCUS_BORDER } from '../../../common/theme.js'; import { activeContrastBorder, contrastBorder, focusBorder } from '../../../../platform/theme/common/colorRegistry.js'; @@ -371,10 +371,16 @@ export class ActivityBarCompositeBar extends PaneCompositeBar { getActivityBarContextMenuActions(): IAction[] { const activityBarPositionMenu = this.menuService.getMenuActions(MenuId.ActivityBarPositionMenu, this.contextKeyService, { shouldForwardArgs: true, renderShortTitle: true }); const positionActions = getContextMenuActions(activityBarPositionMenu).secondary; - return [ + const actions = [ new SubmenuAction('workbench.action.panel.position', localize('activity bar position', "Activity Bar Position"), positionActions), - toAction({ id: ToggleSidebarPositionAction.ID, label: ToggleSidebarPositionAction.getLabel(this.layoutService), run: () => this.instantiationService.invokeFunction(accessor => new ToggleSidebarPositionAction().run(accessor)) }) + toAction({ id: ToggleSidebarPositionAction.ID, label: ToggleSidebarPositionAction.getLabel(this.layoutService), run: () => this.instantiationService.invokeFunction(accessor => new ToggleSidebarPositionAction().run(accessor)) }), ]; + + if (this.part === Parts.SIDEBAR_PART) { + actions.push(toAction({ id: ToggleSidebarVisibilityAction.ID, label: ToggleSidebarVisibilityAction.LABEL, run: () => this.instantiationService.invokeFunction(accessor => new ToggleSidebarVisibilityAction().run(accessor)) })); + } + + return actions; } } @@ -493,15 +499,10 @@ MenuRegistry.appendMenuItem(MenuId.MenubarAppearanceMenu, { MenuRegistry.appendMenuItem(MenuId.ViewContainerTitleContext, { submenu: MenuId.ActivityBarPositionMenu, title: localize('positionActivituBar', "Activity Bar Position"), - when: ContextKeyExpr.equals('viewContainerLocation', ViewContainerLocationToString(ViewContainerLocation.Sidebar)), - group: '3_workbench_layout_move', - order: 1 -}); - -MenuRegistry.appendMenuItem(MenuId.ViewTitleContext, { - submenu: MenuId.ActivityBarPositionMenu, - title: localize('positionActivituBar', "Activity Bar Position"), - when: ContextKeyExpr.equals('viewLocation', ViewContainerLocationToString(ViewContainerLocation.Sidebar)), + when: ContextKeyExpr.or( + ContextKeyExpr.equals('viewContainerLocation', ViewContainerLocationToString(ViewContainerLocation.Sidebar)), + ContextKeyExpr.equals('viewContainerLocation', ViewContainerLocationToString(ViewContainerLocation.AuxiliaryBar)) + ), group: '3_workbench_layout_move', order: 1 }); diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts index e564a8d49d7..e5cc8c36550 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts @@ -149,14 +149,14 @@ MenuRegistry.appendMenuItems([ order: 2 } }, { - id: MenuId.ViewTitleContext, + id: MenuId.ViewContainerTitleContext, item: { group: '3_workbench_layout_move', command: { id: ToggleAuxiliaryBarAction.ID, title: localize2('hideAuxiliaryBar', 'Hide Secondary Side Bar'), }, - when: ContextKeyExpr.and(AuxiliaryBarVisibleContext, ContextKeyExpr.equals('viewLocation', ViewContainerLocationToString(ViewContainerLocation.AuxiliaryBar))), + when: ContextKeyExpr.and(AuxiliaryBarVisibleContext, ContextKeyExpr.equals('viewContainerLocation', ViewContainerLocationToString(ViewContainerLocation.AuxiliaryBar))), order: 2 } } diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 1bbe6258eb0..20c084c9603 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -312,6 +312,7 @@ function registerActiveEditorMoveCopyCommand(): void { } else if (sourceGroup.id !== targetGroup.id) { sourceGroup.copyEditors(editors.map(editor => ({ editor })), targetGroup); } + targetGroup.focus(); } } @@ -972,8 +973,8 @@ function registerFocusEditorGroupWihoutWrapCommands(): void { CommandsRegistry.registerCommand(command.id, async (accessor: ServicesAccessor) => { const editorGroupsService = accessor.get(IEditorGroupsService); - const group = editorGroupsService.findGroup({ direction: command.direction }, editorGroupsService.activeGroup, false); - group?.focus(); + const group = editorGroupsService.findGroup({ direction: command.direction }, editorGroupsService.activeGroup, false) ?? editorGroupsService.activeGroup; + group.focus(); }); } } diff --git a/src/vs/workbench/browser/parts/paneCompositeBar.ts b/src/vs/workbench/browser/parts/paneCompositeBar.ts index 05b43a296e1..89d07a87bc0 100644 --- a/src/vs/workbench/browser/parts/paneCompositeBar.ts +++ b/src/vs/workbench/browser/parts/paneCompositeBar.ts @@ -99,7 +99,7 @@ export class PaneCompositeBar extends Disposable { constructor( protected readonly options: IPaneCompositeBarOptions, - private readonly part: Parts, + protected readonly part: Parts, private readonly paneCompositePart: IPaneCompositePart, @IInstantiationService protected readonly instantiationService: IInstantiationService, @IStorageService private readonly storageService: IStorageService, diff --git a/src/vs/workbench/browser/parts/panel/panelActions.ts b/src/vs/workbench/browser/parts/panel/panelActions.ts index b3be54f49d7..6e43176a62e 100644 --- a/src/vs/workbench/browser/parts/panel/panelActions.ts +++ b/src/vs/workbench/browser/parts/panel/panelActions.ts @@ -14,7 +14,7 @@ import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/conte import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; -import { ViewContainerLocationToString, ViewContainerLocation, IViewDescriptorService } from '../../../common/views.js'; +import { ViewContainerLocation, IViewDescriptorService } from '../../../common/views.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { IPaneCompositePartService } from '../../../services/panecomposite/browser/panecomposite.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; @@ -326,17 +326,6 @@ MenuRegistry.appendMenuItems([ when: ContextKeyExpr.or(ContextKeyExpr.equals('config.workbench.layoutControl.type', 'toggles'), ContextKeyExpr.equals('config.workbench.layoutControl.type', 'both')), order: 1 } - }, { - id: MenuId.ViewTitleContext, - item: { - group: '3_workbench_layout_move', - command: { - id: TogglePanelAction.ID, - title: localize2('hidePanel', 'Hide Panel'), - }, - when: ContextKeyExpr.and(PanelVisibleContext, ContextKeyExpr.equals('viewLocation', ViewContainerLocationToString(ViewContainerLocation.Panel))), - order: 2 - } } ]); diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts index 3de50cb8937..149ee4cbff5 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts @@ -8,7 +8,7 @@ import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle import { SimpleIconLabel } from '../../../../base/browser/ui/iconLabel/simpleIconLabel.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IStatusbarEntry, ShowTooltipCommand, StatusbarEntryKinds } from '../../../services/statusbar/browser/statusbar.js'; +import { IStatusbarEntry, isTooltipWithCommands, ShowTooltipCommand, StatusbarEntryKinds, TooltipContent } from '../../../services/statusbar/browser/statusbar.js'; import { WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification } from '../../../../base/common/actions.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ThemeColor } from '../../../../base/common/themables.js'; @@ -24,7 +24,7 @@ import { spinningLoading, syncing } from '../../../../platform/theme/common/icon import { isMarkdownString, markdownStringEqual } from '../../../../base/common/htmlContent.js'; import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js'; import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; -import type { IManagedHover } from '../../../../base/browser/ui/hover/hover.js'; +import { IManagedHover, IManagedHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; export class StatusbarEntryItem extends Disposable { @@ -116,11 +116,26 @@ export class StatusbarEntryItem extends Disposable { // Update: Hover if (!this.entry || !this.isEqualTooltip(this.entry, entry)) { - const hoverContents = isMarkdownString(entry.tooltip) ? { markdown: entry.tooltip, markdownNotSupportedFallback: undefined } : entry.tooltip; - if (this.hover) { - this.hover.update(hoverContents); + let hoverOptions: IManagedHoverOptions | undefined; + let hoverTooltip: TooltipContent | undefined; + if (isTooltipWithCommands(entry.tooltip)) { + hoverTooltip = entry.tooltip.content; + hoverOptions = { + actions: entry.tooltip.commands.map(command => ({ + commandId: command.id, + label: command.title, + run: () => this.executeCommand(command) + })) + }; } else { - this.hover = this._register(this.hoverService.setupManagedHover(this.hoverDelegate, this.container, hoverContents)); + hoverTooltip = entry.tooltip; + } + + const hoverContents = isMarkdownString(hoverTooltip) ? { markdown: hoverTooltip, markdownNotSupportedFallback: undefined } : hoverTooltip; + if (this.hover) { + this.hover.update(hoverContents, hoverOptions); + } else { + this.hover = this._register(this.hoverService.setupManagedHover(this.hoverDelegate, this.container, hoverContents, hoverOptions)); } } diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts index f84bf3f06b7..e09172e4a7f 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts @@ -66,7 +66,7 @@ registerAction2(class ToggleNavigationControl extends ToggleTitleBarConfigAction registerAction2(class ToggleLayoutControl extends ToggleTitleBarConfigAction { constructor() { - super(LayoutSettings.LAYOUT_ACTIONS, localize('toggle.layout', 'Layout Controls'), localize('toggle.layoutDescription', "Toggle visibility of the Layout Controls in title bar"), 3, true); + super(LayoutSettings.LAYOUT_ACTIONS, localize('toggle.layout', 'Layout Controls'), localize('toggle.layoutDescription', "Toggle visibility of the Layout Controls in title bar"), 4, true); } }); diff --git a/src/vs/workbench/browser/parts/views/viewFilter.ts b/src/vs/workbench/browser/parts/views/viewFilter.ts index 9eae6e9bc5f..ee6f952a249 100644 --- a/src/vs/workbench/browser/parts/views/viewFilter.ts +++ b/src/vs/workbench/browser/parts/views/viewFilter.ts @@ -95,7 +95,7 @@ export class FilterWidget extends Widget { @IKeybindingService private readonly keybindingService: IKeybindingService ) { super(); - this.delayedFilterUpdate = new Delayer(400); + this.delayedFilterUpdate = new Delayer(300); this._register(toDisposable(() => this.delayedFilterUpdate.cancel())); if (options.focusContextKey) { diff --git a/src/vs/workbench/common/editor/textEditorModel.ts b/src/vs/workbench/common/editor/textEditorModel.ts index 482364fda50..53b900ec6ed 100644 --- a/src/vs/workbench/common/editor/textEditorModel.ts +++ b/src/vs/workbench/common/editor/textEditorModel.ts @@ -75,13 +75,22 @@ export class BaseTextEditorModel extends EditorModel implements ITextEditorModel return true; } - private _hasLanguageSetExplicitly: boolean = false; - get hasLanguageSetExplicitly(): boolean { return this._hasLanguageSetExplicitly; } + private _blockLanguageChangeListener = false; + private _languageChangeSource: 'user' | 'api' | undefined = undefined; + get languageChangeSource() { return this._languageChangeSource; } + get hasLanguageSetExplicitly() { + // This is technically not 100% correct, because 'api' can also be + // set as source if a model is resolved as text first and then + // transitions into the resolved language. But to preserve the current + // behaviour, we do not change this property. Rather, `languageChangeSource` + // can be used to get more fine grained information. + return typeof this._languageChangeSource === 'string'; + } setLanguageId(languageId: string, source?: string): void { // Remember that an explicit language was set - this._hasLanguageSetExplicitly = true; + this._languageChangeSource = 'user'; this.setLanguageIdInternal(languageId, source); } @@ -95,18 +104,26 @@ export class BaseTextEditorModel extends EditorModel implements ITextEditorModel return; } - this.textEditorModel.setLanguage(this.languageService.createById(languageId), source); + this._blockLanguageChangeListener = true; + try { + this.textEditorModel.setLanguage(this.languageService.createById(languageId), source); + } finally { + this._blockLanguageChangeListener = false; + } } protected installModelListeners(model: ITextModel): void { // Setup listener for lower level language changes - const disposable = this._register(model.onDidChangeLanguage((e) => { - if (e.source === LanguageDetectionLanguageEventSource) { + const disposable = this._register(model.onDidChangeLanguage(e => { + if ( + e.source === LanguageDetectionLanguageEventSource || + this._blockLanguageChangeListener + ) { return; } - this._hasLanguageSetExplicitly = true; + this._languageChangeSource = 'api'; disposable.dispose(); })); } diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleViewActions.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleViewActions.ts index d1be576a131..f587bd7cb1e 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleViewActions.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleViewActions.ts @@ -233,7 +233,7 @@ class AccessibilityHelpConfigureKeybindingsAction extends Action2 { super({ id: AccessibilityCommandId.AccessibilityHelpConfigureKeybindings, precondition: ContextKeyExpr.and(accessibilityHelpIsShown, accessibleViewHasUnassignedKeybindings), - icon: Codicon.key, + icon: Codicon.recordKeys, keybinding: { primary: KeyMod.Alt | KeyCode.KeyK, weight: KeybindingWeight.WorkbenchContrib @@ -260,7 +260,7 @@ class AccessibilityHelpConfigureAssignedKeybindingsAction extends Action2 { super({ id: AccessibilityCommandId.AccessibilityHelpConfigureAssignedKeybindings, precondition: ContextKeyExpr.and(accessibilityHelpIsShown, accessibleViewHasAssignedKeybindings), - icon: Codicon.key, + icon: Codicon.recordKeys, keybinding: { primary: KeyMod.Alt | KeyCode.KeyA, weight: KeybindingWeight.WorkbenchContrib diff --git a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts index 9f0b4190a22..9a004312088 100644 --- a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts @@ -19,6 +19,7 @@ import { CommentContextKeys } from '../../comments/common/commentContextKeys.js' import { NEW_UNTITLED_FILE_COMMAND_ID } from '../../files/browser/fileConstants.js'; import { IAccessibleViewService, IAccessibleViewContentProvider, AccessibleViewProviderId, IAccessibleViewOptions, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; import { AccessibilityVerbositySettingId } from './accessibilityConfiguration.js'; +import { ctxHasEditorModification, ctxHasRequestInProgress } from '../../chat/browser/chatEditorController.js'; export class EditorAccessibilityHelpContribution extends Disposable { static ID: 'editorAccessibilityHelpContribution'; @@ -72,9 +73,15 @@ class EditorAccessibilityHelpProvider extends Disposable implements IAccessibleV } } + const chatEditInfo = getChatEditInfo(this._keybindingService, this._contextKeyService, this._editor); + if (chatEditInfo) { + content.push(chatEditInfo); + } + content.push(AccessibilityHelpNLS.listSignalSounds); content.push(AccessibilityHelpNLS.listAlerts); + const chatCommandInfo = getChatCommandInfo(this._keybindingService, this._contextKeyService); if (chatCommandInfo) { content.push(chatCommandInfo); @@ -120,3 +127,13 @@ export function getChatCommandInfo(keybindingService: IKeybindingService, contex } return; } + +export function getChatEditInfo(keybindingService: IKeybindingService, contextKeyService: IContextKeyService, editor: ICodeEditor): string | undefined { + const editorContext = contextKeyService.getContext(editor.getDomNode()!); + if (editorContext.getValue(ctxHasEditorModification.key)) { + return AccessibilityHelpNLS.chatEditorModification + '\n' + AccessibilityHelpNLS.chatEditActions; + } else if (editorContext.getValue(ctxHasRequestInProgress.key)) { + return AccessibilityHelpNLS.chatEditorRequestInProgress; + } + return; +} diff --git a/src/vs/workbench/contrib/bulkEdit/browser/bulkFileEdits.ts b/src/vs/workbench/contrib/bulkEdit/browser/bulkFileEdits.ts index 237b768fbd4..858d20cd458 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/bulkFileEdits.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/bulkFileEdits.ts @@ -255,15 +255,16 @@ class DeleteOperation implements IFileOperation { // read file contents for undo operation. when a file is too large it won't be restored let fileContent: IFileContent | undefined; - if (!edit.undoesCreate && !edit.options.folder && !(typeof edit.options.maxSize === 'number' && fileStat.size > edit.options.maxSize)) { + const isSizeLimitExceeded = typeof edit.options.maxSize === 'number' && fileStat.size > edit.options.maxSize; + if (!edit.undoesCreate && !edit.options.folder && !isSizeLimitExceeded) { try { fileContent = await this._fileService.readFile(edit.oldUri); } catch (err) { this._logService.error(err); } } - if (fileContent !== undefined) { - undoes.push(new CreateEdit(edit.oldUri, edit.options, fileContent.value)); + if (!fileContent || !isSizeLimitExceeded) { + undoes.push(new CreateEdit(edit.oldUri, edit.options, fileContent?.value)); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index b1fcadf5d50..09805452279 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -12,6 +12,7 @@ import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType import { IAccessibleViewImplentation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { ActiveAuxiliaryContext } from '../../../../common/contextkeys.js'; import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { INLINE_CHAT_ID } from '../../../inlineChat/common/inlineChat.js'; import { ChatAgentLocation } from '../../common/chatAgents.js'; @@ -22,7 +23,7 @@ export class PanelChatAccessibilityHelp implements IAccessibleViewImplentation { readonly priority = 107; readonly name = 'panelChat'; readonly type = AccessibleViewType.Help; - readonly when = ContextKeyExpr.and(ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), ChatContextKeys.inQuickChat.negate(), ContextKeyExpr.or(ChatContextKeys.inChatSession, ChatContextKeys.isResponse, ChatContextKeys.isRequest)); + readonly when = ContextKeyExpr.and(ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel), ChatContextKeys.inQuickChat.negate(), ActiveAuxiliaryContext.isEqualTo('workbench.panel.chat'), ContextKeyExpr.or(ChatContextKeys.inChatSession, ChatContextKeys.isResponse, ChatContextKeys.isRequest)); getProvider(accessor: ServicesAccessor) { const codeEditor = accessor.get(ICodeEditorService).getActiveCodeEditor() || accessor.get(ICodeEditorService).getFocusedCodeEditor(); return getChatAccessibilityHelpProvider(accessor, codeEditor ?? undefined, 'panelChat'); @@ -40,7 +41,18 @@ export class QuickChatAccessibilityHelp implements IAccessibleViewImplentation { } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat', keybindingService: IKeybindingService): string { +export class EditsChatAccessibilityHelp implements IAccessibleViewImplentation { + readonly priority = 119; + readonly name = 'editsView'; + readonly type = AccessibleViewType.Help; + readonly when = ContextKeyExpr.and(ActiveAuxiliaryContext.isEqualTo('workbench.panel.chatEditing'), ChatContextKeys.inChatInput); + getProvider(accessor: ServicesAccessor) { + const codeEditor = accessor.get(ICodeEditorService).getActiveCodeEditor() || accessor.get(ICodeEditorService).getFocusedCodeEditor(); + return getChatAccessibilityHelpProvider(accessor, codeEditor ?? undefined, 'editsView'); + } +} + +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView', keybindingService: IKeybindingService): string { const content = []; if (type === 'panelChat' || type === 'quickChat') { if (type === 'quickChat') { @@ -61,6 +73,25 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('workbench.action.chat.newChat', 'To create a new chat session, invoke the New Chat command{0}.', '')); } } + if (type === 'editsView') { + content.push(localize('chatEditing.overview', 'The chat editing view is used to apply edits across files.')); + content.push(localize('chatEditing.format', 'It is comprised of an input box and a file working set (Shift+Tab).')); + content.push(localize('chatEditing.expectation', 'When a request is made, a progress indicator will play while the edits are being applied.')); + content.push(localize('chatEditing.review', 'Once the edits are applied, focus the editor(s) to review, accept, and discard changes.')); + content.push(localize('chatEditing.sections', 'Navigate between edits in the editor with navigate previous{0} and next{1}', '', '')); + content.push(localize('chatEditing.acceptHunk', 'In the editor, Accept{0} and Reject the current Change{1}.', '', '')); + content.push(localize('chatEditing.helpfulCommands', 'When in the edits view, some helpful commands include:')); + content.push(localize('workbench.action.chat.undoEdits', '- Undo Edits{0}.', '')); + content.push(localize('workbench.action.chat.editing.attachFiles', '- Attach Files{0}.', '')); + content.push(localize('chatEditing.removeFileFromWorkingSet', '- Remove File from Working Set{0}.', '')); + content.push(localize('chatEditing.acceptFile', '- Accept{0} and Discard File{1}.', '', '')); + content.push(localize('chatEditing.saveAllFiles', '- Save All Files{0}.', '')); + content.push(localize('chatEditing.acceptAllFiles', '- Accept All Edits{0}.', '')); + content.push(localize('chatEditing.discardAllFiles', '- Discard All Edits{0}.', '')); + content.push(localize('chatEditing.openFileInDiff', '- Open File in Diff{0}.', '')); + content.push(localize('chatEditing.addFileToWorkingSet', '- Add File to Working Set{0}.', '')); + content.push(localize('chatEditing.viewChanges', '- View Changes{0}.', '')); + } else { content.push(localize('inlineChat.overview', "Inline chat occurs within a code editor and takes into account the current selection. It is useful for making changes to the current editor. For example, fixing diagnostics, documenting or refactoring code. Keep in mind that AI generated code may be incorrect.")); content.push(localize('inlineChat.access', "It can be activated via code actions or directly using the command: Inline Chat: Start Inline Chat{0}.", '')); @@ -75,10 +106,10 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui return content.join('\n'); } -export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat' | 'quickChat') { +export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView') { const widgetService = accessor.get(IChatWidgetService); const keybindingService = accessor.get(IKeybindingService); - const inputEditor: ICodeEditor | undefined = type === 'panelChat' || type === 'quickChat' ? widgetService.lastFocusedWidget?.inputEditor : editor; + const inputEditor: ICodeEditor | undefined = type === 'panelChat' || type === 'editsView' || type === 'quickChat' ? widgetService.lastFocusedWidget?.inputEditor : editor; if (!inputEditor) { return; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index d1d7d951559..4b1b916b751 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -20,7 +20,7 @@ import { ILocalizedString, localize, localize2 } from '../../../../../nls.js'; import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { DropdownWithPrimaryActionViewItem } from '../../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; import { Action2, MenuId, MenuItemAction, MenuRegistry, registerAction2, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsLinuxContext, IsWindowsContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; @@ -464,7 +464,7 @@ export function registerChatActions() { } }); - function registerOpenLinkAction(id: string, title: ILocalizedString, url: string, order: number): void { + function registerOpenLinkAction(id: string, title: ILocalizedString, url: string, order: number, contextKey: ContextKeyExpression = ChatContextKeys.enabled): void { registerAction2(class extends Action2 { constructor() { super({ @@ -472,11 +472,12 @@ export function registerChatActions() { title, category: CHAT_CATEGORY, f1: true, - precondition: ChatContextKeys.enabled, + precondition: contextKey, menu: { id: MenuId.ChatCommandCenter, group: 'y_manage', - order + order, + when: contextKey } }); } @@ -488,8 +489,9 @@ export function registerChatActions() { }); } - registerOpenLinkAction('workbench.action.chat.managePlan', localize2('managePlan', "Manage Copilot Plan"), defaultChat.managePlanUrl, 1); - registerOpenLinkAction('workbench.action.chat.manageSettings', localize2('manageSettings', "Manage Copilot Settings"), defaultChat.manageSettingsUrl, 2); + const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals('config.github.copilot.advanced.authProvider', 'github-enterprise')); + registerOpenLinkAction('workbench.action.chat.managePlan', localize2('managePlan', "Manage Copilot Plan"), defaultChat.managePlanUrl, 1, nonEnterpriseCopilotUsers); + registerOpenLinkAction('workbench.action.chat.manageSettings', localize2('manageSettings', "Manage Copilot Settings"), defaultChat.manageSettingsUrl, 2, nonEnterpriseCopilotUsers); registerOpenLinkAction('workbench.action.chat.learnMore', localize2('learnMore', "Learn More"), defaultChat.documentationUrl, 3); registerAction2(class ShowExtensionsUsingCopilit extends Action2 { @@ -543,7 +545,7 @@ registerAction2(class ToggleCopilotControl extends ToggleTitleBarConfigAction { super( 'chat.commandCenter.enabled', localize('toggle.chatControl', 'Copilot Controls'), - localize('toggle.chatControlsDescription', "Toggle visibility of the Copilot Controls in title bar"), 4, false, + localize('toggle.chatControlsDescription', "Toggle visibility of the Copilot Controls in title bar"), 5, false, ContextKeyExpr.and( ChatContextKeys.supported, ContextKeyExpr.has('config.window.commandCenter') diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts index c812ec0d053..320eeaec1ff 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts @@ -22,6 +22,7 @@ import { Action2, IAction2Options, MenuId, registerAction2 } from '../../../../. import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { AnythingQuickAccessProviderRunOptions } from '../../../../../platform/quickinput/common/quickAccess.js'; @@ -52,6 +53,7 @@ import { IChatWidget, IChatWidgetService, IQuickChatService, showChatView, showE import { imageToHash, isImage } from '../chatPasteProviders.js'; import { isQuickChat } from '../chatWidget.js'; import { convertBufferToScreenshotVariable, ScreenshotVariableId } from '../contrib/screenshot.js'; +import { resizeImage } from '../imageUtils.js'; import { CHAT_CATEGORY } from './chatActions.js'; export function registerChatContextActions() { @@ -65,7 +67,9 @@ export function registerChatContextActions() { /** * We fill the quickpick with these types, and enable some quick access providers */ -type IAttachmentQuickPickItem = ICommandVariableQuickPickItem | IQuickAccessQuickPickItem | IToolQuickPickItem | IImageQuickPickItem | IVariableQuickPickItem | IOpenEditorsQuickPickItem | ISearchResultsQuickPickItem | IScreenShotQuickPickItem | IRelatedFilesQuickPickItem; +type IAttachmentQuickPickItem = ICommandVariableQuickPickItem | IQuickAccessQuickPickItem | IToolQuickPickItem | + IImageQuickPickItem | IVariableQuickPickItem | IOpenEditorsQuickPickItem | ISearchResultsQuickPickItem | + IScreenShotQuickPickItem | IRelatedFilesQuickPickItem | IPromptInstructionsQuickPickItem; /** * These are the types that we can get out of the quick pick @@ -119,6 +123,17 @@ function isRelatedFileQuickPickItem(obj: unknown): obj is IRelatedFilesQuickPick ); } +/** + * Checks is a provided object is a prompt instructions quick pick item. + */ +function isPromptInstructionsQuickPickItem(obj: unknown): obj is IPromptInstructionsQuickPickItem { + if (!obj || typeof obj !== 'object') { + return false; + } + + return ('kind' in obj && obj.kind === 'prompt-instructions'); +} + interface IRelatedFilesQuickPickItem extends IQuickPickItem { kind: 'related-files'; id: string; @@ -177,6 +192,22 @@ interface IScreenShotQuickPickItem extends IQuickPickItem { icon?: ThemeIcon; } +/** + * Quick pick item for prompt instructions attachment. + */ +interface IPromptInstructionsQuickPickItem extends IQuickPickItem { + /** + * Unique kind identifier of the prompt instructions + * attachment quick pick item. + */ + kind: 'prompt-instructions'; + + /** + * The id of the qucik pick item. + */ + id: string; +} + abstract class AttachFileAction extends Action2 { getFiles(accessor: ServicesAccessor, ...args: any[]): URI[] { const textEditorService = accessor.get(IEditorService); @@ -429,7 +460,7 @@ export class AttachContextAction extends Action2 { `:${item.range.startLineNumber}`); } - private async _attachContext(widget: IChatWidget, quickInputService: IQuickInputService, commandService: ICommandService, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, isInBackground?: boolean, ...picks: IChatContextQuickPickItem[]) { + private async _attachContext(widget: IChatWidget, quickInputService: IQuickInputService, commandService: ICommandService, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, fileService: IFileService, isInBackground?: boolean, ...picks: IChatContextQuickPickItem[]) { const toAttach: IChatRequestVariableEntry[] = []; for (const pick of picks) { if (isISymbolQuickPickItem(pick) && pick.symbol) { @@ -446,14 +477,19 @@ export class AttachContextAction extends Action2 { } else if (isIQuickPickItemWithResource(pick) && pick.resource) { if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(pick.resource.path)) { // checks if the file is an image - toAttach.push({ - id: pick.resource.toString(), - name: pick.label, - fullName: pick.label, - value: pick.resource, - isDynamic: true, - isImage: true - }); + if (URI.isUri(pick.resource)) { + // read the image and attach a new file context. + const readFile = await fileService.readFile(pick.resource); + const resizedImage = await resizeImage(readFile.value.buffer); + toAttach.push({ + id: pick.resource.toString(), + name: pick.label, + fullName: pick.label, + value: resizedImage, + isDynamic: true, + isImage: true + }); + } } else { // file attachment if (chatEditingService) { @@ -544,6 +580,40 @@ export class AttachContextAction extends Action2 { if (blob) { toAttach.push(convertBufferToScreenshotVariable(blob)); } + } else if (isPromptInstructionsQuickPickItem(pick)) { + const { promptInstructions } = widget.attachmentModel; + + // find all prompt instruction files in the user project + // and present them to the user so they can select one + const filesPromise = promptInstructions.listNonAttachedFiles() + .then((files) => { + return files.map((file) => { + const result: IQuickPickItem & { value: URI } = { + type: 'item', + label: labelService.getUriBasenameLabel(file), + description: labelService.getUriLabel(dirname(file), { relative: true }), + tooltip: file.fsPath, + value: file, + }; + + return result; + }); + }); + + const selectedFile = await quickInputService.pick( + filesPromise, + { + placeHolder: localize('promptInstructions', 'Add prompt instructions file'), + canPickMany: false, + }); + + // if the quick pick dialog was dismissed, nothing to do + if (!selectedFile) { + return; + } + + // add selected prompt instructions reference to the chat attachments model + promptInstructions.add(selectedFile.value); } else { // Anything else is an attachment const attachmentPick = pick as IAttachmentQuickPickItem; @@ -618,6 +688,7 @@ export class AttachContextAction extends Action2 { const viewsService = accessor.get(IViewsService); const hostService = accessor.get(IHostService); const extensionService = accessor.get(IExtensionService); + const fileService = accessor.get(IFileService); const context: { widget?: IChatWidget; showFilesOnly?: boolean; placeholder?: string } | undefined = args[0]; const widget = context?.widget ?? widgetService.lastFocusedWidget; @@ -757,6 +828,17 @@ export class AttachContextAction extends Action2 { } } + // if the `prompt instructions` feature is enabled, add + // the `Prompt Instructions` attachment type to the list + if (widget.attachmentModel.promptInstructions.featureEnabled) { + quickPickItems.push({ + kind: 'prompt-instructions', + id: 'prompt-instructions', + label: localize('chatContext.promptInstructions', 'Prompt Instructions'), + iconClass: ThemeIcon.asClassName(Codicon.lightbulbSparkle), + }); + } + function extractTextFromIconLabel(label: string | undefined): string { if (!label) { return ''; @@ -771,19 +853,19 @@ export class AttachContextAction extends Action2 { const second = extractTextFromIconLabel(b.label).toUpperCase(); return compare(first, second); - }), clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, '', context?.placeholder); + }), clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, fileService, '', context?.placeholder); } - private _show(quickInputService: IQuickInputService, commandService: ICommandService, widget: IChatWidget, quickChatService: IQuickChatService, quickPickItems: (IChatContextQuickPickItem | QuickPickItem)[] | undefined, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, query: string = '', placeholder?: string) { + private _show(quickInputService: IQuickInputService, commandService: ICommandService, widget: IChatWidget, quickChatService: IQuickChatService, quickPickItems: (IChatContextQuickPickItem | QuickPickItem)[] | undefined, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, fileService: IFileService, query: string = '', placeholder?: string) { const providerOptions: AnythingQuickAccessProviderRunOptions = { handleAccept: (item: IChatContextQuickPickItem, isBackgroundAccept: boolean) => { if ('prefix' in item) { - this._show(quickInputService, commandService, widget, quickChatService, quickPickItems, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, item.prefix, placeholder); + this._show(quickInputService, commandService, widget, quickChatService, quickPickItems, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, fileService, item.prefix, placeholder); } else { if (!clipboardService) { return; } - this._attachContext(widget, quickInputService, commandService, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, isBackgroundAccept, item); + this._attachContext(widget, quickInputService, commandService, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, fileService, isBackgroundAccept, item); if (isQuickChat(widget)) { quickChatService.open(); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 1b8f11d4851..9b44062187d 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -6,11 +6,11 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatAgentLocation, IChatAgentService } from '../../common/chatAgents.js'; @@ -46,13 +46,21 @@ export class ChatSubmitAction extends SubmitAction { static readonly ID = 'workbench.action.chat.submit'; constructor() { + const precondition = ContextKeyExpr.and( + // if the input has prompt instructions attached, allow submitting requests even + // without text present - having instructions is enough context for a request + ContextKeyExpr.or(ChatContextKeys.inputHasText, ChatContextKeys.instructionsAttached), + ChatContextKeys.requestInProgress.negate(), + ChatContextKeys.location.notEqualsTo(ChatAgentLocation.EditingSession), + ); + super({ id: ChatSubmitAction.ID, title: localize2('interactive.submit.label', "Send and Dispatch"), f1: false, category: CHAT_CATEGORY, icon: Codicon.send, - precondition: ContextKeyExpr.and(ChatContextKeys.inputHasText, ChatContextKeys.requestInProgress.negate(), ChatContextKeys.location.notEqualsTo(ChatAgentLocation.EditingSession)), + precondition, keybinding: { when: ChatContextKeys.inChatInput, primary: KeyCode.Enter, @@ -75,6 +83,52 @@ export class ChatSubmitAction extends SubmitAction { } } +export const ToggleAgentModeActionId = 'workbench.action.chat.toggleAgentMode'; +export class ToggleAgentModeAction extends Action2 { + static readonly ID = ToggleAgentModeActionId; + + constructor() { + super({ + id: ToggleAgentModeAction.ID, + title: localize2('interactive.toggleAgent.label', "Toggle Agent Mode"), + f1: true, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and( + ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), + ChatContextKeys.Editing.hasToolsAgent), + icon: Codicon.edit, + toggled: { + condition: ChatContextKeys.Editing.agentMode, + icon: Codicon.tools, + tooltip: localize('agentEnabled', "Agent Mode Enabled"), + }, + tooltip: localize('agentDisabled', "Agent Mode Disabled"), + keybinding: { + when: ContextKeyExpr.and( + ChatContextKeys.inChatInput, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession)), + primary: KeyMod.CtrlCmd | KeyCode.Period, + weight: KeybindingWeight.EditorContrib + }, + menu: [ + { + id: MenuId.ChatExecute, + order: 1, + when: ContextKeyExpr.and( + ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), + ChatContextKeys.Editing.hasToolsAgent), + group: 'navigation', + }, + ] + }); + } + + override run(accessor: ServicesAccessor, ...args: any[]): void { + const agentService = accessor.get(IChatAgentService); + agentService.toggleToolsAgentMode(); + } +} + export class ChatEditingSessionSubmitAction extends SubmitAction { static readonly ID = 'workbench.action.edits.submit'; @@ -85,7 +139,7 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { f1: false, category: CHAT_CATEGORY, icon: Codicon.send, - precondition: ContextKeyExpr.and(ChatContextKeys.inputHasText, ChatContextKeys.requestInProgress.negate(), ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), applyingChatEditsContextKey.toNegated()), + precondition: ContextKeyExpr.and(ChatContextKeys.inputHasText, ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), applyingChatEditsContextKey.toNegated()), keybinding: { when: ChatContextKeys.inChatInput, primary: KeyCode.Enter, @@ -388,4 +442,5 @@ export function registerChatExecuteActions() { registerAction2(SendToNewChatAction); registerAction2(ChatSubmitSecondaryAgentAction); registerAction2(SendToChatEditingAction); + registerAction2(ToggleAgentModeAction); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts b/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts index 8dc7d4dee91..ea35e44e016 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts @@ -16,6 +16,7 @@ import { IDefaultChatAgent } from '../../../../../base/common/product.js'; import { IViewDescriptorService } from '../../../../common/views.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; import { ensureSideBarChatViewSize } from '../chat.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; export class ChatGettingStartedContribution extends Disposable implements IWorkbenchContribution { @@ -32,6 +33,7 @@ export class ChatGettingStartedContribution extends Disposable implements IWorkb @IStorageService private readonly storageService: IStorageService, @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); @@ -60,14 +62,25 @@ export class ChatGettingStartedContribution extends Disposable implements IWorkb if (ExtensionIdentifier.equals(defaultChatAgent.extensionId, ext.value)) { const extensionStatus = this.extensionService.getExtensionsStatus(); if (extensionStatus[ext.value].activationTimes && this.recentlyInstalled) { - await this.commandService.executeCommand(CHAT_OPEN_ACTION_ID); - ensureSideBarChatViewSize(400, this.viewDescriptorService, this.layoutService); - this.storageService.store(ChatGettingStartedContribution.hideWelcomeView, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - this.recentlyInstalled = false; + this.onDidInstallChat(); return; } } } })); } + + private async onDidInstallChat() { + + // Enable chat command center if previously disabled + this.configurationService.updateValue('chat.commandCenter.enabled', true); + + // Open and configure chat view + await this.commandService.executeCommand(CHAT_OPEN_ACTION_ID); + ensureSideBarChatViewSize(400, this.viewDescriptorService, this.layoutService); + + // Only do this once + this.storageService.store(ChatGettingStartedContribution.hideWelcomeView, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + this.recentlyInstalled = false; + } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts index e927be89e77..e732c01efbb 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts @@ -18,6 +18,7 @@ import { IEditorGroupsService } from '../../../../services/editor/common/editorG import { ACTIVE_GROUP, AUX_WINDOW_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { isChatViewTitleActionContext } from '../../common/chatActions.js'; +import { ChatAgentLocation } from '../../common/chatAgents.js'; enum MoveToNewLocation { Editor = 'Editor', @@ -99,7 +100,7 @@ async function executeMoveToAction(accessor: ServicesAccessor, moveTo: MoveToNew const widget = (_sessionId ? widgetService.getWidgetBySessionId(_sessionId) : undefined) ?? widgetService.lastFocusedWidget; - if (!widget || !('viewId' in widget.viewContext)) { + if (!widget || widget.location !== ChatAgentLocation.Panel) { await editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options: { pinned: true } }, moveTo === MoveToNewLocation.Window ? AUX_WINDOW_GROUP : ACTIVE_GROUP); return; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts index 5462565f4ab..5fafadbd6c0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts @@ -2,22 +2,21 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { coalesce } from '../../../../../base/common/arrays.js'; import { AsyncIterableObject } from '../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { CharCode } from '../../../../../base/common/charCode.js'; import { isCancellationError } from '../../../../../base/common/errors.js'; import { isEqual } from '../../../../../base/common/resources.js'; import * as strings from '../../../../../base/common/strings.js'; +import { URI } from '../../../../../base/common/uri.js'; import { IActiveCodeEditor, isCodeEditor, isDiffEditor } from '../../../../../editor/browser/editorBrowser.js'; import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { Range } from '../../../../../editor/common/core/range.js'; -import { ConversationRequest, ConversationResponse, DocumentContextItem, IWorkspaceFileEdit, IWorkspaceTextEdit } from '../../../../../editor/common/languages.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { ITextModel } from '../../../../../editor/common/model.js'; -import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; import { localize } from '../../../../../nls.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -29,9 +28,9 @@ import { InlineChatController } from '../../../inlineChat/browser/inlineChatCont import { insertCell } from '../../../notebook/browser/controller/cellOperations.js'; import { IActiveNotebookEditor, INotebookEditor } from '../../../notebook/browser/notebookBrowser.js'; import { CellKind, NOTEBOOK_EDITOR_ID } from '../../../notebook/common/notebookCommon.js'; -import { getReferencesAsDocumentContext } from '../../common/chatCodeMapperService.js'; +import { ICodeMapperCodeBlock, ICodeMapperRequest, ICodeMapperResponse, ICodeMapperService } from '../../common/chatCodeMapperService.js'; import { ChatUserAction, IChatService } from '../../common/chatService.js'; -import { isRequestVM, isResponseVM } from '../../common/chatViewModel.js'; +import { isResponseVM } from '../../common/chatViewModel.js'; import { ICodeBlockActionContext } from '../codeBlockPart.js'; export class InsertCodeBlockOperation { @@ -98,7 +97,7 @@ export class InsertCodeBlockOperation { } } -type IComputeEditsResult = { readonly edits?: Array; readonly codeMapper?: string }; +type IComputeEditsResult = { readonly editsProposed: boolean; readonly codeMapper?: string }; export class ApplyCodeBlockOperation { @@ -107,15 +106,14 @@ export class ApplyCodeBlockOperation { constructor( @IEditorService private readonly editorService: IEditorService, @ITextFileService private readonly textFileService: ITextFileService, - @IBulkEditService private readonly bulkEditService: IBulkEditService, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IChatService private readonly chatService: IChatService, - @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, - @IProgressService private readonly progressService: IProgressService, @ILanguageService private readonly languageService: ILanguageService, @IFileService private readonly fileService: IFileService, @IDialogService private readonly dialogService: IDialogService, @ILogService private readonly logService: ILogService, + @ICodeMapperService private readonly codeMapperService: ICodeMapperService, + @IProgressService private readonly progressService: IProgressService ) { } @@ -166,7 +164,7 @@ export class ApplyCodeBlockOperation { codeBlockIndex: context.codeBlockIndex, totalCharacters: context.code.length, codeMapper: result?.codeMapper, - editsProposed: !!result?.edits, + editsProposed: !!result?.editsProposed }); } @@ -182,64 +180,37 @@ export class ApplyCodeBlockOperation { } private async handleTextEditor(codeEditor: IActiveCodeEditor, codeBlockContext: ICodeBlockActionContext): Promise { - if (isReadOnly(codeEditor.getModel(), this.textFileService)) { + const activeModel = codeEditor.getModel(); + if (isReadOnly(activeModel, this.textFileService)) { this.notify(localize('applyCodeBlock.readonly', "Cannot apply code block to read-only file.")); return undefined; } - const result = await this.computeEdits(codeEditor, codeBlockContext); - if (result.edits) { - const showWithPreview = await this.applyWithInlinePreview(result.edits, codeEditor); - if (!showWithPreview) { - await this.bulkEditService.apply(result.edits, { showPreview: true }); - const activeModel = codeEditor.getModel(); - this.codeEditorService.listCodeEditors().find(editor => editor.getModel()?.uri.toString() === activeModel.uri.toString())?.focus(); - } + const resource = codeBlockContext.codemapperUri ?? activeModel.uri; + const codeBlock = { code: codeBlockContext.code, resource, markdownBeforeBlock: undefined }; + + const codeMapper = this.codeMapperService.providers[0]?.displayName; + if (!codeMapper) { + this.notify(localize('applyCodeBlock.noCodeMapper', "No code mapper available.")); + return undefined; } - return result; - } - private async computeEdits(codeEditor: IActiveCodeEditor, codeBlockActionContext: ICodeBlockActionContext): Promise { - const activeModel = codeEditor.getModel(); - - const mappedEditsProviders = this.languageFeaturesService.mappedEditsProvider.ordered(activeModel); - if (mappedEditsProviders.length > 0) { - - // 0th sub-array - editor selections array if there are any selections - // 1st sub-array - array with documents used to get the chat reply - const docRefs: DocumentContextItem[][] = []; - collectDocumentContextFromSelections(codeEditor, docRefs); - collectDocumentContextFromContext(codeBlockActionContext, docRefs); + const editorToApply = await this.codeEditorService.openCodeEditor({ resource }, codeEditor); + let result = false; + if (editorToApply && editorToApply.hasModel()) { const cancellationTokenSource = new CancellationTokenSource(); - let codeMapper; // the last used code mapper try { - const result = await this.progressService.withProgress( + const iterable = await this.progressService.withProgress>( { location: ProgressLocation.Notification, delay: 500, sticky: true, cancellable: true }, async progress => { - for (const provider of mappedEditsProviders) { - codeMapper = provider.displayName; - progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) }); - const mappedEdits = await provider.provideMappedEdits( - activeModel, - [codeBlockActionContext.code], - { - documents: docRefs, - conversation: getChatConversation(codeBlockActionContext), - }, - cancellationTokenSource.token - ); - if (mappedEdits) { - return { edits: mappedEdits.edits, codeMapper }; - } - } - return undefined; + progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) }); + const editsIterable = this.getEdits(codeBlock, cancellationTokenSource.token); + return await this.waitForFirstElement(editsIterable); }, () => cancellationTokenSource.cancel() ); - if (result) { - return result; - } + result = await this.applyWithInlinePreview(iterable, editorToApply, cancellationTokenSource); } catch (e) { if (!isCancellationError(e)) { this.notify(localize('applyCodeBlock.error', "Failed to apply code block: {0}", e.message)); @@ -247,41 +218,66 @@ export class ApplyCodeBlockOperation { } finally { cancellationTokenSource.dispose(); } - return { edits: [], codeMapper }; } - return { edits: [], codeMapper: undefined }; + return { + editsProposed: result, + codeMapper + }; } - private async applyWithInlinePreview(edits: Array, codeEditor: IActiveCodeEditor): Promise { - const firstEdit = edits[0]; - if (!ResourceTextEdit.is(firstEdit)) { - return false; - } - const resource = firstEdit.resource; - const textEdits = coalesce(edits.map(edit => ResourceTextEdit.is(edit) && isEqual(resource, edit.resource) ? edit.textEdit : undefined)); - if (textEdits.length !== edits.length) { // more than one file has changed, fall back to bulk edit preview - return false; - } - const editorToApply = await this.codeEditorService.openCodeEditor({ resource }, codeEditor); - if (editorToApply) { - const inlineChatController = InlineChatController.get(editorToApply); - if (inlineChatController) { - const tokenSource = new CancellationTokenSource(); - let isOpen = true; - const firstEdit = textEdits[0]; - editorToApply.revealLineInCenterIfOutsideViewport(firstEdit.range.startLineNumber); - const promise = inlineChatController.reviewEdits(textEdits[0].range, AsyncIterableObject.fromArray([textEdits]), tokenSource.token); - promise.finally(() => { - isOpen = false; - tokenSource.dispose(); - }); - this.inlineChatPreview = { - promise, - isOpen: () => isOpen, - cancel: () => tokenSource.cancel(), - }; - return true; + private getEdits(codeBlock: ICodeMapperCodeBlock, token: CancellationToken): AsyncIterable { + return new AsyncIterableObject(async executor => { + const request: ICodeMapperRequest = { + codeBlocks: [codeBlock] + }; + const response: ICodeMapperResponse = { + textEdit: (target: URI, edit: TextEdit[]) => { + executor.emitOne(edit); + } + }; + const result = await this.codeMapperService.mapCode(request, response, token); + if (result?.errorMessage) { + executor.reject(new Error(result.errorMessage)); } + }); + } + + private async waitForFirstElement(iterable: AsyncIterable): Promise> { + const iterator = iterable[Symbol.asyncIterator](); + const firstResult = await iterator.next(); + + if (firstResult.done) { + return { + async *[Symbol.asyncIterator]() { + return; + } + }; + } + + return { + async *[Symbol.asyncIterator]() { + yield firstResult.value; + yield* iterable; + } + }; + } + + private async applyWithInlinePreview(edits: AsyncIterable, codeEditor: IActiveCodeEditor, tokenSource: CancellationTokenSource): Promise { + const inlineChatController = InlineChatController.get(codeEditor); + if (inlineChatController) { + let isOpen = true; + const promise = inlineChatController.reviewEdits(codeEditor.getSelection(), edits, tokenSource.token); + promise.finally(() => { + isOpen = false; + tokenSource.dispose(); + }); + this.inlineChatPreview = { + promise, + isOpen: () => isOpen, + cancel: () => tokenSource.cancel(), + }; + return true; + } return false; } @@ -360,49 +356,6 @@ function isReadOnly(model: ITextModel, textFileService: ITextFileService): boole return !!activeTextModel?.isReadonly(); } -function collectDocumentContextFromSelections(codeEditor: IActiveCodeEditor, result: DocumentContextItem[][]): void { - const activeModel = codeEditor.getModel(); - const currentDocUri = activeModel.uri; - const currentDocVersion = activeModel.getVersionId(); - const selections = codeEditor.getSelections(); - if (selections.length > 0) { - result.push([ - { - uri: currentDocUri, - version: currentDocVersion, - ranges: selections, - } - ]); - } -} - - -function collectDocumentContextFromContext(context: ICodeBlockActionContext, result: DocumentContextItem[][]): void { - if (isResponseVM(context.element) && context.element.usedContext?.documents) { - result.push(context.element.usedContext.documents); - } -} - -function getChatConversation(context: ICodeBlockActionContext): (ConversationRequest | ConversationResponse)[] { - // TODO@aeschli for now create a conversation with just the current element - // this will be expanded in the future to include the request and any other responses - - if (isResponseVM(context.element)) { - return [{ - type: 'response', - message: context.element.response.getMarkdown(), - references: getReferencesAsDocumentContext(context.element.contentReferences) - }]; - } else if (isRequestVM(context.element)) { - return [{ - type: 'request', - message: context.element.messageText, - }]; - } else { - return []; - } -} - function reindent(codeBlockContent: string, model: ITextModel, seletionStartLine: number): string { const newContent = strings.splitLines(codeBlockContent); if (newContent.length === 0) { diff --git a/src/vs/workbench/contrib/chat/browser/attachments/instructionsAttachment/instructionAttachments.ts b/src/vs/workbench/contrib/chat/browser/attachments/instructionsAttachment/instructionAttachments.ts new file mode 100644 index 00000000000..2e8d11ca7f5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/attachments/instructionsAttachment/instructionAttachments.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as dom from '../../../../../../base/browser/dom.js'; +import { ResourceLabels } from '../../../../../browser/labels.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { InstructionsAttachmentWidget } from './instructionsAttachment.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ChatInstructionAttachmentsModel } from '../../chatAttachmentModel/chatInstructionAttachmentsModel.js'; + +/** + * Widget fot a collection of prompt instructions attachments. + * See {@linkcode InstructionsAttachmentWidget}. + */ +export class InstructionAttachmentsWidget extends Disposable { + /** + * The root DOM node of the widget. + */ + public readonly domNode: HTMLElement; + + /** + * List of child instruction attachment widgets. + */ + private children: InstructionsAttachmentWidget[] = []; + + /** + * Get all `URI`s of all valid references, including all + * the possible references nested inside the children. + */ + public get references(): readonly URI[] { + return this.model.references; + } + + /** + * Check if child widget list is empty (no attachments present). + */ + public get empty(): boolean { + return this.children.length === 0; + } + + constructor( + private readonly model: ChatInstructionAttachmentsModel, + private readonly resourceLabels: ResourceLabels, + @IInstantiationService private readonly initService: IInstantiationService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this.render = this.render.bind(this); + this.domNode = dom.$('.chat-prompt-instructions-attachments'); + + this._register(this.model.onUpdate(this.render)); + + // when a new attachment model is added, create a new child widget for it + this.model.onAdd((attachment) => { + const widget = this.initService.createInstance( + InstructionsAttachmentWidget, + attachment, + this.resourceLabels, + ); + + // handle the child widget disposal event, removing it from the list + widget.onDispose(this.onChildDispose.bind(this, widget)); + + // register the new child widget + this.children.push(widget); + this.domNode.appendChild(widget.domNode); + this.render(); + }); + } + + /** + * Handle child widget disposal. + * @param widget The child widget that was disposed. + */ + public onChildDispose(widget: InstructionsAttachmentWidget): this { + // common prefix for all log messages + const logPrefix = `[onChildDispose] Widget for instructions attachment '${widget.uri.path}'`; + + // flag to check if the widget was found in the children list + let widgetExists = false; + + // filter out disposed child widget from the list + this.children = this.children.filter((child) => { + if (child === widget) { + // because we filter out all objects here it might be ok to have multiple of them, but + // it also highlights a potential issue in our logic somewhere else, so trace a warning here + if (widgetExists) { + this.logService.warn( + `${logPrefix} is present in the children references list multiple times.`, + ); + } + + widgetExists = true; + return false; + } + + return true; + }); + + // no widget was found in the children list, while it might be ok it also + // highlights a potential issue in our logic, so trace a warning here + if (!widgetExists) { + this.logService.warn( + `${logPrefix} was disposed, but was not found in the child references.`, + ); + } + + // remove the child widget root node from the DOM + this.domNode.removeChild(widget.domNode); + + // re-render the whole widget + return this.render(); + } + + /** + * Render this widget. + */ + private render(): this { + // set visibility of the root node based on the presence of attachments + dom.setVisibility(!this.empty, this.domNode); + + return this; + } + + /** + * Dispose of the widget, including all the child + * widget instances. + */ + public override dispose(): void { + for (const child of this.children) { + child.dispose(); + } + + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/attachments/instructionsAttachment/instructionsAttachment.ts b/src/vs/workbench/contrib/chat/browser/attachments/instructionsAttachment/instructionsAttachment.ts new file mode 100644 index 00000000000..fd0484ceb5e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/attachments/instructionsAttachment/instructionsAttachment.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../../nls.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import * as dom from '../../../../../../base/browser/dom.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { ResourceLabels } from '../../../../../browser/labels.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { ResourceContextKey } from '../../../../../common/contextkeys.js'; +import { Button } from '../../../../../../base/browser/ui/button/button.js'; +import { basename, dirname } from '../../../../../../base/common/resources.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; +import { FileKind, IFileService } from '../../../../../../platform/files/common/files.js'; +import { IMenuService, MenuId } from '../../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; +import { ChatInstructionsAttachmentModel } from '../../chatAttachmentModel/chatInstructionsAttachment.js'; +import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; + +/** + * Widget for a single prompt instructions attachment. + */ +export class InstructionsAttachmentWidget extends Disposable { + /** + * The root DOM node of the widget. + */ + public readonly domNode: HTMLElement; + + /** + * Get the `URI` associated with the model reference. + */ + public get uri(): URI { + return this.model.reference.uri; + } + + /** + * Event that fires when the object is disposed. + * + * See {@linkcode onDispose}. + */ + protected _onDispose = this._register(new Emitter()); + /** + * Subscribe to the `onDispose` event. + * @param callback Function to invoke on dispose. + */ + public onDispose(callback: () => unknown): this { + this._register(this._onDispose.event(callback)); + + return this; + } + + /** + * Temporary disposables used for rendering purposes. + */ + private readonly renderDisposables = this._register(new DisposableStore()); + + constructor( + private readonly model: ChatInstructionsAttachmentModel, + private readonly resourceLabels: ResourceLabels, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IHoverService private readonly hoverService: IHoverService, + @ILabelService private readonly labelService: ILabelService, + @IMenuService private readonly menuService: IMenuService, + @IFileService private readonly fileService: IFileService, + @ILanguageService private readonly languageService: ILanguageService, + @IModelService private readonly modelService: IModelService, + ) { + super(); + + this.domNode = dom.$('.chat-prompt-instructions-attachment.chat-attached-context-attachment.show-file-icons.implicit'); + + this.render = this.render.bind(this); + this.dispose = this.dispose.bind(this); + + this.model.onUpdate(this.render); + this.model.onDispose(this.dispose); + + this.render(); + } + + /** + * Render this widget. + */ + private render() { + dom.clearNode(this.domNode); + this.renderDisposables.clear(); + this.domNode.classList.remove('warning', 'error', 'disabled'); + + const { enabled, resolveIssue: errorCondition } = this.model; + if (!enabled) { + this.domNode.classList.add('disabled'); + } + + const label = this.resourceLabels.create(this.domNode, { supportIcons: true }); + const file = this.model.reference.uri; + + const fileBasename = basename(file); + const fileDirname = dirname(file); + const friendlyName = `${fileBasename} ${fileDirname}`; + const ariaLabel = localize('chat.instructionsAttachment', "Prompt instructions attachment, {0}", friendlyName); + + const uriLabel = this.labelService.getUriLabel(file, { relative: true }); + const currentFile = localize('openEditor', "Prompt instructions"); + const inactive = localize('enableHint', "disabled"); + const currentFileHint = currentFile + (enabled ? '' : ` (${inactive})`); + + let title = `${currentFileHint} ${uriLabel}`; + + // if there are some errors/warning during the process of resolving + // attachment references (including all the nested child references), + // add the issue details in the hover title for the attachment + if (errorCondition) { + const { type, message: details } = errorCondition; + this.domNode.classList.add(type); + + const errorCaption = type === 'warning' + ? localize('warning', "Warning") + : localize('error', "Error"); + + title += `\n-\n[${errorCaption}]: ${details}`; + } + + label.setFile(file, { + fileKind: FileKind.FILE, + hidePath: true, + range: undefined, + title, + icon: ThemeIcon.fromId(Codicon.lightbulbSparkle.id), + extraClasses: [], + }); + this.domNode.ariaLabel = ariaLabel; + this.domNode.tabIndex = 0; + + const hintElement = dom.append(this.domNode, dom.$('span.chat-implicit-hint', undefined, localize('instructions', 'Instructions'))); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), hintElement, title)); + + // create `toggle` enabled state button + const toggleButtonMsg = enabled + ? localize('disable', "Disable") + : localize('enable', "Enable"); + + const toggleButton = this.renderDisposables.add(new Button(this.domNode, { supportIcons: true, title: toggleButtonMsg })); + toggleButton.icon = enabled ? Codicon.eye : Codicon.eyeClosed; + this.renderDisposables.add(toggleButton.onDidClick((e) => { + e.stopPropagation(); + this.model.toggle(); + })); + + // create the `remove` button + const removeButton = this.renderDisposables.add(new Button(this.domNode, { supportIcons: true, title: localize('remove', "Remove") })); + removeButton.icon = Codicon.x; + this.renderDisposables.add(removeButton.onDidClick((e) => { + e.stopPropagation(); + this.model.dispose(); + })); + + // Context menu + const scopedContextKeyService = this.renderDisposables.add(this.contextKeyService.createScoped(this.domNode)); + + const resourceContextKey = this.renderDisposables.add( + new ResourceContextKey(scopedContextKeyService, this.fileService, this.languageService, this.modelService), + ); + resourceContextKey.set(file); + + this.renderDisposables.add(dom.addDisposableListener(this.domNode, dom.EventType.CONTEXT_MENU, async domEvent => { + const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); + dom.EventHelper.stop(domEvent, true); + + this.contextMenuService.showContextMenu({ + contextKeyService: scopedContextKeyService, + getAnchor: () => event, + getActions: () => { + const menu = this.menuService.getMenuActions(MenuId.ChatInputResourceAttachmentContext, scopedContextKeyService, { arg: file }); + return getFlatContextMenuActions(menu); + }, + }); + })); + } + + public override dispose(): void { + this._onDispose.fire(); + + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 456c8817cd4..a43f6b50cac 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -37,7 +37,7 @@ import { ILanguageModelStatsService, LanguageModelStatsService } from '../common import { ILanguageModelToolsService } from '../common/languageModelToolsService.js'; import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js'; -import { PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; +import { EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; import { ChatCommandCenterRendering, registerChatActions } from './actions/chatActions.js'; import { ACTION_ID_NEW_CHAT, registerNewChatActions } from './actions/chatClearActions.js'; import { registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/chatCodeblockActions.js'; @@ -81,6 +81,7 @@ import { Extensions, IConfigurationMigrationRegistry } from '../../../common/con import { ChatEditorOverlayController } from './chatEditorOverlay.js'; import { ChatRelatedFilesContribution } from './contrib/chatInputRelatedFilesContrib.js'; import { ChatQuotasService, ChatQuotasStatusBarEntry, IChatQuotasService } from './chatQuotasService.js'; +import { BuiltinToolsContribution } from './tools/tools.js'; import { ChatSetupContribution } from './chatSetup.js'; // Register configuration @@ -206,6 +207,7 @@ class ChatResolverContribution extends Disposable { AccessibleViewRegistry.register(new ChatResponseAccessibleView()); AccessibleViewRegistry.register(new PanelChatAccessibilityHelp()); AccessibleViewRegistry.register(new QuickChatAccessibilityHelp()); +AccessibleViewRegistry.register(new EditsChatAccessibilityHelp()); registerEditorFeature(ChatInputBoxContentProvider); @@ -319,6 +321,7 @@ registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandl registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatQuotasStatusBarEntry.ID, ChatQuotasStatusBarEntry, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually); registerChatActions(); registerChatCopyActions(); diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts index 5dd6ddf986b..739be7f185d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts @@ -10,8 +10,27 @@ import { URI } from '../../../../base/common/uri.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { IChatEditingService } from '../common/chatEditingService.js'; import { IChatRequestVariableEntry } from '../common/chatModel.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ChatInstructionAttachmentsModel } from './chatAttachmentModel/chatInstructionAttachmentsModel.js'; export class ChatAttachmentModel extends Disposable { + /** + * Collection on prompt instruction attachments. + */ + public readonly promptInstructions: ChatInstructionAttachmentsModel; + + constructor( + @IInstantiationService private readonly initService: IInstantiationService, + ) { + super(); + + this.promptInstructions = this._register( + this.initService.createInstance(ChatInstructionAttachmentsModel), + ).onUpdate(() => { + this._onDidChangeContext.fire(); + }); + } + private _attachments = new Map(); get attachments(): ReadonlyArray { return Array.from(this._attachments.values()); @@ -44,13 +63,14 @@ export class ChatAttachmentModel extends Disposable { this.addContext(this.asVariableEntry(uri, range)); } - asVariableEntry(uri: URI, range?: IRange): IChatRequestVariableEntry { + asVariableEntry(uri: URI, range?: IRange, isMarkedReadonly?: boolean): IChatRequestVariableEntry { return { value: range ? { uri, range } : uri, id: uri.toString() + (range?.toString() ?? ''), name: basename(uri), isFile: true, - isDynamic: true + isDynamic: true, + isMarkedReadonly, }; } @@ -91,8 +111,9 @@ export class EditsAttachmentModel extends ChatAttachmentModel { constructor( @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IInstantiationService _initService: IInstantiationService, ) { - super(); + super(_initService); } private isExcludeFileAttachment(fileAttachmentId: string) { diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatInstructionAttachmentsModel.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatInstructionAttachmentsModel.ts new file mode 100644 index 00000000000..d981e0e655a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatInstructionAttachmentsModel.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../../base/common/event.js'; +import { ChatInstructionsFileLocator } from './chatInstructionsFileLocator.js'; +import { ChatInstructionsAttachmentModel } from './chatInstructionsAttachment.js'; +import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; + +/** + * Configuration setting name for the `prompt instructions` feature. + * Set to `true` to enable the feature for yourself. + */ +const PROMPT_INSTRUCTIONS_SETTING_NAME = 'chat.experimental.prompt-instructions.enabled'; + +/** + * Model for a collection of prompt instruction attachments. + * See {@linkcode ChatInstructionsAttachmentModel}. + */ +export class ChatInstructionAttachmentsModel extends Disposable { + /** + * Helper to locate prompt instruction files on the disk. + */ + private readonly instructionsFileReader: ChatInstructionsFileLocator; + + /** + * List of all prompt instruction attachments. + */ + private attachments: DisposableMap = + this._register(new DisposableMap()); + + /** + * Get all `URI`s of all valid references, including all + * the possible references nested inside the children. + */ + public get references(): readonly URI[] { + const result = []; + + for (const child of this.attachments.values()) { + result.push(...child.references); + } + + return result; + } + + /** + * Event that fires then this model is updated. + * + * See {@linkcode onUpdate}. + */ + protected _onUpdate = this._register(new Emitter()); + /** + * Subscribe to the `onUpdate` event. + * @param callback Function to invoke on update. + */ + public onUpdate(callback: () => unknown): this { + this._register(this._onUpdate.event(callback)); + + return this; + } + + /** + * Event that fires when a new prompt instruction attachment is added. + * See {@linkcode onAdd}. + */ + protected _onAdd = this._register(new Emitter()); + /** + * The `onAdd` event fires when a new prompt instruction attachment is added. + * + * @param callback Function to invoke on add. + */ + public onAdd(callback: (attachment: ChatInstructionsAttachmentModel) => unknown): this { + this._register(this._onAdd.event(callback)); + + return this; + } + + constructor( + @IInstantiationService private readonly initService: IInstantiationService, + @IConfigurationService private readonly configService: IConfigurationService, + ) { + super(); + + this._onUpdate.fire = this._onUpdate.fire.bind(this._onUpdate); + this.instructionsFileReader = initService.createInstance(ChatInstructionsFileLocator); + } + + /** + * Add a prompt instruction attachment instance with the provided `URI`. + * @param uri URI of the prompt instruction attachment to add. + */ + public add(uri: URI): this { + // if already exists, nothing to do + if (this.attachments.has(uri.path)) { + return this; + } + + const instruction = this.initService.createInstance(ChatInstructionsAttachmentModel, uri) + .onUpdate(this._onUpdate.fire) + .onDispose(() => { + // note! we have to use `deleteAndLeak` here, because the `*AndDispose` + // alternative results in an infinite loop of calling this callback + this.attachments.deleteAndLeak(uri.path); + this._onUpdate.fire(); + }); + + this.attachments.set(uri.path, instruction); + instruction.resolve(); + + this._onAdd.fire(instruction); + this._onUpdate.fire(); + + return this; + } + + /** + * Remove a prompt instruction attachment instance by provided `URI`. + * @param uri URI of the prompt instruction attachment to remove. + */ + public remove(uri: URI): this { + // if does not exist, nothing to do + if (!this.attachments.has(uri.path)) { + return this; + } + + this.attachments.deleteAndDispose(uri.path); + + return this; + } + + /** + * List prompt instruction files available and not attached yet. + */ + public async listNonAttachedFiles(): Promise { + return await this.instructionsFileReader.listFiles(this.references); + } + + /** + * Checks if the prompt instructions feature is enabled in the user settings. + * The setting can be set to `true`, `'true'`, `True`, `TRUE`, etc. + * All other values are treated as the `false` value, which is the `default`. + */ + public get featureEnabled(): boolean { + const value = this.configService.getValue(PROMPT_INSTRUCTIONS_SETTING_NAME); + + if (!value) { + return false; + } + + if (value === true) { + return true; + } + + if (typeof value === 'string' && value.toLowerCase().trim() === 'true') { + return true; + } + + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatInstructionsAttachment.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatInstructionsAttachment.ts new file mode 100644 index 00000000000..40288ee891f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatInstructionsAttachment.ts @@ -0,0 +1,274 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { basename } from '../../../../../base/common/resources.js'; +import { assertDefined } from '../../../../../base/common/types.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { PromptFileReference, TErrorCondition } from '../../common/promptFileReference.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { FileOpenFailed, NonPromptSnippetFile, RecursiveReference } from '../../common/promptFileReferenceErrors.js'; + +/** + * Well-known localized error messages. + */ +const errorMessages = { + recursion: localize('chatPromptInstructionsRecursiveReference', 'Recursive reference found'), + fileOpenFailed: localize('chatPromptInstructionsFileOpenFailed', 'Failed to open file'), + brokenChild: localize('chatPromptInstructionsBrokenReference', 'Contains a broken reference that will be ignored'), +}; + +/** + * Object that represents an error that may occur during + * the process of resolving prompt instructions reference. + */ +interface IIssue { + /** + * Type of the failure. Currently all errors that occur on + * the "main" root reference directly attached to the chat + * are considered to be `error`s, while all failures on nested + * child references are considered to be `warning`s. + */ + type: 'error' | 'warning'; + + /** + * Error or warning message. + */ + message: string; +} + +/** + * Model for a single chat prompt instructions attachment. + */ +export class ChatInstructionsAttachmentModel extends Disposable { + /** + * Private reference of the underlying prompt instructions + * reference instance. + */ + private readonly _reference: PromptFileReference; + /** + * Get the prompt instructions reference instance. + */ + public get reference(): PromptFileReference { + return this._reference; + } + + + /** + * Get `URI` for the main reference and `URI`s of all valid + * child references it may contain. + */ + public get references(): readonly URI[] { + const { reference, enabled, resolveIssue } = this; + + // return no references if the attachment is disabled + if (!enabled) { + return []; + } + + // if the model has an error, return no references + if (resolveIssue && !(resolveIssue instanceof NonPromptSnippetFile)) { + return []; + } + + // otherwise return `URI` for the main reference and + // all valid child `URI` references it may contain + return [ + ...reference.validFileReferenceUris, + reference.uri, + ]; + } + + + /** + * If the prompt instructions reference (or any of its child references) has + * failed to resolve, this field contains the failure details, otherwise `undefined`. + * + * See {@linkcode IIssue}. + */ + public get resolveIssue(): IIssue | undefined { + const { errorCondition } = this._reference; + + const errorConditions = this.collectErrorConditions(); + if (errorConditions.length === 0) { + return undefined; + } + + const [firstError, ...restErrors] = errorConditions; + + // if the first error is the error of the root reference, + // then return it as an `error` otherwise use `warning` + const isRootError = (firstError === errorCondition); + const type = (isRootError) + ? 'error' + : 'warning'; + + const moreSuffix = restErrors.length > 0 + ? `\n-\n +${restErrors.length} more error${restErrors.length > 1 ? 's' : ''}` + : ''; + + const errorMessage = this.getErrorMessage(firstError, isRootError); + return { + type, + message: `${errorMessage}${moreSuffix}`, + }; + } + + /** + * Get message for the provided error condition object. + * + * @param error Error object. + * @param isRootError If the error happened on the the "main" root reference. + * @returns Error message. + */ + private getErrorMessage( + error: TErrorCondition, + isRootError: boolean, + ): string { + const { uri } = error; + + // if a child error - the error is somewhere in the nested references tree, + // then use message prefix to highlight that this is not a root error + const prefix = (!isRootError) + ? `${errorMessages.brokenChild}: ` + : ''; + + // if failed to open a file, return approprivate message and the file path + if (error instanceof FileOpenFailed) { + return `${prefix}${errorMessages.fileOpenFailed} '${uri.path}'.`; + } + + // if a recursion, provide the entire recursion path so users can use + // it for the debugging purposes + if (error instanceof RecursiveReference) { + const { recursivePath } = error; + + const recursivePathString = recursivePath + .map((path) => { + return basename(URI.file(path)); + }) + .join(' -> '); + + return `${prefix}${errorMessages.recursion}:\n${recursivePathString}`; + } + + return `${prefix}${error.message}`; + } + + /** + * Collect all failures that may have occurred during the process + * of resolving references in the entire references tree. + * + * @returns List of errors in the references tree. + */ + private collectErrorConditions(): TErrorCondition[] { + return this.reference + // get all references (including the root) as a flat array + .flatten() + // filter out children without error conditions or + // the ones that are non-prompt snippet files + .filter((childReference) => { + const { errorCondition } = childReference; + + return errorCondition && !(errorCondition instanceof NonPromptSnippetFile); + }) + // map to error condition objects + .map((childReference): TErrorCondition => { + const { errorCondition } = childReference; + + // `must` always be `true` because of the `filter` call above + assertDefined( + errorCondition, + `Error condition must be present for '${childReference.uri.path}'.`, + ); + + return errorCondition; + }); + } + + /** + * Event that fires when the error condition of the prompt + * reference changes. + * + * See {@linkcode onUpdate}. + */ + protected _onUpdate = this._register(new Emitter()); + /** + * Subscribe to the `onUpdate` event. + * @param callback Function to invoke on update. + */ + public onUpdate(callback: () => unknown): this { + this._register(this._onUpdate.event(callback)); + + return this; + } + + /** + * Event that fires when the object is disposed. + * + * See {@linkcode onDispose}. + */ + protected _onDispose = this._register(new Emitter()); + /** + * Subscribe to the `onDispose` event. + * @param callback Function to invoke on dispose. + */ + public onDispose(callback: () => unknown): this { + this._register(this._onDispose.event(callback)); + + return this; + } + + /** + * Private property to track the `enabled` state of the prompt + * instructions attachment. + */ + private _enabled: boolean = true; + /** + * Get the `enabled` state of the prompt instructions attachment. + */ + public get enabled(): boolean { + return this._enabled; + } + + constructor( + uri: URI, + @IInstantiationService private readonly initService: IInstantiationService, + ) { + super(); + + this._onUpdate.fire = this._onUpdate.fire.bind(this._onUpdate); + this._reference = this._register(this.initService.createInstance(PromptFileReference, uri)) + .onUpdate(this._onUpdate.fire); + } + + /** + * Start resolving the prompt instructions reference and child references + * that it may contain. + */ + public resolve(): this { + this._reference.resolve(); + + return this; + } + + /** + * Toggle the `enabled` state of the prompt instructions attachment. + */ + public toggle(): this { + this._enabled = !this._enabled; + this._onUpdate.fire(); + + return this; + } + + public override dispose(): void { + this._onDispose.fire(); + + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatInstructionsFileLocator.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatInstructionsFileLocator.ts new file mode 100644 index 00000000000..e31f5e0fe83 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatInstructionsFileLocator.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { dirname, extUri } from '../../../../../base/common/resources.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; + +/** + * Configuration setting name for the prompt instructions source folder paths. + */ +const PROMPT_FILES_LOCATION_SETTING_NAME = 'chat.experimental.prompt-files.location'; + +/** + * Default prompt instructions source folder paths. + */ +const PROMPT_FILES_DEFAULT_LOCATION = ['.copilot/prompts']; + +/** + * Extension of the prompt instructions files. + */ +const INSTRUCTIONS_FILE_EXTENSION = '.md'; + +/** + * Class to locate prompt instructions files. + */ +export class ChatInstructionsFileLocator { + constructor( + @IFileService private readonly fileService: IFileService, + @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, + @IConfigurationService private readonly configService: IConfigurationService, + ) { } + + /** + * List all prompt instructions files from the filesystem. + * + * @param exclude List of `URIs` to exclude from the result. + * @returns List of prompt instructions files found in the workspace. + */ + public async listFiles(exclude: ReadonlyArray): Promise { + // create a set from the list of URIs for convenience + const excludeSet: Set = new Set(); + for (const excludeUri of exclude) { + excludeSet.add(excludeUri.path); + } + + // filter out the excluded paths from the locations list + const locations = this.getSourceLocations() + .filter((location) => { + return !excludeSet.has(location.path); + }); + + return await this.findInstructionFiles(locations, excludeSet); + } + + /** + * Get all possible prompt instructions file locations based on the current + * workspace folder structure. + * + * @returns List of possible prompt instructions file locations. + */ + private getSourceLocations(): readonly URI[] { + const state = this.workspaceService.getWorkbenchState(); + + // nothing to do if the workspace is empty + if (state === WorkbenchState.EMPTY) { + return []; + } + + const sourceLocations = this.getSourceLocationsConfigValue(); + const result = []; + + // otherwise for each folder provided in the configuration, create + // a URI per each folder in the current workspace + const { folders } = this.workspaceService.getWorkspace(); + for (const folder of folders) { + for (const sourceFolderName of sourceLocations) { + const folderUri = extUri.resolvePath(folder.uri, sourceFolderName); + result.push(folderUri); + } + } + + // if inside a workspace, add the specified source locations inside the workspace + // root too, to allow users to use `.copilot/prompts` folder (or whatever they + // specify in the setting) in the workspace root + if (folders.length > 1) { + const workspaceRootUri = dirname(folders[0].uri); + for (const sourceFolderName of sourceLocations) { + const folderUri = extUri.resolvePath(workspaceRootUri, sourceFolderName); + result.push(folderUri); + } + } + + return result; + } + + /** + * Get the configuation value for the prompt instructions source locations. + * Defaults to {@linkcode PROMPT_FILES_DEFAULT_LOCATION} if the value is not set. + * + * @returns List of prompt instructions source locations that were provided in + * user settings. + */ + private getSourceLocationsConfigValue(): readonly string[] { + const value = this.configService.getValue(PROMPT_FILES_LOCATION_SETTING_NAME); + + if (value === undefined || value === null) { + return PROMPT_FILES_DEFAULT_LOCATION; + } + + if (typeof value === 'string') { + return [value]; + } + + // if not a string nor an array, return an empty array + if (!Array.isArray(value)) { + return []; + } + + // filter out non-string values from the list + const result = value.filter((item) => { + return typeof item === 'string'; + }); + + return result; + } + + /** + * Finds all existent prompt instruction files in the provided locations. + * + * @param locations List of locations to search for prompt instruction files in. + * @param exclude Map of `path -> boolean` to exclude from the result. + * @returns List of prompt instruction files found in the provided locations. + */ + private async findInstructionFiles( + locations: readonly URI[], + exclude: ReadonlySet, + ): Promise { + const results = await this.fileService.resolveAll( + locations.map((location) => { + return { resource: location }; + }), + ); + + const files = []; + for (const result of results) { + const { stat, success } = result; + + if (!success) { + continue; + } + + if (!stat || !stat.children) { + continue; + } + + for (const child of stat.children) { + const { name, resource, isDirectory } = child; + + if (isDirectory) { + continue; + } + + if (!name.endsWith(INSTRUCTIONS_FILE_EXTENSION)) { + continue; + } + + if (exclude.has(resource.path)) { + continue; + } + + files.push(resource); + } + } + + return files; + + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts index 9e745f6554f..6bd5ad40023 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts @@ -7,6 +7,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { IManagedHoverTooltipMarkdownString } from '../../../../../base/browser/ui/hover/hover.js'; import { createInstantHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { basename, dirname } from '../../../../../base/common/path.js'; @@ -38,7 +39,7 @@ import { fillEditorsDragData } from '../../../../browser/dnd.js'; import { ResourceLabels } from '../../../../browser/labels.js'; import { ResourceContextKey } from '../../../../common/contextkeys.js'; import { revealInSideBarCommand } from '../../../files/browser/fileActions.contribution.js'; -import { IChatRequestVariableEntry, isPasteVariableEntry } from '../../common/chatModel.js'; +import { IChatRequestVariableEntry, isLinkVariableEntry, isPasteVariableEntry } from '../../common/chatModel.js'; import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../common/chatService.js'; export const chatAttachmentResourceContextKey = new RawContextKey('chatAttachmentResource', undefined, { type: 'URI', description: localize('resource', "The full value of the chat attachment resource, including scheme and path") }); @@ -185,6 +186,10 @@ export class ChatAttachmentsContentPart extends Disposable { this.attachedContextDisposables.add(this.instantiationService.invokeFunction(accessor => hookUpResourceAttachmentDragAndContextMenu(accessor, widget, resource))); } } + } else if (isLinkVariableEntry(attachment)) { + ariaLabel = localize('chat.attachment.link', "Attached link, {0}", attachment.name); + + label.setResource({ resource: attachment.value, name: attachment.name }, { icon: Codicon.link, title: attachment.value.toString() }); } else { const attachmentLabel = attachment.fullName ?? attachment.name; const withIcon = attachment.icon?.id ? `$(${attachment.icon.id}) ${attachmentLabel}` : attachmentLabel; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts index a281df1c40c..44c0c0dbb6b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts @@ -76,17 +76,22 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP // We release editors in order so that it's more likely that the same editor will be assigned if this element is re-rendered right away, like it often is during progressive rendering const orderedDisposablesList: IDisposable[] = []; - let codeBlockIndex = codeBlockStartIndex; + + // Need to track the index of the codeblock within the response so it can have a unique ID, + // and within this part to find it within the codeblocks array + let globalCodeBlockIndexStart = codeBlockStartIndex; + let thisPartCodeBlockIndexStart = 0; const result = this._register(renderer.render(markdown.content, { fillInIncompleteTokens, codeBlockRendererSync: (languageId, text, raw) => { - const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || raw?.endsWith('```'); + const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || raw?.trim().endsWith('```'); if ((!text || (text.startsWith('') && !text.includes('\n'))) && !isCodeBlockComplete && rendererOptions.renderCodeBlockPills) { const hideEmptyCodeblock = $('div'); hideEmptyCodeblock.style.display = 'none'; return hideEmptyCodeblock; } - const index = codeBlockIndex++; + const globalIndex = globalCodeBlockIndexStart++; + const thisPartIndex = thisPartCodeBlockIndexStart++; let textModel: Promise; let range: Range | undefined; let vulns: readonly IMarkdownVulnerability[] | undefined; @@ -101,15 +106,15 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } else { const sessionId = isResponseVM(element) || isRequestVM(element) ? element.sessionId : ''; - const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, index); - const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, index, { text, languageId, isComplete: isCodeBlockComplete }); + const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, globalIndex); + const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, globalIndex, { text, languageId, isComplete: isCodeBlockComplete }); vulns = modelEntry.vulns; codemapperUri = fastUpdateModelEntry.codemapperUri; textModel = modelEntry.model; } const hideToolbar = isResponseVM(element) && element.errorDetails?.responseIsFiltered; - const codeBlockInfo: ICodeBlockData = { languageId, textModel, codeBlockIndex: index, element, range, hideToolbar, parentContextKeyService: contextKeyService, vulns, codemapperUri }; + const codeBlockInfo: ICodeBlockData = { languageId, textModel, codeBlockIndex: globalIndex, codeBlockPartIndex: thisPartIndex, element, range, hideToolbar, parentContextKeyService: contextKeyService, vulns, codemapperUri }; if (!rendererOptions.renderCodeBlockPills || element.isCompleteAddedRequest || !codemapperUri) { const ref = this.renderCodeBlock(codeBlockInfo, text, isCodeBlockComplete, currentWidth); @@ -122,7 +127,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const ownerMarkdownPartId = this.id; const info: IChatCodeBlockInfo = new class { readonly ownerMarkdownPartId = ownerMarkdownPartId; - readonly codeBlockIndex = index; + readonly codeBlockIndex = globalIndex; readonly element = element; readonly isStreaming = !rendererOptions.renderCodeBlockPills; codemapperUri = undefined; // will be set async @@ -149,7 +154,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP // 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.codeBlockIndex].codemapperUri = e.codemapperUri; + this.codeblocks[codeBlockInfo.codeBlockPartIndex].codemapperUri = e.codemapperUri; this._onDidChangeHeight.fire(); }); } @@ -157,7 +162,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const ownerMarkdownPartId = this.id; const info: IChatCodeBlockInfo = new class { readonly ownerMarkdownPartId = ownerMarkdownPartId; - readonly codeBlockIndex = index; + readonly codeBlockIndex = globalIndex; readonly element = element; readonly isStreaming = !isCodeBlockComplete; readonly codemapperUri = codemapperUri; @@ -205,7 +210,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP if (isResponseVM(data.element)) { this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.codeBlockIndex, { text, languageId: data.languageId, isComplete }).then((e) => { // Update the existing object's codemapperUri - this.codeblocks[data.codeBlockIndex].codemapperUri = e.codemapperUri; + this.codeblocks[data.codeBlockPartIndex].codemapperUri = e.codemapperUri; this._onDidChangeHeight.fire(); }); } @@ -282,7 +287,7 @@ class CollapsedCodeBlock extends Disposable { return this._uri; } - private readonly _progressStore = new DisposableStore(); + private readonly _progressStore = this._store.add(new DisposableStore()); constructor( sessionId: string, diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts index a06c21d966b..04ec43faa61 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts @@ -37,7 +37,7 @@ import { ResourceContextKey } from '../../../../common/contextkeys.js'; import { SETTINGS_AUTHORITY } from '../../../../services/preferences/common/preferences.js'; import { createFileIconThemableTreeContainerScope } from '../../../files/browser/views/explorerView.js'; import { ExplorerFolderContext } from '../../../files/common/files.js'; -import { chatEditingWidgetFileStateContextKey, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { chatEditingWidgetFileReadonlyContextKey, chatEditingWidgetFileStateContextKey, WorkingSetEntryState } from '../../common/chatEditingService.js'; import { ChatResponseReferencePartStatusKind, IChatContentReference, IChatWarningMessage } from '../../common/chatService.js'; import { IChatVariablesService } from '../../common/chatVariables.js'; import { IChatRendererContent, IChatResponseViewModel } from '../../common/chatViewModel.js'; @@ -52,6 +52,7 @@ export interface IChatReferenceListItem extends IChatContentReference { description?: string; state?: WorkingSetEntryState; excluded?: boolean; + isMarkedReadonly?: boolean; } export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage; @@ -435,8 +436,11 @@ class CollapsibleListRenderer implements IListRenderer { // Image - const imageContext = getImageAttachContext(editorInput); + const imageContext = await getImageAttachContext(editorInput, this.fileService); if (imageContext) { return this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? imageContext : undefined; } @@ -425,18 +426,20 @@ function getResourceAttachContext(resource: URI, isDirectory: boolean): IChatReq }; } -function getImageAttachContext(editor: EditorInput | IDraggedResourceEditorInput): IChatRequestVariableEntry | undefined { +async function getImageAttachContext(editor: EditorInput | IDraggedResourceEditorInput, fileService: IFileService): Promise { if (!editor.resource) { return undefined; } if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(editor.resource.path)) { const fileName = basename(editor.resource); + const readFile = await fileService.readFile(editor.resource); + const resizedImage = await resizeImage(readFile.value.buffer); return { id: editor.resource.toString(), name: fileName, fullName: editor.resource.path, - value: editor.resource, + value: resizedImage, icon: Codicon.fileMedia, isDynamic: true, isImage: true, diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditing.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditing.ts new file mode 100644 index 00000000000..7258043aa36 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditing.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../../base/common/resources.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { findDiffEditorContainingCodeEditor } from '../../../../../editor/browser/widget/diffEditor/commands.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IModifiedFileEntry } from '../../common/chatEditingService.js'; + +export function isDiffEditorForEntry(accessor: ServicesAccessor, entry: IModifiedFileEntry, editor: ICodeEditor) { + const diffEditor = findDiffEditorContainingCodeEditor(accessor, editor); + if (!diffEditor) { + return false; + } + const originalModel = diffEditor.getOriginalEditor().getModel(); + const modifiedModel = diffEditor.getModifiedEditor().getModel(); + return isEqual(originalModel?.uri, entry.originalURI) && isEqual(modifiedModel?.uri, entry.modifiedURI); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 8a2ac519e78..57df67d6ed8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -28,7 +28,7 @@ import { GroupsOrder, IEditorGroupsService } from '../../../../services/editor/c import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatAgentLocation } from '../../common/chatAgents.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; -import { applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingResourceContextKey, chatEditingWidgetFileStateContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, WorkingSetEntryRemovalReason, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, chatEditingWidgetFileReadonlyContextKey, chatEditingWidgetFileStateContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, WorkingSetEntryRemovalReason, WorkingSetEntryState } from '../../common/chatEditingService.js'; import { IChatService } from '../../common/chatService.js'; import { isRequestVM, isResponseVM } from '../../common/chatViewModel.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; @@ -64,6 +64,35 @@ abstract class WorkingSetAction extends Action2 { abstract runWorkingSetAction(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, chatWidget: IChatWidget | undefined, ...uris: URI[]): any; } +registerAction2(class MarkFileAsReadonly extends WorkingSetAction { + constructor() { + super({ + id: 'chatEditing.markFileAsReadonly', + title: localize2('markFileAsReadonly', 'Mark as read-only'), + icon: Codicon.lock, + toggled: chatEditingWidgetFileReadonlyContextKey, + menu: [{ + id: MenuId.ChatEditingWidgetModifiedFilesToolbar, + when: ContextKeyExpr.and( + chatEditingAgentSupportsReadonlyReferencesContextKey, + ContextKeyExpr.or( + ContextKeyExpr.equals(chatEditingWidgetFileReadonlyContextKey.key, true), + ContextKeyExpr.equals(chatEditingWidgetFileReadonlyContextKey.key, false), + ) + ), + order: 10, + group: 'navigation' + }], + }); + } + + async runWorkingSetAction(_accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, _chatWidget: IChatWidget, ...uris: URI[]): Promise { + for (const uri of uris) { + currentEditingSession.markIsReadonly(uri); + } + } +}); + registerAction2(class AddFileToWorkingSet extends WorkingSetAction { constructor() { super({ @@ -98,11 +127,36 @@ registerAction2(class RemoveFileFromWorkingSet extends WorkingSetAction { order: 5, group: 'navigation' }], + keybinding: { + primary: KeyCode.Delete, + when: ContextKeyExpr.and(ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), ChatContextKeys.inChatEditWorkingSet), + weight: KeybindingWeight.WorkbenchContrib, + } }); } async runWorkingSetAction(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, chatWidget: IChatWidget, ...uris: URI[]): Promise { + const dialogService = accessor.get(IDialogService); + + const pendingEntries = currentEditingSession.entries.get().filter((entry) => uris.includes(entry.modifiedURI) && entry.state.get() === WorkingSetEntryState.Modified); + if (pendingEntries.length > 0) { + // Ask for confirmation if there are any pending edits + const file = pendingEntries.length > 1 + ? localize('chat.editing.removeFile.confirmationmanyFiles', "{0} files", pendingEntries.length) + : basename(pendingEntries[0].modifiedURI); + const confirmation = await dialogService.confirm({ + title: localize('chat.editing.removeFile.confirmation.title', "Remove {0} from working set?", file), + message: localize('chat.editing.removeFile.confirmation.message', "This will remove {0} from your working set and undo the edits made to it. Do you want to proceed?", file), + primaryButton: localize('chat.editing.removeFile.confirmation.primaryButton', "Yes"), + type: 'info' + }); + if (!confirmation.confirmed) { + return; + } + } + // Remove from working set + await currentEditingSession.reject(...uris); currentEditingSession.remove(WorkingSetEntryRemovalReason.User, ...uris); // Remove from chat input part diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts index aa105b2e767..37a6b9a2b68 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts @@ -14,6 +14,7 @@ import { EditOperation, ISingleEditOperation } from '../../../../../editor/commo import { OffsetEdit } from '../../../../../editor/common/core/offsetEdit.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IDocumentDiff, nullDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; +import { DetailedLineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js'; import { TextEdit } from '../../../../../editor/common/languages.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IModelDeltaDecoration, ITextModel, OverviewRulerLane } from '../../../../../editor/common/model.js'; @@ -343,6 +344,41 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie }); } + async acceptHunk(change: DetailedLineRangeMapping): Promise { + if (!this._diffInfo.get().changes.includes(change)) { + // diffInfo should have model version ids and check them (instead of the caller doing that) + return false; + } + const edits: ISingleEditOperation[] = []; + for (const edit of change.innerChanges ?? []) { + const newText = this.modifiedModel.getValueInRange(edit.modifiedRange); + edits.push(EditOperation.replace(edit.originalRange, newText)); + } + this.docSnapshot.pushEditOperations(null, edits, _ => null); + await this._updateDiffInfoSeq(); + if (this.diffInfo.get().identical) { + this._stateObs.set(WorkingSetEntryState.Accepted, undefined); + } + return true; + } + + async rejectHunk(change: DetailedLineRangeMapping): Promise { + if (!this._diffInfo.get().changes.includes(change)) { + return false; + } + const edits: ISingleEditOperation[] = []; + for (const edit of change.innerChanges ?? []) { + const newText = this.docSnapshot.getValueInRange(edit.originalRange); + edits.push(EditOperation.replace(edit.modifiedRange, newText)); + } + this.doc.pushEditOperations(null, edits, _ => null); + await this._updateDiffInfoSeq(); + if (this.diffInfo.get().identical) { + this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + } + return true; + } + private _applyEdits(edits: ISingleEditOperation[]) { // make the actual edit this._isEditFromUs = true; @@ -358,13 +394,14 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie } } - private _updateDiffInfoSeq() { + private async _updateDiffInfoSeq() { const myDiffOperationId = ++this._diffOperationIds; - Promise.resolve(this._diffOperation).then(() => { - if (this._diffOperationIds === myDiffOperationId) { - this._diffOperation = this._updateDiffInfo(); - } - }); + await Promise.resolve(this._diffOperation); + if (this._diffOperationIds === myDiffOperationId) { + const thisDiffOperation = this._updateDiffInfo(); + this._diffOperation = thisDiffOperation; + await thisDiffOperation; + } } private async _updateDiffInfo(): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts index 4d064636e7a..9a2b6ae27ea 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts @@ -29,11 +29,12 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../pla import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; import { ChatAgentLocation, IChatAgentService } from '../../common/chatAgents.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; -import { applyingChatEditsContextKey, applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingMaxFileAssignmentName, chatEditingResourceContextKey, ChatEditingSessionState, decidedChatEditingResourceContextKey, defaultChatEditingMaxFileLimit, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, IChatEditingSessionStream, IChatRelatedFile, IChatRelatedFilesProvider, IModifiedFileEntry, inChatEditingSessionContextKey, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { applyingChatEditsContextKey, applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingMaxFileAssignmentName, chatEditingResourceContextKey, ChatEditingSessionState, decidedChatEditingResourceContextKey, defaultChatEditingMaxFileLimit, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, IChatEditingSessionStream, IChatRelatedFile, IChatRelatedFilesProvider, IModifiedFileEntry, inChatEditingSessionContextKey, WorkingSetEntryState } from '../../common/chatEditingService.js'; import { IChatResponseModel, IChatTextEditGroup } from '../../common/chatModel.js'; import { IChatService } from '../../common/chatService.js'; import { ChatEditingSession } from './chatEditingSession.js'; @@ -91,14 +92,15 @@ export class ChatEditingService extends Disposable implements IChatEditingServic @IWorkbenchAssignmentService private readonly _workbenchAssignmentService: IWorkbenchAssignmentService, @IStorageService storageService: IStorageService, @ILogService logService: ILogService, + @IExtensionService extensionService: IExtensionService, ) { super(); this._applyingChatEditsFailedContextKey = applyingChatEditsFailedContextKey.bindTo(contextKeyService); this._applyingChatEditsFailedContextKey.set(false); this._register(decorationsService.registerDecorationsProvider(_instantiationService.createInstance(ChatDecorationsProvider, this._currentSessionObs))); this._register(multiDiffSourceResolverService.registerResolver(_instantiationService.createInstance(ChatEditingMultiDiffSourceResolver, this._currentSessionObs))); - textModelService.registerTextModelContentProvider(ChatEditingTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingTextModelContentProvider, this._currentSessionObs)); - textModelService.registerTextModelContentProvider(ChatEditingSnapshotTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingSnapshotTextModelContentProvider, this._currentSessionObs)); + this._register(textModelService.registerTextModelContentProvider(ChatEditingTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingTextModelContentProvider, this._currentSessionObs))); + this._register(textModelService.registerTextModelContentProvider(ChatEditingSnapshotTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingSnapshotTextModelContentProvider, this._currentSessionObs))); this._register(bindContextKey(decidedChatEditingResourceContextKey, contextKeyService, (reader) => { const currentSession = this._currentSessionObs.read(reader); if (!currentSession) { @@ -144,6 +146,16 @@ export class ChatEditingService extends Disposable implements IChatEditingServic } })); + // todo@connor4312: temporary until chatReadonlyPromptReference proposal is finalized + const readonlyEnabledContextKey = chatEditingAgentSupportsReadonlyReferencesContextKey.bindTo(contextKeyService); + const setReadonlyFilesEnabled = () => { + const enabled = extensionService.extensions.some(e => e.enabledApiProposals?.includes('chatReadonlyPromptReference')); + readonlyEnabledContextKey.set(enabled); + }; + setReadonlyFilesEnabled(); + this._register(extensionService.onDidRegisterExtensions(setReadonlyFilesEnabled)); + this._register(extensionService.onDidChangeExtensions(setReadonlyFilesEnabled)); + this._register(this.lifecycleService.onWillShutdown((e) => { const session = this._currentSessionObs.get(); if (session) { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 31e9a672a13..6bb25683e10 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -18,7 +18,6 @@ import { ITextModel } from '../../../../../editor/common/model.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../../nls.js'; -import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -29,12 +28,10 @@ import { IEditorGroupsService } from '../../../../services/editor/common/editorG import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEditor.js'; import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; -import { ChatAgentLocation, IChatAgentService } from '../../common/chatAgents.js'; import { ChatEditingSessionChangeType, ChatEditingSessionState, ChatEditKind, getMultiDiffSourceUri, IChatEditingSession, IModifiedFileEntry, WorkingSetDisplayMetadata, WorkingSetEntryRemovalReason, WorkingSetEntryState } from '../../common/chatEditingService.js'; import { IChatResponseModel } from '../../common/chatModel.js'; import { ChatEditingModifiedFileEntry, IModifiedEntryTelemetryInfo, ISnapshotEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; -import { Schemas } from '../../../../../base/common/network.js'; import { isEqual, joinPath } from '../../../../../base/common/resources.js'; import { StringSHA1 } from '../../../../../base/common/hash.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; @@ -94,7 +91,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio * Contains the contents of a file when the AI first began doing edits to it. */ private readonly _initialFileContents = new ResourceMap(); - private readonly _filesToSkipCreating = new ResourceSet(); private readonly _entriesObs = observableValue(this, []); public get entries(): IObservable { @@ -147,7 +143,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return linearHistory.slice(linearHistoryIndex).map(s => s.requestId).filter((r): r is string => !!r); }); - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = this._register(new Emitter()); get onDidChange() { this._assertNotDisposed(); return this._onDidChange.event; @@ -174,10 +170,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio @IBulkEditService public readonly _bulkEditService: IBulkEditService, @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, @IEditorService private readonly _editorService: IEditorService, - @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @IFileService private readonly _fileService: IFileService, - @IFileDialogService private readonly _dialogService: IFileDialogService, - @IChatAgentService private readonly _chatAgentService: IChatAgentService, @IChatService private readonly _chatService: IChatService, @INotebookService private readonly _notebookService: INotebookService, ) { @@ -187,9 +179,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio public async init(): Promise { const restoredSessionState = await this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionId).restoreState(); if (restoredSessionState) { - for (const uri of restoredSessionState.filesToSkipCreating) { - this._filesToSkipCreating.add(uri); - } for (const [uri, content] of restoredSessionState.initialFileContents) { this._initialFileContents.set(uri, content); } @@ -225,7 +214,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio public storeState(): Promise { const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionId); const state: StoredSessionState = { - filesToSkipCreating: [...this._filesToSkipCreating], initialFileContents: this._initialFileContents, pendingSnapshot: this._pendingSnapshot, recentSnapshot: this._createSnapshot(undefined), @@ -265,8 +253,8 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } if (existingTransientEntries.has(uri)) { existingTransientEntries.delete(uri); - } else if (!this._workingSet.has(uri) && !this._removedTransientEntries.has(uri)) { - // Don't add as a transient entry if it's already part of the working set + } else if ((!this._workingSet.has(uri) || this._workingSet.get(uri)?.state === WorkingSetEntryState.Suggested) && !this._removedTransientEntries.has(uri)) { + // Don't add as a transient entry if it's already a confirmed part of the working set // or if the user has intentionally removed it from the working set activeEditors.add(uri); } @@ -432,6 +420,22 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); } + markIsReadonly(resource: URI, isReadonly?: boolean): void { + const entry = this._workingSet.get(resource); + if (entry) { + if (entry.state === WorkingSetEntryState.Transient || entry.state === WorkingSetEntryState.Suggested) { + entry.state = WorkingSetEntryState.Attached; + } + entry.isMarkedReadonly = isReadonly ?? !entry.isMarkedReadonly; + } else { + this._workingSet.set(resource, { + state: WorkingSetEntryState.Attached, + isMarkedReadonly: isReadonly ?? true + }); + } + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } + private _assertNotDisposed(): void { if (this._state.get() === ChatEditingSessionState.Disposed) { throw new BugIndicatingError(`Cannot access a disposed editing session`); @@ -529,6 +533,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio super.dispose(); this._state.set(ChatEditingSessionState.Disposed, undefined); this._onDidDispose.fire(); + this._onDidDispose.dispose(); } getVirtualModel(documentId: string): ITextModel | null { @@ -625,26 +630,11 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } private async _acceptTextEdits(resource: URI, textEdits: TextEdit[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise { - if (this._filesToSkipCreating.has(resource)) { - return; - } - if (!this._entriesObs.get().find(e => isEqual(e.modifiedURI, resource)) && this._entriesObs.get().length >= (await this.editingSessionFileLimitPromise)) { // Do not create files in a single editing session that would be in excess of our limit return; } - if (resource.scheme !== Schemas.untitled && !this._workspaceContextService.getWorkspaceFolder(resource) && !(await this._fileService.exists(resource))) { - // if the file doesn't exist yet and is outside the workspace, prompt the user for a location to save it to - const saveLocation = await this._dialogService.showSaveDialog({ title: localize('chatEditing.fileSave', '{0} wants to create a file. Choose where it should be saved.', this._chatAgentService.getDefaultAgent(ChatAgentLocation.EditingSession)?.fullName ?? 'Chat') }); - if (!saveLocation) { - // don't ask the user to create the file again when the next text edit for this same resource streams in - this._filesToSkipCreating.add(resource); - return; - } - resource = saveLocation; - } - // Make these getters because the response result is not available when the file first starts to be edited const telemetryInfo = new class { get agentId() { return responseModel.agent?.id; } @@ -667,6 +657,11 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio this._onDidChange.fire(ChatEditingSessionChangeType.Other); } + /** + * Retrieves or creates a modified file entry. + * + * @returns The modified file entry. + */ private async _getOrCreateModifiedFileEntry(resource: URI, responseModel: IModifiedEntryTelemetryInfo): Promise { const existingEntry = this._entriesObs.get().find(e => isEqual(e.modifiedURI, resource)); if (existingEntry) { @@ -730,7 +725,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } interface StoredSessionState { - readonly filesToSkipCreating: URI[]; readonly initialFileContents: ResourceMap; readonly pendingSnapshot?: IChatEditingSessionSnapshot; readonly recentSnapshot: IChatEditingSessionSnapshot; @@ -801,7 +795,6 @@ class ChatEditingSessionStorage { } const linearHistory = await Promise.all(data.linearHistory.map(deserializeChatEditingSessionSnapshot)); - const filesToSkipCreating = data.filesToSkipCreating.map((uriStr: string) => URI.parse(uriStr)); const initialFileContents = new ResourceMap(); for (const fileContentDTO of data.initialFileContents) { @@ -811,7 +804,6 @@ class ChatEditingSessionStorage { const recentSnapshot = await deserializeChatEditingSessionSnapshot(data.recentSnapshot); return { - filesToSkipCreating, initialFileContents, pendingSnapshot, recentSnapshot, @@ -889,7 +881,6 @@ class ChatEditingSessionStorage { initialFileContents: serializeResourceMap(state.initialFileContents, value => addFileContent(value)), pendingSnapshot: state.pendingSnapshot ? serializeChatEditingSessionSnapshot(state.pendingSnapshot) : undefined, recentSnapshot: serializeChatEditingSessionSnapshot(state.recentSnapshot), - filesToSkipCreating: state.filesToSkipCreating.map(uri => uri.toString()), } satisfies IChatEditingSessionDTO; this._logService.debug(`chatEditingSession: Storing editing session at ${storageFolder.toString()}: ${fileContents.size} files`); @@ -959,5 +950,4 @@ interface IChatEditingSessionDTO { readonly linearHistoryIndex: number; readonly pendingSnapshot: IChatEditingSessionSnapshotDTO | undefined; readonly initialFileContents: ResourceMapDTO; - readonly filesToSkipCreating: string[]; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts index b7f3ae1d0b4..75822df0d72 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ICodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { localize2 } from '../../../../nls.js'; import { EditorAction2, ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -54,7 +54,10 @@ abstract class NavigateAction extends Action2 { const chatEditingService = accessor.get(IChatEditingService); const editorService = accessor.get(IEditorService); - const editor = editorService.activeTextEditorControl; + let editor = editorService.activeTextEditorControl; + if (isDiffEditor(editor)) { + editor = editor.getModifiedEditor(); + } if (!isCodeEditor(editor) || !editor.hasModel()) { return; } @@ -126,7 +129,7 @@ abstract class AcceptDiscardAction extends Action2 { ? localize2('accept2', 'Accept') : localize2('discard2', 'Discard'), category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(ctxHasRequestInProgress.negate(), hasUndecidedChatEditingResourceContextKey, ContextKeyExpr.or(ctxHasEditorModification, ctxNotebookHasEditorModification)), + precondition: ContextKeyExpr.and(ctxHasRequestInProgress.negate(), hasUndecidedChatEditingResourceContextKey), icon: accept ? Codicon.check : Codicon.discard, @@ -152,8 +155,13 @@ abstract class AcceptDiscardAction extends Action2 { let uri = getNotebookEditorFromEditorPane(editorService.activeEditorPane)?.textModel?.uri; if (!uri) { - const editor = editorService.activeTextEditorControl; - uri = isCodeEditor(editor) && editor.hasModel() ? editor.getModel().uri : undefined; + let editor = editorService.activeTextEditorControl; + if (isDiffEditor(editor)) { + editor = editor.getModifiedEditor(); + } + uri = isCodeEditor(editor) && editor.hasModel() + ? editor.getModel().uri + : undefined; } if (!uri) { return; @@ -190,12 +198,11 @@ export class RejectAction extends AcceptDiscardAction { } } -class UndoHunkAction extends EditorAction2 { +class RejectHunkAction extends EditorAction2 { constructor() { super({ id: 'chatEditor.action.undoHunk', - title: localize2('undo', 'Undo this Change'), - shortTitle: localize2('undo2', 'Undo'), + title: localize2('undo', 'Discard this Change'), category: CHAT_CATEGORY, precondition: ContextKeyExpr.and(ChatContextKeys.requestInProgress.negate(), hasUndecidedChatEditingResourceContextKey), icon: Codicon.discard, @@ -213,27 +220,61 @@ class UndoHunkAction extends EditorAction2 { } override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { - ChatEditorController.get(editor)?.undoNearestChange(args[0]); + ChatEditorController.get(editor)?.rejectNearestChange(args[0]); } } -class OpenDiffFromHunkAction extends EditorAction2 { +class AcceptHunkAction extends EditorAction2 { constructor() { super({ - id: 'chatEditor.action.diffHunk', - title: localize2('diff', 'Open Diff'), + id: 'chatEditor.action.acceptHunk', + title: localize2('acceptHunk', 'Accept this Change'), category: CHAT_CATEGORY, precondition: ContextKeyExpr.and(ChatContextKeys.requestInProgress.negate(), hasUndecidedChatEditingResourceContextKey), - icon: Codicon.diffSingle, + icon: Codicon.check, + f1: true, + keybinding: { + when: EditorContextKeys.focus, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Enter + }, menu: { id: MenuId.ChatEditingEditorHunk, - order: 10 + order: 0 } }); } override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { - ChatEditorController.get(editor)?.openDiff(args[0]); + ChatEditorController.get(editor)?.acceptNearestChange(args[0]); + } +} + +class OpenDiffAction extends EditorAction2 { + constructor() { + super({ + id: 'chatEditor.action.diffHunk', + title: localize2('diff', 'Toggle Diff Editor'), + category: CHAT_CATEGORY, + toggled: { + condition: EditorContextKeys.inDiffEditor, + icon: Codicon.goToFile, + }, + precondition: ContextKeyExpr.and(ChatContextKeys.requestInProgress.negate(), hasUndecidedChatEditingResourceContextKey), + icon: Codicon.diffSingle, + menu: [{ + id: MenuId.ChatEditingEditorHunk, + order: 10 + }, { + id: MenuId.ChatEditingEditorContent, + group: 'a_resolve', + order: 2, + }] + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { + ChatEditorController.get(editor)?.toggleDiff(args[0]); } } @@ -241,7 +282,8 @@ export function registerChatEditorActions() { registerAction2(class NextAction extends NavigateAction { constructor() { super(true); } }); registerAction2(class PrevAction extends NavigateAction { constructor() { super(false); } }); registerAction2(AcceptAction); + registerAction2(AcceptHunkAction); registerAction2(RejectAction); - registerAction2(UndoHunkAction); - registerAction2(OpenDiffFromHunkAction); + registerAction2(RejectHunkAction); + registerAction2(OpenDiffAction); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorController.ts b/src/vs/workbench/contrib/chat/browser/chatEditorController.ts index e2a186b5886..cfe1214f7d8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorController.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorController.ts @@ -12,7 +12,6 @@ import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, IOverlayWidgetPosi import { LineSource, renderLines, RenderOptions } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; import { diffAddDecoration, diffDeleteDecoration, diffWholeLineAddDecoration } from '../../../../editor/browser/widget/diffEditor/registrations.contribution.js'; import { EditorOption, IEditorStickyScrollOptions } from '../../../../editor/common/config/editorOptions.js'; -import { EditOperation, ISingleEditOperation } from '../../../../editor/common/core/editOperation.js'; import { Range } from '../../../../editor/common/core/range.js'; import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js'; import { IEditorContribution, ScrollType } from '../../../../editor/common/editorCommon.js'; @@ -31,6 +30,11 @@ import { Selection } from '../../../../editor/common/core/selection.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; import { minimapGutterAddedBackground, minimapGutterDeletedBackground, minimapGutterModifiedBackground, overviewRulerAddedForeground, overviewRulerDeletedForeground, overviewRulerModifiedForeground } from '../../scm/common/quickDiff.js'; +import { DetailedLineRangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; +import { isDiffEditorForEntry } from './chatEditing/chatEditing.js'; +import { basename } from '../../../../base/common/resources.js'; +import { ChatAgentLocation, IChatAgentService } from '../common/chatAgents.js'; +import { IEditorIdentifier } from '../../../common/editor.js'; export const ctxHasEditorModification = new RawContextKey('chat.hasEditorModifications', undefined, localize('chat.hasEditorModifications', "The current editor contains chat modifications")); export const ctxHasRequestInProgress = new RawContextKey('chat.ctxHasRequestInProgress', false, localize('chat.ctxHasRequestInProgress', "The current editor shows a file from an edit session which is still in progress")); @@ -67,6 +71,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut private readonly _editor: ICodeEditor, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IChatAgentService private readonly _chatAgentService: IChatAgentService, @IEditorService private readonly _editorService: IEditorService, @IContextKeyService contextKeyService: IContextKeyService, ) { @@ -104,11 +109,6 @@ export class ChatEditorController extends Disposable implements IEditorContribut this._register(autorunWithStore((r, store) => { - if (this._editor.getOption(EditorOption.inDiffEditor)) { - this._clearRendering(); - return; - } - const currentEditorEntry = entryForEditor.read(r); if (!currentEditorEntry) { @@ -117,6 +117,11 @@ export class ChatEditorController extends Disposable implements IEditorContribut return; } + if (this._editor.getOption(EditorOption.inDiffEditor) && !_instantiationService.invokeFunction(isDiffEditorForEntry, currentEditorEntry.entry, this._editor)) { + this._clearRendering(); + return; + } + const { session, entry } = currentEditorEntry; const entryIndex = session.entries.read(r).indexOf(entry); @@ -328,13 +333,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut } // Add content widget for each diff change - const undoEdits: ISingleEditOperation[] = []; - for (const c of diffEntry.innerChanges ?? []) { - const oldText = originalModel.getValueInRange(c.originalRange); - undoEdits.push(EditOperation.replace(c.modifiedRange, oldText)); - } - - const widget = this._instantiationService.createInstance(DiffHunkWidget, entry, undoEdits, this._editor.getModel()!.getVersionId(), this._editor, isCreatedContent ? 0 : result.heightInLines); + const widget = this._instantiationService.createInstance(DiffHunkWidget, entry, diffEntry, this._editor.getModel()!.getVersionId(), this._editor, isCreatedContent ? 0 : result.heightInLines); widget.layout(diffEntry.modified.startLineNumber); this._diffHunkWidgets.push(widget); @@ -492,32 +491,45 @@ export class ChatEditorController extends Disposable implements IEditorContribut return true; } - undoNearestChange(closestWidget: DiffHunkWidget | undefined): void { + private _findClosestWidget(): DiffHunkWidget | undefined { if (!this._editor.hasModel()) { - return; + return undefined; } const lineRelativeTop = this._editor.getTopForLineNumber(this._editor.getPosition().lineNumber) - this._editor.getScrollTop(); + let closestWidget: DiffHunkWidget | undefined; let closestDistance = Number.MAX_VALUE; - if (!(closestWidget instanceof DiffHunkWidget)) { - for (const widget of this._diffHunkWidgets) { - const widgetTop = (widget.getPosition()?.preference)?.top; - if (widgetTop !== undefined) { - const distance = Math.abs(widgetTop - lineRelativeTop); - if (distance < closestDistance) { - closestDistance = distance; - closestWidget = widget; - } + for (const widget of this._diffHunkWidgets) { + const widgetTop = (widget.getPosition()?.preference)?.top; + if (widgetTop !== undefined) { + const distance = Math.abs(widgetTop - lineRelativeTop); + if (distance < closestDistance) { + closestDistance = distance; + closestWidget = widget; } } } + return closestWidget; + } + + rejectNearestChange(closestWidget: DiffHunkWidget | undefined): void { + closestWidget = closestWidget ?? this._findClosestWidget(); if (closestWidget instanceof DiffHunkWidget) { - closestWidget.undo(); + closestWidget.reject(); + this.revealNext(); } } - async openDiff(widget: DiffHunkWidget | undefined): Promise { + acceptNearestChange(closestWidget: DiffHunkWidget | undefined): void { + closestWidget = closestWidget ?? this._findClosestWidget(); + if (closestWidget instanceof DiffHunkWidget) { + closestWidget.accept(); + this.revealNext(); + } + } + + async toggleDiff(widget: DiffHunkWidget | undefined): Promise { if (!this._editor.hasModel()) { return; } @@ -537,22 +549,50 @@ export class ChatEditorController extends Disposable implements IEditorContribut } } - if (widget instanceof DiffHunkWidget) { + if (!(widget instanceof DiffHunkWidget)) { + return; + } - const lineNumber = widget.getStartLineNumber(); - const position = lineNumber ? new Position(lineNumber, 1) : undefined; - let selection = this._editor.getSelection(); - if (position && !selection.containsPosition(position)) { - selection = Selection.fromPositions(position); - } + const lineNumber = widget.getStartLineNumber(); + const position = lineNumber ? new Position(lineNumber, 1) : undefined; + let selection = this._editor.getSelection(); + if (position && !selection.containsPosition(position)) { + selection = Selection.fromPositions(position); + } + + const isDiffEditor = this._editor.getOption(EditorOption.inDiffEditor); + + if (isDiffEditor) { + // normal EDITOR + await this._editorService.openEditor({ resource: widget.entry.modifiedURI }); + + } else { + // DIFF editor + const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.EditingSession)?.fullName; const diffEditor = await this._editorService.openEditor({ original: { resource: widget.entry.originalURI, options: { selection: undefined } }, modified: { resource: widget.entry.modifiedURI, options: { selection } }, + label: defaultAgentName + ? localize('diff.agent', '{0} (changes from {1})', basename(widget.entry.modifiedURI), defaultAgentName) + : localize('diff.generic', '{0} (changes from chat)', basename(widget.entry.modifiedURI)) }); - // this is needed, passing the selection doesn't seem to work - diffEditor?.getControl()?.setSelection(selection); + if (diffEditor && diffEditor.input) { + const editorIdent: IEditorIdentifier = { editor: diffEditor.input, groupId: diffEditor.group.id }; + + // this is needed, passing the selection doesn't seem to work + diffEditor.getControl()?.setSelection(selection); + + // close diff editor when entry is decided + const d = autorun(r => { + const state = widget.entry.state.read(r); + if (state === WorkingSetEntryState.Accepted || state === WorkingSetEntryState.Rejected) { + d.dispose(); + this._editorService.closeEditor(editorIdent); + } + }); + } } } } @@ -570,7 +610,7 @@ class DiffHunkWidget implements IOverlayWidget { constructor( readonly entry: IModifiedFileEntry, - private readonly _undoEdits: ISingleEditOperation[], + private readonly _change: DetailedLineRangeMapping, private readonly _versionId: number, private readonly _editor: ICodeEditor, private readonly _lineDelta: number, @@ -590,6 +630,7 @@ class DiffHunkWidget implements IOverlayWidget { }); this._store.add(toolbar); + this._store.add(toolbar.actionRunner.onWillRun(_ => _editor.focus())); this._editor.addOverlayWidget(this); } @@ -641,9 +682,15 @@ class DiffHunkWidget implements IOverlayWidget { // --- - undo() { + reject(): void { if (this._versionId === this._editor.getModel()?.getVersionId()) { - this._editor.executeEdits('chatEdits.undo', this._undoEdits); + this.entry.rejectHunk(this._change); + } + } + + accept(): void { + if (this._versionId === this._editor.getModel()?.getVersionId()) { + this.entry.acceptHunk(this._change); } } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts index bb94d4e0fc9..6306e2361ce 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import './media/chatEditorOverlay.css'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -18,7 +17,6 @@ import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/ed import { Range } from '../../../../editor/common/core/range.js'; import { IActionRunner } from '../../../../base/common/actions.js'; import { $, append, EventLike, reset } from '../../../../base/browser/dom.js'; -import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -27,6 +25,9 @@ import { localize } from '../../../../nls.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { AcceptAction, RejectAction } from './chatEditorActions.js'; import { ChatEditorController } from './chatEditorController.js'; +import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; +import { isDiffEditorForEntry } from './chatEditing/chatEditing.js'; +import './media/chatEditorOverlay.css'; class ChatEditorOverlayWidget implements IOverlayWidget { @@ -273,14 +274,11 @@ export class ChatEditorOverlayController implements IEditorContribution { constructor( private readonly _editor: ICodeEditor, @IChatEditingService chatEditingService: IChatEditingService, - @IInstantiationService instaService: IInstantiationService, + @IInstantiationService instaService: IInstantiationService ) { const modelObs = observableFromEvent(this._editor.onDidChangeModel, () => this._editor.getModel()); const widget = this._store.add(instaService.createInstance(ChatEditorOverlayWidget, this._editor)); - if (this._editor.getOption(EditorOption.inDiffEditor)) { - return; - } this._store.add(autorun(r => { const model = modelObs.read(r); @@ -303,13 +301,23 @@ export class ChatEditorOverlayController implements IEditorContribution { return; } + const entry = entries[idx]; + + if (this._editor.getOption(EditorOption.inDiffEditor) && !instaService.invokeFunction(isDiffEditorForEntry, entry, this._editor)) { + widget.hide(); + return; + } + const isModifyingOrModified = entries.some(e => e.state.read(r) === WorkingSetEntryState.Modified || e.isCurrentlyBeingModified.read(r)); if (!isModifyingOrModified) { widget.hide(); return; } - const entry = entries[idx]; + if (entry.state.read(r) === WorkingSetEntryState.Accepted || entry.state.read(r) === WorkingSetEntryState.Rejected) { + widget.hide(); + return; + } widget.show(session, entry, entries[(idx + 1) % entries.length]); })); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts b/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts index 0970f7ae916..378f2363085 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts @@ -26,7 +26,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IEditorIdentifier, SaveReason } from '../../../common/editor.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js'; +import { AutoSaveMode, IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js'; import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; import { ChatAgentLocation, IChatAgentService } from '../common/chatAgents.js'; @@ -116,6 +116,7 @@ export class ChatEditorSaving extends Disposable implements IWorkbenchContributi @IConfigurationService configService: IConfigurationService, @IChatEditingService chatEditingService: IChatEditingService, @IChatAgentService chatAgentService: IChatAgentService, + @IFilesConfigurationService fileConfigService: IFilesConfigurationService, @ITextFileService textFileService: ITextFileService, @ILabelService labelService: ILabelService, @IDialogService dialogService: IDialogService, @@ -141,13 +142,11 @@ export class ChatEditorSaving extends Disposable implements IWorkbenchContributi })); })); - const store = this._store.add(new DisposableStore()); + const alwaysSaveConfig = observableConfigValue(ChatEditorSaving._config, false, configService); + this._store.add(autorunWithStore((r, store) => { - const update = () => { + const alwaysSave = alwaysSaveConfig.read(r); - store.clear(); - - const alwaysSave = configService.getValue(ChatEditorSaving._config); if (alwaysSave) { return; } @@ -235,14 +234,27 @@ export class ChatEditorSaving extends Disposable implements IWorkbenchContributi return saveJobs.add(entry.modifiedURI); } })); - }; + })); - configService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatEditorSaving._config)) { - update(); + // autosave: OFF & alwaysSaveWithAIChanges - save files after accept + this._store.add(autorun(r => { + const saveConfig = fileConfigService.getAutoSaveMode(undefined); + if (saveConfig.mode !== AutoSaveMode.OFF) { + return; } - }); - update(); + if (!alwaysSaveConfig.read(r)) { + return; + } + const session = chatEditingService.currentEditingSessionObs.read(r); + if (!session) { + return; + } + for (const entry of session.entries.read(r)) { + if (entry.state.read(r) === WorkingSetEntryState.Accepted) { + textFileService.save(entry.modifiedURI); + } + } + })); } private _reportSaved(entry: IModifiedFileEntry) { @@ -277,13 +289,11 @@ export class ChatEditorSaving extends Disposable implements IWorkbenchContributi export class ChatEditingSaveAllAction extends Action2 { static readonly ID = 'chatEditing.saveAllFiles'; - static readonly LABEL = localize('save.allFiles', 'Save All'); constructor() { super({ id: ChatEditingSaveAllAction.ID, - title: ChatEditingSaveAllAction.LABEL, - tooltip: ChatEditingSaveAllAction.LABEL, + title: localize('save.allFiles', 'Save All'), precondition: ContextKeyExpr.and(ChatContextKeys.requestInProgress.negate(), hasUndecidedChatEditingResourceContextKey), icon: Codicon.saveAll, menu: [ diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index 39bb44149b6..78a14c2e3fb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -46,6 +46,7 @@ import { CopyPasteController } from '../../../../editor/contrib/dropOrPasteInto/ import { DropIntoEditorController } from '../../../../editor/contrib/dropOrPasteInto/browser/dropIntoEditorController.js'; import { ContentHoverController } from '../../../../editor/contrib/hover/browser/contentHoverController.js'; import { GlyphHoverController } from '../../../../editor/contrib/hover/browser/glyphHoverController.js'; +import { LinkDetector } from '../../../../editor/contrib/links/browser/links.js'; import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; import { localize } from '../../../../nls.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; @@ -90,6 +91,7 @@ import { IChatHistoryEntry, IChatInputState, IChatWidgetHistoryService } from '. import { ILanguageModelChatMetadata, ILanguageModelsService } from '../common/languageModels.js'; import { CancelAction, ChatModelPickerActionId, ChatSubmitAction, ChatSubmitSecondaryAgentAction, IChatExecuteActionContext } from './actions/chatExecuteActions.js'; import { ImplicitContextAttachmentWidget } from './attachments/implicitContextAttachment.js'; +import { InstructionAttachmentsWidget } from './attachments/instructionsAttachment/instructionAttachments.js'; import { IChatWidget } from './chat.js'; import { ChatAttachmentModel, EditsAttachmentModel } from './chatAttachmentModel.js'; import { hookUpResourceAttachmentDragAndContextMenu, hookUpSymbolAttachmentDragAndContextMenu } from './chatContentParts/chatAttachmentsContentPart.js'; @@ -125,6 +127,11 @@ interface IChatInputPartOptions { enableImplicitContext?: boolean; } +export interface IWorkingSetEntry { + uri: URI; + isMarkedReadonly?: boolean; +} + export class ChatInputPart extends Disposable implements IHistoryNavigationWidget { static readonly INPUT_SCHEME = 'chatSessionInput'; private static _counter = 0; @@ -148,6 +155,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge readonly onDidAcceptFollowup = this._onDidAcceptFollowup.event; private readonly _attachmentModel: ChatAttachmentModel; + private _inChatEditWorkingSetCtx: IContextKey | undefined; + public get attachmentModel(): ChatAttachmentModel { return this._attachmentModel; } @@ -158,6 +167,27 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge contextArr.push(this.implicitContext.toBaseEntry()); } + // retrieve links from the input editor + const linkOccurrences = this.inputEditor.getContribution(LinkDetector.ID)?.getAllLinkOccurrences() ?? []; + const linksSeen = new Set(); + for (const linkOccurrence of linkOccurrences) { + const link = linkOccurrence.link; + const uri = URI.isUri(link.url) ? link.url : link.url ? URI.parse(link.url) : undefined; + if (!uri || linksSeen.has(uri.toString())) { + continue; + } + + linksSeen.add(uri.toString()); + contextArr.push({ + kind: 'link', + id: uri.toString(), + name: uri.fsPath, + value: uri, + isFile: false, + isDynamic: true, + }); + } + // factor in nested file references into the implicit context const variables = this.variableService.getDynamicVariables(sessionId); for (const variable of variables) { @@ -178,9 +208,28 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + for (const uri of this.instructionAttachmentsPart.references) { + contextArr.push({ + id: 'vscode.prompt.instructions', + name: basename(uri.path), + value: uri, + isSelection: false, + enabled: true, + isFile: true, + isDynamic: true, + }); + } + return contextArr; } + /** + * Check if the chat input part has any prompt instruction attachments. + */ + public get hasInstructionAttachments(): boolean { + return !this.instructionAttachmentsPart.empty; + } + private _indexOfLastAttachedContextDeletedWithKeyboard: number = -1; private _implicitContext: ChatImplicitContext | undefined; @@ -236,6 +285,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private inputEditorHasText: IContextKey; private chatCursorAtTop: IContextKey; private inputEditorHasFocus: IContextKey; + /** + * Context key is set when prompt instructions are attached.3 + */ + private promptInstructionsAttached: IContextKey; private readonly _waitForPersistedLanguageModel = this._register(new MutableDisposable()); private _onDidChangeCurrentLanguageModel = this._register(new Emitter()); @@ -276,13 +329,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge public get attemptedWorkingSetEntriesCount() { return this._attemptedWorkingSetEntriesCount; } - private _combinedChatEditWorkingSetEntries: URI[] = []; + private _combinedChatEditWorkingSetEntries: IWorkingSetEntry[] = []; public get chatEditWorkingSetFiles() { return this._combinedChatEditWorkingSetEntries; } private readonly getInputState: () => IChatInputState; + /** + * Child widget of prompt instruction attachments. + * See {@linkcode InstructionAttachmentsWidget}. + */ + private instructionAttachmentsPart: InstructionAttachmentsWidget; + constructor( // private readonly editorOptions: ChatEditorOptions, // TODO this should be used private readonly location: ChatAgentLocation, @@ -332,6 +391,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.inputEditorHasText = ChatContextKeys.inputHasText.bindTo(contextKeyService); this.chatCursorAtTop = ChatContextKeys.inputCursorAtTop.bindTo(contextKeyService); this.inputEditorHasFocus = ChatContextKeys.inputHasFocus.bindTo(contextKeyService); + this.promptInstructionsAttached = ChatContextKeys.instructionsAttached.bindTo(contextKeyService); this.history = this.loadHistory(); this._register(this.historyService.onDidClearHistory(() => this.history = new HistoryNavigator2([{ text: '' }], 50, historyKeyFn))); @@ -346,6 +406,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService); + this.instructionAttachmentsPart = this._register( + instantiationService.createInstance( + InstructionAttachmentsWidget, + this.attachmentModel.promptInstructions, + this._contextResourceLabels, + ), + ); + this.initSelectedModel(); } @@ -621,6 +689,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ChatContextKeys.inChatInput.bindTo(inputScopedContextKeyService).set(true); const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService]))); + this._inChatEditWorkingSetCtx = ChatContextKeys.inChatEditWorkingSet.bindTo(this.contextKeyService); + const { historyNavigationBackwardsEnablement, historyNavigationForwardsEnablement } = this._register(registerAndCreateHistoryNavigationContext(inputScopedContextKeyService, this)); this.historyNavigationBackwardsEnablement = historyNavigationBackwardsEnablement; this.historyNavigationForewardsEnablement = historyNavigationForwardsEnablement; @@ -649,7 +719,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditorElement = dom.append(editorContainer!, $(chatInputEditorContainerSelector)); const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, CopyPasteController.ID])); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, CopyPasteController.ID, LinkDetector.ID])); this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); SuggestController.get(this._inputEditor)?.forceRenderingAbove(); @@ -815,7 +885,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Render as attachments anything that isn't a file, but still render specific ranges in a file ? [...this.attachmentModel.attachments.entries()].filter(([_, attachment]) => !attachment.isFile || attachment.isFile && typeof attachment.value === 'object' && !!attachment.value && 'range' in attachment.value) : [...this.attachmentModel.attachments.entries()]; - dom.setVisibility(Boolean(attachments.length) || Boolean(this.implicitContext?.value), this.attachedContextContainer); + dom.setVisibility(Boolean(attachments.length) || Boolean(this.implicitContext?.value) || !this.instructionAttachmentsPart.empty, this.attachedContextContainer); if (!attachments.length) { this._indexOfLastAttachedContextDeletedWithKeyboard = -1; } @@ -825,6 +895,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge container.appendChild(implicitPart.domNode); } + this.promptInstructionsAttached.set(!this.instructionAttachmentsPart.empty); + container.appendChild(this.instructionAttachmentsPart.domNode); + const attachmentInitPromises: Promise[] = []; for (const [index, attachment] of attachments) { const widget = dom.append(container, $('.chat-attached-context-attachment.show-file-icons')); @@ -1099,6 +1172,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge state: metadata.state, description: metadata.description, kind: 'reference', + isMarkedReadonly: metadata.isMarkedReadonly, }); seenEntries.add(file); } @@ -1246,6 +1320,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Working set const workingSetContainer = innerContainer.querySelector('.chat-editing-session-list') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-list')); + this._register(addDisposableListener(workingSetContainer, 'focusin', () => this._inChatEditWorkingSetCtx?.set(true))); + this._register(addDisposableListener(workingSetContainer, 'focusout', () => this._inChatEditWorkingSetCtx?.set(false))); if (!this._chatEditList) { this._chatEditList = this._chatEditsListPool.get(); const list = this._chatEditList.object; @@ -1287,7 +1363,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge list.getHTMLElement().style.height = `${height}px`; list.splice(0, list.length, entries); list.splice(entries.length, 0, excludedEntries); - this._combinedChatEditWorkingSetEntries = coalesce(entries.map((e) => e.kind === 'reference' && URI.isUri(e.reference) ? e.reference : undefined)); + this._combinedChatEditWorkingSetEntries = coalesce(entries.map((e) => e.kind === 'reference' && URI.isUri(e.reference) ? ({ uri: e.reference, isMarkedReadonly: e.isMarkedReadonly }) : undefined)); const addFilesElement = innerContainer.querySelector('.chat-editing-session-toolbar-actions') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-toolbar-actions')); diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts index 3d16ee993b7..6949c731803 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts @@ -199,7 +199,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { continue; } - if (providerDescriptor.isDefault && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) { + if ((providerDescriptor.isDefault || providerDescriptor.isAgent) && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) { this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: defaultChatParticipant.`); continue; } @@ -245,6 +245,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { name: providerDescriptor.name, fullName: providerDescriptor.fullName, isDefault: providerDescriptor.isDefault, + isToolsAgent: providerDescriptor.isAgent, locations: isNonEmptyArray(providerDescriptor.locations) ? providerDescriptor.locations.map(ChatAgentLocation.fromRaw) : [ChatAgentLocation.Panel], diff --git a/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts b/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts index 9d990d09302..5550d3d33b9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts @@ -21,6 +21,7 @@ import { Mimes } from '../../../../base/common/mime.js'; import { IModelService } from '../../../../editor/common/services/model.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { basename } from '../../../../base/common/resources.js'; +import { resizeImage } from './imageUtils.js'; const COPY_MIME_TYPES = 'application/vnd.code.additional-editor-data'; @@ -89,19 +90,25 @@ export class PasteImageProvider implements DocumentPasteEditProvider { tempDisplayName = `${displayName} ${appendValue}`; } - const imageContext = await getImageAttachContext(currClipboard, mimeType, token, tempDisplayName); - - if (token.isCancellationRequested || !imageContext) { + const scaledImageData = await resizeImage(currClipboard); + if (token.isCancellationRequested || !scaledImageData) { return; } + const scaledImageContext = await getImageAttachContext(scaledImageData, mimeType, token, tempDisplayName); + if (token.isCancellationRequested || !scaledImageContext) { + return; + } + + widget.attachmentModel.addContext(scaledImageContext); + // Make sure to attach only new contexts const currentContextIds = widget.attachmentModel.getAttachmentIDs(); - if (currentContextIds.has(imageContext.id)) { + if (currentContextIds.has(scaledImageContext.id)) { return; } - const edit = createCustomPasteEdit(model, imageContext, mimeType, this.kind, localize('pastedImageAttachment', 'Pasted Image Attachment'), this.chatWidgetService); + const edit = createCustomPasteEdit(model, scaledImageContext, mimeType, this.kind, localize('pastedImageAttachment', 'Pasted Image Attachment'), this.chatWidgetService); return createEditSession(edit); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts b/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts index 62bf59aca5e..6e2ab0a5b34 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts @@ -123,7 +123,7 @@ export class ChatQuotasService extends Disposable implements IChatQuotasService message = localize('chatAndCompletionsQuotaExceeded', "You've reached the limit of the Copilot Free plan. These limits will reset on {0}.", dateFormatter.format(that.quotas.quotaResetDate)); } - const upgradeToPro = localize('upgradeToPro', "Here's what you can expect when upgrading to Copilot Pro:\n- Unlimited code completions\n- Unlimited chat messages\n- 30-day free trial"); + const upgradeToPro = localize('upgradeToPro', "Upgrade to Copilot Pro (your first 30 days are free) for:\n- Unlimited code completions\n- Unlimited chat messages\n- Access to additional models"); await dialogService.prompt({ type: 'none', diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup.ts b/src/vs/workbench/contrib/chat/browser/chatSetup.ts index 53a86ec5245..0ad1f26e379 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup.ts @@ -60,6 +60,8 @@ import { IHostService } from '../../../services/host/browser/host.js'; import Severity from '../../../../base/common/severity.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { isWeb } from '../../../../base/common/platform.js'; +import { ExtensionUrlHandlerOverrideRegistry } from '../../../services/extensions/browser/extensionUrlHandler.js'; +import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', @@ -109,7 +111,9 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr constructor( @IProductService private readonly productService: IProductService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @ICommandService private readonly commandService: ICommandService, + @ITelemetryService private readonly telemetryService: ITelemetryService ) { super(); @@ -122,6 +126,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr this.registerChatWelcome(); this.registerActions(); + this.registerUrlLinkHandler(); } private registerChatWelcome(): void { @@ -170,8 +175,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr ensureSideBarChatViewSize(400, viewDescriptorService, layoutService); if (startSetup === true) { - const controller = that.controller.value; - controller.setup(); + that.controller.value.setup(); } configurationService.updateValue('chat.commandCenter.enabled', true); @@ -292,6 +296,18 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr registerAction2(ChatSetupHideAction); registerAction2(UpgradePlanAction); } + + private registerUrlLinkHandler(): void { + this._register(ExtensionUrlHandlerOverrideRegistry.registerHandler(URI.parse(`${this.productService.urlProtocol}://${defaultChat.chatExtensionId}`), { + handleURL: async () => { + this.telemetryService.publicLog2('workbenchActionExecuted', { id: TRIGGER_SETUP_COMMAND_ID, from: 'url' }); + + await this.commandService.executeCommand(TRIGGER_SETUP_COMMAND_ID); + + return true; + } + })); + } } //#endregion @@ -302,6 +318,7 @@ type EntitlementClassification = { entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating the chat entitlement state' }; quotaChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat completions available to the user' }; quotaCompletions: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat completions available to the user' }; + quotaResetDate: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The date the quota will reset' }; owner: 'bpasero'; comment: 'Reporting chat setup entitlements'; }; @@ -310,6 +327,7 @@ type EntitlementEvent = { entitlement: ChatEntitlement; quotaChat: number | undefined; quotaCompletions: number | undefined; + quotaResetDate: string | undefined; }; interface IEntitlementsResponse { @@ -520,7 +538,8 @@ class ChatSetupRequests extends Disposable { this.telemetryService.publicLog2('chatInstallEntitlement', { entitlement: entitlements.entitlement, quotaChat: entitlementsResponse.limited_user_quotas?.chat, - quotaCompletions: entitlementsResponse.limited_user_quotas?.completions + quotaCompletions: entitlementsResponse.limited_user_quotas?.completions, + quotaResetDate: entitlementsResponse.limited_user_reset_date }); return entitlements; @@ -572,7 +591,7 @@ class ChatSetupRequests extends Disposable { return this.resolveEntitlement(session, CancellationToken.None); } - async signUpLimited(session: AuthenticationSession): Promise { + async signUpLimited(session: AuthenticationSession): Promise { const body = { restricted_telemetry: this.telemetryService.telemetryLevel === TelemetryLevel.NONE ? 'disabled' : 'enabled', public_code_suggestions: 'enabled' @@ -581,7 +600,7 @@ class ChatSetupRequests extends Disposable { const response = await this.request(defaultChat.entitlementSignupLimitedUrl, 'POST', body, session, CancellationToken.None); if (!response) { this.onUnknownSignUpError('[chat setup] sign-up: no response'); - return undefined; + return { errorCode: 1 }; } if (response.res.statusCode && response.res.statusCode !== 200) { @@ -592,7 +611,7 @@ class ChatSetupRequests extends Disposable { const responseError: { message: string } = JSON.parse(responseText); if (typeof responseError.message === 'string' && responseError.message) { this.onUnprocessableSignUpError(`[chat setup] sign-up: unprocessable entity (${responseError.message})`, responseError.message); - return undefined; + return { errorCode: response.res.statusCode }; } } } catch (error) { @@ -600,7 +619,7 @@ class ChatSetupRequests extends Disposable { } } this.onUnknownSignUpError(`[chat setup] sign-up: unexpected status code ${response.res.statusCode}`); - return undefined; + return { errorCode: response.res.statusCode }; } let responseText: string | null = null; @@ -612,7 +631,7 @@ class ChatSetupRequests extends Disposable { if (!responseText) { this.onUnknownSignUpError('[chat setup] sign-up: response has no content'); - return undefined; + return { errorCode: 2 }; } let parsedResult: { subscribed: boolean } | undefined = undefined; @@ -621,7 +640,7 @@ class ChatSetupRequests extends Disposable { this.logService.trace(`[chat setup] sign-up: response is ${responseText}`); } catch (err) { this.onUnknownSignUpError(`[chat setup] sign-up: error parsing response (${err})`); - return undefined; + return { errorCode: 3 }; } // We have made it this far, so the user either did sign-up or was signed-up already. @@ -671,10 +690,12 @@ type InstallChatClassification = { comment: 'Provides insight into chat installation.'; installResult: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the extension was installed successfully, cancelled or failed to install.' }; signedIn: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user did sign in prior to installing the extension.' }; + signUpErrorCode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The error code in case of an error signing up.' }; }; type InstallChatEvent = { - installResult: 'installed' | 'cancelled' | 'failedInstall' | 'failedNotSignedIn' | 'failedSignUp'; + installResult: 'installed' | 'cancelled' | 'failedInstall' | 'failedNotSignedIn' | 'failedSignUp' | 'failedNotTrusted'; signedIn: boolean; + signUpErrorCode: number | undefined; }; enum ChatSetupStep { @@ -707,7 +728,8 @@ class ChatSetupController extends Disposable { @IChatAgentService private readonly chatAgentService: IChatAgentService, @IActivityService private readonly activityService: IActivityService, @ICommandService private readonly commandService: ICommandService, - @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService ) { super(); @@ -757,6 +779,7 @@ class ChatSetupController extends Disposable { this.setStep(ChatSetupStep.SigningIn); const result = await this.signIn(); if (!result.session) { + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', signedIn: false, signUpErrorCode: undefined }); return; // user cancelled } @@ -767,10 +790,19 @@ class ChatSetupController extends Disposable { if (!session) { session = (await this.authenticationService.getSessions(defaultChat.providerId)).at(0); if (!session) { + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', signedIn: false, signUpErrorCode: undefined }); return; // unexpected } } + 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', signedIn: true, signUpErrorCode: undefined }); + return; + } + const activeElement = getActiveElement(); // Install @@ -804,10 +836,6 @@ class ChatSetupController extends Disposable { this.logService.error(`[chat setup] signIn: error ${error}`); } - if (!session) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', signedIn: false }); - } - return { session, entitlement }; } @@ -816,15 +844,15 @@ class ChatSetupController extends Disposable { let installResult: 'installed' | 'cancelled' | 'failedInstall' | undefined = undefined; const wasInstalled = this.context.state.installed; - let didSignUp: boolean | undefined = undefined; + let didSignUp: boolean | { errorCode: number } | undefined = undefined; try { showCopilotView(this.viewsService, this.layoutService); if (entitlement !== ChatEntitlement.Limited && entitlement !== ChatEntitlement.Pro && entitlement !== ChatEntitlement.Unavailable) { didSignUp = await this.requests.signUpLimited(session); - if (typeof didSignUp === 'undefined' /* error */) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedSignUp', signedIn }); + if (typeof didSignUp !== 'boolean' /* error */) { + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedSignUp', signedIn, signUpErrorCode: didSignUp.errorCode }); } } @@ -854,7 +882,7 @@ class ChatSetupController extends Disposable { } } - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult, signedIn }); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult, signedIn, signUpErrorCode: undefined }); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index 3bb7d3b2285..0da2466b9d9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -538,7 +538,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // Re-render once content references are loaded (isResponseVM(element) ? `_${element.contentReferences.length}` : '') + // Re-render if element becomes hidden due to undo/redo - `_${element.isHidden ? '1' : '0'}` + + `_${element.shouldBeRemovedOnSend ? '1' : '0'}` + // Rerender request if we got new content references in the response // since this may change how we render the corresponding attachments in the request (isRequestVM(element) && element.contentReferences ? `_${element.contentReferences?.length}` : ''); @@ -650,7 +650,8 @@ export class ChatWidget extends Disposable implements IChatWidget { noCommandDetection: true, attempt: request.attempt + 1, location: this.location, - userSelectedModelId: this.input.currentLanguageModel + userSelectedModelId: this.input.currentLanguageModel, + hasInstructionAttachments: this.input.hasInstructionAttachments, }; this.chatService.resendRequest(request, options).catch(e => this.logService.error('FAILED to rerun request', e)); } @@ -985,6 +986,10 @@ export class ChatWidget extends Disposable implements IChatWidget { } private async _acceptInput(query: { query: string } | { prefix: string } | undefined, options?: IChatAcceptInputOptions): Promise { + if (this.viewModel?.requestInProgress) { + return; + } + if (this.viewModel) { this._onDidAcceptInput.fire(); if (!this.viewOptions.autoScroll) { @@ -998,13 +1003,7 @@ export class ChatWidget extends Disposable implements IChatWidget { `${query.prefix} ${editorValue}`; const isUserQuery = !query || 'prefix' in query; - const requests = this.viewModel.model.getRequests(); - for (let i = requests.length - 1; i >= 0; i -= 1) { - const request = requests[i]; - if (request.isHidden) { - this.chatService.removeRequest(this.viewModel.sessionId, request.id); - } - } + let attachedContext = this.inputPart.getAttachedAndImplicitContext(this.viewModel.sessionId); let workingSet: URI[] | undefined; @@ -1016,13 +1015,13 @@ export class ChatWidget extends Disposable implements IChatWidget { const editingSessionAttachedContext: IChatRequestVariableEntry[] = []; // Pick up everything that the user sees is part of the working set. // This should never exceed the maximum file entries limit above. - for (const v of this.inputPart.chatEditWorkingSetFiles) { + for (const { uri, isMarkedReadonly } of this.inputPart.chatEditWorkingSetFiles) { // Skip over any suggested files that haven't been confirmed yet in the working set - if (currentEditingSession?.workingSet.get(v)?.state === WorkingSetEntryState.Suggested) { - unconfirmedSuggestions.add(v); + if (currentEditingSession?.workingSet.get(uri)?.state === WorkingSetEntryState.Suggested) { + unconfirmedSuggestions.add(uri); } else { - uniqueWorkingSetEntries.add(v); - editingSessionAttachedContext.push(this.attachmentModel.asVariableEntry(v)); + uniqueWorkingSetEntries.add(uri); + editingSessionAttachedContext.push(this.attachmentModel.asVariableEntry(uri, undefined, isMarkedReadonly)); } } let maximumFileEntries = this.chatEditingService.editingSessionFileLimit - editingSessionAttachedContext.length; @@ -1080,6 +1079,7 @@ export class ChatWidget extends Disposable implements IChatWidget { attachedContext, workingSet, noCommandDetection: options?.noCommandDetection, + hasInstructionAttachments: this.inputPart.hasInstructionAttachments, }); if (result) { @@ -1097,6 +1097,13 @@ export class ChatWidget extends Disposable implements IChatWidget { } } }); + + const RESPONSE_TIMEOUT = 20000; + setTimeout(() => { + // Stop the signal if the promise is still unresolved + this.chatAccessibilityService.acceptResponse(undefined, requestId, options?.isVoiceInput); + }, RESPONSE_TIMEOUT); + return result.responseCreatedPromise; } } diff --git a/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts index 6f34a50203b..29ed76b29c4 100644 --- a/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts @@ -74,6 +74,7 @@ const $ = dom.$; export interface ICodeBlockData { readonly codeBlockIndex: number; + readonly codeBlockPartIndex: number; readonly element: unknown; readonly textModel: Promise; diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables/chatFileReference.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables/chatFileReference.ts index 2a5ecdf5048..15bf36b7017 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables/chatFileReference.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables/chatFileReference.ts @@ -7,6 +7,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { assert } from '../../../../../../base/common/assert.js'; import { IDynamicVariable } from '../../../common/chatVariables.js'; import { IRange } from '../../../../../../editor/common/core/range.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; import { PromptFileReference } from '../../../common/promptFileReference.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -22,6 +23,7 @@ export class ChatFileReference extends PromptFileReference implements IDynamicVa */ constructor( public readonly reference: IDynamicVariable, + @ILogService logService: ILogService, @IFileService fileService: IFileService, @IConfigurationService configService: IConfigurationService, ) { @@ -32,7 +34,7 @@ export class ChatFileReference extends PromptFileReference implements IDynamicVa `Variable data must be an URI, got '${data}'.`, ); - super(data, fileService, configService); + super(data, logService, fileService, configService); } /** diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts index bf5dfa67cef..68e436eeeef 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { coalesce } from '../../../../../base/common/arrays.js'; import { raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { isPatternInWord } from '../../../../../base/common/filters.js'; @@ -258,7 +259,11 @@ class AgentCompletions extends Disposable { return { suggestions: justAgents.concat( - agents.flatMap(agent => agent.slashCommands.map((c, i) => { + coalesce(agents.flatMap(agent => agent.slashCommands.map((c, i) => { + if (agent.isDefault && this.chatAgentService.getDefaultAgent(widget.location)?.id !== agent.id) { + return; + } + const { label: agentLabel, isDupe } = this.getAgentCompletionDetails(agent); const label = `${agentLabel} ${chatSubcommandLeader}${c.name}`; const item: CompletionItem = { @@ -284,7 +289,7 @@ class AgentCompletions extends Disposable { } return item; - }))) + })))) }; } })); @@ -313,7 +318,11 @@ class AgentCompletions extends Disposable { .filter(a => a.locations.includes(widget.location)); return { - suggestions: agents.flatMap(agent => agent.slashCommands.map((c, i) => { + suggestions: coalesce(agents.flatMap(agent => agent.slashCommands.map((c, i) => { + if (agent.isDefault && this.chatAgentService.getDefaultAgent(widget.location)?.id !== agent.id) { + return; + } + const { label: agentLabel, isDupe } = this.getAgentCompletionDetails(agent); const withSlash = `${chatSubcommandLeader}${c.name}`; const extraSortText = agent.id === 'github.copilot.terminalPanel' ? `z` : ``; @@ -338,7 +347,7 @@ class AgentCompletions extends Disposable { } return item; - })) + }))) }; } })); diff --git a/src/vs/workbench/contrib/chat/browser/imageUtils.ts b/src/vs/workbench/contrib/chat/browser/imageUtils.ts new file mode 100644 index 00000000000..c176876c538 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/imageUtils.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +/** + * Resizes an image provided as a UInt8Array string. Resizing is based on Open AI's algorithm for tokenzing images. + * https://platform.openai.com/docs/guides/vision#calculating-costs + * @param data - The UInt8Array string of the image to resize. + * @returns A promise that resolves to the UInt8Array string of the resized image. + */ + +export async function resizeImage(data: Uint8Array): Promise { + const blob = new Blob([data]); + const img = new Image(); + const url = URL.createObjectURL(blob); + img.src = url; + + return new Promise((resolve, reject) => { + img.onload = () => { + URL.revokeObjectURL(url); + let { width, height } = img; + + // Calculate the new dimensions while maintaining the aspect ratio + if (width > 2048 || height > 2048) { + const scaleFactor = 2048 / Math.max(width, height); + width = Math.round(width * scaleFactor); + height = Math.round(height * scaleFactor); + } + + const scaleFactor = 768 / Math.min(width, height); + width = Math.round(width * scaleFactor); + height = Math.round(height * scaleFactor); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0, width, height); + canvas.toBlob((blob) => { + if (blob) { + const reader = new FileReader(); + reader.onload = () => { + resolve(new Uint8Array(reader.result as ArrayBuffer)); + }; + reader.onerror = (error) => reject(error); + reader.readAsArrayBuffer(blob); + } else { + reject(new Error('Failed to create blob from canvas')); + } + }, 'image/png'); + } else { + reject(new Error('Failed to get canvas context')); + } + }; + img.onerror = (error) => { + URL.revokeObjectURL(url); + reject(error); + }; + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts index 2b3f6b2dc49..d53c0db6859 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts @@ -5,14 +5,15 @@ import { renderStringAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; import { Iterable } from '../../../../base/common/iterator.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { ChatModel } from '../common/chatModel.js'; @@ -37,12 +38,16 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo private _tools = new Map(); private _toolContextKeys = new Set(); + + private _callsByRequestId = new Map(); + constructor( @IExtensionService private readonly _extensionService: IExtensionService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IChatService private readonly _chatService: IChatService, @IDialogService private readonly _dialogService: IDialogService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -122,6 +127,8 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { + this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`); + // When invoking a tool, don't validate the "when" clause. An extension may have invoked a tool just as it was becoming disabled, and just let it go through rather than throw and break the chat. let tool = this._tools.get(dto.toolId); if (!tool) { @@ -141,10 +148,37 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo // Shortcut to write to the model directly here, but could call all the way back to use the real stream. let toolInvocation: ChatToolInvocation | undefined; + let requestId: string | undefined; + let store: DisposableStore | undefined; try { if (dto.context) { - const model = this._chatService.getSession(dto.context?.sessionId) as ChatModel; + store = new DisposableStore(); + const model = this._chatService.getSession(dto.context?.sessionId) as ChatModel | undefined; + if (!model) { + throw new Error(`Tool called for unknown chat session`); + } + const request = model.getRequests().at(-1)!; + requestId = request.id; + + // Replace the token with a new token that we can cancel when cancelToolCallsForRequest is called + if (!this._callsByRequestId.has(requestId)) { + this._callsByRequestId.set(requestId, []); + } + this._callsByRequestId.get(requestId)!.push(store); + + const source = new CancellationTokenSource(); + store.add(toDisposable(() => { + source.dispose(true); + })); + store.add(token.onCancellationRequested(() => { + toolInvocation?.confirmed.complete(false); + source.cancel(); + })); + store.add(source.token.onCancellationRequested(() => { + toolInvocation?.confirmed.complete(false); + })); + token = source.token; const prepared = tool.impl.prepareToolInvocation ? await tool.impl.prepareToolInvocation(dto.parameters, token) @@ -152,15 +186,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const defaultMessage = localize('toolInvocationMessage', "Using {0}", `"${tool.data.displayName}"`); const invocationMessage = prepared?.invocationMessage ?? defaultMessage; - toolInvocation = new ChatToolInvocation(invocationMessage, prepared?.confirmationMessages); - token.onCancellationRequested(() => { - toolInvocation!.confirmed.complete(false); - }); - model.acceptResponseProgress(request, toolInvocation); - if (prepared?.confirmationMessages) { - const userConfirmed = await toolInvocation.confirmed.p; - if (!userConfirmed) { - throw new CancellationError(); + if (tool.data.id !== 'vscode_editFile') { + toolInvocation = new ChatToolInvocation(invocationMessage, prepared?.confirmationMessages); + model.acceptResponseProgress(request, toolInvocation); + if (prepared?.confirmationMessages) { + const userConfirmed = await toolInvocation.confirmed.p; + if (!userConfirmed) { + throw new CancellationError(); + } } } } else { @@ -176,6 +209,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } + if (token.isCancellationRequested) { + throw new CancellationError(); + } const result = await tool.impl.invoke(dto, countTokens, token); this._telemetryService.publicLog2( @@ -200,8 +236,40 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo throw err; } finally { toolInvocation?.isCompleteDeferred.complete(); + + if (requestId && store) { + this.cleanupCallDisposables(requestId, store); + } } } + + private cleanupCallDisposables(requestId: string, store: DisposableStore): void { + const disposables = this._callsByRequestId.get(requestId); + if (disposables) { + const index = disposables.indexOf(store); + if (index > -1) { + disposables.splice(index, 1); + } + if (disposables.length === 0) { + this._callsByRequestId.delete(requestId); + } + } + store.dispose(); + } + + cancelToolCallsForRequest(requestId: string): void { + const calls = this._callsByRequestId.get(requestId); + if (calls) { + calls.forEach(call => call.dispose()); + this._callsByRequestId.delete(requestId); + } + } + + public override dispose(): void { + super.dispose(); + + this._callsByRequestId.forEach(calls => dispose(calls)); + } } type LanguageModelToolInvokedEvent = { diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index f9065621637..a3b4fb8384d 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -566,6 +566,12 @@ have to be updated for changes to the rules above, or to support more deeply nes display: inherit; } +.interactive-session .chat-editing-session .monaco-list-row .chat-collapsible-list-action-bar .action-label.checked { + color: var(--vscode-inputOption-activeForeground); + background-color: var(--vscode-inputOption-activeBackground); + box-shadow: inset 0 0 0 1px var(--vscode-inputOption-activeBorder); +} + .interactive-session .chat-editing-session .chat-editing-session-container.show-file-icons .monaco-scrollable-element .monaco-list-rows .monaco-list-row { border-radius: 2px; } @@ -925,6 +931,73 @@ have to be updated for changes to the rules above, or to support more deeply nes border-color: var(--vscode-notificationsWarningIcon-foreground); } +/** + * Styles for the `prompt instructions` attachment widget. + */ +.chat-attached-context .chat-prompt-instructions-attachments { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} +.chat-attached-context .chat-prompt-instructions-attachment { + display: flex; + gap: 4px; +} +.chat-attached-context .chat-prompt-instructions-attachment .codicon { + color: inherit; + text-decoration: none; +} +.chat-attached-context .chat-prompt-instructions-attachment .monaco-icon-label::before { + color: var(--vscode-notificationsWarningIcon-foreground); +} +.chat-attached-context .chat-prompt-instructions-attachment .chat-implicit-hint { + opacity: 0.7; + font-size: .9em; +} +.chat-attached-context .chat-prompt-instructions-attachment.warning { + color: var(--vscode-notificationsWarningIcon-foreground); +} +.chat-attached-context .chat-prompt-instructions-attachment.error { + color: var(--vscode-notificationsErrorIcon-foreground); +} +.chat-attached-context .chat-prompt-instructions-attachment.disabled { + border-style: dashed; + opacity: 0.75; +} +/* + * This overly specific CSS selector is needed to beat priority of some + * styles applied on the the `.chat-attached-context-attachment` element. + */ +.chat-attached-context .chat-prompt-instructions-attachments .chat-prompt-instructions-attachment.error.implicit, +.chat-attached-context .chat-prompt-instructions-attachments .chat-prompt-instructions-attachment.warning.implicit { + border: 1px solid currentColor; +} +/* + * If in one of the non-normal states, make sure the `main icon` of + * the component has the same color as the component itself + */ +.chat-attached-context .chat-prompt-instructions-attachment.error .monaco-icon-label::before, +.chat-attached-context .chat-prompt-instructions-attachment.warning .monaco-icon-label::before, +.chat-attached-context .chat-prompt-instructions-attachment.disabled .monaco-icon-label::before { + color: inherit; +} +.chat-attached-context .chat-prompt-instructions-attachment.disabled .monaco-icon-label::before { + font-style: italic; +} +.chat-attached-context .chat-prompt-instructions-attachment.disabled:hover { + opacity: 1; +} +.chat-attached-context .chat-prompt-instructions-attachment.disabled .chat-implicit-hint, +.chat-attached-context .chat-prompt-instructions-attachment.disabled .label-name { + font-style: italic; + text-decoration: line-through; +} +.chat-attached-context .chat-prompt-instructions-attachment.disabled:focus { + outline: none; + border-color: var(--vscode-focusBorder); +} + .chat-notification-widget .chat-warning-codicon .codicon-warning, .chat-quota-error-widget .codicon-warning { color: var(--vscode-notificationsWarningIcon-foreground) !important; /* Have to override default styles which apply to all lists */ diff --git a/src/vs/workbench/contrib/chat/browser/media/chatEditorController.css b/src/vs/workbench/contrib/chat/browser/media/chatEditorController.css index 03d8b6e6171..ccef0cfb191 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatEditorController.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatEditorController.css @@ -7,6 +7,7 @@ opacity: 0; transition: opacity 0.2s ease-in-out; display: flex; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); } .chat-diff-change-content-widget.hover { diff --git a/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css index 26ece80c64c..b7a54ada1a2 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css @@ -12,6 +12,23 @@ display: flex; align-items: center; z-index: 10; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); +} + +@keyframes pulse { + 0% { + box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); + } + 50% { + box-shadow: 0 2px 8px 4px var(--vscode-widget-shadow); + } + 100% { + box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); + } +} + +.chat-editor-overlay-widget.busy { + animation: pulse ease-in 2.3s infinite; } .chat-editor-overlay-widget .chat-editor-overlay-progress { diff --git a/src/vs/workbench/contrib/chat/browser/tools/tools.ts b/src/vs/workbench/contrib/chat/browser/tools/tools.ts new file mode 100644 index 00000000000..53320370c5a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/tools/tools.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { IJSONSchema } from '../../../../../base/common/jsonSchema.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { ICodeMapperService } from '../../common/chatCodeMapperService.js'; +import { IChatEditingService } from '../../common/chatEditingService.js'; +import { ChatModel } from '../../common/chatModel.js'; +import { IChatService } from '../../common/chatService.js'; +import { CountTokensCallback, ILanguageModelToolsService, IToolData, IToolImpl, IToolInvocation, IToolResult } from '../../common/languageModelToolsService.js'; + +export class BuiltinToolsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.builtinTools'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const editTool = instantiationService.createInstance(EditTool); + this._register(toolsService.registerToolData(editTool)); + this._register(toolsService.registerToolImplementation(editTool.id, editTool)); + } +} + +interface EditToolParams { + filePath: string; + explanation: string; + code: string; +} + +const codeInstructions = ` +The user is very smart and can understand how to apply your edits to their files, you just need to provide minimal hints. +Avoid repeating existing code, instead use comments to represent regions of unchanged code. The user prefers that you are as concise as possible. For example: +// ...existing code... +{ changed code } +// ...existing code... +{ changed code } +// ...existing code... + +Here is an example of how you should format an edit to an existing Person class: +class Person { + // ...existing code... + age: number; + // ...existing code... + getAge() { + return this.age; + } +} +`; + +class EditTool implements IToolData, IToolImpl { + readonly id = 'vscode_editFile'; + readonly tags = ['vscode_editing']; + readonly displayName = localize('chat.tools.editFile', "Edit File"); + readonly modelDescription = `Edit a file in the workspace. Use this tool once per file that needs to be modified, even if there are multiple changes for a file. ${codeInstructions}`; + readonly inputSchema: IJSONSchema; + + constructor( + @IChatService private readonly chatService: IChatService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @ICodeMapperService private readonly codeMapperService: ICodeMapperService + ) { + this.inputSchema = { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'An absolute path to the file to edit', + }, + explanation: { + type: 'string', + description: 'A short explanation of the edit being made. Can be the same as the explanation you showed to the user.', + }, + code: { + type: 'string', + description: 'The code change to apply to the file. ' + codeInstructions + } + }, + required: ['filePath', 'explanation', 'code'] + }; + } + + async invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { + if (!invocation.context) { + throw new Error('toolInvocationToken is required for this tool'); + } + + + const parameters = invocation.parameters as EditToolParams; + if (!parameters.filePath || !parameters.explanation || !parameters.code) { + throw new Error(`Invalid tool input: ${JSON.stringify(parameters)}`); + } + + const model = this.chatService.getSession(invocation.context?.sessionId) as ChatModel; + const request = model.getRequests().at(-1)!; + + const uri = URI.file(parameters.filePath); + model.acceptResponseProgress(request, { + kind: 'markdownContent', + content: new MarkdownString('\n````\n') + }); + model.acceptResponseProgress(request, { + kind: 'codeblockUri', + uri + }); + model.acceptResponseProgress(request, { + kind: 'markdownContent', + content: new MarkdownString(parameters.code + '\n````\n') + }); + + if (this.chatEditingService.currentEditingSession?.chatSessionId !== model.sessionId) { + throw new Error('This tool must be called from within an editing session'); + } + + const result = await this.codeMapperService.mapCode({ + codeBlocks: [{ code: parameters.code, resource: uri, markdownBeforeBlock: parameters.explanation }] + }, { + textEdit: (target, edits) => { + model.acceptResponseProgress(request, { kind: 'textEdit', uri: target, edits }); + } + }, token); + + model.acceptResponseProgress(request, { kind: 'textEdit', uri, edits: [], done: true }); + + if (result?.errorMessage) { + throw new Error(result.errorMessage); + } + + await new Promise((resolve) => { + autorun((r) => { + const currentEditingSession = this.chatEditingService.currentEditingSessionObs.read(r); + const entries = currentEditingSession?.entries.read(r); + const currentFile = entries?.find((e) => e.modifiedURI.toString() === uri.toString()); + if (currentFile && !currentFile.isCurrentlyBeingModified.read(r)) { + resolve(true); + } + }); + }); + + return { + content: [{ kind: 'text', value: 'Success' }] + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/chatAgents.ts b/src/vs/workbench/contrib/chat/common/chatAgents.ts index e8d88c92598..8045fc3d091 100644 --- a/src/vs/workbench/contrib/chat/common/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/chatAgents.ts @@ -70,6 +70,8 @@ export interface IChatAgentData { extensionDisplayName: string; /** The agent invoked when no agent is specified */ isDefault?: boolean; + /** The default agent when "agent-mode" is enabled */ + isToolsAgent?: boolean; /** This agent is not contributed in package.json, but is registered dynamically */ isDynamic?: boolean; metadata: IChatAgentMetadata; @@ -201,9 +203,11 @@ export interface IChatAgentCompletionItem { export interface IChatAgentService { _serviceBrand: undefined; /** - * undefined when an agent was removed IChatAgent + * undefined when an agent was removed */ readonly onDidChangeAgents: Event; + readonly toolsAgentModeEnabled: boolean; + toggleToolsAgentMode(): void; registerAgent(id: string, data: IChatAgentData): IDisposable; registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable; registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable; @@ -235,6 +239,8 @@ export interface IChatAgentService { updateAgent(id: string, updateMetadata: IChatAgentMetadata): void; } +const ChatToolsAgentModeStorageKey = 'chat.toolsAgentMode'; + export class ChatAgentService extends Disposable implements IChatAgentService { public static readonly AGENT_LEADER = '@'; @@ -250,11 +256,14 @@ export class ChatAgentService extends Disposable implements IChatAgentService { private readonly _hasDefaultAgent: IContextKey; private readonly _defaultAgentRegistered: IContextKey; private readonly _editingAgentRegistered: IContextKey; + private readonly _agentModeContextKey: IContextKey; + private readonly _hasToolsAgentContextKey: IContextKey; private _chatParticipantDetectionProviders = new Map(); constructor( @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, ) { super(); this._hasDefaultAgent = ChatContextKeys.enabled.bindTo(this.contextKeyService); @@ -265,6 +274,13 @@ export class ChatAgentService extends Disposable implements IChatAgentService { this._updateContextKeys(); } })); + + this._agentModeContextKey = ChatContextKeys.Editing.agentMode.bindTo(contextKeyService); + this._hasToolsAgentContextKey = ChatContextKeys.Editing.hasToolsAgent.bindTo(contextKeyService); + this._agentModeContextKey.set( + this.storageService.getBoolean(ChatToolsAgentModeStorageKey, StorageScope.WORKSPACE, false)); + this._register( + this.storageService.onWillSaveState(() => this.storageService.store(ChatToolsAgentModeStorageKey, this._agentModeContextKey.get(), StorageScope.WORKSPACE, StorageTarget.USER))); } registerAgent(id: string, data: IChatAgentData): IDisposable { @@ -311,15 +327,23 @@ export class ChatAgentService extends Disposable implements IChatAgentService { private _updateContextKeys(): void { let editingAgentRegistered = false; let defaultAgentRegistered = false; + let toolsAgentRegistered = false; for (const agent of this.getAgents()) { if (agent.isDefault && agent.locations.includes(ChatAgentLocation.EditingSession)) { editingAgentRegistered = true; + if (agent.isToolsAgent) { + toolsAgentRegistered = true; + } } else if (agent.isDefault) { defaultAgentRegistered = true; } } this._editingAgentRegistered.set(editingAgentRegistered); this._defaultAgentRegistered.set(defaultAgentRegistered); + if (toolsAgentRegistered !== this._hasToolsAgentContextKey.get()) { + this._hasToolsAgentContextKey.set(toolsAgentRegistered); + this._onDidChangeAgents.fire(this.getDefaultAgent(ChatAgentLocation.EditingSession)); + } } registerAgentImplementation(id: string, agentImpl: IChatAgentImplementation): IDisposable { @@ -384,7 +408,22 @@ export class ChatAgentService extends Disposable implements IChatAgentService { } getDefaultAgent(location: ChatAgentLocation): IChatAgent | undefined { - return findLast(this.getActivatedAgents(), a => !!a.isDefault && a.locations.includes(location)); + return findLast(this.getActivatedAgents(), a => { + if (location === ChatAgentLocation.EditingSession && this.toolsAgentModeEnabled !== !!a.isToolsAgent) { + return false; + } + + return !!a.isDefault && a.locations.includes(location); + }); + } + + public get toolsAgentModeEnabled(): boolean { + return !!this._hasToolsAgentContextKey.get() && !!this._agentModeContextKey.get(); + } + + toggleToolsAgentMode(): void { + this._agentModeContextKey.set(!this._agentModeContextKey.get()); + this._onDidChangeAgents.fire(this.getDefaultAgent(ChatAgentLocation.EditingSession)); } getContributedDefaultAgent(location: ChatAgentLocation): IChatAgentData | undefined { @@ -404,8 +443,8 @@ export class ChatAgentService extends Disposable implements IChatAgentService { return this._agents.get(id)?.data; } - private _agentIsEnabled(id: string): boolean { - const entry = this._agents.get(id); + private _agentIsEnabled(idOrAgent: string | IChatAgentEntry): boolean { + const entry = typeof idOrAgent === 'string' ? this._agents.get(idOrAgent) : idOrAgent; return !entry?.data.when || this.contextKeyService.contextMatchesRules(ContextKeyExpr.deserialize(entry.data.when)); } @@ -554,6 +593,7 @@ export class MergedChatAgent implements IChatAgent { get extensionPublisherDisplayName() { return this.data.publisherDisplayName; } get extensionDisplayName(): string { return this.data.extensionDisplayName; } get isDefault(): boolean | undefined { return this.data.isDefault; } + get isToolsAgent(): boolean | undefined { return this.data.isToolsAgent; } get metadata(): IChatAgentMetadata { return this.data.metadata; } get slashCommands(): IChatAgentCommand[] { return this.data.slashCommands; } get locations(): ChatAgentLocation[] { return this.data.locations; } diff --git a/src/vs/workbench/contrib/chat/common/chatCodeMapperService.ts b/src/vs/workbench/contrib/chat/common/chatCodeMapperService.ts index 097ca6d8915..809a3771480 100644 --- a/src/vs/workbench/contrib/chat/common/chatCodeMapperService.ts +++ b/src/vs/workbench/contrib/chat/common/chatCodeMapperService.ts @@ -4,18 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { CharCode } from '../../../../base/common/charCode.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../base/common/map.js'; -import { splitLinesIncludeSeparators } from '../../../../base/common/strings.js'; -import { isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; -import { DocumentContextItem, isLocation, TextEdit } from '../../../../editor/common/languages.js'; +import { TextEdit } from '../../../../editor/common/languages.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IChatAgentResult } from './chatAgents.js'; -import { IChatResponseModel } from './chatModel.js'; -import { IChatContentReference } from './chatService.js'; - export interface ICodeMapperResponse { textEdit: (resource: URI, textEdit: TextEdit[]) => void; @@ -27,21 +19,8 @@ export interface ICodeMapperCodeBlock { readonly markdownBeforeBlock?: string; } -export interface ConversationRequest { - readonly type: 'request'; - readonly message: string; -} - -export interface ConversationResponse { - readonly type: 'response'; - readonly message: string; - readonly result?: IChatAgentResult; - readonly references?: DocumentContextItem[]; -} - export interface ICodeMapperRequest { readonly codeBlocks: ICodeMapperCodeBlock[]; - readonly conversation: (ConversationResponse | ConversationRequest)[]; } export interface ICodeMapperResult { @@ -49,6 +28,7 @@ export interface ICodeMapperResult { } export interface ICodeMapperProvider { + readonly displayName: string; mapCode(request: ICodeMapperRequest, response: ICodeMapperResponse, token: CancellationToken): Promise; } @@ -56,15 +36,15 @@ export const ICodeMapperService = createDecorator('codeMappe export interface ICodeMapperService { readonly _serviceBrand: undefined; + readonly providers: ICodeMapperProvider[]; registerCodeMapperProvider(handle: number, provider: ICodeMapperProvider): IDisposable; mapCode(request: ICodeMapperRequest, response: ICodeMapperResponse, token: CancellationToken): Promise; - mapCodeFromResponse(responseModel: IChatResponseModel, response: ICodeMapperResponse, token: CancellationToken): Promise; } export class CodeMapperService implements ICodeMapperService { _serviceBrand: undefined; - private readonly providers: ICodeMapperProvider[] = []; + public readonly providers: ICodeMapperProvider[] = []; registerCodeMapperProvider(handle: number, provider: ICodeMapperProvider): IDisposable { this.providers.push(provider); @@ -81,128 +61,12 @@ export class CodeMapperService implements ICodeMapperService { async mapCode(request: ICodeMapperRequest, response: ICodeMapperResponse, token: CancellationToken) { for (const provider of this.providers) { const result = await provider.mapCode(request, response, token); - if (result) { - return result; + if (token.isCancellationRequested) { + return undefined; } + return result; } return undefined; } - - async mapCodeFromResponse(responseModel: IChatResponseModel, response: ICodeMapperResponse, token: CancellationToken) { - const fenceLanguageRegex = /^`{3,}/; - const codeBlocks: ICodeMapperCodeBlock[] = []; - - const currentBlock = []; - const markdownBeforeBlock = []; - let currentBlockUri = undefined; - - let fence = undefined; // if set, we are in a block - - for (const lineOrUri of iterateLinesOrUris(responseModel)) { - if (isString(lineOrUri)) { - const fenceLanguageIdMatch = lineOrUri.match(fenceLanguageRegex); - if (fenceLanguageIdMatch) { - // we found a line that starts with a fence - if (fence !== undefined && fenceLanguageIdMatch[0] === fence) { - // we are in a code block and the fence matches the opening fence: Close the code block - fence = undefined; - if (currentBlockUri) { - // report the code block if we have a URI - codeBlocks.push({ code: currentBlock.join(''), resource: currentBlockUri, markdownBeforeBlock: markdownBeforeBlock.join('') }); - currentBlock.length = 0; - markdownBeforeBlock.length = 0; - currentBlockUri = undefined; - } - } else { - // we are not in a code block. Open the block - fence = fenceLanguageIdMatch[0]; - } - } else { - if (fence !== undefined) { - currentBlock.push(lineOrUri); - } else { - markdownBeforeBlock.push(lineOrUri); - } - } - } else { - currentBlockUri = lineOrUri; - } - } - const conversation: (ConversationRequest | ConversationResponse)[] = []; - for (const request of responseModel.session.getRequests()) { - const response = request.response; - if (!response || response === responseModel) { - break; - } - conversation.push({ - type: 'request', - message: request.message.text - }); - conversation.push({ - type: 'response', - message: response.response.getMarkdown(), - result: response.result, - references: getReferencesAsDocumentContext(response.contentReferences) - }); - } - return this.mapCode({ codeBlocks, conversation }, response, token); - } } -function iterateLinesOrUris(responseModel: IChatResponseModel): Iterable { - return { - *[Symbol.iterator](): Iterator { - let lastIncompleteLine = undefined; - for (const part of responseModel.response.value) { - if (part.kind === 'markdownContent' || part.kind === 'markdownVuln') { - const lines = splitLinesIncludeSeparators(part.content.value); - if (lines.length > 0) { - if (lastIncompleteLine !== undefined) { - lines[0] = lastIncompleteLine + lines[0]; // merge the last incomplete line with the first markdown line - } - lastIncompleteLine = isLineIncomplete(lines[lines.length - 1]) ? lines.pop() : undefined; - for (const line of lines) { - yield line; - } - } - } else if (part.kind === 'codeblockUri') { - yield part.uri; - } - } - if (lastIncompleteLine !== undefined) { - yield lastIncompleteLine; - } - } - }; -} - -function isLineIncomplete(line: string) { - const lastChar = line.charCodeAt(line.length - 1); - return lastChar !== CharCode.LineFeed && lastChar !== CharCode.CarriageReturn; -} - - -export function getReferencesAsDocumentContext(res: readonly IChatContentReference[]): DocumentContextItem[] { - const map = new ResourceMap(); - for (const r of res) { - let uri; - let range; - if (URI.isUri(r.reference)) { - uri = r.reference; - } else if (isLocation(r.reference)) { - uri = r.reference.uri; - range = r.reference.range; - } - if (uri) { - const item = map.get(uri); - if (item) { - if (range) { - item.ranges.push(range); - } - } else { - map.set(uri, { uri, version: -1, ranges: range ? [range] : [] }); - } - } - } - return [...map.values()]; -} diff --git a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts index be03126dd2d..f7553fe6654 100644 --- a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts @@ -28,6 +28,8 @@ export namespace ChatContextKeys { export const inputHasFocus = new RawContextKey('chatInputHasFocus', false, { type: 'boolean', description: localize('interactiveInputHasFocus', "True when the chat input has focus.") }); export const inChatInput = new RawContextKey('inChatInput', false, { type: 'boolean', description: localize('inInteractiveInput', "True when focus is in the chat input, false otherwise.") }); export const inChatSession = new RawContextKey('inChat', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") }); + export const inChatEditWorkingSet = new RawContextKey('inChatEditWorkingSet', false, { type: 'boolean', description: localize('inChatEditWorkingSet', "True when focus is in the chat edit working set, false otherwise.") }); + export const instructionsAttached = new RawContextKey('chatInstructionsAttached', false, { type: 'boolean', description: localize('chatInstructionsAttachedContextDescription', "True when the chat has a prompt instructions attached.") }); export const supported = ContextKeyExpr.or(IsWebContext.toNegated(), RemoteNameContext.notEqualsTo('')); // supported on desktop and in web only with a remote connection export const enabled = new RawContextKey('chatIsEnabled', false, { type: 'boolean', description: localize('chatIsEnabled', "True when chat is enabled because a default chat participant is activated with an implementation.") }); @@ -76,4 +78,9 @@ export namespace ChatContextKeys { export const chatQuotaExceeded = new RawContextKey('chatQuotaExceeded', false, true); export const completionsQuotaExceeded = new RawContextKey('completionsQuotaExceeded', false, true); + + export const Editing = { + hasToolsAgent: new RawContextKey('chatHasToolsAgent', false, { type: 'boolean', description: localize('chatEditingHasToolsAgent', "True when a tools agent is registered.") }), + agentMode: new RawContextKey('chatAgentMode', false, { type: 'boolean', description: localize('chatEditingAgentMode', "True when edits is in agent mode.") }), + }; } diff --git a/src/vs/workbench/contrib/chat/common/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/chatEditingService.ts index ed970c30625..ec99832ce0c 100644 --- a/src/vs/workbench/contrib/chat/common/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/chatEditingService.ts @@ -10,6 +10,7 @@ import { ResourceMap } from '../../../../base/common/map.js'; import { IObservable, IReader, ITransaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js'; +import { DetailedLineRangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; import { TextEdit } from '../../../../editor/common/languages.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { localize } from '../../../../nls.js'; @@ -61,7 +62,11 @@ export interface IChatRelatedFilesProvider { provideRelatedFiles(chatRequest: IChatRequestDraft, token: CancellationToken): Promise; } -export interface WorkingSetDisplayMetadata { state: WorkingSetEntryState; description?: string } +export interface WorkingSetDisplayMetadata { + state: WorkingSetEntryState; + description?: string; + isMarkedReadonly?: boolean; +} export interface IChatEditingSession { readonly chatSessionId: string; @@ -74,6 +79,7 @@ export interface IChatEditingSession { addFileToWorkingSet(uri: URI, description?: string, kind?: WorkingSetEntryState.Transient | WorkingSetEntryState.Suggested): void; show(): Promise; remove(reason: WorkingSetEntryRemovalReason, ...uris: URI[]): void; + markIsReadonly(uri: URI, isReadonly?: boolean): void; accept(...uris: URI[]): Promise; reject(...uris: URI[]): Promise; getEntry(uri: URI): IModifiedFileEntry | undefined; @@ -120,6 +126,8 @@ export interface IModifiedFileEntry { readonly rewriteRatio: IObservable; readonly maxLineNumber: IObservable; readonly diffInfo: IObservable; + acceptHunk(change: DetailedLineRangeMapping): Promise; + rejectHunk(change: DetailedLineRangeMapping): Promise; readonly lastModifyingRequestId: string; accept(transaction: ITransaction | undefined): Promise; reject(transaction: ITransaction | undefined): Promise; @@ -139,6 +147,8 @@ export const enum ChatEditingSessionState { export const CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME = 'chat-editing-multi-diff-source'; export const chatEditingWidgetFileStateContextKey = new RawContextKey('chatEditingWidgetFileState', undefined, localize('chatEditingWidgetFileState', "The current state of the file in the chat editing widget")); +export const chatEditingWidgetFileReadonlyContextKey = new RawContextKey('chatEditingWidgetFileReadonly', undefined, localize('chatEditingWidgetFileReadonly', "Whether the file has been marked as read-only in the chat editing widget")); +export const chatEditingAgentSupportsReadonlyReferencesContextKey = new RawContextKey('chatEditingAgentSupportsReadonlyReferences', undefined, localize('chatEditingAgentSupportsReadonlyReferences', "Whether the chat editing agent supports readonly references (temporary)")); export const decidedChatEditingResourceContextKey = new RawContextKey('decidedChatEditingResource', []); export const chatEditingResourceContextKey = new RawContextKey('chatEditingResource', undefined); export const inChatEditingSessionContextKey = new RawContextKey('inChatEditingSession', undefined); diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 14fa03dffa0..d79d8c7cd90 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -9,6 +9,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { revive } from '../../../../base/common/marshalling.js'; +import { Schemas } from '../../../../base/common/network.js'; import { equals } from '../../../../base/common/objects.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -29,6 +30,7 @@ export interface IBaseChatRequestVariableEntry { fullName?: string; icon?: ThemeIcon; name: string; + isMarkedReadonly?: boolean; modelDescription?: string; range?: IOffsetRange; value: IChatRequestVariableValue; @@ -84,7 +86,13 @@ export interface ICommandResultVariableEntry extends Omit { + readonly kind: 'link'; + readonly isDynamic: true; + readonly value: URI; +} + +export type IChatRequestVariableEntry = IChatRequestImplicitVariableEntry | IChatRequestPasteVariableEntry | ISymbolVariableEntry | ICommandResultVariableEntry | ILinkVariableEntry | IBaseChatRequestVariableEntry; export function isImplicitVariableEntry(obj: IChatRequestVariableEntry): obj is IChatRequestImplicitVariableEntry { return obj.kind === 'implicit'; @@ -94,6 +102,10 @@ export function isPasteVariableEntry(obj: IChatRequestVariableEntry): obj is ICh return obj.kind === 'paste'; } +export function isLinkVariableEntry(obj: IChatRequestVariableEntry): obj is ILinkVariableEntry { + return obj.kind === 'link'; +} + export function isChatRequestVariableEntry(obj: unknown): obj is IChatRequestVariableEntry { const entry = obj as IChatRequestVariableEntry; return typeof entry === 'object' && @@ -121,7 +133,7 @@ export interface IChatRequestModel { readonly workingSet?: URI[]; readonly isCompleteAddedRequest: boolean; readonly response?: IChatResponseModel; - isHidden: boolean; + shouldBeRemovedOnSend: boolean; } export interface IChatTextEditGroupState { @@ -196,7 +208,7 @@ export interface IChatResponseModel { readonly response: IResponse; readonly isComplete: boolean; readonly isCanceled: boolean; - readonly isHidden: boolean; + readonly shouldBeRemovedOnSend: boolean; readonly isCompleteAddedRequest: boolean; /** A stale response is one that has been persisted and rehydrated, so e.g. Commands that have their arguments stored in the EH are gone. */ readonly isStale: boolean; @@ -219,7 +231,7 @@ export class ChatRequestModel implements IChatRequestModel { return this._session; } - public isHidden: boolean = false; + public shouldBeRemovedOnSend: boolean = false; public get username(): string { return this.session.requesterUsername; @@ -341,7 +353,9 @@ export class Response extends Disposable implements IResponse { // The last part can't be merged with- not markdown, or markdown with different permissions this._responseParts.push(progress); } else { - lastResponsePart.content = appendMarkdownString(lastResponsePart.content, progress.content); + // Don't modify the current object, since it's being diffed by the renderer + const idx = this._responseParts.indexOf(lastResponsePart); + this._responseParts[idx] = { ...lastResponsePart, content: appendMarkdownString(lastResponsePart.content, progress.content) }; } this._updateRepr(quiet); } else if (progress.kind === 'textEdit') { @@ -397,38 +411,12 @@ export class Response extends Disposable implements IResponse { } private _updateRepr(quiet?: boolean) { - const inlineRefToRepr = (part: IChatContentInlineReference) => - 'uri' in part.inlineReference - ? basename(part.inlineReference.uri) - : 'name' in part.inlineReference - ? part.inlineReference.name - : basename(part.inlineReference); - - this._responseRepr = this._responseParts.map(part => { - if (part.kind === 'treeData') { - return ''; - } else if (part.kind === 'inlineReference') { - return inlineRefToRepr(part); - } else if (part.kind === 'command') { - return part.command.title; - } else if (part.kind === 'textEditGroup') { - return localize('editsSummary', "Made changes."); - } else if (part.kind === 'progressMessage' || part.kind === 'codeblockUri' || part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') { - return ''; - } else if (part.kind === 'confirmation') { - return `${part.title}\n${part.message}`; - } else { - return part.content.value; - } - }) - .filter(s => s.length > 0) - .join('\n\n'); - + this._responseRepr = this.partsToRepr(this._responseParts); this._responseRepr += this._citations.length ? '\n\n' + getCodeCitationsMessage(this._citations) : ''; this._markdownContent = this._responseParts.map(part => { if (part.kind === 'inlineReference') { - return inlineRefToRepr(part); + return this.inlineRefToRepr(part); } else if (part.kind === 'markdownContent' || part.kind === 'markdownVuln') { return part.content.value; } else { @@ -442,6 +430,73 @@ export class Response extends Disposable implements IResponse { this._onDidChangeValue.fire(); } } + + private partsToRepr(parts: readonly IChatProgressResponseContent[]): string { + const blocks: string[] = []; + let currentBlockSegments: string[] = []; + + for (const part of parts) { + let segment: { text: string; isBlock?: boolean } | undefined; + switch (part.kind) { + case 'treeData': + case 'progressMessage': + case 'codeblockUri': + case 'toolInvocation': + case 'toolInvocationSerialized': + // Ignore + continue; + case 'inlineReference': + segment = { text: this.inlineRefToRepr(part) }; + break; + case 'command': + segment = { text: part.command.title, isBlock: true }; + break; + case 'textEditGroup': + segment = { text: localize('editsSummary', "Made changes."), isBlock: true }; + break; + case 'confirmation': + segment = { text: `${part.title}\n${part.message}`, isBlock: true }; + break; + default: + segment = { text: part.content.value }; + break; + } + + if (segment.isBlock) { + if (currentBlockSegments.length) { + blocks.push(currentBlockSegments.join('')); + currentBlockSegments = []; + } + blocks.push(segment.text); + } else { + currentBlockSegments.push(segment.text); + } + } + + if (currentBlockSegments.length) { + blocks.push(currentBlockSegments.join('')); + } + + return blocks.join('\n\n'); + } + + private inlineRefToRepr(part: IChatContentInlineReference) { + if ('uri' in part.inlineReference) { + return this.uriToRepr(part.inlineReference.uri); + } + + return 'name' in part.inlineReference + ? '`' + part.inlineReference.name + '`' + : this.uriToRepr(part.inlineReference); + } + + private uriToRepr(uri: URI): string { + if (uri.scheme === Schemas.http || uri.scheme === Schemas.https) { + return uri.toString(false); + } + + return basename(uri); + } } export class ChatResponseModel extends Disposable implements IChatResponseModel { @@ -454,16 +509,16 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return this._session; } - public get isHidden() { - return this._isHidden; + public get shouldBeRemovedOnSend() { + return this._shouldBeRemovedOnSend; } public get isComplete(): boolean { return this._isComplete; } - public set isHidden(hidden: boolean) { - this._isHidden = hidden; + public set shouldBeRemovedOnSend(hidden: boolean) { + this._shouldBeRemovedOnSend = hidden; this._onDidChange.fire(); } @@ -553,7 +608,7 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel private _result?: IChatAgentResult, followups?: ReadonlyArray, public readonly isCompleteAddedRequest = false, - private _isHidden: boolean = false, + private _shouldBeRemovedOnSend: boolean = false, restoredId?: string ) { super(); @@ -681,6 +736,8 @@ export interface ISerializableChatRequestData { /** Is really like "prompt data". This is the message in the format in which the agent gets it + variable values. */ variableData: IChatRequestVariableData; response: ReadonlyArray | undefined; + + /**Old, persisted name for shouldBeRemovedOnSend */ isHidden: boolean; responseId?: string; agent?: ISerializableChatAgentData; @@ -999,12 +1056,17 @@ export class ChatModel extends Disposable implements IChatModel { ) { super(); - this._isImported = (!!initialData && !isSerializableSessionData(initialData)) || (initialData?.isImported ?? false); - this._sessionId = (isSerializableSessionData(initialData) && initialData.sessionId) || generateUuid(); + const isValid = isSerializableSessionData(initialData); + if (initialData && !isValid) { + this.logService.warn(`ChatModel#constructor: Loaded malformed session data: ${JSON.stringify(initialData)}`); + } + + this._isImported = (!!initialData && !isValid) || (initialData?.isImported ?? false); + this._sessionId = (isValid && initialData.sessionId) || generateUuid(); this._requests = initialData ? this._deserialize(initialData) : []; - this._creationDate = (isSerializableSessionData(initialData) && initialData.creationDate) || Date.now(); - this._lastMessageDate = (isSerializableSessionData(initialData) && initialData.lastMessageDate) || this._creationDate; - this._customTitle = isSerializableSessionData(initialData) ? initialData.customTitle : undefined; + this._creationDate = (isValid && initialData.creationDate) || Date.now(); + this._lastMessageDate = (isValid && initialData.lastMessageDate) || this._creationDate; + this._customTitle = isValid ? initialData.customTitle : undefined; this._initialRequesterAvatarIconUri = initialData?.requesterAvatarIconUri && URI.revive(initialData.requesterAvatarIconUri); this._initialResponderAvatarIconUri = isUriComponents(initialData?.responderAvatarIconUri) ? URI.revive(initialData.responderAvatarIconUri) : initialData?.responderAvatarIconUri; @@ -1027,7 +1089,7 @@ export class ChatModel extends Disposable implements IChatModel { // Old messages don't have variableData, or have it in the wrong (non-array) shape const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData); const request = new ChatRequestModel(this, parsedRequest, variableData, raw.timestamp ?? -1, undefined, undefined, undefined, undefined, raw.workingSet?.map((uri) => URI.revive(uri)), undefined, raw.requestId); - request.isHidden = !!raw.isHidden; + request.shouldBeRemovedOnSend = !!raw.isHidden; if (raw.response || raw.result || (raw as any).responseErrorDetails) { const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format reviveSerializedAgent(raw.agent) : undefined; @@ -1037,7 +1099,7 @@ export class ChatModel extends Disposable implements IChatModel { // eslint-disable-next-line local/code-no-dangerous-type-assertions { errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result; request.response = new ChatResponseModel(raw.response ?? [new MarkdownString(raw.response)], this, agent, raw.slashCommand, request.id, true, raw.isCanceled, raw.vote, raw.voteDownReason, result, raw.followups, undefined, undefined, raw.responseId); - request.response.isHidden = !!raw.isHidden; + request.response.shouldBeRemovedOnSend = !!raw.isHidden; if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway? request.response.applyReference(revive(raw.usedContext)); } @@ -1136,11 +1198,14 @@ export class ChatModel extends Disposable implements IChatModel { } disableRequests(requestIds: ReadonlyArray) { + + const toHide = new Set(requestIds); + this._requests.forEach((request) => { - const isHidden = requestIds.includes(request.id); - request.isHidden = isHidden; + const shouldBeRemovedOnSend = toHide.has(request.id); + request.shouldBeRemovedOnSend = shouldBeRemovedOnSend; if (request.response) { - request.response.isHidden = isHidden; + request.response.shouldBeRemovedOnSend = shouldBeRemovedOnSend; } }); @@ -1307,7 +1372,7 @@ export class ChatModel extends Disposable implements IChatModel { }) : undefined, responseId: r.response?.id, - isHidden: r.isHidden, + isHidden: r.shouldBeRemovedOnSend, result: r.response?.result, followups: r.response?.followups, isCanceled: r.response?.isCanceled, diff --git a/src/vs/workbench/contrib/chat/common/chatParticipantContribTypes.ts b/src/vs/workbench/contrib/chat/common/chatParticipantContribTypes.ts index 211d7e7e9c9..401211b3c4f 100644 --- a/src/vs/workbench/contrib/chat/common/chatParticipantContribTypes.ts +++ b/src/vs/workbench/contrib/chat/common/chatParticipantContribTypes.ts @@ -22,6 +22,7 @@ export interface IRawChatParticipantContribution { when?: string; description?: string; isDefault?: boolean; + isAgent?: boolean; isSticky?: boolean; sampleRequest?: string; commands?: IRawChatCommandContribution[]; diff --git a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts index 7632ee2f0b6..76e572c91df 100644 --- a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts @@ -48,10 +48,6 @@ export class ChatToolInvocation implements IChatToolInvocation { this._confirmDeferred.p.then(confirmed => { this._isConfirmed = confirmed; this._confirmationMessages = undefined; - if (!confirmed) { - // Spinner -> check - this._isCompleteDeferred.complete(); - } }); this._isCompleteDeferred.p.then(() => { diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index f56ac22858a..20089cb5315 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -431,6 +431,11 @@ export interface IChatSendRequestOptions { * The label of the confirmation action that was selected. */ confirmation?: string; + + /** + * Flag to indicate whether a prompt instructions attachment is present. + */ + hasInstructionAttachments?: boolean; } export const IChatService = createDecorator('IChatService'); diff --git a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts index d5a8839dd11..6e2741852cf 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -33,6 +33,7 @@ import { ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { IChatSlashCommandService } from './chatSlashCommands.js'; import { IChatVariablesService } from './chatVariables.js'; import { ChatMessageRole, IChatMessage } from './languageModels.js'; +import { ILanguageModelToolsService } from './languageModelToolsService.js'; const serializedChatKey = 'interactive.sessions'; @@ -86,7 +87,8 @@ const maxPersistedSessions = 25; class CancellableRequest implements IDisposable { constructor( public readonly cancellationTokenSource: CancellationTokenSource, - public requestId?: string | undefined + public requestId: string | undefined, + @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService ) { } dispose() { @@ -94,6 +96,10 @@ class CancellableRequest implements IDisposable { } cancel() { + if (this.requestId) { + this.toolsService.cancelToolCallsForRequest(this.requestId); + } + this.cancellationTokenSource.cancel(); } } @@ -469,13 +475,21 @@ export class ChatService extends Disposable implements IChatService { locationData: request.locationData, attachedContext: request.attachedContext, workingSet: request.workingSet, + hasInstructionAttachments: options?.hasInstructionAttachments ?? false, }; await this._sendRequestAsync(model, model.sessionId, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions).responseCompletePromise; } async sendRequest(sessionId: string, request: string, options?: IChatSendRequestOptions): Promise { this.trace('sendRequest', `sessionId: ${sessionId}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); - if (!request.trim() && !options?.slashCommand && !options?.agentId) { + + // if text is not provided, but chat input has `prompt instructions` + // attached, use the default prompt text to avoid empty messages + if (!request.trim() && options?.hasInstructionAttachments) { + request = 'Follow these instructions.'; + } + + if (!request.trim() && !options?.slashCommand && !options?.agentId && !options?.hasInstructionAttachments) { this.trace('sendRequest', 'Rejected empty message'); return; } @@ -492,6 +506,14 @@ export class ChatService extends Disposable implements IChatService { return; } + const requests = model.getRequests(); + for (let i = requests.length - 1; i >= 0; i -= 1) { + const request = requests[i]; + if (request.shouldBeRemovedOnSend) { + this.removeRequest(sessionId, request.id); + } + } + const location = options?.location ?? model.initialLocation; const attempt = options?.attempt ?? 0; const defaultAgent = this.chatAgentService.getDefaultAgent(location)!; @@ -778,7 +800,8 @@ export class ChatService extends Disposable implements IChatService { } }; const rawResponsePromise = sendRequestInternal(); - this._pendingRequests.set(model.sessionId, new CancellableRequest(source)); + // Note- requestId is not known at this point, assigned later + this._pendingRequests.set(model.sessionId, this.instantiationService.createInstance(CancellableRequest, source, undefined)); rawResponsePromise.finally(() => { this._pendingRequests.deleteAndDispose(model.sessionId); }); diff --git a/src/vs/workbench/contrib/chat/common/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/chatViewModel.ts index b7cd3f36141..5345af9ac65 100644 --- a/src/vs/workbench/contrib/chat/common/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatViewModel.ts @@ -5,7 +5,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, dispose } from '../../../../base/common/lifecycle.js'; import * as marked from '../../../../base/common/marked/marked.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -74,7 +74,7 @@ export interface IChatRequestViewModel { readonly contentReferences?: ReadonlyArray; readonly workingSet?: ReadonlyArray; readonly confirmation?: string; - readonly isHidden: boolean; + readonly shouldBeRemovedOnSend: boolean; readonly isComplete: boolean; readonly isCompleteAddedRequest: boolean; readonly slashCommand: IChatAgentCommand | undefined; @@ -147,7 +147,7 @@ export interface IChatCodeCitations { export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations; export interface IChatLiveUpdateData { - firstWordTime: number; + totalTime: number; lastUpdateTime: number; impliedWordLoadRate: number; lastWordCount: number; @@ -180,7 +180,7 @@ export interface IChatResponseViewModel { readonly errorDetails?: IChatResponseErrorDetails; readonly result?: IChatAgentResult; readonly contentUpdateTimings?: IChatLiveUpdateData; - readonly isHidden: boolean; + readonly shouldBeRemovedOnSend: boolean; readonly isCompleteAddedRequest: boolean; renderData?: IChatResponseRenderData; currentRenderedHeight: number | undefined; @@ -299,14 +299,12 @@ export class ChatViewModel extends Disposable implements IChatViewModel { } getItems(): (IChatRequestViewModel | IChatResponseViewModel)[] { - return [...this._items].filter((item) => !item.isHidden); + return this._items.filter((item) => !item.shouldBeRemovedOnSend); } override dispose() { super.dispose(); - this._items - .filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel) - .forEach((item: ChatResponseViewModel) => item.dispose()); + dispose(this._items.filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel)); } updateCodeBlockTextModels(model: IChatRequestViewModel | IChatResponseViewModel) { @@ -385,8 +383,8 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._model.isCompleteAddedRequest; } - get isHidden() { - return this._model.isHidden; + get shouldBeRemovedOnSend() { + return this._model.shouldBeRemovedOnSend; } get slashCommand(): IChatAgentCommand | undefined { @@ -486,8 +484,8 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi return this._model.isCanceled; } - get isHidden() { - return this._model.isHidden; + get shouldBeRemovedOnSend() { + return this._model.shouldBeRemovedOnSend; } get isCompleteAddedRequest() { @@ -566,10 +564,10 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi if (!_model.isComplete) { this._contentUpdateTimings = { - firstWordTime: 0, + totalTime: 0, lastUpdateTime: Date.now(), impliedWordLoadRate: 0, - lastWordCount: 0 + lastWordCount: 0, }; } @@ -579,12 +577,14 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi const now = Date.now(); const wordCount = countWords(_model.response.getMarkdown()); - // Apply a min time difference, or the rate is typically too high for first few words - const timeDiff = Math.max(now - this._contentUpdateTimings.firstWordTime, 250); - const impliedWordLoadRate = this._contentUpdateTimings.lastWordCount / (timeDiff / 1000); - this.trace('onDidChange', `Update- got ${this._contentUpdateTimings.lastWordCount} words over last ${timeDiff}ms = ${impliedWordLoadRate} words/s. ${wordCount} words are now available.`); + const timeDiff = Math.min(now - this._contentUpdateTimings.lastUpdateTime, 1000); + const newTotalTime = Math.max(this._contentUpdateTimings.totalTime + timeDiff, 250); + const impliedWordLoadRate = this._contentUpdateTimings.lastWordCount / (newTotalTime / 1000); + this.trace('onDidChange', `Update- got ${this._contentUpdateTimings.lastWordCount} words over last ${newTotalTime}ms = ${impliedWordLoadRate} words/s. ${wordCount} words are now available.`); this._contentUpdateTimings = { - firstWordTime: this._contentUpdateTimings.firstWordTime === 0 && this.response.value.some(v => v.kind === 'markdownContent') ? now : this._contentUpdateTimings.firstWordTime, + totalTime: this._contentUpdateTimings.totalTime !== 0 || this.response.value.some(v => v.kind === 'markdownContent') ? + newTotalTime : + this._contentUpdateTimings.totalTime, lastUpdateTime: now, impliedWordLoadRate, lastWordCount: wordCount diff --git a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts index f2251ca8166..f69be4fed5c 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -86,4 +86,5 @@ export interface ILanguageModelToolsService { getTool(id: string): IToolData | undefined; getToolByName(name: string): IToolData | undefined; invokeTool(invocation: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise; + cancelToolCallsForRequest(requestId: string): void; } diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index b3310ea5332..66fee6ef6c2 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -101,7 +101,7 @@ export interface ILanguageModelChat { export interface ILanguageModelChatSelector { readonly name?: string; - readonly identifier?: string; + readonly id?: string; readonly vendor?: string; readonly version?: string; readonly family?: string; @@ -264,7 +264,7 @@ export class LanguageModelsService implements ILanguageModelsService { if ((selector.vendor === undefined || model.metadata.vendor === selector.vendor) && (selector.family === undefined || model.metadata.family === selector.family) && (selector.version === undefined || model.metadata.version === selector.version) - && (selector.identifier === undefined || model.metadata.id === selector.identifier) + && (selector.id === undefined || model.metadata.id === selector.id) && (!model.metadata.targetExtensions || model.metadata.targetExtensions.some(candidate => ExtensionIdentifier.equals(candidate, selector.extension))) ) { result.push(identifier); diff --git a/src/vs/workbench/contrib/chat/common/promptFileReference.ts b/src/vs/workbench/contrib/chat/common/promptFileReference.ts index 878c800d785..e31f7da5722 100644 --- a/src/vs/workbench/contrib/chat/common/promptFileReference.ts +++ b/src/vs/workbench/contrib/chat/common/promptFileReference.ts @@ -8,6 +8,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { extUri } from '../../../../base/common/resources.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Location } from '../../../../editor/common/languages.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { ChatPromptCodec } from './codecs/chatPromptCodec/chatPromptCodec.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { FileOpenFailed, NonPromptSnippetFile, RecursiveReference } from './promptFileReferenceErrors.js'; @@ -109,6 +110,7 @@ export class PromptFileReference extends Disposable { constructor( private readonly _uri: URI | Location, + @ILogService private readonly logService: ILogService, @IFileService private readonly fileService: IFileService, @IConfigurationService private readonly configService: IConfigurationService, ) { @@ -116,18 +118,17 @@ export class PromptFileReference extends Disposable { this.onFilesChanged = this.onFilesChanged.bind(this); // make sure the variable is updated on file changes - // but only for the prompt snippet files - if (this.isPromptSnippetFile) { - this.addFilesystemListeners(); - } + this.addFilesystemListeners(); } /** * Subscribe to the `onUpdate` event. * @param callback */ - public onUpdate(callback: () => unknown) { + public onUpdate(callback: () => unknown): this { this._register(this._onUpdate.event(callback)); + + return this; } /** @@ -199,10 +200,32 @@ export class PromptFileReference extends Disposable { private onFilesChanged(event: FileChangesEvent) { const fileChanged = event.contains(this.uri, FileChangeType.UPDATED); const fileDeleted = event.contains(this.uri, FileChangeType.DELETED); - if (!fileChanged && !fileDeleted) { + const fileAdded = event.contains(this.uri, FileChangeType.ADDED); + + // if the change does not relate to the current file, nothing to do + if (!fileChanged && !fileDeleted && !fileAdded) { return; } + // handle file changes only for prompt snippet files but in the case a file was + // deleted, it does not matter if it was a prompt - we still need to handle it by + // calling the `resolve()` method, which will set an error condition if the file + // does not exist anymore, or of it is not a prompt snippet file + if (fileChanged && !this.isPromptSnippetFile) { + return; + } + + // if we receive an `add` event, validate that the file was previously deleted, because + // that is the only way we could have end up in this state of the file reference object + if (fileAdded && (!this._errorCondition || !(this._errorCondition instanceof FileOpenFailed))) { + this.logService.warn( + [ + `Received 'add' event for file at '${this.uri.path}', but it was not previously deleted.`, + 'This most likely indicates a bug in our logic, so please report it.', + ].join(' '), + ); + } + // if file is changed or deleted, re-resolve the file reference // in the case when the file is deleted, this should result in // failure to open the file, so the `errorCondition` field will @@ -307,6 +330,7 @@ export class PromptFileReference extends Disposable { const child = new PromptFileReference( childUri, + this.logService, this.fileService, this.configService, ); diff --git a/src/vs/workbench/contrib/chat/test/browser/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/languageModelToolsService.test.ts index 9679c7a8908..fb866bbfade 100644 --- a/src/vs/workbench/contrib/chat/test/browser/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/languageModelToolsService.test.ts @@ -6,24 +6,32 @@ import * as assert from 'assert'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { ContextKeyEqualsExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { LanguageModelToolsService } from '../../browser/languageModelToolsService.js'; +import { IChatModel } from '../../common/chatModel.js'; +import { IChatService } from '../../common/chatService.js'; import { IToolData, IToolImpl, IToolInvocation } from '../../common/languageModelToolsService.js'; -import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; -import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { MockChatService } from '../common/mockChatService.js'; +import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; +import { Barrier } from '../../../../../base/common/async.js'; suite('LanguageModelToolsService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let contextKeyService: IContextKeyService; let service: LanguageModelToolsService; + let chatService: MockChatService; setup(() => { const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(new TestConfigurationService)) + contextKeyService: () => store.add(new ContextKeyService(new TestConfigurationService)), }, store); contextKeyService = instaService.get(IContextKeyService); + chatService = new MockChatService(); + instaService.stub(IChatService, chatService); service = store.add(instaService.createInstance(LanguageModelToolsService)); }); @@ -122,4 +130,61 @@ suite('LanguageModelToolsService', () => { const result = await service.invokeTool(dto, async () => 0, CancellationToken.None); assert.strictEqual(result.content[0].value, 'result'); }); + + test('cancel tool call', async () => { + const toolData: IToolData = { + id: 'testTool', + modelDescription: 'Test Tool', + displayName: 'Test Tool' + }; + + store.add(service.registerToolData(toolData)); + + const toolBarrier = new Barrier(); + const toolImpl: IToolImpl = { + invoke: async (invocation, countTokens, cancelToken) => { + assert.strictEqual(invocation.callId, '1'); + assert.strictEqual(invocation.toolId, 'testTool'); + assert.deepStrictEqual(invocation.parameters, { a: 1 }); + await toolBarrier.wait(); + if (cancelToken.isCancellationRequested) { + throw new CancellationError(); + } else { + throw new Error('Tool call should be cancelled'); + } + } + }; + + store.add(service.registerToolImplementation('testTool', toolImpl)); + + const sessionId = 'sessionId'; + const requestId = 'requestId'; + const dto: IToolInvocation = { + callId: '1', + toolId: 'testTool', + tokenBudget: 100, + parameters: { + a: 1 + }, + context: { + sessionId + }, + }; + chatService.addSession({ + sessionId: sessionId, + getRequests: () => { + return [{ + id: requestId + }]; + }, + acceptResponseProgress: () => { } + } as any as IChatModel); + + const toolPromise = service.invokeTool(dto, async () => 0, CancellationToken.None); + service.cancelToolCallsForRequest(requestId); + toolBarrier.open(); + await assert.rejects(toolPromise, err => { + return isCancellationError(err); + }, 'Expected tool call to be cancelled'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Response_inline_reference.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Response_inline_reference.0.snap index b26d84334a0..3a54719571d 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Response_inline_reference.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Response_inline_reference.0.snap @@ -1,7 +1,7 @@ [ { content: { - value: "text before", + value: "text before ", isTrusted: false, supportThemeIcons: false, supportHtml: false @@ -14,7 +14,7 @@ }, { content: { - value: "text after", + value: " text after", isTrusted: false, supportThemeIcons: false, supportHtml: false diff --git a/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts b/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts index cec803118e1..580fa44ce45 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 { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; const testAgentId = 'testAgent'; const testAgentData: IChatAgentData = { @@ -41,7 +42,7 @@ suite('ChatAgents', function () { let contextKeyService: TestingContextKeyService; setup(() => { contextKeyService = new TestingContextKeyService(); - chatAgentService = store.add(new ChatAgentService(contextKeyService)); + chatAgentService = store.add(new ChatAgentService(contextKeyService, store.add(new TestStorageService()))); }); test('registerAgent', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts index be40ba599bd..78a712f1e45 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts @@ -163,8 +163,8 @@ suite('ChatModel', () => { assert.strictEqual(request1.isCompleteAddedRequest, true); assert.strictEqual(request1.response!.isCompleteAddedRequest, true); - assert.strictEqual(request1.isHidden, false); - assert.strictEqual(request1.response!.isHidden, false); + assert.strictEqual(request1.shouldBeRemovedOnSend, false); + assert.strictEqual(request1.response!.shouldBeRemovedOnSend, false); }); }); @@ -191,10 +191,13 @@ suite('Response', () => { test('inline reference', async () => { const response = store.add(new Response([])); - response.updateContent({ content: new MarkdownString('text before'), kind: 'markdownContent' }); - response.updateContent({ inlineReference: URI.parse('https://microsoft.com'), kind: 'inlineReference' }); - response.updateContent({ content: new MarkdownString('text after'), kind: 'markdownContent' }); + response.updateContent({ content: new MarkdownString('text before '), kind: 'markdownContent' }); + response.updateContent({ inlineReference: URI.parse('https://microsoft.com/'), kind: 'inlineReference' }); + response.updateContent({ content: new MarkdownString(' text after'), kind: 'markdownContent' }); await assertSnapshot(response.value); + + assert.strictEqual(response.toString(), 'text before https://microsoft.com/ text after'); + }); }); 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 bc148fcd693..06560d694d6 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -144,7 +144,7 @@ suite('LanguageModels', function () { } })); - const models = await languageModels.selectLanguageModels({ identifier: 'actual-lm' }); + const models = await languageModels.selectLanguageModels({ id: 'actual-lm' }); assert.ok(models.length === 1); const first = models[0]; diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts index ad4262a7dbd..31fbb654b63 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts @@ -15,6 +15,8 @@ export class MockChatService implements IChatService { _serviceBrand: undefined; transferredSessionData: IChatTransferredSessionData | undefined; + private sessions = new Map(); + isEnabled(location: ChatAgentLocation): boolean { throw new Error('Method not implemented.'); } @@ -27,9 +29,12 @@ export class MockChatService implements IChatService { startSession(location: ChatAgentLocation, token: CancellationToken): ChatModel | undefined { throw new Error('Method not implemented.'); } + addSession(session: IChatModel): void { + this.sessions.set(session.sessionId, session); + } getSession(sessionId: string): IChatModel | undefined { // eslint-disable-next-line local/code-no-dangerous-type-assertions - return {} as IChatModel; + return this.sessions.get(sessionId) ?? {} as IChatModel; } getOrRestoreSession(sessionId: string): IChatModel | undefined { throw new Error('Method not implemented.'); diff --git a/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts index a192334cb9d..d854a0beb5e 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockLanguageModelToolsService.ts @@ -13,6 +13,9 @@ export class MockLanguageModelToolsService implements ILanguageModelToolsService constructor() { } + cancelToolCallsForRequest(requestId: string): void { + } + onDidChangeTools: Event = Event.None; registerToolData(toolData: IToolData): IDisposable { diff --git a/src/vs/workbench/contrib/chat/test/common/promptFileReference.test.ts b/src/vs/workbench/contrib/chat/test/common/promptFileReference.test.ts index e7256cf7099..4a3b710e8ce 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptFileReference.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptFileReference.test.ts @@ -60,7 +60,7 @@ class ExpectedReference extends PromptFileReference { nullPolicyService, nullLogService, ); - super(uri, nullFileService, nullConfigService); + super(uri, nullLogService, nullFileService, nullConfigService); this._register(nullFileService); this._register(nullConfigService); @@ -83,6 +83,7 @@ class TestPromptFileReference extends Disposable { private readonly fileStructure: IFolder, private readonly rootFileUri: URI, private readonly expectedReferences: ExpectedReference[], + @ILogService private readonly logService: ILogService, @IFileService private readonly fileService: IFileService, @IConfigurationService private readonly configService: IConfigurationService, ) { @@ -111,6 +112,7 @@ class TestPromptFileReference extends Disposable { // start resolving references for the specified root file const rootReference = this._register(new PromptFileReference( this.rootFileUri, + this.logService, this.fileService, this.configService, )); diff --git a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts index 60e55441ce7..f84b3ba640b 100644 --- a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts @@ -64,15 +64,6 @@ suite('VoiceChat', () => { ]; class TestChatAgentService implements IChatAgentService { - hasChatParticipantDetectionProviders(): boolean { - throw new Error('Method not implemented.'); - } - registerChatParticipantDetectionProvider(handle: number, provider: IChatParticipantDetectionProvider): IDisposable { - throw new Error('Method not implemented.'); - } - detectAgentOrCommand(request: IChatAgentRequest, history: IChatAgentHistoryEntry[], options: { location: ChatAgentLocation }, token: CancellationToken): Promise<{ agent: IChatAgentData; command?: IChatAgentCommand } | undefined> { - throw new Error('Method not implemented.'); - } _serviceBrand: undefined; readonly onDidChangeAgents = Event.None; registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable { throw new Error(); } @@ -93,6 +84,19 @@ suite('VoiceChat', () => { getAgentCompletionItems(id: string, query: string, token: CancellationToken): Promise { throw new Error('Method not implemented.'); } agentHasDupeName(id: string): boolean { throw new Error('Method not implemented.'); } getChatTitle(id: string, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + readonly toolsAgentModeEnabled: boolean = false; + toggleToolsAgentMode(): void { + throw new Error('Method not implemented.'); + } + hasChatParticipantDetectionProviders(): boolean { + throw new Error('Method not implemented.'); + } + registerChatParticipantDetectionProvider(handle: number, provider: IChatParticipantDetectionProvider): IDisposable { + throw new Error('Method not implemented.'); + } + detectAgentOrCommand(request: IChatAgentRequest, history: IChatAgentHistoryEntry[], options: { location: ChatAgentLocation }, token: CancellationToken): Promise<{ agent: IChatAgentData; command?: IChatAgentCommand } | undefined> { + throw new Error('Method not implemented.'); + } } class TestSpeechService implements ISpeechService { diff --git a/src/vs/workbench/contrib/codeEditor/browser/inspectEditorTokens/inspectEditorTokens.ts b/src/vs/workbench/contrib/codeEditor/browser/inspectEditorTokens/inspectEditorTokens.ts index 2b0f345b3c5..4da76c31144 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/inspectEditorTokens/inspectEditorTokens.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/inspectEditorTokens/inspectEditorTokens.ts @@ -405,7 +405,7 @@ class InspectEditorTokensWidget extends Disposable implements IContentWidget { const tbody = dom.append(table, $('tbody')); dom.append(tbody, $('tr', undefined, - $('td.tiw-metadata-key', undefined, 'tree-sitter token' as string), + $('td.tiw-metadata-key', undefined, `tree-sitter token ${treeSitterTokenInfo.id}` as string), $('td.tiw-metadata-value', undefined, `${treeSitterTokenInfo.text}`) )); const scopes = new Array(); diff --git a/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts b/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts index 76bc1b9ddb0..524d3457777 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts @@ -12,6 +12,7 @@ import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.j import { IActiveCodeEditor, ICodeEditor, IDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { EditorAction, EditorContributionInstantiation, ServicesAccessor, registerDiffEditorContribution, registerEditorAction, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { findDiffEditorContainingCodeEditor } from '../../../../editor/browser/widget/diffEditor/commands.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { IDiffEditorContribution, IEditorContribution } from '../../../../editor/common/editorCommon.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; @@ -19,6 +20,7 @@ import { ITextModel } from '../../../../editor/common/model.js'; import * as nls from '../../../../nls.js'; import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -68,6 +70,7 @@ class ToggleWordWrapAction extends EditorAction { public run(accessor: ServicesAccessor, editor: ICodeEditor): void { const codeEditorService = accessor.get(ICodeEditorService); + const instaService = accessor.get(IInstantiationService); if (!canToggleWordWrap(codeEditorService, editor)) { return; @@ -93,7 +96,7 @@ class ToggleWordWrapAction extends EditorAction { writeTransientState(model, newState, codeEditorService); // if we are in a diff editor, update the other editor (if possible) - const diffEditor = findDiffEditorContainingCodeEditor(editor, codeEditorService); + const diffEditor = instaService.invokeFunction(findDiffEditorContainingCodeEditor, editor); if (diffEditor) { const originalEditor = diffEditor.getOriginalEditor(); const modifiedEditor = diffEditor.getModifiedEditor(); @@ -106,24 +109,6 @@ class ToggleWordWrapAction extends EditorAction { } } -/** - * If `editor` is the original or modified editor of a diff editor, it returns it. - * It returns null otherwise. - */ -function findDiffEditorContainingCodeEditor(editor: ICodeEditor, codeEditorService: ICodeEditorService): IDiffEditor | null { - if (!editor.getOption(EditorOption.inDiffEditor)) { - return null; - } - for (const diffEditor of codeEditorService.listDiffEditors()) { - const originalEditor = diffEditor.getOriginalEditor(); - const modifiedEditor = diffEditor.getModifiedEditor(); - if (originalEditor === editor || modifiedEditor === editor) { - return diffEditor; - } - } - return null; -} - class ToggleWordWrapController extends Disposable implements IEditorContribution { public static readonly ID = 'editor.contrib.toggleWordWrapController'; diff --git a/src/vs/workbench/contrib/comments/browser/commentService.ts b/src/vs/workbench/contrib/comments/browser/commentService.ts index ca5a8aa59d9..b7d1cde9aea 100644 --- a/src/vs/workbench/contrib/comments/browser/commentService.ts +++ b/src/vs/workbench/contrib/comments/browser/commentService.ts @@ -167,6 +167,7 @@ export class CommentService extends Disposable implements ICommentService { private _commentMenus = new Map(); private _isCommentingEnabled: boolean = true; private _workspaceHasCommenting: IContextKey; + private _commentingEnabled: IContextKey; private _continueOnComments = new Map(); // uniqueOwner -> PendingCommentThread[] private _continueOnCommentProviders = new Set(); @@ -190,6 +191,7 @@ export class CommentService extends Disposable implements ICommentService { this._handleConfiguration(); this._handleZenMode(); this._workspaceHasCommenting = CommentContextKeys.WorkspaceHasCommenting.bindTo(contextKeyService); + this._commentingEnabled = CommentContextKeys.commentingEnabled.bindTo(contextKeyService); const storageListener = this._register(new DisposableStore()); const storageEvent = Event.debounce(this.storageService.onDidChangeValue(StorageScope.WORKSPACE, CONTINUE_ON_COMMENTS, storageListener), (last, event) => last?.external ? last : event, 500); @@ -277,6 +279,7 @@ export class CommentService extends Disposable implements ICommentService { enableCommenting(enable: boolean): void { if (enable !== this._isCommentingEnabled) { this._isCommentingEnabled = enable; + this._commentingEnabled.set(enable); this._onDidChangeCommentingEnabled.fire(enable); } } diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts b/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts index fa68fe09303..035b88131fe 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts @@ -41,7 +41,6 @@ export class CommentThreadBody extends D return this._commentElements.filter(node => node.isEditing)[0]; } - constructor( private readonly _parentEditor: LayoutableEditor, readonly owner: string, @@ -77,6 +76,10 @@ export class CommentThreadBody extends D this._commentsElement.focus(); } + hasCommentsInEditMode() { + return this._commentElements.some(commentNode => commentNode.isEditing); + } + ensureFocusIntoNewEditingComment() { if (this._commentElements.length === 1 && this._commentElements[0].isEditing) { this._commentElements[0].setFocus(true); diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts b/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts index 3f42e0cdf32..cb721233760 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts @@ -44,9 +44,9 @@ export class CommentThreadHeader extends Disposable { private _delegate: { collapse: () => void }, private _commentMenus: CommentMenus, private _commentThread: languages.CommentThread, - private _contextKeyService: IContextKeyService, - private instantiationService: IInstantiationService, - private _contextMenuService: IContextMenuService + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService ) { super(); this._headElement = dom.$('.head'); @@ -63,7 +63,7 @@ export class CommentThreadHeader extends Disposable { const actionsContainer = dom.append(this._headElement, dom.$('.review-actions')); this._actionbarWidget = new ActionBar(actionsContainer, { - actionViewItemProvider: createActionViewItem.bind(undefined, this.instantiationService) + actionViewItemProvider: createActionViewItem.bind(undefined, this._instantiationService) }); this._register(this._actionbarWidget); diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts index 94578614d7b..41fbd153cfd 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts @@ -5,6 +5,7 @@ import './media/review.css'; import * as dom from '../../../../base/browser/dom.js'; +import * as nls from '../../../../nls.js'; import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -28,7 +29,6 @@ import { IRange, Range } from '../../../../editor/common/core/range.js'; import { commentThreadStateBackgroundColorVar, commentThreadStateColorVar } from './commentColors.js'; import { ICellRange } from '../../notebook/common/notebookRange.js'; import { FontInfo } from '../../../../editor/common/config/fontInfo.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { registerNavigableContainer } from '../../../browser/actions/widgetNavigationCommands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { COMMENTS_SECTION, ICommentsConfiguration } from '../common/commentsConfiguration.js'; @@ -38,6 +38,8 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; import { LayoutableEditor } from './simpleCommentEditor.js'; import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import Severity from '../../../../base/common/severity.js'; export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration'; @@ -76,10 +78,11 @@ export class CommentThreadWidget extends actionRunner: (() => void) | null; collapse: () => void; }, - @ICommentService private commentService: ICommentService, - @IContextMenuService contextMenuService: IContextMenuService, - @IConfigurationService private configurationService: IConfigurationService, - @IKeybindingService private _keybindingService: IKeybindingService + @ICommentService private readonly commentService: ICommentService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IDialogService private readonly _dialogService: IDialogService ) { super(); @@ -89,16 +92,14 @@ export class CommentThreadWidget extends this._commentMenus = this.commentService.getCommentMenus(this._owner); - this._register(this._header = new CommentThreadHeader( + this._register(this._header = this._scopedInstantiationService.createInstance( + CommentThreadHeader, container, { - collapse: this.collapse.bind(this) + collapse: this.collapseAction.bind(this) }, this._commentMenus, - this._commentThread, - this._contextKeyService, - this._scopedInstantiationService, - contextMenuService + this._commentThread )); this._header.updateCommentThread(this._commentThread); @@ -159,6 +160,21 @@ export class CommentThreadWidget extends this.currentThreadListeners(); } + private async confirmCollapse(): Promise { + const confirmSetting = this._configurationService.getValue<'whenHasUnsubmittedComments' | 'never'>('comments.thread.confirmOnCollapse'); + + const hasUnsubmitted = !!this._commentReply?.commentEditor.getValue() || this._body.hasCommentsInEditMode(); + if (confirmSetting === 'whenHasUnsubmittedComments' && hasUnsubmitted) { + const result = await this._dialogService.confirm({ + message: nls.localize('confirmCollapse', "This comment thread has unsubmitted comments. Do you want to collapse it?"), + primaryButton: nls.localize('collapse', "Collapse"), + type: Severity.Warning + }); + return result.confirmed; + } + return true; + } + private _setAriaLabel(): void { let ariaLabel = localize('commentLabel', "Comment"); let keybinding: string | undefined; @@ -375,6 +391,12 @@ export class CommentThreadWidget extends } } + private async collapseAction() { + if (await this.confirmCollapse()) { + this.collapse(); + } + } + collapse() { if (Range.isIRange(this.commentThread.range) && isCodeEditor(this._parentEditor)) { this._parentEditor.setSelection(this.commentThread.range); diff --git a/src/vs/workbench/contrib/comments/browser/comments.contribution.ts b/src/vs/workbench/contrib/comments/browser/comments.contribution.ts index d2cfe1a5680..3cf98a0cde9 100644 --- a/src/vs/workbench/contrib/comments/browser/comments.contribution.ts +++ b/src/vs/workbench/contrib/comments/browser/comments.contribution.ts @@ -138,6 +138,12 @@ Registry.as(ConfigurationExtensions.Configuration).regis type: 'boolean', default: true, description: nls.localize('collapseOnResolve', "Controls whether the comment thread should collapse when the thread is resolved.") + }, + 'comments.thread.confirmOnCollapse': { + type: 'string', + enum: ['whenHasUnsubmittedComments', 'never'], + default: 'never', + description: nls.localize('confirmOnCollapse', "Controls whether a confirmation dialog is shown when collapsing a comment thread with unsubmitted comments.") } } }); diff --git a/src/vs/workbench/contrib/comments/common/commentContextKeys.ts b/src/vs/workbench/contrib/comments/common/commentContextKeys.ts index 210465efe6f..2a5d0776c45 100644 --- a/src/vs/workbench/contrib/comments/common/commentContextKeys.ts +++ b/src/vs/workbench/contrib/comments/common/commentContextKeys.ts @@ -66,4 +66,12 @@ export namespace CommentContextKeys { * The comment widget is focused. */ export const commentFocused = new RawContextKey('commentFocused', false, { type: 'boolean', description: nls.localize('commentFocused', "Set when the comment is focused") }); + + /** + * A context key that is set when commenting is enabled. + */ + export const commentingEnabled = new RawContextKey('commentingEnabled', true, { + description: nls.localize('commentingEnabled', "Whether commenting functionality is enabled"), + type: 'boolean' + }); } diff --git a/src/vs/workbench/contrib/debug/browser/baseDebugView.ts b/src/vs/workbench/contrib/debug/browser/baseDebugView.ts index 9576b7e5bb7..efb22ae81c1 100644 --- a/src/vs/workbench/contrib/debug/browser/baseDebugView.ts +++ b/src/vs/workbench/contrib/debug/browser/baseDebugView.ts @@ -9,19 +9,21 @@ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { HighlightedLabel, IHighlight } from '../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IInputValidationOptions, InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { IKeyboardNavigationLabelProvider } from '../../../../base/browser/ui/list/list.js'; import { IAsyncDataSource, ITreeNode, ITreeRenderer } from '../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { FuzzyScore, createMatches } from '../../../../base/common/filters.js'; import { createSingleCallFunction } from '../../../../base/common/functional.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { DisposableStore, IDisposable, dispose, toDisposable } from '../../../../base/common/lifecycle.js'; +import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { IDebugService, IExpression } from '../common/debug.js'; +import { IDebugService, IExpression, IScope } from '../common/debug.js'; import { Variable } from '../common/debugModel.js'; import { IDebugVisualizerService } from '../common/debugVisualizers.js'; import { LinkDetector } from './linkDetector.js'; @@ -51,7 +53,7 @@ export interface IVariableTemplateData { export function renderViewTree(container: HTMLElement): HTMLElement { const treeContainer = $('.'); - treeContainer.classList.add('debug-view-content'); + treeContainer.classList.add('debug-view-content', 'file-icon-themable-tree'); container.appendChild(treeContainer); return treeContainer; } @@ -78,6 +80,32 @@ export interface IExpressionTemplateData { currentElement: IExpression | undefined; } +/** Splits highlights based on matching of the {@link expressionAndScopeLabelProvider} */ +export const splitExpressionOrScopeHighlights = (e: IExpression | IScope, highlights: IHighlight[]) => { + const nameEndsAt = e.name.length; + const labelBeginsAt = e.name.length + 2; + const name: IHighlight[] = []; + const value: IHighlight[] = []; + for (const hl of highlights) { + if (hl.start < nameEndsAt) { + name.push({ start: hl.start, end: Math.min(hl.end, nameEndsAt) }); + } + if (hl.end > labelBeginsAt) { + value.push({ start: Math.max(hl.start - labelBeginsAt, 0), end: hl.end - labelBeginsAt }); + } + } + + return { name, value }; +}; + +/** Keyboard label provider for expression and scope tree elements. */ +export const expressionAndScopeLabelProvider: IKeyboardNavigationLabelProvider = { + getKeyboardNavigationLabel(e) { + const stripAnsi = e.getSession()?.rememberedCapabilities?.supportsANSIStyling; + return `${e.name}: ${stripAnsi ? removeAnsiEscapeCodes(e.value) : e.value}`; + }, +}; + export abstract class AbstractExpressionDataSource implements IAsyncDataSource { constructor( @IDebugService protected debugService: IDebugService, diff --git a/src/vs/workbench/contrib/debug/browser/callStackWidget.ts b/src/vs/workbench/contrib/debug/browser/callStackWidget.ts index dc2c12dd730..2948bbb715b 100644 --- a/src/vs/workbench/contrib/debug/browser/callStackWidget.ts +++ b/src/vs/workbench/contrib/debug/browser/callStackWidget.ts @@ -119,10 +119,18 @@ export class CallStackWidget extends Disposable { private readonly currentFramesDs = this._register(new DisposableStore()); private cts?: CancellationTokenSource; + public get onDidChangeContentHeight() { + return this.list.onDidChangeContentHeight; + } + public get onDidScroll() { return this.list.onDidScroll; } + public get contentHeight() { + return this.list.contentHeight; + } + constructor( container: HTMLElement, containingEditor: ICodeEditor | undefined, diff --git a/src/vs/workbench/contrib/debug/browser/debugANSIHandling.ts b/src/vs/workbench/contrib/debug/browser/debugANSIHandling.ts index 16923a73f61..4db2d7eb8c7 100644 --- a/src/vs/workbench/contrib/debug/browser/debugANSIHandling.ts +++ b/src/vs/workbench/contrib/debug/browser/debugANSIHandling.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IHighlight } from '../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; import { Color, RGBA } from '../../../../base/common/color.js'; import { isDefined } from '../../../../base/common/types.js'; import { editorHoverBackground, listActiveSelectionBackground, listFocusBackground, listInactiveFocusBackground, listInactiveSelectionBackground } from '../../../../platform/theme/common/colorRegistry.js'; @@ -16,7 +17,7 @@ import { ILinkDetector } from './linkDetector.js'; * @param text The content to stylize. * @returns An {@link HTMLSpanElement} that contains the potentially stylized text. */ -export function handleANSIOutput(text: string, linkDetector: ILinkDetector, workspaceFolder: IWorkspaceFolder | undefined): HTMLSpanElement { +export function handleANSIOutput(text: string, linkDetector: ILinkDetector, workspaceFolder: IWorkspaceFolder | undefined, highlights: IHighlight[] | undefined): HTMLSpanElement { const root: HTMLSpanElement = document.createElement('span'); const textLength: number = text.length; @@ -27,6 +28,7 @@ export function handleANSIOutput(text: string, linkDetector: ILinkDetector, work let customUnderlineColor: RGBA | string | undefined; let colorsInverted: boolean = false; let currentPos: number = 0; + let unprintedChars = 0; let buffer: string = ''; while (currentPos < textLength) { @@ -58,8 +60,10 @@ export function handleANSIOutput(text: string, linkDetector: ILinkDetector, work if (sequenceFound) { + unprintedChars += 2 + ansiSequence.length; + // Flush buffer with previous styles. - appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, workspaceFolder, customFgColor, customBgColor, customUnderlineColor); + appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, workspaceFolder, customFgColor, customBgColor, customUnderlineColor, highlights, currentPos - buffer.length - unprintedChars); buffer = ''; @@ -105,7 +109,7 @@ export function handleANSIOutput(text: string, linkDetector: ILinkDetector, work // Flush remaining text buffer if not empty. if (buffer) { - appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, workspaceFolder, customFgColor, customBgColor, customUnderlineColor); + appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, workspaceFolder, customFgColor, customBgColor, customUnderlineColor, highlights, currentPos - buffer.length); } return root; @@ -395,6 +399,8 @@ export function handleANSIOutput(text: string, linkDetector: ILinkDetector, work * @param customTextColor If provided, will apply custom color with inline style. * @param customBackgroundColor If provided, will apply custom backgroundColor with inline style. * @param customUnderlineColor If provided, will apply custom textDecorationColor with inline style. + * @param highlights The ranges to highlight. + * @param offset The starting index of the stringContent in the original text. */ export function appendStylizedStringToContainer( root: HTMLElement, @@ -402,15 +408,24 @@ export function appendStylizedStringToContainer( cssClasses: string[], linkDetector: ILinkDetector, workspaceFolder: IWorkspaceFolder | undefined, - customTextColor?: RGBA | string, - customBackgroundColor?: RGBA | string, - customUnderlineColor?: RGBA | string, + customTextColor: RGBA | string | undefined, + customBackgroundColor: RGBA | string | undefined, + customUnderlineColor: RGBA | string | undefined, + highlights: IHighlight[] | undefined, + offset: number, ): void { if (!root || !stringContent) { return; } - const container = linkDetector.linkify(stringContent, true, workspaceFolder); + const container = linkDetector.linkify( + stringContent, + true, + workspaceFolder, + undefined, + undefined, + highlights?.map(h => ({ start: h.start - offset, end: h.end - offset, extraClasses: h.extraClasses })), + ); container.className = cssClasses.join(' '); if (customTextColor) { diff --git a/src/vs/workbench/contrib/debug/browser/debugExpressionRenderer.ts b/src/vs/workbench/contrib/debug/browser/debugExpressionRenderer.ts index 767950dc826..0835365d408 100644 --- a/src/vs/workbench/contrib/debug/browser/debugExpressionRenderer.ts +++ b/src/vs/workbench/contrib/debug/browser/debugExpressionRenderer.ts @@ -16,7 +16,7 @@ import { observableConfigValue } from '../../../../platform/observable/common/pl import { IDebugSession, IExpressionValue } from '../common/debug.js'; import { Expression, ExpressionContainer, Variable } from '../common/debugModel.js'; import { ReplEvaluationResult } from '../common/replModel.js'; -import { IVariableTemplateData } from './baseDebugView.js'; +import { IVariableTemplateData, splitExpressionOrScopeHighlights } from './baseDebugView.js'; import { handleANSIOutput } from './debugANSIHandling.js'; import { COPY_EVALUATE_PATH_ID, COPY_VALUE_ID } from './debugCommands.js'; import { DebugLinkHoverBehavior, DebugLinkHoverBehaviorTypeData, ILinkDetector, LinkDetector } from './linkDetector.js'; @@ -32,6 +32,7 @@ export interface IRenderValueOptions { /** If not false, a rich hover will be shown on the element. */ hover?: false | IValueHoverOptions; colorize?: boolean; + highlights?: IHighlight[]; /** * Indicates areas where VS Code implicitly always supported ANSI escape @@ -90,6 +91,7 @@ export class DebugExpressionRenderer { renderVariable(data: IVariableTemplateData, variable: Variable, options: IRenderVariableOptions = {}): IDisposable { const displayType = this.displayType.get(); + const highlights = splitExpressionOrScopeHighlights(variable, options.highlights || []); if (variable.available) { data.type.textContent = ''; @@ -103,7 +105,7 @@ export class DebugExpressionRenderer { } } - data.label.set(text, options.highlights, variable.type && !displayType ? variable.type : variable.name); + data.label.set(text, highlights.name, variable.type && !displayType ? variable.type : variable.name); data.name.classList.toggle('virtual', variable.presentationHint?.kind === 'virtual'); data.name.classList.toggle('internal', variable.presentationHint?.visibility === 'internal'); } else if (variable.value && typeof variable.name === 'string' && variable.name) { @@ -122,6 +124,7 @@ export class DebugExpressionRenderer { showChanged: options.showChanged, maxValueLength: MAX_VALUE_RENDER_LENGTH_IN_VIEWLET, hover: { commands }, + highlights: highlights.value, colorize: true, session: variable.getSession(), }); @@ -184,9 +187,9 @@ export class DebugExpressionRenderer { } if (supportsANSI) { - container.appendChild(handleANSIOutput(value, linkDetector, session ? session.root : undefined)); + container.appendChild(handleANSIOutput(value, linkDetector, session ? session.root : undefined, options.highlights)); } else { - container.appendChild(linkDetector.linkify(value, false, session?.root, true, hoverBehavior)); + container.appendChild(linkDetector.linkify(value, false, session?.root, true, hoverBehavior, options.highlights)); } if (options.hover !== false) { @@ -199,7 +202,7 @@ export class DebugExpressionRenderer { if (supportsANSI) { // note: intentionally using `this.linkDetector` so we don't blindly linkify the // entire contents and instead only link file paths that it contains. - hoverContentsPre.appendChild(handleANSIOutput(value, this.linkDetector, session ? session.root : undefined)); + hoverContentsPre.appendChild(handleANSIOutput(value, this.linkDetector, session ? session.root : undefined, options.highlights)); } else { hoverContentsPre.textContent = value; } diff --git a/src/vs/workbench/contrib/debug/browser/linkDetector.ts b/src/vs/workbench/contrib/debug/browser/linkDetector.ts index 98a89453a5a..8d08e6f1be9 100644 --- a/src/vs/workbench/contrib/debug/browser/linkDetector.ts +++ b/src/vs/workbench/contrib/debug/browser/linkDetector.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { getWindow } from '../../../../base/browser/dom.js'; +import { getWindow, isHTMLElement, reset } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; @@ -23,6 +23,8 @@ import { IDebugSession } from '../common/debug.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IPathService } from '../../../services/path/common/pathService.js'; +import { IHighlight } from '../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; +import { Iterable } from '../../../../base/common/iterator.js'; const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f'; const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|data:|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug'); @@ -42,6 +44,7 @@ type LinkPart = { kind: LinkKind; value: string; captures: string[]; + index: number; }; export const enum DebugLinkHoverBehavior { @@ -61,7 +64,7 @@ export type DebugLinkHoverBehaviorTypeData = { type: DebugLinkHoverBehavior.None | { type: DebugLinkHoverBehavior.Rich; store: DisposableStore }; export interface ILinkDetector { - linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData): HTMLElement; + linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, highlights?: IHighlight[]): HTMLElement; linkifyLocation(text: string, locationReference: number, session: IDebugSession, hoverBehavior?: DebugLinkHoverBehaviorTypeData): HTMLElement; } @@ -88,11 +91,11 @@ export class LinkDetector implements ILinkDetector { * If a `hoverBehavior` is passed, hovers may be added using the workbench hover service. * This should be preferred for new code where hovers are desirable. */ - linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData): HTMLElement { - return this._linkify(text, splitLines, workspaceFolder, includeFulltext, hoverBehavior); + linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, highlights?: IHighlight[]): HTMLElement { + return this._linkify(text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, highlights); } - private _linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, defaultRef?: { locationReference: number; session: IDebugSession }): HTMLElement { + private _linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder, includeFulltext?: boolean, hoverBehavior?: DebugLinkHoverBehaviorTypeData, highlights?: IHighlight[], defaultRef?: { locationReference: number; session: IDebugSession }): HTMLElement { if (splitLines) { const lines = text.split('\n'); for (let i = 0; i < lines.length - 1; i++) { @@ -102,7 +105,7 @@ export class LinkDetector implements ILinkDetector { // Remove the last element ('') that split added. lines.pop(); } - const elements = lines.map(line => this._linkify(line, false, workspaceFolder, includeFulltext, hoverBehavior, defaultRef)); + const elements = lines.map(line => this._linkify(line, false, workspaceFolder, includeFulltext, hoverBehavior, highlights, defaultRef)); if (elements.length === 1) { // Do not wrap single line with extra span. return elements[0]; @@ -115,21 +118,26 @@ export class LinkDetector implements ILinkDetector { const container = document.createElement('span'); for (const part of this.detectLinks(text)) { try { + let node: Node; switch (part.kind) { case 'text': - container.appendChild(defaultRef ? this.linkifyLocation(part.value, defaultRef.locationReference, defaultRef.session, hoverBehavior) : document.createTextNode(part.value)); + node = defaultRef ? this.linkifyLocation(part.value, defaultRef.locationReference, defaultRef.session, hoverBehavior) : document.createTextNode(part.value); break; case 'web': - container.appendChild(this.createWebLink(includeFulltext ? text : undefined, part.value, hoverBehavior)); + node = this.createWebLink(includeFulltext ? text : undefined, part.value, hoverBehavior); break; case 'path': { const path = part.captures[0]; const lineNumber = part.captures[1] ? Number(part.captures[1]) : 0; const columnNumber = part.captures[2] ? Number(part.captures[2]) : 0; - container.appendChild(this.createPathLink(includeFulltext ? text : undefined, part.value, path, lineNumber, columnNumber, workspaceFolder, hoverBehavior)); + node = this.createPathLink(includeFulltext ? text : undefined, part.value, path, lineNumber, columnNumber, workspaceFolder, hoverBehavior); break; } + default: + node = document.createTextNode(part.value); } + + container.append(...this.applyHighlights(node, part.index, part.value.length, highlights)); } catch (e) { container.appendChild(document.createTextNode(part.value)); } @@ -137,6 +145,50 @@ export class LinkDetector implements ILinkDetector { return container; } + private applyHighlights(node: Node, startIndex: number, length: number, highlights: IHighlight[] | undefined): Iterable { + const children: (Node | string)[] = []; + let currentIndex = startIndex; + const endIndex = startIndex + length; + + for (const highlight of highlights || []) { + if (highlight.end <= currentIndex || highlight.start >= endIndex) { + continue; + } + + if (highlight.start > currentIndex) { + children.push(node.textContent!.substring(currentIndex - startIndex, highlight.start - startIndex)); + currentIndex = highlight.start; + } + + const highlightEnd = Math.min(highlight.end, endIndex); + const highlightedText = node.textContent!.substring(currentIndex - startIndex, highlightEnd - startIndex); + const highlightSpan = document.createElement('span'); + highlightSpan.classList.add('highlight'); + if (highlight.extraClasses) { + highlightSpan.classList.add(...highlight.extraClasses); + } + highlightSpan.textContent = highlightedText; + children.push(highlightSpan); + currentIndex = highlightEnd; + } + + if (currentIndex === startIndex) { + return Iterable.single(node); // no changes made + } + + if (currentIndex < endIndex) { + children.push(node.textContent!.substring(currentIndex - startIndex)); + } + + // reuse the element if it's a link + if (isHTMLElement(node)) { + reset(node, ...children); + return Iterable.single(node); + } + + return children; + } + /** * Linkifies a location reference. */ @@ -161,8 +213,8 @@ export class LinkDetector implements ILinkDetector { */ makeReferencedLinkDetector(locationReference: number, session: IDebugSession): ILinkDetector { return { - linkify: (text, splitLines, workspaceFolder, includeFulltext, hoverBehavior) => - this._linkify(text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, { locationReference, session }), + linkify: (text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, highlights) => + this._linkify(text, splitLines, workspaceFolder, includeFulltext, hoverBehavior, highlights, { locationReference, session }), linkifyLocation: this.linkifyLocation.bind(this), }; } @@ -295,16 +347,16 @@ export class LinkDetector implements ILinkDetector { private detectLinks(text: string): LinkPart[] { if (text.length > MAX_LENGTH) { - return [{ kind: 'text', value: text, captures: [] }]; + return [{ kind: 'text', value: text, captures: [], index: 0 }]; } const regexes: RegExp[] = [WEB_LINK_REGEX, PATH_LINK_REGEX]; const kinds: LinkKind[] = ['web', 'path']; const result: LinkPart[] = []; - const splitOne = (text: string, regexIndex: number) => { + const splitOne = (text: string, regexIndex: number, baseIndex: number) => { if (regexIndex >= regexes.length) { - result.push({ value: text, kind: 'text', captures: [] }); + result.push({ value: text, kind: 'text', captures: [], index: baseIndex }); return; } const regex = regexes[regexIndex]; @@ -314,23 +366,24 @@ export class LinkDetector implements ILinkDetector { while ((match = regex.exec(text)) !== null) { const stringBeforeMatch = text.substring(currentIndex, match.index); if (stringBeforeMatch) { - splitOne(stringBeforeMatch, regexIndex + 1); + splitOne(stringBeforeMatch, regexIndex + 1, baseIndex + currentIndex); } const value = match[0]; result.push({ value: value, kind: kinds[regexIndex], - captures: match.slice(1) + captures: match.slice(1), + index: baseIndex + match.index }); currentIndex = match.index + value.length; } const stringAfterMatches = text.substring(currentIndex); if (stringAfterMatches) { - splitOne(stringAfterMatches, regexIndex + 1); + splitOne(stringAfterMatches, regexIndex + 1, baseIndex + currentIndex); } }; - splitOne(text, 0); + splitOne(text, 0, 0); return result; } } diff --git a/src/vs/workbench/contrib/debug/browser/loadedScriptsView.ts b/src/vs/workbench/contrib/debug/browser/loadedScriptsView.ts index 3c6e37f7bc0..4876cc3a797 100644 --- a/src/vs/workbench/contrib/debug/browser/loadedScriptsView.ts +++ b/src/vs/workbench/contrib/debug/browser/loadedScriptsView.ts @@ -3,47 +3,46 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as nls from '../../../../nls.js'; -import { IViewletViewOptions } from '../../../browser/parts/views/viewsViewlet.js'; -import { normalize, isAbsolute, posix } from '../../../../base/common/path.js'; -import { ViewPane, ViewAction } from '../../../browser/parts/views/viewPane.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { renderViewTree } from './baseDebugView.js'; -import { IDebugSession, IDebugService, CONTEXT_LOADED_SCRIPTS_ITEM_TYPE, LOADED_SCRIPTS_VIEW_ID } from '../common/debug.js'; -import { Source } from '../common/debugSource.js'; -import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; -import { IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { normalizeDriveLetter, tildify } from '../../../../base/common/labels.js'; -import { isWindows } from '../../../../base/common/platform.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ltrim } from '../../../../base/common/strings.js'; -import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { ResourceLabels, IResourceLabelProps, IResourceLabelOptions, IResourceLabel } from '../../../browser/labels.js'; -import { FileKind } from '../../../../platform/files/common/files.js'; import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; -import { ITreeNode, ITreeFilter, TreeVisibility, TreeFilterResult, ITreeElement } from '../../../../base/browser/ui/tree/tree.js'; import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { WorkbenchCompressibleObjectTree } from '../../../../platform/list/browser/listService.js'; -import { dispose } from '../../../../base/common/lifecycle.js'; -import { createMatches, FuzzyScore } from '../../../../base/common/filters.js'; -import { DebugContentProvider } from '../common/debugContentProvider.js'; -import { ILabelService } from '../../../../platform/label/common/label.js'; +import { TreeFindMode } from '../../../../base/browser/ui/tree/abstractTree.js'; import type { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; import type { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; -import { registerAction2, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ITreeElement, ITreeFilter, ITreeNode, TreeFilterResult, TreeVisibility } from '../../../../base/browser/ui/tree/tree.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; import { Codicon } from '../../../../base/common/codicons.js'; - -import { IViewDescriptorService } from '../../../common/views.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IPathService } from '../../../services/path/common/pathService.js'; -import { TreeFindMode } from '../../../../base/browser/ui/tree/abstractTree.js'; +import { createMatches, FuzzyScore } from '../../../../base/common/filters.js'; +import { normalizeDriveLetter, tildify } from '../../../../base/common/labels.js'; +import { dispose } from '../../../../base/common/lifecycle.js'; +import { isAbsolute, normalize, posix } from '../../../../base/common/path.js'; +import { isWindows } from '../../../../base/common/platform.js'; +import { ltrim } from '../../../../base/common/strings.js'; +import { URI } from '../../../../base/common/uri.js'; +import * as nls from '../../../../nls.js'; +import { MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { WorkbenchCompressibleObjectTree } from '../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IFileIconTheme, IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; +import { IResourceLabel, IResourceLabelOptions, IResourceLabelProps, ResourceLabels } from '../../../browser/labels.js'; +import { ViewAction, ViewPane } from '../../../browser/parts/views/viewPane.js'; +import { IViewletViewOptions } from '../../../browser/parts/views/viewsViewlet.js'; +import { IViewDescriptorService } from '../../../common/views.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { CONTEXT_LOADED_SCRIPTS_ITEM_TYPE, IDebugService, IDebugSession, LOADED_SCRIPTS_VIEW_ID } from '../common/debug.js'; +import { DebugContentProvider } from '../common/debugContentProvider.js'; +import { Source } from '../common/debugSource.js'; +import { renderViewTree } from './baseDebugView.js'; const NEW_STYLE_COMPRESS = true; @@ -439,7 +438,7 @@ export class LoadedScriptsView extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, - @IHoverService hoverService: IHoverService + @IHoverService hoverService: IHoverService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this.loadedScriptsItemType = CONTEXT_LOADED_SCRIPTS_ITEM_TYPE.bindTo(contextKeyService); @@ -449,8 +448,7 @@ export class LoadedScriptsView extends ViewPane { super.renderBody(container); this.element.classList.add('debug-pane'); - container.classList.add('debug-loaded-scripts'); - container.classList.add('show-file-icons'); + container.classList.add('debug-loaded-scripts', 'show-file-icons'); this.treeContainer = renderViewTree(container); @@ -461,6 +459,14 @@ export class LoadedScriptsView extends ViewPane { this.treeLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility }); this._register(this.treeLabels); + const onFileIconThemeChange = (fileIconTheme: IFileIconTheme) => { + this.treeContainer.classList.toggle('align-icons-and-twisties', fileIconTheme.hasFileIcons && !fileIconTheme.hasFolderIcons); + this.treeContainer.classList.toggle('hide-arrows', fileIconTheme.hidesExplorerArrows === true); + }; + + this._register(this.themeService.onDidFileIconThemeChange(onFileIconThemeChange)); + onFileIconThemeChange(this.themeService.getFileIconTheme()); + this.tree = this.instantiationService.createInstance(WorkbenchCompressibleObjectTree, 'LoadedScriptsView', this.treeContainer, diff --git a/src/vs/workbench/contrib/debug/browser/media/debugViewlet.css b/src/vs/workbench/contrib/debug/browser/media/debugViewlet.css index 90dfb51bc60..f795c6c22eb 100644 --- a/src/vs/workbench/contrib/debug/browser/media/debugViewlet.css +++ b/src/vs/workbench/contrib/debug/browser/media/debugViewlet.css @@ -274,6 +274,12 @@ font-family: var(--monaco-monospace-font); font-weight: normal; } +.debug-view-content .monaco-tl-contents .highlight { + color: unset !important; + background-color: var(--vscode-list-filterMatchBackground); + outline: 1px dotted var(--vscode-list-filterMatchBorder); + outline-offset: -1px; +} /* Breakpoints */ diff --git a/src/vs/workbench/contrib/debug/browser/repl.ts b/src/vs/workbench/contrib/debug/browser/repl.ts index edef7e72b70..04823d1f137 100644 --- a/src/vs/workbench/contrib/debug/browser/repl.ts +++ b/src/vs/workbench/contrib/debug/browser/repl.ts @@ -174,7 +174,11 @@ export class Repl extends FilterViewPane implements IHistoryNavigationWidget { this.onDidFocusSession(this.debugService.getViewModel().focusedSession); } - this._register(this.debugService.getViewModel().onDidFocusSession(async session => this.onDidFocusSession(session))); + this._register(this.debugService.getViewModel().onDidFocusSession(session => { + if (this.isVisible()) { + this.onDidFocusSession(session); + } + })); this._register(this.debugService.getViewModel().onDidEvaluateLazyExpression(async e => { if (e instanceof Variable && this.tree?.hasNode(e)) { await this.tree.updateChildren(e, false, true); @@ -201,19 +205,27 @@ export class Repl extends FilterViewPane implements IHistoryNavigationWidget { } })); this._register(this.onDidChangeBodyVisibility(visible => { - if (visible) { - if (!this.model) { - this.model = this.modelService.getModel(Repl.URI) || this.modelService.createModel('', null, Repl.URI, true); - } - this.setMode(); - this.replInput.setModel(this.model); - this.updateInputDecoration(); - this.refreshReplElements(true); - if (this.styleChangedWhenInvisible) { - this.styleChangedWhenInvisible = false; - this.tree?.updateChildren(undefined, true, false); - this.onDidStyleChange(); - } + if (!visible) { + return; + } + if (!this.model) { + this.model = this.modelService.getModel(Repl.URI) || this.modelService.createModel('', null, Repl.URI, true); + } + + const focusedSession = this.debugService.getViewModel().focusedSession; + if (this.tree && this.tree.getInput() !== focusedSession) { + this.onDidFocusSession(focusedSession); + } + + this.setMode(); + this.replInput.setModel(this.model); + this.updateInputDecoration(); + this.refreshReplElements(true); + + if (this.styleChangedWhenInvisible) { + this.styleChangedWhenInvisible = false; + this.tree?.updateChildren(undefined, true, false); + this.onDidStyleChange(); } })); this._register(this.configurationService.onDidChangeConfiguration(e => { diff --git a/src/vs/workbench/contrib/debug/browser/variablesView.ts b/src/vs/workbench/contrib/debug/browser/variablesView.ts index 16d53c4f87e..74c22d9ee97 100644 --- a/src/vs/workbench/contrib/debug/browser/variablesView.ts +++ b/src/vs/workbench/contrib/debug/browser/variablesView.ts @@ -46,7 +46,7 @@ import { CONTEXT_BREAK_WHEN_VALUE_CHANGES_SUPPORTED, CONTEXT_BREAK_WHEN_VALUE_IS import { getContextForVariable } from '../common/debugContext.js'; import { ErrorScope, Expression, Scope, StackFrame, Variable, VisualizedExpression, getUriForDebugMemory } from '../common/debugModel.js'; import { DebugVisualizer, IDebugVisualizerService } from '../common/debugVisualizers.js'; -import { AbstractExpressionDataSource, AbstractExpressionsRenderer, IExpressionTemplateData, IInputBoxOptions, renderViewTree } from './baseDebugView.js'; +import { AbstractExpressionDataSource, AbstractExpressionsRenderer, expressionAndScopeLabelProvider, IExpressionTemplateData, IInputBoxOptions, renderViewTree } from './baseDebugView.js'; import { ADD_TO_WATCH_ID, ADD_TO_WATCH_LABEL, COPY_EVALUATE_PATH_ID, COPY_EVALUATE_PATH_LABEL, COPY_VALUE_ID, COPY_VALUE_LABEL } from './debugCommands.js'; import { DebugExpressionRenderer } from './debugExpressionRenderer.js'; @@ -138,7 +138,7 @@ export class VariablesView extends ViewPane implements IDebugViewWithVariables { this.instantiationService.createInstance(VariablesDataSource), { accessibilityProvider: new VariablesAccessibilityProvider(), identityProvider: { getId: (element: IExpression | IScope) => element.getId() }, - keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: IExpression | IScope) => e.name }, + keyboardNavigationLabelProvider: expressionAndScopeLabelProvider, overrideStyles: this.getLocationBasedColors().listOverrideStyles }); diff --git a/src/vs/workbench/contrib/debug/browser/watchExpressionsView.ts b/src/vs/workbench/contrib/debug/browser/watchExpressionsView.ts index b325c138b0a..a56d4aae462 100644 --- a/src/vs/workbench/contrib/debug/browser/watchExpressionsView.ts +++ b/src/vs/workbench/contrib/debug/browser/watchExpressionsView.ts @@ -31,7 +31,7 @@ import { IViewletViewOptions } from '../../../browser/parts/views/viewsViewlet.j import { IViewDescriptorService } from '../../../common/views.js'; import { CONTEXT_CAN_VIEW_MEMORY, CONTEXT_VARIABLE_IS_READONLY, CONTEXT_WATCH_EXPRESSIONS_EXIST, CONTEXT_WATCH_EXPRESSIONS_FOCUSED, CONTEXT_WATCH_ITEM_TYPE, IDebugConfiguration, IDebugService, IDebugViewWithVariables, IExpression, WATCH_VIEW_ID } from '../common/debug.js'; import { Expression, Variable, VisualizedExpression } from '../common/debugModel.js'; -import { AbstractExpressionDataSource, AbstractExpressionsRenderer, IExpressionTemplateData, IInputBoxOptions, renderViewTree } from './baseDebugView.js'; +import { AbstractExpressionDataSource, AbstractExpressionsRenderer, expressionAndScopeLabelProvider, IExpressionTemplateData, IInputBoxOptions, renderViewTree } from './baseDebugView.js'; import { DebugExpressionRenderer } from './debugExpressionRenderer.js'; import { watchExpressionsAdd, watchExpressionsRemoveAll } from './debugIcons.js'; import { VariablesRenderer, VisualizedVariableRenderer } from './variablesView.js'; @@ -109,7 +109,7 @@ export class WatchExpressionsView extends ViewPane implements IDebugViewWithVari return undefined; } - return e.name; + return expressionAndScopeLabelProvider.getKeyboardNavigationLabel(e); } }, dnd: new WatchExpressionsDragAndDrop(this.debugService), diff --git a/src/vs/workbench/contrib/debug/test/browser/baseDebugView.test.ts b/src/vs/workbench/contrib/debug/test/browser/baseDebugView.test.ts index fcefad3c516..9122412e421 100644 --- a/src/vs/workbench/contrib/debug/test/browser/baseDebugView.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/baseDebugView.test.ts @@ -106,7 +106,7 @@ suite('Debug - Base Debug View', () => { const container = $('.container'); const treeContainer = renderViewTree(container); - assert.strictEqual(treeContainer.className, 'debug-view-content'); + assert.strictEqual(treeContainer.className, 'debug-view-content file-icon-themable-tree'); assert.strictEqual(container.childElementCount, 1); assert.strictEqual(container.firstChild, treeContainer); assert.strictEqual(dom.isHTMLDivElement(treeContainer), true); diff --git a/src/vs/workbench/contrib/debug/test/browser/debugANSIHandling.test.ts b/src/vs/workbench/contrib/debug/test/browser/debugANSIHandling.test.ts index eb31a388b2e..17d23dc6303 100644 --- a/src/vs/workbench/contrib/debug/test/browser/debugANSIHandling.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/debugANSIHandling.test.ts @@ -51,8 +51,8 @@ suite('Debug - ANSI Handling', () => { assert.strictEqual(0, root.children.length); - appendStylizedStringToContainer(root, 'content1', ['class1', 'class2'], linkDetector, session.root); - appendStylizedStringToContainer(root, 'content2', ['class2', 'class3'], linkDetector, session.root); + appendStylizedStringToContainer(root, 'content1', ['class1', 'class2'], linkDetector, session.root, undefined, undefined, undefined, undefined, 0); + appendStylizedStringToContainer(root, 'content2', ['class2', 'class3'], linkDetector, session.root, undefined, undefined, undefined, undefined, 0); assert.strictEqual(2, root.children.length); @@ -82,7 +82,7 @@ suite('Debug - ANSI Handling', () => { * @returns An {@link HTMLSpanElement} that contains the stylized text. */ function getSequenceOutput(sequence: string): HTMLSpanElement { - const root: HTMLSpanElement = handleANSIOutput(sequence, linkDetector, session.root); + const root: HTMLSpanElement = handleANSIOutput(sequence, linkDetector, session.root, []); assert.strictEqual(1, root.children.length); const child: Node = root.lastChild!; if (isHTMLSpanElement(child)) { @@ -395,7 +395,7 @@ suite('Debug - ANSI Handling', () => { if (elementsExpected === undefined) { elementsExpected = assertions.length; } - const root: HTMLSpanElement = handleANSIOutput(sequence, linkDetector, session.root); + const root: HTMLSpanElement = handleANSIOutput(sequence, linkDetector, session.root, []); assert.strictEqual(elementsExpected, root.children.length); for (let i = 0; i < elementsExpected; i++) { const child: Node = root.children[i]; diff --git a/src/vs/workbench/contrib/debug/test/browser/linkDetector.test.ts b/src/vs/workbench/contrib/debug/test/browser/linkDetector.test.ts index c078c4a371e..9e2d0c8c767 100644 --- a/src/vs/workbench/contrib/debug/test/browser/linkDetector.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/linkDetector.test.ts @@ -13,6 +13,7 @@ import { ITunnelService } from '../../../../../platform/tunnel/common/tunnel.js' import { WorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; import { LinkDetector } from '../../browser/linkDetector.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { IHighlight } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; suite('Debug - Link Detector', () => { @@ -168,4 +169,67 @@ suite('Debug - Link Detector', () => { assertElementIsLink(output.children[1].children[0]); assert.strictEqual(isWindows ? 'C:/foo/bar.js:12:34' : '/Users/foo/bar.js:12:34', output.children[1].children[0].textContent); }); + + test('highlightNoLinks', () => { + const input = 'I am a string'; + const highlights: IHighlight[] = [{ start: 2, end: 5 }]; + const expectedOutput = 'I am a string'; + const output = linkDetector.linkify(input, false, undefined, false, undefined, highlights); + + assert.strictEqual(1, output.children.length); + assert.strictEqual('SPAN', output.tagName); + assert.strictEqual(expectedOutput, output.outerHTML); + }); + + test('highlightWithLink', () => { + const input = isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34'; + const highlights: IHighlight[] = [{ start: 0, end: 5 }]; + const expectedOutput = isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34'; + const output = linkDetector.linkify(input, false, undefined, false, undefined, highlights); + + assert.strictEqual(1, output.children.length); + assert.strictEqual('SPAN', output.tagName); + assert.strictEqual('A', output.firstElementChild!.tagName); + assert.strictEqual(expectedOutput, output.outerHTML); + assertElementIsLink(output.firstElementChild!); + }); + + test('highlightOverlappingLinkStart', () => { + const input = isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34'; + const highlights: IHighlight[] = [{ start: 0, end: 10 }]; + const expectedOutput = isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34'; + const output = linkDetector.linkify(input, false, undefined, false, undefined, highlights); + + assert.strictEqual(1, output.children.length); + assert.strictEqual('SPAN', output.tagName); + assert.strictEqual('A', output.firstElementChild!.tagName); + assert.strictEqual(expectedOutput, output.outerHTML); + assertElementIsLink(output.firstElementChild!); + }); + + test('highlightOverlappingLinkEnd', () => { + const input = isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34'; + const highlights: IHighlight[] = [{ start: 10, end: 20 }]; + const expectedOutput = isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34'; + const output = linkDetector.linkify(input, false, undefined, false, undefined, highlights); + + assert.strictEqual(1, output.children.length); + assert.strictEqual('SPAN', output.tagName); + assert.strictEqual('A', output.firstElementChild!.tagName); + assert.strictEqual(expectedOutput, output.outerHTML); + assertElementIsLink(output.firstElementChild!); + }); + + test('highlightOverlappingLinkStartAndEnd', () => { + const input = isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34'; + const highlights: IHighlight[] = [{ start: 5, end: 15 }]; + const expectedOutput = isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34'; + const output = linkDetector.linkify(input, false, undefined, false, undefined, highlights); + + assert.strictEqual(1, output.children.length); + assert.strictEqual('SPAN', output.tagName); + assert.strictEqual('A', output.firstElementChild!.tagName); + assert.strictEqual(expectedOutput, output.outerHTML); + assertElementIsLink(output.firstElementChild!); + }); }); diff --git a/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts b/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts index b056dbf64a2..1bb5eb33737 100644 --- a/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts +++ b/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts @@ -455,6 +455,9 @@ export class EditSessionsWorkbenchService extends Disposable implements IEditSes } private registerSignInAction() { + if (!this.serverConfiguration?.url) { + return; + } const that = this; const id = 'workbench.editSessions.actions.signIn'; const when = ContextKeyExpr.and(ContextKeyExpr.equals(EDIT_SESSIONS_PENDING_KEY, false), ContextKeyExpr.equals(EDIT_SESSIONS_SIGNED_IN_KEY, false)); diff --git a/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts b/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts index 38aeb6c161b..359c7be57d5 100644 --- a/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts +++ b/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts @@ -50,11 +50,13 @@ import { TestStorageService } from '../../../../test/common/workbenchTestService import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; import { IWorkspaceIdentityService, WorkspaceIdentityService } from '../../../../services/workspaces/common/workspaceIdentityService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; const folderName = 'test-folder'; const folderUri = URI.file(`/${folderName}`); suite('Edit session sync', () => { + let instantiationService: TestInstantiationService; let editSessionsContribution: EditSessionsContribution; let fileService: FileService; @@ -63,6 +65,7 @@ suite('Edit session sync', () => { const disposables = new DisposableStore(); suiteSetup(() => { + sandbox = sinon.createSandbox(); instantiationService = new TestInstantiationService(); @@ -172,6 +175,10 @@ suite('Edit session sync', () => { disposables.clear(); }); + suiteTeardown(() => { + disposables.dispose(); + }); + test('Can apply edit session', async function () { const fileUri = joinPath(folderUri, 'dir1', 'README.md'); const fileContents = '# readme'; @@ -218,4 +225,6 @@ suite('Edit session sync', () => { // Verify that we did not attempt to write the edit session assert.equal(writeStub.called, false); }); + + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsList.ts b/src/vs/workbench/contrib/extensions/browser/extensionsList.ts index 2afa3e73bbf..7293bfef2b0 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsList.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsList.ts @@ -114,7 +114,7 @@ export class Renderer implements IPagedRenderer { focusOnlyEnabledItems: true }); actionbar.setFocusable(false); - actionbar.onDidRun(({ error }) => error && this.notificationService.error(error)); + const actionBarListener = actionbar.onDidRun(({ error }) => error && this.notificationService.error(error)); const extensionStatusIconAction = this.instantiationService.createInstance(ExtensionStatusAction); const actions = [ @@ -150,7 +150,7 @@ export class Renderer implements IPagedRenderer { const extensionContainers: ExtensionContainers = this.instantiationService.createInstance(ExtensionContainers, [...actions, ...widgets]); actionbar.push(actions, { icon: true, label: true }); - const disposable = combinedDisposable(...actions, ...widgets, actionbar, extensionContainers); + const disposable = combinedDisposable(...actions, ...widgets, actionbar, actionBarListener, extensionContainers); return { root, element, icon, name, installCount, ratings, description, publisherDisplayName, disposables: [disposable], actionbar, diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts index 7ae9f77168d..ee75bba1ed6 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts @@ -59,7 +59,7 @@ import { IPaneCompositePartService } from '../../../services/panecomposite/brows import { coalesce } from '../../../../base/common/arrays.js'; import { extractEditorsAndFilesDropData } from '../../../../platform/dnd/browser/dnd.js'; import { extname } from '../../../../base/common/resources.js'; -import { areSameExtensions } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; +import { isMalicious } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; import { ILocalizedString } from '../../../../platform/action/common/action.js'; import { registerNavigableContainer } from '../../../browser/actions/widgetNavigationCommands.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; @@ -992,8 +992,7 @@ export class MaliciousExtensionChecker implements IWorkbenchContribution { return this.extensionsManagementService.getExtensionsControlManifest().then(extensionsControlManifest => { return this.extensionsManagementService.getInstalled(ExtensionType.User).then(installed => { - const maliciousExtensions = installed - .filter(e => extensionsControlManifest.malicious.some(identifier => areSameExtensions(e.identifier, identifier))); + const maliciousExtensions = installed.filter(e => isMalicious(e.identifier, extensionsControlManifest)); if (maliciousExtensions.length) { return Promises.settled(maliciousExtensions.map(e => this.extensionsManagementService.uninstall(e).then(() => { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 33692adf148..56e0f1638ca 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -23,7 +23,7 @@ import { AllowedExtensionsConfigKey } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { IWorkbenchExtensionEnablementService, EnablementState, IExtensionManagementServerService, IExtensionManagementServer, IWorkbenchExtensionManagementService, DefaultIconPath, IResourceExtension } from '../../../services/extensionManagement/common/extensionManagement.js'; -import { getGalleryExtensionTelemetryData, getLocalExtensionTelemetryData, areSameExtensions, groupByExtension, getGalleryExtensionId } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; +import { getGalleryExtensionTelemetryData, getLocalExtensionTelemetryData, areSameExtensions, groupByExtension, getGalleryExtensionId, isMalicious } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IHostService } from '../../../services/host/browser/host.js'; @@ -550,7 +550,7 @@ ${this.description} } setExtensionsControlManifest(extensionsControlManifest: IExtensionsControlManifest): void { - this.isMalicious = extensionsControlManifest.malicious.some(identifier => areSameExtensions(this.identifier, identifier)); + this.isMalicious = isMalicious(this.identifier, extensionsControlManifest); this.deprecationInfo = extensionsControlManifest.deprecated ? extensionsControlManifest.deprecated[this.identifier.id.toLowerCase()] : undefined; this._extensionEnabledWithPreRelease = extensionsControlManifest?.extensionsEnabledWithPreRelease?.includes(this.identifier.id.toLowerCase()); } diff --git a/src/vs/workbench/contrib/extensions/test/electron-sandbox/extensionsWorkbenchService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-sandbox/extensionsWorkbenchService.test.ts index 03fd27f96b8..c49ca4dfa18 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-sandbox/extensionsWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-sandbox/extensionsWorkbenchService.test.ts @@ -1640,6 +1640,9 @@ suite('ExtensionsWorkbenchServiceTest', () => { return true; }, }); + }, + inspect: (key: string) => { + return {}; } }); } diff --git a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts index be7dddf26ff..6cd04336cfb 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts @@ -14,7 +14,7 @@ import { COPY_PATH_COMMAND_ID, REVEAL_IN_EXPLORER_COMMAND_ID, OPEN_TO_SIDE_COMMA import { CommandsRegistry, ICommandHandler } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { FilesExplorerFocusCondition, ExplorerRootContext, ExplorerFolderContext, ExplorerResourceNotReadonlyContext, ExplorerResourceCut, ExplorerResourceMoveableToTrash, ExplorerResourceAvailableEditorIdsContext, FoldersViewVisibleContext } from '../common/files.js'; +import { FilesExplorerFocusCondition, ExplorerRootContext, ExplorerFolderContext, ExplorerResourceWritableContext, ExplorerResourceCut, ExplorerResourceMoveableToTrash, ExplorerResourceAvailableEditorIdsContext, FoldersViewVisibleContext } from '../common/files.js'; import { ADD_ROOT_FOLDER_COMMAND_ID, ADD_ROOT_FOLDER_LABEL } from '../../../browser/actions/workspaceCommands.js'; import { CLOSE_SAVED_EDITORS_COMMAND_ID, CLOSE_EDITORS_IN_GROUP_COMMAND_ID, CLOSE_EDITOR_COMMAND_ID, CLOSE_OTHER_EDITORS_IN_GROUP_COMMAND_ID, REOPEN_WITH_COMMAND_ID } from '../../../browser/parts/editor/editorCommands.js'; import { AutoSaveAfterShortDelayContext } from '../../../services/filesConfiguration/common/filesConfigurationService.js'; @@ -52,7 +52,7 @@ const RENAME_ID = 'renameFile'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: RENAME_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerRootContext.toNegated(), ExplorerResourceNotReadonlyContext), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerRootContext.toNegated(), ExplorerResourceWritableContext), primary: KeyCode.F2, mac: { primary: KeyCode.Enter @@ -64,7 +64,7 @@ const MOVE_FILE_TO_TRASH_ID = 'moveFileToTrash'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: MOVE_FILE_TO_TRASH_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceNotReadonlyContext, ExplorerResourceMoveableToTrash), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceMoveableToTrash), primary: KeyCode.Delete, mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace, @@ -77,7 +77,7 @@ const DELETE_FILE_ID = 'deleteFile'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: DELETE_FILE_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceNotReadonlyContext), + when: FilesExplorerFocusCondition, primary: KeyMod.Shift | KeyCode.Delete, mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Backspace @@ -88,7 +88,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ KeybindingsRegistry.registerCommandAndKeybindingRule({ id: DELETE_FILE_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceNotReadonlyContext, ExplorerResourceMoveableToTrash.toNegated()), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceMoveableToTrash.toNegated()), primary: KeyCode.Delete, mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace @@ -100,7 +100,7 @@ const CUT_FILE_ID = 'filesExplorer.cut'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: CUT_FILE_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerRootContext.toNegated(), ExplorerResourceNotReadonlyContext), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerRootContext.toNegated(), ExplorerResourceWritableContext), primary: KeyMod.CtrlCmd | KeyCode.KeyX, handler: cutFileHandler, }); @@ -121,7 +121,7 @@ CommandsRegistry.registerCommand(PASTE_FILE_ID, pasteFileHandler); KeybindingsRegistry.registerKeybindingRule({ id: `^${PASTE_FILE_ID}`, // the `^` enables pasting files into the explorer by preventing default bubble up weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceNotReadonlyContext), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceWritableContext), primary: KeyMod.CtrlCmd | KeyCode.KeyV, }); @@ -479,7 +479,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { command: { id: NEW_FILE_COMMAND_ID, title: NEW_FILE_LABEL, - precondition: ExplorerResourceNotReadonlyContext + precondition: ExplorerResourceWritableContext }, when: ExplorerFolderContext }); @@ -490,7 +490,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { command: { id: NEW_FOLDER_COMMAND_ID, title: NEW_FOLDER_LABEL, - precondition: ExplorerResourceNotReadonlyContext + precondition: ExplorerResourceWritableContext }, when: ExplorerFolderContext }); @@ -540,7 +540,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { id: CUT_FILE_ID, title: nls.localize('cut', "Cut"), }, - when: ContextKeyExpr.and(ExplorerRootContext.toNegated(), ExplorerResourceNotReadonlyContext) + when: ContextKeyExpr.and(ExplorerRootContext.toNegated(), ExplorerResourceWritableContext) }); MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { @@ -559,7 +559,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { command: { id: PASTE_FILE_ID, title: PASTE_FILE_LABEL, - precondition: ContextKeyExpr.and(ExplorerResourceNotReadonlyContext, FileCopiedContext) + precondition: ContextKeyExpr.and(ExplorerResourceWritableContext, FileCopiedContext) }, when: ExplorerFolderContext }); @@ -593,8 +593,8 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, ({ IsWebContext, // only on folders ExplorerFolderContext, - // only on editable folders - ExplorerResourceNotReadonlyContext + // only on writable folders + ExplorerResourceWritableContext ) })); @@ -638,7 +638,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { command: { id: RENAME_ID, title: TRIGGER_RENAME_LABEL, - precondition: ExplorerResourceNotReadonlyContext, + precondition: ExplorerResourceWritableContext, }, when: ExplorerRootContext.toNegated() }); @@ -648,13 +648,11 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { order: 20, command: { id: MOVE_FILE_TO_TRASH_ID, - title: MOVE_FILE_TO_TRASH_LABEL, - precondition: ContextKeyExpr.and(ExplorerResourceNotReadonlyContext), + title: MOVE_FILE_TO_TRASH_LABEL }, alt: { id: DELETE_FILE_ID, - title: nls.localize('deleteFile', "Delete Permanently"), - precondition: ContextKeyExpr.and(ExplorerResourceNotReadonlyContext), + title: nls.localize('deleteFile', "Delete Permanently") }, when: ContextKeyExpr.and(ExplorerRootContext.toNegated(), ExplorerResourceMoveableToTrash) }); @@ -664,8 +662,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { order: 20, command: { id: DELETE_FILE_ID, - title: nls.localize('deleteFile', "Delete Permanently"), - precondition: ExplorerResourceNotReadonlyContext, + title: nls.localize('deleteFile', "Delete Permanently") }, when: ContextKeyExpr.and(ExplorerRootContext.toNegated(), ExplorerResourceMoveableToTrash.toNegated()) }); diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index 635765f09b7..50f786ea2f7 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -93,7 +93,7 @@ async function refreshIfSeparator(value: string, explorerService: IExplorerServi } } -async function deleteFiles(explorerService: IExplorerService, workingCopyFileService: IWorkingCopyFileService, dialogService: IDialogService, configurationService: IConfigurationService, elements: ExplorerItem[], useTrash: boolean, skipConfirm = false, ignoreIfNotExists = false): Promise { +async function deleteFiles(explorerService: IExplorerService, workingCopyFileService: IWorkingCopyFileService, dialogService: IDialogService, configurationService: IConfigurationService, filesConfigurationService: IFilesConfigurationService, elements: ExplorerItem[], useTrash: boolean, skipConfirm = false, ignoreIfNotExists = false): Promise { let primaryButton: string; if (useTrash) { primaryButton = isWindows ? nls.localize('deleteButtonLabelRecycleBin', "&&Move to Recycle Bin") : nls.localize({ key: 'deleteButtonLabelTrash', comment: ['&& denotes a mnemonic'] }, "&&Move to Trash"); @@ -109,7 +109,7 @@ async function deleteFiles(explorerService: IExplorerService, workingCopyFileSer dirtyWorkingCopies.add(dirtyWorkingCopy); } } - let confirmed = true; + if (dirtyWorkingCopies.size) { let message: string; if (distinctElements.length > 1) { @@ -132,18 +132,40 @@ async function deleteFiles(explorerService: IExplorerService, workingCopyFileSer }); if (!response.confirmed) { - confirmed = false; + return; } else { skipConfirm = true; } } - // Check if file is dirty in editor and save it to avoid data loss - if (!confirmed) { - return; + // Handle readonly + if (!skipConfirm) { + const readonlyResources = distinctElements.filter(e => filesConfigurationService.isReadonly(e.resource)); + if (readonlyResources.length) { + let message: string; + if (readonlyResources.length > 1) { + message = nls.localize('readonlyMessageFilesDelete', "You are deleting files that are configured to be read-only. Do you want to continue?"); + } else if (readonlyResources[0].isDirectory) { + message = nls.localize('readonlyMessageFolderOneDelete', "You are deleting a folder {0} that is configured to be read-only. Do you want to continue?", distinctElements[0].name); + } else { + message = nls.localize('readonlyMessageFolderDelete', "You are deleting a file {0} that is configured to be read-only. Do you want to continue?", distinctElements[0].name); + } + + const response = await dialogService.confirm({ + type: 'warning', + message, + detail: nls.localize('continueDetail', "The read-only protection will be overridden if you continue."), + primaryButton: nls.localize('continueButtonLabel', "Continue") + }); + + if (!response.confirmed) { + return; + } + } } let confirmation: IConfirmationResult; + // We do not support undo of folders, so in that case the delete action is irreversible const deleteDetail = distinctElements.some(e => e.isDirectory) ? nls.localize('irreversible', "This action is irreversible!") : distinctElements.length > 1 ? nls.localize('restorePlural', "You can restore these files using the Undo command.") : nls.localize('restore', "You can restore this file using the Undo command."); @@ -234,7 +256,7 @@ async function deleteFiles(explorerService: IExplorerService, workingCopyFileSer skipConfirm = true; ignoreIfNotExists = true; - return deleteFiles(explorerService, workingCopyFileService, dialogService, configurationService, elements, useTrash, skipConfirm, ignoreIfNotExists); + return deleteFiles(explorerService, workingCopyFileService, dialogService, configurationService, filesConfigurationService, elements, useTrash, skipConfirm, ignoreIfNotExists); } } } @@ -1020,7 +1042,7 @@ export const moveFileToTrashHandler = async (accessor: ServicesAccessor) => { const explorerService = accessor.get(IExplorerService); const stats = explorerService.getContext(true).filter(s => !s.isRoot); if (stats.length) { - await deleteFiles(accessor.get(IExplorerService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), stats, true); + await deleteFiles(accessor.get(IExplorerService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), accessor.get(IFilesConfigurationService), stats, true); } }; @@ -1029,7 +1051,7 @@ export const deleteFileHandler = async (accessor: ServicesAccessor) => { const stats = explorerService.getContext(true).filter(s => !s.isRoot); if (stats.length) { - await deleteFiles(accessor.get(IExplorerService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), stats, false); + await deleteFiles(accessor.get(IExplorerService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), accessor.get(IFilesConfigurationService), stats, false); } }; diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index 266995ddc6d..689be82dc16 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -619,7 +619,7 @@ configurationRegistry.registerConfiguration({ '*.jsx': '${capture}.js', '*.tsx': '${capture}.ts', 'tsconfig.json': 'tsconfig.*.json', - 'package.json': 'package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb', + 'package.json': 'package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock', } } } diff --git a/src/vs/workbench/contrib/files/browser/views/explorerView.ts b/src/vs/workbench/contrib/files/browser/views/explorerView.ts index 1c718837501..f91029d58d5 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerView.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerView.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../../base/common/uri.js'; import * as perf from '../../../../../base/common/performance.js'; import { WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification } from '../../../../../base/common/actions.js'; import { memoize } from '../../../../../base/common/decorators.js'; -import { IFilesConfiguration, ExplorerFolderContext, FilesExplorerFocusedContext, ExplorerFocusedContext, ExplorerRootContext, ExplorerResourceReadonlyContext, ExplorerResourceCut, ExplorerResourceMoveableToTrash, ExplorerCompressedFocusContext, ExplorerCompressedFirstFocusContext, ExplorerCompressedLastFocusContext, ExplorerResourceAvailableEditorIdsContext, VIEW_ID, ExplorerResourceNotReadonlyContext, ViewHasSomeCollapsibleRootItemContext, FoldersViewVisibleContext, ExplorerResourceParentReadOnlyContext, ExplorerFindProviderActive } from '../../common/files.js'; +import { IFilesConfiguration, ExplorerFolderContext, FilesExplorerFocusedContext, ExplorerFocusedContext, ExplorerRootContext, ExplorerResourceReadonlyContext, ExplorerResourceCut, ExplorerResourceMoveableToTrash, ExplorerCompressedFocusContext, ExplorerCompressedFirstFocusContext, ExplorerCompressedLastFocusContext, ExplorerResourceAvailableEditorIdsContext, VIEW_ID, ExplorerResourceWritableContext, ViewHasSomeCollapsibleRootItemContext, FoldersViewVisibleContext, ExplorerResourceParentReadOnlyContext, ExplorerFindProviderActive } from '../../common/files.js'; import { FileCopiedContext, NEW_FILE_COMMAND_ID, NEW_FOLDER_COMMAND_ID } from '../fileActions.js'; import * as DOM from '../../../../../base/browser/dom.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; @@ -988,7 +988,7 @@ export function createFileIconThemableTreeContainerScope(container: HTMLElement, const CanCreateContext = ContextKeyExpr.or( // Folder: can create unless readonly - ContextKeyExpr.and(ExplorerFolderContext, ExplorerResourceNotReadonlyContext), + ContextKeyExpr.and(ExplorerFolderContext, ExplorerResourceWritableContext), // File: can create unless parent is readonly ContextKeyExpr.and(ExplorerFolderContext.toNegated(), ExplorerResourceParentReadOnlyContext.toNegated()) ); diff --git a/src/vs/workbench/contrib/files/common/files.ts b/src/vs/workbench/contrib/files/common/files.ts index 56715560f15..0eb091699a5 100644 --- a/src/vs/workbench/contrib/files/common/files.ts +++ b/src/vs/workbench/contrib/files/common/files.ts @@ -40,7 +40,7 @@ export const ExplorerViewletVisibleContext = new RawContextKey('explore export const FoldersViewVisibleContext = new RawContextKey('foldersViewVisible', true, { type: 'boolean', description: localize('foldersViewVisible', "True when the FOLDERS view (the file tree within the explorer view container) is visible.") }); export const ExplorerFolderContext = new RawContextKey('explorerResourceIsFolder', false, { type: 'boolean', description: localize('explorerResourceIsFolder', "True when the focused item in the EXPLORER is a folder.") }); export const ExplorerResourceReadonlyContext = new RawContextKey('explorerResourceReadonly', false, { type: 'boolean', description: localize('explorerResourceReadonly', "True when the focused item in the EXPLORER is read-only.") }); -export const ExplorerResourceNotReadonlyContext = ExplorerResourceReadonlyContext.toNegated(); +export const ExplorerResourceWritableContext = ExplorerResourceReadonlyContext.toNegated(); export const ExplorerResourceParentReadOnlyContext = new RawContextKey('explorerResourceParentReadonly', false, { type: 'boolean', description: localize('explorerResourceParentReadonly', "True when the focused item in the EXPLORER's parent is read-only.") }); /** diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 30dd09c6e48..561bef81e76 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -11,7 +11,7 @@ import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diff import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { InlineChatController, InlineChatRunOptions } from './inlineChatController.js'; -import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_STASHED_SESSION, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, CTX_INLINE_CHAT_INNER_CURSOR_LAST, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_DOCUMENT_CHANGED, CTX_INLINE_CHAT_EDIT_MODE, EditMode, MENU_INLINE_CHAT_WIDGET_STATUS, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, InlineChatResponseType, ACTION_REGENERATE_RESPONSE, ACTION_VIEW_IN_CHAT, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, MENU_INLINE_CHAT_ZONE, ACTION_DISCARD_CHANGES, CTX_INLINE_CHAT_POSSIBLE, ACTION_START } from '../common/inlineChat.js'; +import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_STASHED_SESSION, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, CTX_INLINE_CHAT_INNER_CURSOR_LAST, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_WIDGET_STATUS, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, InlineChatResponseType, ACTION_REGENERATE_RESPONSE, ACTION_VIEW_IN_CHAT, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, MENU_INLINE_CHAT_ZONE, ACTION_DISCARD_CHANGES, CTX_INLINE_CHAT_POSSIBLE, ACTION_START } from '../common/inlineChat.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -264,7 +264,7 @@ export class AcceptChanges extends AbstractInlineChatAction { shortTitle: localize('apply2', 'Accept'), icon: Codicon.check, f1: true, - precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE, ContextKeyExpr.or(CTX_INLINE_CHAT_DOCUMENT_CHANGED.toNegated(), CTX_INLINE_CHAT_EDIT_MODE.notEqualsTo(EditMode.Preview))), + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE), keybinding: [{ weight: KeybindingWeight.WorkbenchContrib + 10, primary: KeyMod.CtrlCmd | KeyCode.Enter, @@ -300,16 +300,6 @@ export class DiscardHunkAction extends AbstractInlineChatAction { icon: Codicon.chromeClose, precondition: CTX_INLINE_CHAT_VISIBLE, menu: [{ - id: MENU_INLINE_CHAT_WIDGET_STATUS, - group: '0_main', - order: 2, - when: ContextKeyExpr.and( - ChatContextKeys.inputHasText.toNegated(), - CTX_INLINE_CHAT_REQUEST_IN_PROGRESS.negate(), - CTX_INLINE_CHAT_RESPONSE_TYPE.isEqualTo(InlineChatResponseType.MessagesAndEdits), - CTX_INLINE_CHAT_EDIT_MODE.isEqualTo(EditMode.Live) - ), - }, { id: MENU_INLINE_CHAT_ZONE, group: 'navigation', order: 2 @@ -392,10 +382,7 @@ export class CloseAction extends AbstractInlineChatAction { order: 1, when: ContextKeyExpr.and( CTX_INLINE_CHAT_REQUEST_IN_PROGRESS.negate(), - ContextKeyExpr.or( - CTX_INLINE_CHAT_RESPONSE_TYPE.isEqualTo(InlineChatResponseType.Messages), - CTX_INLINE_CHAT_EDIT_MODE.isEqualTo(EditMode.Preview) - ) + CTX_INLINE_CHAT_RESPONSE_TYPE.isEqualTo(InlineChatResponseType.Messages) ), }] }); @@ -506,7 +493,7 @@ export class ToggleDiffForChange extends AbstractInlineChatAction { constructor() { super({ id: ACTION_TOGGLE_DIFF, - precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_EDIT_MODE.isEqualTo(EditMode.Live), CTX_INLINE_CHAT_CHANGE_HAS_DIFF), + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_CHANGE_HAS_DIFF), title: localize2('showChanges', 'Toggle Changes'), icon: Codicon.diffSingle, toggled: { @@ -515,7 +502,6 @@ export class ToggleDiffForChange extends AbstractInlineChatAction { menu: [{ id: MENU_INLINE_CHAT_WIDGET_STATUS, group: 'zzz', - when: ContextKeyExpr.and(CTX_INLINE_CHAT_EDIT_MODE.isEqualTo(EditMode.Live)), order: 1, }, { id: MENU_INLINE_CHAT_ZONE, diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 9b4f17b4173..d32358f537d 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -43,12 +43,12 @@ import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; import { ChatModel, ChatRequestRemovalReason, IChatRequestModel, IChatTextEditGroup, IChatTextEditGroupState, IResponse } from '../../chat/common/chatModel.js'; import { IChatService } from '../../chat/common/chatService.js'; import { INotebookEditorService } from '../../notebook/browser/services/notebookEditorService.js'; -import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_USER_DID_EDIT, CTX_INLINE_CHAT_VISIBLE, EditMode, INLINE_CHAT_ID, InlineChatConfigKeys, InlineChatResponseType } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_USER_DID_EDIT, CTX_INLINE_CHAT_VISIBLE, INLINE_CHAT_ID, InlineChatConfigKeys, InlineChatResponseType } from '../common/inlineChat.js'; import { IInlineChatSavingService } from './inlineChatSavingService.js'; import { HunkInformation, Session, StashedSession } from './inlineChatSession.js'; import { IInlineChatSessionService } from './inlineChatSessionService.js'; import { InlineChatError } from './inlineChatSessionServiceImpl.js'; -import { EditModeStrategy, HunkAction, IEditObserver, LiveStrategy, PreviewStrategy, ProgressingEditsOptions } from './inlineChatStrategies.js'; +import { HunkAction, IEditObserver, LiveStrategy, ProgressingEditsOptions } from './inlineChatStrategies.js'; import { InlineChatZoneWidget } from './inlineChatZoneWidget.js'; export const enum State { @@ -131,7 +131,7 @@ export class InlineChatController implements IEditorContribution { private readonly _sessionStore = this._store.add(new DisposableStore()); private readonly _stashedSession = this._store.add(new MutableDisposable()); private _session?: Session; - private _strategy?: EditModeStrategy; + private _strategy?: LiveStrategy; constructor( private readonly _editor: ICodeEditor, @@ -255,10 +255,6 @@ export class InlineChatController implements IEditorContribution { return INLINE_CHAT_ID; } - private _getMode(): EditMode { - return this._configurationService.getValue(InlineChatConfigKeys.Mode); - } - getWidgetPosition(): Position | undefined { return this._ui.value.position; } @@ -338,7 +334,7 @@ export class InlineChatController implements IEditorContribution { try { session = await this._inlineChatSessionService.createSession( this._editor, - { editMode: this._getMode(), wholeRange: options.initialRange }, + { wholeRange: options.initialRange }, createSessionCts.token ); } catch (error) { @@ -371,15 +367,7 @@ export class InlineChatController implements IEditorContribution { await session.chatModel.waitForInitialization(); // create a new strategy - switch (session.editMode) { - case EditMode.Preview: - this._strategy = this._instaService.createInstance(PreviewStrategy, session, this._editor, this._ui.value); - break; - case EditMode.Live: - default: - this._strategy = this._instaService.createInstance(LiveStrategy, session, this._editor, this._ui.value, session.headless); - break; - } + this._strategy = this._instaService.createInstance(LiveStrategy, session, this._editor, this._ui.value, session.headless); this._session = session; return State.INIT_UI; @@ -658,7 +646,6 @@ export class InlineChatController implements IEditorContribution { const newSession = await this._inlineChatSessionService.createSession( newEditor, { - editMode: this._getMode(), session: this._session, }, CancellationToken.None); // TODO@ulugbekna: add proper cancellation? @@ -1116,13 +1103,8 @@ export class InlineChatController implements IEditorContribution { finishExistingSession(): void { if (this._session) { - if (this._session.editMode === EditMode.Preview) { - this._log('finishing existing session, using CANCEL', this._session.editMode); - this.cancelSession(); - } else { - this._log('finishing existing session, using APPLY', this._session.editMode); - this.acceptSession(); - } + this._log('finishing existing session, using APPLY'); + this.acceptSession(); } } @@ -1157,7 +1139,7 @@ export class InlineChatController implements IEditorContribution { return false; } - const session = await this._inlineChatSessionService.createSession(this._editor, { editMode: EditMode.Live, wholeRange: anchor, headless: true }, token); + const session = await this._inlineChatSessionService.createSession(this._editor, { wholeRange: anchor, headless: true }, token); if (!session) { return false; } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts index 83d2d451dec..c153bb89559 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts @@ -6,7 +6,7 @@ import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IIdentifiedSingleEditOperation, IModelDecorationOptions, IModelDeltaDecoration, ITextModel, IValidEditOperation, TrackedRangeStickiness } from '../../../../editor/common/model.js'; -import { EditMode, CTX_INLINE_CHAT_HAS_STASHED_SESSION } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_HAS_STASHED_SESSION } from '../common/inlineChat.js'; import { IRange, Range } from '../../../../editor/common/core/range.js'; import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; import { EditOperation, ISingleEditOperation } from '../../../../editor/common/core/editOperation.js'; @@ -36,7 +36,6 @@ export type TelemetryData = { finishedByEdit: boolean; startTime: string; endTime: string; - editMode: string; acceptedHunks: number; discardedHunks: number; responseTypes: string; @@ -53,7 +52,6 @@ export type TelemetryDataClassification = { finishedByEdit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Did edits cause the session to terminate' }; startTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When the session started' }; endTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When the session ended' }; - editMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'What edit mode was choosen: live, livePreview, preview' }; acceptedHunks: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of accepted hunks' }; discardedHunks: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of discarded hunks' }; responseTypes: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Comma separated list of response types like edits, message, mixed' }; @@ -125,7 +123,6 @@ export class Session { private readonly _versionByRequest = new Map(); constructor( - readonly editMode: EditMode, readonly headless: boolean, /** * The URI of the document which is being EditorEdit @@ -154,7 +151,6 @@ export class Session { finishedByEdit: false, rounds: '', undos: '', - editMode, unstashed: 0, acceptedHunks: 0, discardedHunks: 0, diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts index 233e79d76e3..6c4541c633e 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts @@ -10,7 +10,6 @@ import { IActiveCodeEditor, ICodeEditor } from '../../../../editor/browser/edito import { IRange } from '../../../../editor/common/core/range.js'; import { IValidEditOperation } from '../../../../editor/common/model.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { EditMode } from '../common/inlineChat.js'; import { Session, StashedSession } from './inlineChatSession.js'; export interface ISessionKeyComputer { @@ -36,7 +35,7 @@ export interface IInlineChatSessionService { onDidStashSession: Event; onDidEndSession: Event; - createSession(editor: IActiveCodeEditor, options: { editMode: EditMode; wholeRange?: IRange; session?: Session; headless?: boolean }, token: CancellationToken): Promise; + createSession(editor: IActiveCodeEditor, options: { wholeRange?: IRange; session?: Session; headless?: boolean }, token: CancellationToken): Promise; moveSession(session: Session, newEditor: ICodeEditor): void; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts index 758764720e8..284212f668f 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -22,7 +22,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { DEFAULT_EDITOR_ASSOCIATION } from '../../../common/editor.js'; import { ChatAgentLocation, IChatAgentService } from '../../chat/common/chatAgents.js'; import { IChatService } from '../../chat/common/chatService.js'; -import { CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_POSSIBLE, EditMode } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_POSSIBLE } from '../common/inlineChat.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { UntitledTextEditorInput } from '../../../services/untitled/common/untitledTextEditorInput.js'; import { HunkData, Session, SessionWholeRange, StashedSession, TelemetryData, TelemetryDataClassification } from './inlineChatSession.js'; @@ -88,7 +88,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { this._sessions.clear(); } - async createSession(editor: IActiveCodeEditor, options: { editMode: EditMode; headless?: boolean; wholeRange?: Range; session?: Session }, token: CancellationToken): Promise { + async createSession(editor: IActiveCodeEditor, options: { headless?: boolean; wholeRange?: Range; session?: Session }, token: CancellationToken): Promise { const agent = this._chatAgentService.getDefaultAgent(ChatAgentLocation.Editor); @@ -197,7 +197,6 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { } const session = new Session( - options.editMode, options.headless ?? false, targetUri, textModel0, diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts index 4d8b814e0df..2518e2f2930 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatStrategies.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { WindowIntervalTimer } from '../../../../base/browser/dom.js'; -import { coalesceInPlace } from '../../../../base/common/arrays.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; @@ -27,9 +26,8 @@ import { SaveReason } from '../../../common/editor.js'; import { countWords } from '../../chat/common/chatWordCounter.js'; import { HunkInformation, Session, HunkState } from './inlineChatSession.js'; import { InlineChatZoneWidget } from './inlineChatZoneWidget.js'; -import { ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, CTX_INLINE_CHAT_DOCUMENT_CHANGED, InlineChatConfigKeys, MENU_INLINE_CHAT_ZONE, minimapInlineChatDiffInserted, overviewRulerInlineChatDiffInserted } from '../common/inlineChat.js'; +import { ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, InlineChatConfigKeys, MENU_INLINE_CHAT_ZONE, minimapInlineChatDiffInserted, overviewRulerInlineChatDiffInserted } from '../common/inlineChat.js'; import { assertType } from '../../../../base/common/types.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; import { performAsyncTextEdit, asProgressiveEdit } from './utils.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -57,183 +55,7 @@ export const enum HunkAction { ToggleDiff } -export abstract class EditModeStrategy { - - protected static _decoBlock = ModelDecorationOptions.register({ - description: 'inline-chat', - showIfCollapsed: false, - isWholeLine: true, - }); - - protected readonly _store = new DisposableStore(); - protected readonly _onDidAccept = this._store.add(new Emitter()); - protected readonly _onDidDiscard = this._store.add(new Emitter()); - - - readonly onDidAccept: Event = this._onDidAccept.event; - readonly onDidDiscard: Event = this._onDidDiscard.event; - - constructor( - protected readonly _session: Session, - protected readonly _editor: ICodeEditor, - protected readonly _zone: InlineChatZoneWidget, - @ITextFileService private readonly _textFileService: ITextFileService, - @IInstantiationService protected readonly _instaService: IInstantiationService, - ) { } - - dispose(): void { - this._store.dispose(); - } - - performHunkAction(_hunk: HunkInformation | undefined, action: HunkAction) { - if (action === HunkAction.Accept) { - this._onDidAccept.fire(); - } else if (action === HunkAction.Discard) { - this._onDidDiscard.fire(); - } - } - - protected async _doApplyChanges(ignoreLocal: boolean): Promise { - - const untitledModels: IUntitledTextEditorModel[] = []; - - const editor = this._instaService.createInstance(DefaultChatTextEditor); - - - for (const request of this._session.chatModel.getRequests()) { - - if (!request.response?.response) { - continue; - } - - for (const item of request.response.response.value) { - if (item.kind !== 'textEditGroup') { - continue; - } - if (ignoreLocal && isEqual(item.uri, this._session.textModelN.uri)) { - continue; - } - - await editor.apply(request.response, item, undefined); - - if (item.uri.scheme === Schemas.untitled) { - const untitled = this._textFileService.untitled.get(item.uri); - if (untitled) { - untitledModels.push(untitled); - } - } - } - } - - for (const untitledModel of untitledModels) { - if (!untitledModel.isDisposed()) { - await untitledModel.resolve(); - await untitledModel.save({ reason: SaveReason.EXPLICIT }); - } - } - } - - abstract apply(): Promise; - - cancel() { - return this._session.hunkData.discardAll(); - } - - - - abstract makeProgressiveChanges(edits: ISingleEditOperation[], obs: IEditObserver, timings: ProgressingEditsOptions, undoStopBefore: boolean): Promise; - - abstract makeChanges(edits: ISingleEditOperation[], obs: IEditObserver, undoStopBefore: boolean): Promise; - - abstract renderChanges(): Promise; - - abstract hasFocus(): boolean; - - getWholeRangeDecoration(): IModelDeltaDecoration[] { - const ranges = [this._session.wholeRange.value]; - const newDecorations = ranges.map(range => range.isEmpty() ? undefined : ({ range, options: EditModeStrategy._decoBlock })); - coalesceInPlace(newDecorations); - return newDecorations; - } -} - -export class PreviewStrategy extends EditModeStrategy { - - private readonly _ctxDocumentChanged: IContextKey; - - constructor( - session: Session, - editor: ICodeEditor, - zone: InlineChatZoneWidget, - @IModelService modelService: IModelService, - @IContextKeyService contextKeyService: IContextKeyService, - @ITextFileService textFileService: ITextFileService, - @IInstantiationService instaService: IInstantiationService - ) { - super(session, editor, zone, textFileService, instaService); - - this._ctxDocumentChanged = CTX_INLINE_CHAT_DOCUMENT_CHANGED.bindTo(contextKeyService); - - const baseModel = modelService.getModel(session.targetUri)!; - Event.debounce(baseModel.onDidChangeContent.bind(baseModel), () => { }, 350)(_ => { - if (!baseModel.isDisposed() && !session.textModel0.isDisposed()) { - this._ctxDocumentChanged.set(session.hasChangedText); - } - }, undefined, this._store); - } - - override dispose(): void { - this._ctxDocumentChanged.reset(); - super.dispose(); - } - - override async apply() { - await super._doApplyChanges(false); - } - - override async makeChanges(): Promise { - } - - override async makeProgressiveChanges(): Promise { - } - - override async renderChanges(): Promise { } - - hasFocus(): boolean { - return this._zone.widget.hasFocus(); - } -} - - -export interface ProgressingEditsOptions { - duration: number; - token: CancellationToken; -} - - - -type HunkDisplayData = { - - decorationIds: string[]; - - diffViewZoneId: string | undefined; - diffViewZone: IViewZone; - - lensActionsViewZoneIds?: string[]; - - distance: number; - position: Position; - acceptHunk: () => void; - discardHunk: () => void; - toggleDiff?: () => any; - remove(): void; - move: (next: boolean) => void; - - hunk: HunkInformation; -}; - - -export class LiveStrategy extends EditModeStrategy { +export class LiveStrategy { private readonly _decoInsertedText = ModelDecorationOptions.register({ description: 'inline-modified-line', @@ -255,17 +77,23 @@ export class LiveStrategy extends EditModeStrategy { stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, }); + protected readonly _store = new DisposableStore(); + protected readonly _onDidAccept = this._store.add(new Emitter()); + protected readonly _onDidDiscard = this._store.add(new Emitter()); private readonly _ctxCurrentChangeHasDiff: IContextKey; private readonly _ctxCurrentChangeShowsDiff: IContextKey; - private readonly _progressiveEditingDecorations: IEditorDecorationsCollection; private readonly _lensActionsFactory: ConflictActionsFactory; private _editCount: number = 0; + private readonly _hunkData = new Map(); + + readonly onDidAccept: Event = this._onDidAccept.event; + readonly onDidDiscard: Event = this._onDidDiscard.event; constructor( - session: Session, - editor: ICodeEditor, - zone: InlineChatZoneWidget, + protected readonly _session: Session, + protected readonly _editor: ICodeEditor, + protected readonly _zone: InlineChatZoneWidget, private readonly _showOverlayToolbar: boolean, @IContextKeyService contextKeyService: IContextKeyService, @IEditorWorkerService protected readonly _editorWorkerService: IEditorWorkerService, @@ -273,21 +101,19 @@ export class LiveStrategy extends EditModeStrategy { @IConfigurationService private readonly _configService: IConfigurationService, @IMenuService private readonly _menuService: IMenuService, @IContextKeyService private readonly _contextService: IContextKeyService, - @ITextFileService textFileService: ITextFileService, - @IInstantiationService instaService: IInstantiationService + @ITextFileService private readonly _textFileService: ITextFileService, + @IInstantiationService protected readonly _instaService: IInstantiationService ) { - super(session, editor, zone, textFileService, instaService); this._ctxCurrentChangeHasDiff = CTX_INLINE_CHAT_CHANGE_HAS_DIFF.bindTo(contextKeyService); this._ctxCurrentChangeShowsDiff = CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF.bindTo(contextKeyService); this._progressiveEditingDecorations = this._editor.createDecorationsCollection(); this._lensActionsFactory = this._store.add(new ConflictActionsFactory(this._editor)); - } - override dispose(): void { + dispose(): void { this._resetDiff(); - super.dispose(); + this._store.dispose(); } private _resetDiff(): void { @@ -297,29 +123,29 @@ export class LiveStrategy extends EditModeStrategy { this._progressiveEditingDecorations.clear(); - for (const data of this._hunkDisplayData.values()) { + for (const data of this._hunkData.values()) { data.remove(); } } - override async apply() { + async apply() { this._resetDiff(); if (this._editCount > 0) { this._editor.pushUndoStop(); } - await super._doApplyChanges(true); + await this._doApplyChanges(true); } - override cancel() { + cancel() { this._resetDiff(); - return super.cancel(); + return this._session.hunkData.discardAll(); } - override async makeChanges(edits: ISingleEditOperation[], obs: IEditObserver, undoStopBefore: boolean): Promise { + async makeChanges(edits: ISingleEditOperation[], obs: IEditObserver, undoStopBefore: boolean): Promise { return this._makeChanges(edits, obs, undefined, undefined, undoStopBefore); } - override async makeProgressiveChanges(edits: ISingleEditOperation[], obs: IEditObserver, opts: ProgressingEditsOptions, undoStopBefore: boolean): Promise { + async makeProgressiveChanges(edits: ISingleEditOperation[], obs: IEditObserver, opts: ProgressingEditsOptions, undoStopBefore: boolean): Promise { // add decorations once per line that got edited const progress = new Progress(edits => { @@ -373,7 +199,7 @@ export class LiveStrategy extends EditModeStrategy { } } - override performHunkAction(hunk: HunkInformation | undefined, action: HunkAction) { + performHunkAction(hunk: HunkInformation | undefined, action: HunkAction) { const displayData = this._findDisplayData(hunk); if (!displayData) { @@ -404,14 +230,14 @@ export class LiveStrategy extends EditModeStrategy { let result: HunkDisplayData | undefined; if (hunkInfo) { // use context hunk (from tool/buttonbar) - result = this._hunkDisplayData.get(hunkInfo); + result = this._hunkData.get(hunkInfo); } if (!result && this._zone.position) { // find nearest from zone position const zoneLine = this._zone.position.lineNumber; let distance: number = Number.MAX_SAFE_INTEGER; - for (const candidate of this._hunkDisplayData.values()) { + for (const candidate of this._hunkData.values()) { if (candidate.hunk.getState() !== HunkState.Pending) { continue; } @@ -433,14 +259,12 @@ export class LiveStrategy extends EditModeStrategy { if (!result) { // fallback: first hunk that is pending - result = Iterable.first(Iterable.filter(this._hunkDisplayData.values(), candidate => candidate.hunk.getState() === HunkState.Pending)); + result = Iterable.first(Iterable.filter(this._hunkData.values(), candidate => candidate.hunk.getState() === HunkState.Pending)); } return result; } - private readonly _hunkDisplayData = new Map(); - - override async renderChanges() { + async renderChanges() { this._progressiveEditingDecorations.clear(); @@ -450,7 +274,7 @@ export class LiveStrategy extends EditModeStrategy { changeDecorationsAndViewZones(this._editor, (decorationsAccessor, viewZoneAccessor) => { - const keysNow = new Set(this._hunkDisplayData.keys()); + const keysNow = new Set(this._hunkData.keys()); widgetData = undefined; for (const hunkData of this._session.hunkData.getInfo()) { @@ -458,7 +282,7 @@ export class LiveStrategy extends EditModeStrategy { keysNow.delete(hunkData); const hunkRanges = hunkData.getRangesN(); - let data = this._hunkDisplayData.get(hunkData); + let data = this._hunkData.get(hunkData); if (!data) { // first time -> create decoration const decorationIds: string[] = []; @@ -570,7 +394,7 @@ export class LiveStrategy extends EditModeStrategy { decorationsAccessor.removeDecoration(decorationId); } if (data.diffViewZoneId) { - viewZoneAccessor.removeZone(data.diffViewZoneId); + viewZoneAccessor.removeZone(data.diffViewZoneId!); } data.decorationIds = []; data.diffViewZoneId = undefined; @@ -583,11 +407,11 @@ export class LiveStrategy extends EditModeStrategy { }; const move = (next: boolean) => { - const keys = Array.from(this._hunkDisplayData.keys()); + const keys = Array.from(this._hunkData.keys()); const idx = keys.indexOf(hunkData); const nextIdx = (idx + (next ? 1 : -1) + keys.length) % keys.length; if (nextIdx !== idx) { - const nextData = this._hunkDisplayData.get(keys[nextIdx])!; + const nextData = this._hunkData.get(keys[nextIdx])!; this._zone.updatePositionAndHeight(nextData?.position); renderHunks(); } @@ -613,7 +437,7 @@ export class LiveStrategy extends EditModeStrategy { move, }; - this._hunkDisplayData.set(hunkData, data); + this._hunkData.set(hunkData, data); } else if (hunkData.getState() !== HunkState.Pending) { data.remove(); @@ -634,9 +458,9 @@ export class LiveStrategy extends EditModeStrategy { } for (const key of keysNow) { - const data = this._hunkDisplayData.get(key); + const data = this._hunkData.get(key); if (data) { - this._hunkDisplayData.delete(key); + this._hunkData.delete(key); data.remove(); } } @@ -652,7 +476,7 @@ export class LiveStrategy extends EditModeStrategy { this._ctxCurrentChangeHasDiff.set(Boolean(widgetData.toggleDiff)); - } else if (this._hunkDisplayData.size > 0) { + } else if (this._hunkData.size > 0) { // everything accepted or rejected let oneAccepted = false; for (const hunkData of this._session.hunkData.getInfo()) { @@ -678,12 +502,77 @@ export class LiveStrategy extends EditModeStrategy { return this._zone.widget.hasFocus(); } - override getWholeRangeDecoration(): IModelDeltaDecoration[] { + getWholeRangeDecoration(): IModelDeltaDecoration[] { // don't render the blue in live mode return []; } + + private async _doApplyChanges(ignoreLocal: boolean): Promise { + + const untitledModels: IUntitledTextEditorModel[] = []; + + const editor = this._instaService.createInstance(DefaultChatTextEditor); + + + for (const request of this._session.chatModel.getRequests()) { + + if (!request.response?.response) { + continue; + } + + for (const item of request.response.response.value) { + if (item.kind !== 'textEditGroup') { + continue; + } + if (ignoreLocal && isEqual(item.uri, this._session.textModelN.uri)) { + continue; + } + + await editor.apply(request.response, item, undefined); + + if (item.uri.scheme === Schemas.untitled) { + const untitled = this._textFileService.untitled.get(item.uri); + if (untitled) { + untitledModels.push(untitled); + } + } + } + } + + for (const untitledModel of untitledModels) { + if (!untitledModel.isDisposed()) { + await untitledModel.resolve(); + await untitledModel.save({ reason: SaveReason.EXPLICIT }); + } + } + } } +export interface ProgressingEditsOptions { + duration: number; + token: CancellationToken; +} + +type HunkDisplayData = { + + decorationIds: string[]; + + diffViewZoneId: string | undefined; + diffViewZone: IViewZone; + + lensActionsViewZoneIds?: string[]; + + distance: number; + position: Position; + acceptHunk: () => void; + discardHunk: () => void; + toggleDiff?: () => any; + remove(): void; + move: (next: boolean) => void; + + hunk: HunkInformation; +}; + function changeDecorationsAndViewZones(editor: ICodeEditor, callback: (accessor: IModelDecorationsChangeAccessor, viewZoneAccessor: IViewZoneChangeAccessor) => void): void { editor.changeDecorations(decorationsAccessor => { editor.changeViewZones(viewZoneAccessor => { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts index ba1d884af6c..783e6f05f59 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts @@ -22,7 +22,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatWidgetLocationOptions } from '../../chat/browser/chatWidget.js'; import { isResponseVM } from '../../chat/common/chatViewModel.js'; -import { ACTION_REGENERATE_RESPONSE, ACTION_REPORT_ISSUE, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, EditMode, InlineChatConfigKeys, MENU_INLINE_CHAT_WIDGET_SECONDARY, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; +import { ACTION_REGENERATE_RESPONSE, ACTION_REPORT_ISSUE, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_WIDGET_SECONDARY, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; export class InlineChatZoneWidget extends ZoneWidget { @@ -84,10 +84,8 @@ export class InlineChatZoneWidget extends ZoneWidget { }, rendererOptions: { renderTextEditsAsSummary: (uri) => { - // render edits as summary only when using Live mode and when - // dealing with the current file in the editor - return isEqual(uri, editor.getModel()?.uri) - && configurationService.getValue(InlineChatConfigKeys.Mode) === EditMode.Live; + // render when dealing with the current file in the editor + return isEqual(uri, editor.getModel()?.uri); }, renderDetectedCommandsWithRequest: true, } diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index b541cb0e621..19f27bf3ad6 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -13,7 +13,6 @@ import { diffInserted, diffRemoved, editorWidgetBackground, editorWidgetBorder, // settings export const enum InlineChatConfigKeys { - Mode = 'inlineChat.mode', FinishOnType = 'inlineChat.finishOnType', AcceptedOrDiscardBeforeSave = 'inlineChat.acceptedOrDiscardBeforeSave', StartWithOverlayWidget = 'inlineChat.startWithOverlayWidget', @@ -23,24 +22,9 @@ export const enum InlineChatConfigKeys { LineNLHint = 'inlineChat.lineNaturalLanguageHint' } -export const enum EditMode { - Live = 'live', - Preview = 'preview' -} - Registry.as(Extensions.Configuration).registerConfiguration({ id: 'editor', properties: { - [InlineChatConfigKeys.Mode]: { - description: localize('mode', "Configure if changes crafted with inline chat are applied directly to the document or are previewed first."), - default: EditMode.Live, - type: 'string', - enum: [EditMode.Live, EditMode.Preview], - markdownEnumDescriptions: [ - localize('mode.live', "Changes are applied directly to the document, can be highlighted via inline diffs, and accepted/discarded by hunks. Ending a session will keep the changes."), - localize('mode.preview', "Changes are previewed only and need to be accepted via the apply button. Ending a session will discard the changes."), - ] - }, [InlineChatConfigKeys.FinishOnType]: { description: localize('finishOnType', "Whether to finish an inline chat session when typing outside of changed regions."), default: false, @@ -103,10 +87,8 @@ export const CTX_INLINE_CHAT_INNER_CURSOR_END = new RawContextKey('inli export const CTX_INLINE_CHAT_OUTER_CURSOR_POSITION = new RawContextKey<'above' | 'below' | ''>('inlineChatOuterCursorPosition', '', localize('inlineChatOuterCursorPosition', "Whether the cursor of the outer editor is above or below the interactive editor input")); export const CTX_INLINE_CHAT_HAS_STASHED_SESSION = new RawContextKey('inlineChatHasStashedSession', false, localize('inlineChatHasStashedSession', "Whether interactive editor has kept a session for quick restore")); export const CTX_INLINE_CHAT_USER_DID_EDIT = new RawContextKey('inlineChatUserDidEdit', undefined, localize('inlineChatUserDidEdit', "Whether the user did changes ontop of the inline chat")); -export const CTX_INLINE_CHAT_DOCUMENT_CHANGED = new RawContextKey('inlineChatDocumentChanged', false, localize('inlineChatDocumentChanged', "Whether the document has changed concurrently")); export const CTX_INLINE_CHAT_CHANGE_HAS_DIFF = new RawContextKey('inlineChatChangeHasDiff', false, localize('inlineChatChangeHasDiff', "Whether the current change supports showing a diff")); export const CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF = new RawContextKey('inlineChatChangeShowsDiff', false, localize('inlineChatChangeShowsDiff', "Whether the current change showing a diff")); -export const CTX_INLINE_CHAT_EDIT_MODE = new RawContextKey('config.inlineChat.mode', EditMode.Live); export const CTX_INLINE_CHAT_REQUEST_IN_PROGRESS = new RawContextKey('inlineChatRequestInProgress', false, localize('inlineChatRequestInProgress', "Whether an inline chat request is currently in progress")); export const CTX_INLINE_CHAT_RESPONSE_TYPE = new RawContextKey('inlineChatResponseType', InlineChatResponseType.None, localize('inlineChatResponseTypes', "What type was the responses have been receieved, nothing yet, just messages, or messaged and local edits")); @@ -140,7 +122,7 @@ export const inlineChatInputBackground = registerColor('inlineChatInput.backgrou export const inlineChatDiffInserted = registerColor('inlineChatDiff.inserted', transparent(diffInserted, .5), localize('inlineChatDiff.inserted', "Background color of inserted text in the interactive editor input")); export const overviewRulerInlineChatDiffInserted = registerColor('editorOverviewRuler.inlineChatInserted', { dark: transparent(diffInserted, 0.6), light: transparent(diffInserted, 0.8), hcDark: transparent(diffInserted, 0.6), hcLight: transparent(diffInserted, 0.8) }, localize('editorOverviewRuler.inlineChatInserted', 'Overview ruler marker color for inline chat inserted content.')); -export const minimapInlineChatDiffInserted = registerColor('editorOverviewRuler.inlineChatInserted', { dark: transparent(diffInserted, 0.6), light: transparent(diffInserted, 0.8), hcDark: transparent(diffInserted, 0.6), hcLight: transparent(diffInserted, 0.8) }, localize('editorOverviewRuler.inlineChatInserted', 'Overview ruler marker color for inline chat inserted content.')); +export const minimapInlineChatDiffInserted = registerColor('editorMinimap.inlineChatInserted', { dark: transparent(diffInserted, 0.6), light: transparent(diffInserted, 0.8), hcDark: transparent(diffInserted, 0.6), hcLight: transparent(diffInserted, 0.8) }, localize('editorMinimap.inlineChatInserted', 'Minimap marker color for inline chat inserted content.')); export const inlineChatDiffRemoved = registerColor('inlineChatDiff.removed', transparent(diffRemoved, .5), localize('inlineChatDiff.removed', "Background color of removed text in the interactive editor input")); export const overviewRulerInlineChatDiffRemoved = registerColor('editorOverviewRuler.inlineChatRemoved', { dark: transparent(diffRemoved, 0.6), light: transparent(diffRemoved, 0.8), hcDark: transparent(diffRemoved, 0.6), hcLight: transparent(diffRemoved, 0.8) }, localize('editorOverviewRuler.inlineChatRemoved', 'Overview ruler marker color for inline chat removed content.')); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts index 7d07547770e..aaeb5de7b5a 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts @@ -35,7 +35,7 @@ import { ChatAgentLocation, ChatAgentService, IChatAgentData, IChatAgentNameServ import { IChatResponseViewModel } from '../../../chat/common/chatViewModel.js'; import { InlineChatController, State } from '../../browser/inlineChatController.js'; import { Session } from '../../browser/inlineChatSession.js'; -import { CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_USER_DID_EDIT, EditMode, InlineChatConfigKeys, InlineChatResponseType } from '../../common/inlineChat.js'; +import { CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_USER_DID_EDIT, InlineChatConfigKeys, InlineChatResponseType } from '../../common/inlineChat.js'; import { TestViewsService, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { IExtensionService, nullExtensionDescription } from '../../../../services/extensions/common/extensions.js'; import { IChatProgress, IChatService } from '../../../chat/common/chatService.js'; @@ -71,6 +71,8 @@ import { ITextModelService } from '../../../../../editor/common/services/resolve import { TextModelResolverService } from '../../../../services/textmodelResolver/common/textModelResolverService.js'; import { ChatInputBoxContentProvider } from '../../../chat/browser/chatEdinputInputContentProvider.js'; import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ILanguageModelToolsService } from '../../../chat/common/languageModelToolsService.js'; +import { MockLanguageModelToolsService } from '../../../chat/test/common/mockLanguageModelToolsService.js'; suite('InlineChatController', function () { @@ -198,13 +200,14 @@ suite('InlineChatController', function () { [IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()], [ILanguageModelsService, new SyncDescriptor(LanguageModelsService)], [ITextModelService, new SyncDescriptor(TextModelResolverService)], + [ILanguageModelToolsService, new SyncDescriptor(MockLanguageModelToolsService)], ); instaService = store.add((store.add(workbenchInstantiationService(undefined, store))).createChild(serviceCollection)); configurationService = instaService.get(IConfigurationService) as TestConfigurationService; configurationService.setUserConfiguration('chat', { editor: { fontSize: 14, fontFamily: 'default' } }); - configurationService.setUserConfiguration('inlineChat', { mode: EditMode.Live }); + configurationService.setUserConfiguration('editor', {}); contextKeyService = instaService.get(IContextKeyService) as MockContextKeyService; @@ -401,7 +404,6 @@ suite('InlineChatController', function () { test.skip('UI is streaming edits minutes after the response is finished #3345', async function () { - configurationService.setUserConfiguration(InlineChatConfigKeys.Mode, EditMode.Live); return runWithFakedTimers({ maxTaskCount: Number.MAX_SAFE_INTEGER }, async () => { @@ -842,7 +844,7 @@ suite('InlineChatController', function () { model.setValue(''); - const newSession = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const newSession = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(newSession); await chatService.sendRequest(newSession.chatModel.sessionId, 'Existing', { location: ChatAgentLocation.Editor }); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts index 665206f55b0..880552dd7ba 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts @@ -30,7 +30,6 @@ import { IInlineChatSavingService } from '../../browser/inlineChatSavingService. import { HunkState, Session } from '../../browser/inlineChatSession.js'; import { IInlineChatSessionService } from '../../browser/inlineChatSessionService.js'; import { InlineChatSessionServiceImpl } from '../../browser/inlineChatSessionServiceImpl.js'; -import { EditMode } from '../../common/inlineChat.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { assertType } from '../../../../../base/common/types.js'; @@ -178,7 +177,7 @@ suite('InlineChatSession', function () { test('Create, release', async function () { - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); inlineChatSessionService.releaseSession(session); }); @@ -187,7 +186,7 @@ suite('InlineChatSession', function () { const decorationCountThen = model.getAllDecorations().length; - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); assert.ok(session.textModelN === model); @@ -213,7 +212,7 @@ suite('InlineChatSession', function () { test('HunkData, accept', async function () { - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(1, 1), 'AI_EDIT\n'), EditOperation.insert(new Position(10, 1), 'AI_EDIT\n')]); @@ -234,7 +233,7 @@ suite('InlineChatSession', function () { test('HunkData, reject', async function () { - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(1, 1), 'AI_EDIT\n'), EditOperation.insert(new Position(10, 1), 'AI_EDIT\n')]); @@ -257,7 +256,7 @@ suite('InlineChatSession', function () { model.setValue('one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelwe\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\n'); - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); assert.ok(session.textModel0.equalsTextBuffer(session.textModelN.getTextBuffer())); @@ -306,7 +305,7 @@ suite('InlineChatSession', function () { const lines = ['one', 'two', 'three']; model.setValue(lines.join('\n')); - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(3, 1), 'AI WAS HERE\n')]); @@ -323,7 +322,7 @@ suite('InlineChatSession', function () { const lines = ['one', 'two', 'three', 'four', 'five']; model.setValue(lines.join('\n')); - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(3, 1), 'AI_EDIT\n')]); @@ -348,7 +347,7 @@ suite('InlineChatSession', function () { const lines = ['one', 'two', 'three']; model.setValue(lines.join('\n')); - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(3, 1), 'AI WAS HERE\n')]); @@ -364,7 +363,7 @@ suite('InlineChatSession', function () { const lines = ['one', 'two', 'three']; model.setValue(lines.join('\n')); - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(3, 1), 'AI WAS HERE\n')]); @@ -387,7 +386,7 @@ suite('InlineChatSession', function () { const lines = ['one', 'two', 'three', 'four', 'five']; model.setValue(lines.join('\n')); - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(3, 1), 'AI_EDIT\n')]); @@ -414,7 +413,7 @@ suite('InlineChatSession', function () { const lines = ['one', 'two', 'three', 'four', 'five']; model.setValue(lines.join('\n')); - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(3, 1), 'AI_EDIT\n')]); @@ -444,7 +443,7 @@ suite('InlineChatSession', function () { test('HunkData, accept, discardAll', async function () { - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(1, 1), 'AI_EDIT\n'), EditOperation.insert(new Position(10, 1), 'AI_EDIT\n')]); @@ -466,7 +465,7 @@ suite('InlineChatSession', function () { test('HunkData, discardAll return undo edits', async function () { - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.insert(new Position(1, 1), 'AI_EDIT\n'), EditOperation.insert(new Position(10, 1), 'AI_EDIT\n')]); @@ -509,7 +508,7 @@ suite('InlineChatSession', function () { }`; model.setValue(origValue); - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); const fakeRequest = new class extends mock() { @@ -543,7 +542,7 @@ suite('InlineChatSession', function () { if (n === 2) return 1; return fib(n - 1) + fib(n - 2); }`); - const session = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + const session = await inlineChatSessionService.createSession(editor, {}, CancellationToken.None); assertType(session); await makeEditAsAi([EditOperation.replace(new Range(5, 1, 6, Number.MAX_SAFE_INTEGER), ` diff --git a/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts b/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts index 63512ae0e38..2741b6681d4 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts @@ -12,7 +12,7 @@ import { LineRangeEdit } from './editing.js'; import { LineRange } from './lineRange.js'; import { ReentrancyBarrier } from '../../../../../base/common/controlFlow.js'; import { IMergeDiffComputer } from './diffComputer.js'; -import { autorun, IObservable, IReader, ITransaction, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { autorun, IObservableWithChange, IReader, ITransaction, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; import { UndoRedoGroup } from '../../../../../platform/undoRedo/common/undoRedo.js'; export class TextModelDiffs extends Disposable { @@ -61,14 +61,14 @@ export class TextModelDiffs extends Disposable { })); } - public get state(): IObservable { + public get state(): IObservableWithChange { return this._state; } /** * Diffs from base to input. */ - public get diffs(): IObservable { + public get diffs(): IObservableWithChange { return this._diffs; } diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts index cdd3aac6b29..ec198f8ad63 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts @@ -139,7 +139,7 @@ export class OpenScmGroupAction extends Action2 { constructor() { super({ id: '_workbench.openScmMultiDiffEditor', - title: localize2('viewChanges', 'View Changes'), + title: localize2('openChanges', 'Open Changes'), f1: false }); } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookChatActionsOverlay.ts b/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookChatActionsOverlay.ts index 552a657df4d..7ac61fbcb30 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookChatActionsOverlay.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookChatActionsOverlay.ts @@ -24,7 +24,7 @@ import { navigationBearingFakeActionId } from '../../../../chat/browser/chatEdit export class NotebookChatActionsOverlayController extends Disposable { constructor( private readonly notebookEditor: INotebookEditor, - cellDiffInfo: IObservable, + cellDiffInfo: IObservable, deletedCellDecorator: INotebookDeletedCellDecorator, @IChatEditingService private readonly _chatEditingService: IChatEditingService, @IInstantiationService instantiationService: IInstantiationService, @@ -60,7 +60,7 @@ export class NotebookChatActionsOverlay extends Disposable { constructor( private readonly notebookEditor: INotebookEditor, entry: IModifiedFileEntry, - cellDiffInfo: IObservable, + cellDiffInfo: IObservable, nextEntry: IModifiedFileEntry, previousEntry: IModifiedFileEntry, deletedCellDecorator: INotebookDeletedCellDecorator, @@ -196,7 +196,7 @@ export class NotebookChatActionsOverlay extends Disposable { class NextPreviousChangeActionRunner extends ActionRunner { constructor( private readonly notebookEditor: INotebookEditor, - private readonly cellDiffInfo: IObservable, + private readonly cellDiffInfo: IObservable, private readonly entry: IModifiedFileEntry, private readonly next: IModifiedFileEntry, private readonly direction: 'next' | 'previous', diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookSynchronizerService.ts b/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookSynchronizerService.ts index 1e4cf151fae..9788828cf87 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookSynchronizerService.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookSynchronizerService.ts @@ -28,7 +28,6 @@ class NotebookSynchronizerSaveParticipant extends NotebookSaveParticipant { } override async participate(workingCopy: IStoredFileWorkingCopy, context: IStoredFileWorkingCopySaveParticipantContext, progress: IProgress, token: CancellationToken): Promise { - console.log('notebook synchronizer participate'); const session = this._chatEditingService.currentEditingSessionObs.get(); if (!session) { diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookSelectionHighlight.ts b/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookSelectionHighlight.ts index a7e82c41a73..305e4d8fde4 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookSelectionHighlight.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookSelectionHighlight.ts @@ -63,6 +63,7 @@ class NotebookSelectionHighlighter extends Disposable implements INotebookEditor this.anchorDisposables.clear(); this.anchorDisposables.add(this.anchorCell[1].onDidChangeCursorPosition((e) => { if (e.reason !== CursorChangeReason.Explicit) { + this.clearNotebookSelectionDecorations(); return; } diff --git a/src/vs/workbench/contrib/notebook/browser/controller/chat/cellChatActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/chat/cellChatActions.ts index e32df03950a..c367f5a8988 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/chat/cellChatActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/chat/cellChatActions.ts @@ -15,7 +15,7 @@ import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/con import { InputFocusedContextKey } from '../../../../../../platform/contextkey/common/contextkeys.js'; import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { CTX_INLINE_CHAT_DOCUMENT_CHANGED, CTX_INLINE_CHAT_EDIT_MODE, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, CTX_INLINE_CHAT_INNER_CURSOR_LAST, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_VISIBLE, EditMode, InlineChatResponseType, MENU_INLINE_CHAT_WIDGET_STATUS } from '../../../../inlineChat/common/inlineChat.js'; +import { CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, CTX_INLINE_CHAT_INNER_CURSOR_LAST, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_VISIBLE, InlineChatResponseType, MENU_INLINE_CHAT_WIDGET_STATUS } from '../../../../inlineChat/common/inlineChat.js'; import { CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST, CTX_NOTEBOOK_CHAT_HAS_AGENT, CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION, CTX_NOTEBOOK_CHAT_USER_DID_EDIT, MENU_CELL_CHAT_INPUT, MENU_CELL_CHAT_WIDGET, MENU_CELL_CHAT_WIDGET_STATUS } from './notebookChatContext.js'; import { NotebookChatController } from './notebookChatController.js'; import { CELL_TITLE_CELL_GROUP_ID, INotebookActionContext, INotebookCellActionContext, NotebookAction, NotebookCellAction, getContextFromActiveEditor, getEditorFromArgsOrActivePane } from '../coreActions.js'; @@ -684,7 +684,6 @@ export class AcceptChangesAndRun extends AbstractInlineChatAction { precondition: ContextKeyExpr.and( NOTEBOOK_EDITOR_EDITABLE.isEqualTo(true), CTX_INLINE_CHAT_VISIBLE, - ContextKeyExpr.or(CTX_INLINE_CHAT_DOCUMENT_CHANGED.toNegated(), CTX_INLINE_CHAT_EDIT_MODE.notEqualsTo(EditMode.Preview)) ), keybinding: undefined, menu: [{ diff --git a/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts b/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts index 3ef8a33b088..a0a5557ddab 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts @@ -847,7 +847,7 @@ abstract class AbstractElementRenderer extends Disposable { return; } - const modifiedMetadataSource = getFormattedMetadataJSON(this.notebookEditor.textModel?.transientOptions.transientCellMetadata, this.cell.modified?.metadata || {}, this.cell.modified?.language); + const modifiedMetadataSource = getFormattedMetadataJSON(this.notebookEditor.textModel?.transientOptions.transientCellMetadata, this.cell.modified?.metadata || {}, this.cell.modified?.language, true); modifiedMetadataModel.object.textEditorModel.setValue(modifiedMetadataSource); })); @@ -869,7 +869,7 @@ abstract class AbstractElementRenderer extends Disposable { const originalMetadataSource = getFormattedMetadataJSON(this.notebookEditor.textModel?.transientOptions.transientCellMetadata, this.cell.type === 'insert' ? this.cell.modified!.metadata || {} - : this.cell.original!.metadata || {}); + : this.cell.original!.metadata || {}, undefined, true); const uri = this.cell.type === 'insert' ? this.cell.modified!.uri : this.cell.original!.uri; diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index a09069d24cf..17568757a13 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -452,7 +452,7 @@ class CellInfoContentProvider { for (const cell of ref.object.notebook.cells) { if (cell.handle === data.handle) { const cellIndex = ref.object.notebook.cells.indexOf(cell); - const metadataSource = getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language); + const metadataSource = getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language, true); result = this._modelService.createModel( metadataSource, mode, @@ -460,9 +460,9 @@ class CellInfoContentProvider { ); this._disposables.push(disposables.add(ref.object.notebook.onDidChangeContent(e => { if (result && e.rawEvents.some(event => (event.kind === NotebookCellsChangeType.ChangeCellMetadata || event.kind === NotebookCellsChangeType.ChangeCellLanguage) && event.index === cellIndex)) { - const value = getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language); + const value = getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language, true); if (result.getValue() !== value) { - result.setValue(getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language)); + result.setValue(value); } } }))); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index b08b816b95a..b528b21b2a6 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -282,7 +282,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD } get visibleRanges() { - return this._list.visibleRanges || []; + return this._list ? (this._list.visibleRanges || []) : []; } private _baseCellEditorOptions = new Map(); diff --git a/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts b/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts index 018aa490c9a..ac481402ced 100644 --- a/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts +++ b/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts @@ -516,7 +516,7 @@ function computeRunStartTimeAdjustment(oldMetadata: NotebookCellInternalMetadata } -export function getFormattedMetadataJSON(transientCellMetadata: TransientCellMetadata | undefined, metadata: NotebookCellMetadata, language?: string) { +export function getFormattedMetadataJSON(transientCellMetadata: TransientCellMetadata | undefined, metadata: NotebookCellMetadata, language?: string, sortKeys?: boolean): string { let filteredMetadata: { [key: string]: any } = {}; if (transientCellMetadata) { @@ -541,7 +541,28 @@ export function getFormattedMetadataJSON(transientCellMetadata: TransientCellMet if (language) { obj.language = language; } - const metadataSource = toFormattedString(obj, {}); + const metadataSource = toFormattedString(sortKeys ? sortObjectPropertiesRecursively(obj) : obj, {}); return metadataSource; } + + +/** + * Sort the JSON to ensure when diffing, the JSON keys are sorted & matched correctly in diff view. + */ +export function sortObjectPropertiesRecursively(obj: any): any { + if (Array.isArray(obj)) { + return obj.map(sortObjectPropertiesRecursively); + } + if (obj !== undefined && obj !== null && typeof obj === 'object' && Object.keys(obj).length > 0) { + return ( + Object.keys(obj) + .sort() + .reduce>((sortedObj, prop) => { + sortedObj[prop] = sortObjectPropertiesRecursively(obj[prop]); + return sortedObj; + }, {}) as any + ); + } + return obj; +} diff --git a/src/vs/workbench/contrib/output/browser/output.contribution.ts b/src/vs/workbench/contrib/output/browser/output.contribution.ts index 7288357d606..0066f704b1f 100644 --- a/src/vs/workbench/contrib/output/browser/output.contribution.ts +++ b/src/vs/workbench/contrib/output/browser/output.contribution.ts @@ -10,7 +10,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { MenuId, registerAction2, Action2, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { OutputService } from './outputServices.js'; -import { OUTPUT_MODE_ID, OUTPUT_MIME, OUTPUT_VIEW_ID, IOutputService, CONTEXT_IN_OUTPUT, LOG_MODE_ID, LOG_MIME, CONTEXT_ACTIVE_FILE_OUTPUT, CONTEXT_OUTPUT_SCROLL_LOCK, IOutputChannelDescriptor, IFileOutputChannelDescriptor, ACTIVE_OUTPUT_CHANNEL_CONTEXT, CONTEXT_ACTIVE_OUTPUT_LEVEL_SETTABLE, IOutputChannelRegistry, Extensions, CONTEXT_ACTIVE_OUTPUT_LEVEL, CONTEXT_ACTIVE_OUTPUT_LEVEL_IS_DEFAULT } from '../../../services/output/common/output.js'; +import { OUTPUT_MODE_ID, OUTPUT_MIME, OUTPUT_VIEW_ID, IOutputService, CONTEXT_IN_OUTPUT, LOG_MODE_ID, LOG_MIME, CONTEXT_ACTIVE_FILE_OUTPUT, CONTEXT_OUTPUT_SCROLL_LOCK, IOutputChannelDescriptor, IFileOutputChannelDescriptor, ACTIVE_OUTPUT_CHANNEL_CONTEXT, CONTEXT_ACTIVE_OUTPUT_LEVEL_SETTABLE, IOutputChannelRegistry, Extensions, CONTEXT_ACTIVE_OUTPUT_LEVEL, CONTEXT_ACTIVE_OUTPUT_LEVEL_IS_DEFAULT, SHOW_INFO_FILTER_CONTEXT, SHOW_TRACE_FILTER_CONTEXT, SHOW_DEBUG_FILTER_CONTEXT, SHOW_ERROR_FILTER_CONTEXT, SHOW_WARNING_FILTER_CONTEXT, OUTPUT_FILTER_FOCUS_CONTEXT } from '../../../services/output/common/output.js'; import { OutputViewPane } from './outputView.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from '../../../common/contributions.js'; @@ -23,7 +23,7 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions, Configur import { IQuickPickItem, IQuickInputService, IQuickPickSeparator, QuickPickInput } from '../../../../platform/quickinput/common/quickInput.js'; import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, IEditorService } from '../../../services/editor/common/editorService.js'; import { assertIsDefined } from '../../../../base/common/types.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; @@ -37,6 +37,9 @@ import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.j import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../platform/accessibility/common/accessibility.js'; import { IsWindowsContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { FocusedViewContext } from '../../../common/contextkeys.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { viewFilterSubmenu } from '../../../browser/parts/views/viewFilter.js'; +import { ViewAction } from '../../../browser/parts/views/viewPane.js'; // Register Service registerSingleton(IOutputService, OutputService, InstantiationType.Delayed); @@ -107,6 +110,7 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { this.registerShowLogsAction(); this.registerOpenLogFileAction(); this.registerConfigureActiveOutputLogLevelAction(); + this.registerFilterActions(); } private registerSwitchOutputAction(): void { @@ -527,6 +531,77 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { })); } + private registerFilterActions(): void { + let order = 0; + const registerLogLevel = (logLevel: LogLevel, toggled: ContextKeyExpression) => { + this._register(registerAction2(class extends ViewAction { + constructor() { + super({ + id: `workbench.actions.${OUTPUT_VIEW_ID}.toggle.${LogLevelToString(logLevel)}`, + title: LogLevelToLocalizedString(logLevel).value, + metadata: { + description: localize2('toggleTraceDescription', "Show or hide {0} messages in the output", LogLevelToString(logLevel)) + }, + toggled, + menu: { + id: viewFilterSubmenu, + group: '1_filter', + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', OUTPUT_VIEW_ID), CONTEXT_ACTIVE_OUTPUT_LEVEL_SETTABLE), + order: order++ + }, + viewId: OUTPUT_VIEW_ID + }); + } + async runInView(serviceAccessor: ServicesAccessor, view: OutputViewPane): Promise { + this.toggleLogLevelFilter(serviceAccessor.get(IOutputService), logLevel); + } + private toggleLogLevelFilter(outputService: IOutputService, logLevel: LogLevel): void { + switch (logLevel) { + case LogLevel.Trace: + outputService.filters.trace = !outputService.filters.trace; + break; + case LogLevel.Debug: + outputService.filters.debug = !outputService.filters.debug; + break; + case LogLevel.Info: + outputService.filters.info = !outputService.filters.info; + break; + case LogLevel.Warning: + outputService.filters.warning = !outputService.filters.warning; + break; + case LogLevel.Error: + outputService.filters.error = !outputService.filters.error; + break; + } + } + })); + }; + + registerLogLevel(LogLevel.Trace, SHOW_TRACE_FILTER_CONTEXT); + registerLogLevel(LogLevel.Debug, SHOW_DEBUG_FILTER_CONTEXT); + registerLogLevel(LogLevel.Info, SHOW_INFO_FILTER_CONTEXT); + registerLogLevel(LogLevel.Warning, SHOW_WARNING_FILTER_CONTEXT); + registerLogLevel(LogLevel.Error, SHOW_ERROR_FILTER_CONTEXT); + + this._register(registerAction2(class extends ViewAction { + constructor() { + super({ + id: `workbench.actions.${OUTPUT_VIEW_ID}.clearFilterText`, + title: localize('clearFiltersText', "Clear filters text"), + keybinding: { + when: OUTPUT_FILTER_FOCUS_CONTEXT, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Escape + }, + viewId: OUTPUT_VIEW_ID + }); + } + async runInView(serviceAccessor: ServicesAccessor, outputView: OutputViewPane): Promise { + outputView.clearFilterText(); + } + })); + } + } Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(OutputContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/output/browser/outputServices.ts b/src/vs/workbench/contrib/output/browser/outputServices.ts index 25950e9f7e9..299491940ce 100644 --- a/src/vs/workbench/contrib/output/browser/outputServices.ts +++ b/src/vs/workbench/contrib/output/browser/outputServices.ts @@ -10,7 +10,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { IOutputChannel, IOutputService, OUTPUT_VIEW_ID, LOG_MIME, OUTPUT_MIME, OutputChannelUpdateMode, IOutputChannelDescriptor, Extensions, IOutputChannelRegistry, ACTIVE_OUTPUT_CHANNEL_CONTEXT, CONTEXT_ACTIVE_FILE_OUTPUT, CONTEXT_ACTIVE_OUTPUT_LEVEL_SETTABLE, CONTEXT_ACTIVE_OUTPUT_LEVEL, CONTEXT_ACTIVE_OUTPUT_LEVEL_IS_DEFAULT } from '../../../services/output/common/output.js'; +import { IOutputChannel, IOutputService, OUTPUT_VIEW_ID, LOG_MIME, OUTPUT_MIME, OutputChannelUpdateMode, IOutputChannelDescriptor, Extensions, IOutputChannelRegistry, ACTIVE_OUTPUT_CHANNEL_CONTEXT, CONTEXT_ACTIVE_FILE_OUTPUT, CONTEXT_ACTIVE_OUTPUT_LEVEL_SETTABLE, CONTEXT_ACTIVE_OUTPUT_LEVEL, CONTEXT_ACTIVE_OUTPUT_LEVEL_IS_DEFAULT, IOutputViewFilters, SHOW_DEBUG_FILTER_CONTEXT, SHOW_ERROR_FILTER_CONTEXT, SHOW_INFO_FILTER_CONTEXT, SHOW_TRACE_FILTER_CONTEXT, SHOW_WARNING_FILTER_CONTEXT } from '../../../services/output/common/output.js'; import { OutputLinkProvider } from './outputLinkProvider.js'; import { ITextModelService, ITextModelContentProvider } from '../../../../editor/common/services/resolverService.js'; import { ITextModel } from '../../../../editor/common/model.js'; @@ -64,6 +64,104 @@ class OutputChannel extends Disposable implements IOutputChannel { } } +interface IOutputFilterOptions { + filterHistory: string[]; + trace: boolean; + debug: boolean; + info: boolean; + warning: boolean; + error: boolean; +} + +class OutputViewFilters extends Disposable implements IOutputViewFilters { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + constructor( + options: IOutputFilterOptions, + private readonly contextKeyService: IContextKeyService + ) { + super(); + + this._trace.set(options.trace); + this._debug.set(options.debug); + this._info.set(options.info); + this._warning.set(options.warning); + this._error.set(options.error); + + this.filterHistory = options.filterHistory; + } + + filterHistory: string[]; + + private _filterText = ''; + get text(): string { + return this._filterText; + } + set text(filterText: string) { + if (this._filterText !== filterText) { + this._filterText = filterText; + this._onDidChange.fire(); + } + } + + private readonly _trace = SHOW_TRACE_FILTER_CONTEXT.bindTo(this.contextKeyService); + get trace(): boolean { + return !!this._trace.get(); + } + set trace(trace: boolean) { + if (this._trace.get() !== trace) { + this._trace.set(trace); + this._onDidChange.fire(); + } + } + + private readonly _debug = SHOW_DEBUG_FILTER_CONTEXT.bindTo(this.contextKeyService); + get debug(): boolean { + return !!this._debug.get(); + } + set debug(debug: boolean) { + if (this._debug.get() !== debug) { + this._debug.set(debug); + this._onDidChange.fire(); + } + } + + private readonly _info = SHOW_INFO_FILTER_CONTEXT.bindTo(this.contextKeyService); + get info(): boolean { + return !!this._info.get(); + } + set info(info: boolean) { + if (this._info.get() !== info) { + this._info.set(info); + this._onDidChange.fire(); + } + } + + private readonly _warning = SHOW_WARNING_FILTER_CONTEXT.bindTo(this.contextKeyService); + get warning(): boolean { + return !!this._warning.get(); + } + set warning(warning: boolean) { + if (this._warning.get() !== warning) { + this._warning.set(warning); + this._onDidChange.fire(); + } + } + + private readonly _error = SHOW_ERROR_FILTER_CONTEXT.bindTo(this.contextKeyService); + get error(): boolean { + return !!this._error.get(); + } + set error(error: boolean) { + if (this._error.get() !== error) { + this._error.set(error); + this._onDidChange.fire(); + } + } +} + export class OutputService extends Disposable implements IOutputService, ITextModelContentProvider { declare readonly _serviceBrand: undefined; @@ -81,6 +179,8 @@ export class OutputService extends Disposable implements IOutputService, ITextMo private readonly activeOutputChannelLevelContext: IContextKey; private readonly activeOutputChannelLevelIsDefaultContext: IContextKey; + readonly filters: OutputViewFilters; + constructor( @IStorageService private readonly storageService: IStorageService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -135,6 +235,15 @@ export class OutputService extends Disposable implements IOutputService, ITextMo })); this._register(this.lifecycleService.onDidShutdown(() => this.dispose())); + + this.filters = this._register(new OutputViewFilters({ + filterHistory: [], + trace: true, + debug: true, + info: true, + warning: true, + error: true + }, contextKeyService)); } provideTextContent(resource: URI): Promise | null { diff --git a/src/vs/workbench/contrib/output/browser/outputView.ts b/src/vs/workbench/contrib/output/browser/outputView.ts index 4d280d00857..c32d92d14b6 100644 --- a/src/vs/workbench/contrib/output/browser/outputView.ts +++ b/src/vs/workbench/contrib/output/browser/outputView.ts @@ -7,20 +7,20 @@ import * as nls from '../../../../nls.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { IEditorOptions as ICodeEditorOptions } from '../../../../editor/common/config/editorOptions.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { AbstractTextResourceEditor } from '../../../browser/parts/editor/textResourceEditor.js'; -import { OUTPUT_VIEW_ID, CONTEXT_IN_OUTPUT, IOutputChannel, CONTEXT_OUTPUT_SCROLL_LOCK } from '../../../services/output/common/output.js'; +import { OUTPUT_VIEW_ID, CONTEXT_IN_OUTPUT, IOutputChannel, CONTEXT_OUTPUT_SCROLL_LOCK, IOutputService, IOutputViewFilters, OUTPUT_FILTER_FOCUS_CONTEXT } from '../../../services/output/common/output.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { CursorChangeReason } from '../../../../editor/common/cursorEvents.js'; -import { ViewPane, IViewPaneOptions } from '../../../browser/parts/views/viewPane.js'; +import { IViewPaneOptions, FilterViewPane } from '../../../browser/parts/views/viewPane.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IViewDescriptorService } from '../../../common/views.js'; @@ -35,8 +35,19 @@ import { ServiceCollection } from '../../../../platform/instantiation/common/ser import { IEditorConfiguration } from '../../../browser/parts/editor/textEditor.js'; import { computeEditorAriaLabel } from '../../../browser/editor.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { localize } from '../../../../nls.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { LogLevel } from '../../../../platform/log/common/log.js'; +import { IEditorContributionDescription, EditorExtensionsRegistry, EditorContributionInstantiation, EditorContributionCtor } from '../../../../editor/browser/editorExtensions.js'; +import { ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IEditorContribution, IEditorDecorationsCollection } from '../../../../editor/common/editorCommon.js'; +import { IModelDeltaDecoration, ITextModel } from '../../../../editor/common/model.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { FindDecorations } from '../../../../editor/contrib/find/browser/findDecorations.js'; +import { Memento, MementoObject } from '../../../common/memento.js'; +import { Markers } from '../../markers/common/markers.js'; -export class OutputViewPane extends ViewPane { +export class OutputViewPane extends FilterViewPane { private readonly editor: OutputEditor; private channelId: string | undefined; @@ -46,6 +57,9 @@ export class OutputViewPane extends ViewPane { get scrollLock(): boolean { return !!this.scrollLockContextKey.get(); } set scrollLock(scrollLock: boolean) { this.scrollLockContextKey.set(scrollLock); } + private readonly memento: Memento; + private readonly panelState: MementoObject; + constructor( options: IViewPaneOptions, @IKeybindingService keybindingService: IKeybindingService, @@ -58,8 +72,31 @@ export class OutputViewPane extends ViewPane { @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, @IHoverService hoverService: IHoverService, + @IOutputService private readonly outputService: IOutputService, + @IStorageService storageService: IStorageService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); + const memento = new Memento(Markers.MARKERS_VIEW_STORAGE_ID, storageService); + const viewState = memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); + super({ + ...options, + filterOptions: { + placeholder: localize('outputView.filter.placeholder', "Filter"), + focusContextKey: OUTPUT_FILTER_FOCUS_CONTEXT.key, + text: viewState['filter'] || '', + history: [] + } + }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); + this.memento = memento; + this.panelState = viewState; + + const filters = outputService.filters; + filters.text = this.panelState['filter'] || ''; + filters.trace = this.panelState['showTrace'] ?? true; + filters.debug = this.panelState['showDebug'] ?? true; + filters.info = this.panelState['showInfo'] ?? true; + filters.warning = this.panelState['showWarning'] ?? true; + filters.error = this.panelState['showError'] ?? true; + this.scrollLockContextKey = CONTEXT_OUTPUT_SCROLL_LOCK.bindTo(this.contextKeyService); const editorInstantiationService = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); @@ -69,6 +106,10 @@ export class OutputViewPane extends ViewPane { this.updateActions(); })); this._register(this.onDidChangeBodyVisibility(() => this.onDidChangeVisibility(this.isBodyVisible()))); + this._register(this.filterWidget.onDidChangeFilterText(text => outputService.filters.text = text)); + + this.checkMoreFilters(); + this._register(outputService.filters.onDidChange(() => this.checkMoreFilters())); } showChannel(channel: IOutputChannel, preserveFocus: boolean): void { @@ -85,6 +126,10 @@ export class OutputViewPane extends ViewPane { this.editorPromise?.then(() => this.editor.focus()); } + public clearFilterText(): void { + this.filterWidget.setFilterText(''); + } + protected override renderBody(container: HTMLElement): void { super.renderBody(container); this.editor.create(container); @@ -114,8 +159,7 @@ export class OutputViewPane extends ViewPane { })); } - protected override layoutBody(height: number, width: number): void { - super.layoutBody(height, width); + protected layoutBodyContent(height: number, width: number): void { this.editor.layout(new Dimension(width, height)); } @@ -138,6 +182,11 @@ export class OutputViewPane extends ViewPane { } + public checkMoreFilters(): void { + const filters = this.outputService.filters; + this.filterWidget.checkMoreFilters(!filters.trace || !filters.debug || !filters.info || !filters.warning || !filters.error); + } + private clearInput(): void { this.channelId = undefined; this.editor.clearInput(); @@ -148,9 +197,22 @@ export class OutputViewPane extends ViewPane { return this.instantiationService.createInstance(TextResourceEditorInput, channel.uri, nls.localize('output model title', "{0} - Output", channel.label), nls.localize('channel', "Output channel for '{0}'", channel.label), undefined, undefined); } + override saveState(): void { + const filters = this.outputService.filters; + this.panelState['filter'] = filters.text; + this.panelState['showTrace'] = filters.trace; + this.panelState['showDebug'] = filters.debug; + this.panelState['showInfo'] = filters.info; + this.panelState['showWarning'] = filters.warning; + this.panelState['showError'] = filters.error; + + this.memento.saveMemento(); + super.saveState(); + } + } -class OutputEditor extends AbstractTextResourceEditor { +export class OutputEditor extends AbstractTextResourceEditor { private readonly resourceContext: ResourceContextKey; constructor( @@ -260,4 +322,210 @@ class OutputEditor extends AbstractTextResourceEditor { CONTEXT_IN_OUTPUT.bindTo(scopedContextKeyService).set(true); } } + + private _getContributions(): IEditorContributionDescription[] { + return [ + ...EditorExtensionsRegistry.getEditorContributions(), + { + id: FilterController.ID, + ctor: FilterController as EditorContributionCtor, + instantiation: EditorContributionInstantiation.Eager + } + ]; + } + + protected override getCodeEditorWidgetOptions(): ICodeEditorWidgetOptions { + return { contributions: this._getContributions() }; + } + +} + + +interface ILogEntry { + readonly logLevel: LogLevel; + readonly lineRange: [number, number]; +} + +const logEntryRegex = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) \[(info|trace|debug|error|warn)\]/; + +export class FilterController extends Disposable implements IEditorContribution { + + public static readonly ID = 'output.editor.contrib.filterController'; + + private readonly modelDisposables: DisposableStore = this._register(new DisposableStore()); + private hiddenAreas: Range[] = []; + private readonly decorationsCollection: IEditorDecorationsCollection; + + private logEntries: ILogEntry[] | undefined; + + constructor( + private readonly editor: ICodeEditor, + @IOutputService private readonly outputService: IOutputService + ) { + super(); + this.decorationsCollection = editor.createDecorationsCollection(); + this._register(editor.onDidChangeModel(() => this.onDidChangeModel())); + this._register(this.outputService.filters.onDidChange(() => editor.hasModel() && this.filter(editor.getModel()))); + } + + private onDidChangeModel(): void { + this.modelDisposables.clear(); + this.logEntries = undefined; + this.hiddenAreas = []; + + if (!this.editor.hasModel()) { + return; + } + + const model = this.editor.getModel(); + this.computeLogEntries(model); + this.filter(model); + + const computeEndLineNumber = () => { + const endLineNumber = model.getLineCount(); + return endLineNumber > 1 && model.getLineMaxColumn(endLineNumber) === 1 ? endLineNumber - 1 : endLineNumber; + }; + + let endLineNumber = computeEndLineNumber(); + + this.modelDisposables.add(model.onDidChangeContent(e => { + if (e.changes.every(e => e.range.startLineNumber > endLineNumber)) { + const filterFrom = this.logEntries?.length ?? endLineNumber + 1; + if (this.logEntries) { + this.computeLogEntriesIncremental(model, endLineNumber + 1); + } + this.filterIncremental(model, filterFrom); + } else { + this.computeLogEntries(model); + this.filter(model); + } + endLineNumber = computeEndLineNumber(); + })); + } + + private computeLogEntries(model: ITextModel): void { + this.logEntries = undefined; + const firstLine = model.getLineContent(1); + if (!logEntryRegex.test(firstLine)) { + return; + } + + this.logEntries = []; + this.computeLogEntriesIncremental(model, 1); + } + + private computeLogEntriesIncremental(model: ITextModel, fromLine: number): void { + if (!this.logEntries) { + return; + } + + const lineCount = model.getLineCount(); + for (let lineNumber = fromLine; lineNumber <= lineCount; lineNumber++) { + const lineContent = model.getLineContent(lineNumber); + const match = logEntryRegex.exec(lineContent); + if (match) { + const logLevel = this.parseLogLevel(match[2]); + const startLine = lineNumber; + let endLine = lineNumber; + + while (endLine < lineCount) { + const nextLineContent = model.getLineContent(endLine + 1); + if (model.getLineFirstNonWhitespaceColumn(endLine + 1) === 0 || logEntryRegex.test(nextLineContent)) { + break; + } + endLine++; + } + + this.logEntries.push({ logLevel, lineRange: [startLine, endLine] }); + lineNumber = endLine; + } + } + } + + private filter(model: ITextModel): void { + this.hiddenAreas = []; + this.decorationsCollection.clear(); + this.filterIncremental(model, 0); + } + + private filterIncremental(model: ITextModel, from: number): void { + const filters = this.outputService.filters; + const findMatchesDecorations: IModelDeltaDecoration[] = []; + + if (this.logEntries) { + const hasLogLevelFilter = !filters.trace || !filters.debug || !filters.info || !filters.warning || filters.error; + if (hasLogLevelFilter || filters.text) { + for (let i = from; i < this.logEntries.length; i++) { + const entry = this.logEntries[i]; + if (hasLogLevelFilter && !this.shouldShowEntry(entry, filters)) { + this.hiddenAreas.push(new Range(entry.lineRange[0], 1, entry.lineRange[1], model.getLineMaxColumn(entry.lineRange[1]))); + continue; + } + if (filters.text) { + const matches = model.findMatches(filters.text, new Range(entry.lineRange[0], 1, entry.lineRange[1], model.getLineLastNonWhitespaceColumn(entry.lineRange[1])), false, false, null, false); + if (matches.length) { + for (const match of matches) { + findMatchesDecorations.push({ range: match.range, options: FindDecorations._FIND_MATCH_DECORATION }); + } + } else { + this.hiddenAreas.push(new Range(entry.lineRange[0], 1, entry.lineRange[1], model.getLineMaxColumn(entry.lineRange[1]))); + } + } + } + } + } else { + if (filters.text) { + const lineCount = model.getLineCount(); + for (let lineNumber = from + 1; lineNumber <= lineCount; lineNumber++) { + const lineRange = new Range(lineNumber, 1, lineNumber, model.getLineMaxColumn(lineNumber)); + const matches = model.findMatches(filters.text, lineRange, false, false, null, false); + if (matches.length) { + for (const match of matches) { + findMatchesDecorations.push({ range: match.range, options: FindDecorations._FIND_MATCH_DECORATION }); + } + } else { + this.hiddenAreas.push(lineRange); + } + } + } + } + + this.editor.setHiddenAreas(this.hiddenAreas, this); + if (findMatchesDecorations.length) { + this.decorationsCollection.append(findMatchesDecorations); + } + } + + private shouldShowEntry(entry: ILogEntry, filters: IOutputViewFilters): boolean { + switch (entry.logLevel) { + case LogLevel.Trace: + return filters.trace; + case LogLevel.Debug: + return filters.debug; + case LogLevel.Info: + return filters.info; + case LogLevel.Warning: + return filters.warning; + case LogLevel.Error: + return filters.error; + } + return true; + } + + private parseLogLevel(level: string): LogLevel { + switch (level.toLowerCase()) { + case 'trace': + return LogLevel.Trace; + case 'debug': + return LogLevel.Debug; + case 'info': + return LogLevel.Info; + case 'warn': + return LogLevel.Warning; + case 'error': + return LogLevel.Error; + default: + throw new Error(`Unknown log level: ${level}`); + } + } } diff --git a/src/vs/workbench/contrib/output/common/outputChannelModelService.ts b/src/vs/workbench/contrib/output/common/outputChannelModelService.ts index 3f500babda6..f075abc08e6 100644 --- a/src/vs/workbench/contrib/output/common/outputChannelModelService.ts +++ b/src/vs/workbench/contrib/output/common/outputChannelModelService.ts @@ -22,7 +22,7 @@ export interface IOutputChannelModelService { } -export class OutputChannelModelService { +export class OutputChannelModelService implements IOutputChannelModelService { declare readonly _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 8e3388a7008..43214c91777 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -1606,7 +1606,7 @@ export class SettingsEditor2 extends EditorPane { } private async triggerSearch(query: string): Promise { - const progressRunner = this.editorProgressService.show(true); + const progressRunner = this.editorProgressService.show(true, 800); this.viewState.tagFilters = new Set(); this.viewState.extensionFilters = new Set(); this.viewState.featureFilters = new Set(); diff --git a/src/vs/workbench/contrib/scm/browser/menus.ts b/src/vs/workbench/contrib/scm/browser/menus.ts index f14d8ea79f6..352f63b6f3e 100644 --- a/src/vs/workbench/contrib/scm/browser/menus.ts +++ b/src/vs/workbench/contrib/scm/browser/menus.ts @@ -6,7 +6,7 @@ import { IAction } from '../../../../base/common/actions.js'; import { equals } from '../../../../base/common/arrays.js'; import { Emitter } from '../../../../base/common/event.js'; -import { DisposableStore, IDisposable, dispose } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, MutableDisposable, dispose } from '../../../../base/common/lifecycle.js'; import './media/scm.css'; import { localize } from '../../../../nls.js'; import { getActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; @@ -70,13 +70,14 @@ interface IContextualResourceMenuItem { class SCMMenusItem implements IDisposable { - private _resourceGroupMenu: IMenu | undefined; + private readonly _resourceGroupMenu = new MutableDisposable(); get resourceGroupMenu(): IMenu { - if (!this._resourceGroupMenu) { - this._resourceGroupMenu = this.menuService.createMenu(MenuId.SCMResourceGroupContext, this.contextKeyService); - } + const contextKeyService = this.contextKeyService.createOverlay([ + ['scmResourceGroupResourceCount', this.group.resources.length], + ]); - return this._resourceGroupMenu; + this._resourceGroupMenu.value = this.menuService.createMenu(MenuId.SCMResourceGroupContext, contextKeyService); + return this._resourceGroupMenu.value; } private _resourceFolderMenu: IMenu | undefined; @@ -92,8 +93,9 @@ class SCMMenusItem implements IDisposable { private contextualResourceMenus: Map | undefined; constructor( - private contextKeyService: IContextKeyService, - private menuService: IMenuService + private readonly group: ISCMResourceGroup, + private readonly contextKeyService: IContextKeyService, + private readonly menuService: IMenuService ) { } getResourceMenu(resource: ISCMResource): IMenu { @@ -206,7 +208,7 @@ export class SCMRepositoryMenus implements ISCMRepositoryMenus, IDisposable { ['multiDiffEditorEnableViewChanges', group.multiDiffEditorEnableViewChanges], ]); - result = new SCMMenusItem(contextKeyService, this.menuService); + result = new SCMMenusItem(group, contextKeyService, this.menuService); this.resourceGroupMenusItems.set(group, result); } diff --git a/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts b/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts index 8ac67f6cd53..37ed63fb396 100644 --- a/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts +++ b/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts @@ -13,7 +13,7 @@ import { ISelectOptionItem } from '../../../../base/browser/ui/selectBox/selectB import { SelectActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { getOuterEditor, peekViewBorder, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground, PeekViewWidget } from '../../../../editor/contrib/peekView/browser/peekView.js'; +import { peekViewBorder, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground, PeekViewWidget } from '../../../../editor/contrib/peekView/browser/peekView.js'; import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; import { IMenu, IMenuService, MenuId, MenuItemAction, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js'; @@ -48,6 +48,7 @@ import { gotoNextLocation, gotoPreviousLocation } from '../../../../platform/the import { Codicon } from '../../../../base/common/codicons.js'; import { Color } from '../../../../base/common/color.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { getOuterEditor } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; export const isQuickDiffVisible = new RawContextKey('dirtyDiffVisible', false); diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index 0c2d7c1af5f..7f8e9c71a09 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -38,11 +38,10 @@ import { IViewsService } from '../../../services/views/common/viewsService.js'; import { IListService, WorkbenchList } from '../../../../platform/list/browser/listService.js'; import { isSCMRepository } from './util.js'; import { SCMHistoryViewPane } from './scmHistoryViewPane.js'; -import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys.js'; -import { RemoteNameContext } from '../../../common/contextkeys.js'; import { QuickDiffModelService, IQuickDiffModelService } from './quickDiffModel.js'; import { QuickDiffEditorController } from './quickDiffWidget.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; +import { RemoteNameContext } from '../../../common/contextkeys.js'; ModesRegistry.registerLanguage({ id: 'scminput', @@ -137,14 +136,8 @@ viewsRegistry.registerViews([{ weight: 40, order: 2, when: ContextKeyExpr.and( - // Repository Count - ContextKeyExpr.and( - ContextKeyExpr.has('scm.providerCount'), - ContextKeyExpr.notEquals('scm.providerCount', 0)), - // Not Serverless - ContextKeyExpr.and( - IsWebContext, - RemoteNameContext.isEqualTo(''))?.negate() + ContextKeyExpr.has('scm.historyProviderCount'), + ContextKeyExpr.notEquals('scm.historyProviderCount', 0), ), containerIcon: sourceControlViewIcon }], viewContainer); @@ -537,7 +530,12 @@ MenuRegistry.appendMenuItem(MenuId.SCMSourceControl, { id: 'scm.openInTerminal', title: localize('open in external terminal', "Open in External Terminal") }, - when: ContextKeyExpr.and(ContextKeyExpr.equals('scmProviderHasRootUri', true), ContextKeyExpr.or(ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'external'), ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'both'))) + when: ContextKeyExpr.and( + RemoteNameContext.isEqualTo(''), + ContextKeyExpr.equals('scmProviderHasRootUri', true), + ContextKeyExpr.or( + ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'external'), + ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'both'))) }); MenuRegistry.appendMenuItem(MenuId.SCMSourceControl, { @@ -546,7 +544,11 @@ MenuRegistry.appendMenuItem(MenuId.SCMSourceControl, { id: 'scm.openInIntegratedTerminal', title: localize('open in integrated terminal', "Open in Integrated Terminal") }, - when: ContextKeyExpr.and(ContextKeyExpr.equals('scmProviderHasRootUri', true), ContextKeyExpr.or(ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'integrated'), ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'both'))) + when: ContextKeyExpr.and( + ContextKeyExpr.equals('scmProviderHasRootUri', true), + ContextKeyExpr.or( + ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'integrated'), + ContextKeyExpr.equals('config.terminal.sourceControlRepositoriesKind', 'both'))) }); KeybindingsRegistry.registerCommandAndKeybindingRule({ diff --git a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts index af17ed451bd..ad0a2c98f51 100644 --- a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts @@ -15,7 +15,7 @@ import { IAsyncDataSource, ITreeContextMenuEvent, ITreeNode, ITreeRenderer } fro import { fromNow, safeIntl } from '../../../../base/common/date.js'; import { createMatches, FuzzyScore, IMatch } from '../../../../base/common/filters.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, autorunWithStore, derived, IObservable, observableValue, waitForState, constObservable, latestChangedValue, observableFromEvent, runOnChange, observableSignal } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; @@ -40,11 +40,11 @@ import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/lis import { stripIcons } from '../../../../base/common/iconLabels.js'; import { IWorkbenchLayoutService, Position } from '../../../services/layout/browser/layoutService.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import { Action2, IMenuService, MenuId, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { Action2, IMenuService, isIMenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { Sequencer, Throttler } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { ActionRunner, IAction, IActionRunner, Separator, SubmenuAction } from '../../../../base/common/actions.js'; +import { ActionRunner, IAction, IActionRunner } from '../../../../base/common/actions.js'; import { delta, groupBy } from '../../../../base/common/arrays.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { IProgressService } from '../../../../platform/progress/common/progress.js'; @@ -238,13 +238,14 @@ registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.scm.action.graph.viewChanges', - title: localize('viewChanges', "View Changes"), + title: localize('openChanges', "Open Changes"), f1: false, menu: [ { id: MenuId.SCMChangesContext, + when: ContextKeyExpr.equals('config.multiDiffEditor.experimental.enabled', true), group: '0_view', - when: ContextKeyExpr.equals('config.multiDiffEditor.experimental.enabled', true) + order: 1 } ] }); @@ -459,10 +460,14 @@ class HistoryItemRenderer implements ITreeRenderer; private readonly _scmCurrentHistoryItemRefInFilter: IContextKey; + private readonly _contextMenuDisposables = new MutableDisposable(); + constructor( options: IViewPaneOptions, @ICommandService private readonly _commandService: ICommandService, @@ -1572,46 +1579,74 @@ export class SCMHistoryViewPane extends ViewPane { return; } + this._contextMenuDisposables.value = new DisposableStore(); + + const historyItemRefMenuItems = MenuRegistry.getMenuItems(MenuId.SCMHistoryItemRefContext).filter(item => isIMenuItem(item)); + + // If there are any history item references we have to add a submenu item for each orignal action, + // and a menu item for each history item ref that matches the `when` clause of the original action. + if (historyItemRefMenuItems.length > 0 && element.historyItemViewModel.historyItem.references?.length) { + const submenuIds = new Map(); + + for (const ref of element.historyItemViewModel.historyItem.references) { + const contextKeyService = this.scopedContextKeyService.createOverlay([ + ['scmHistoryItemRef', ref.id] + ]); + + for (const [, actions] of this._menuService.getMenuActions(MenuId.SCMHistoryItemRefContext, contextKeyService)) { + for (const action of actions) { + let subMenuId = submenuIds.get(action.id); + + if (!subMenuId) { + subMenuId = MenuId.for(action.id); + + // Get the menu item for the original action so that + // we can create a submenu with the same group, order + const historyItemRefMenuItem = historyItemRefMenuItems + .find(item => item.command.id === action.id); + + // Register the submenu for the original action + this._contextMenuDisposables.value.add(MenuRegistry.appendMenuItem(MenuId.SCMChangesContext, { + title: action.label, + submenu: subMenuId, + group: historyItemRefMenuItem?.group, + order: historyItemRefMenuItem?.order + })); + + submenuIds.set(action.id, subMenuId); + } + + // Register a new action for the history item ref + this._contextMenuDisposables.value.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: `${action.id}.${ref.id}`, + title: ref.name, + menu: { + id: subMenuId!, + group: ref.category + } + }); + } + override run(accessor: ServicesAccessor, ...args: any[]): void { + const commandService = accessor.get(ICommandService); + commandService.executeCommand(action.id, ...args, ref.id); + } + })); + } + } + } + } + const historyItemMenuActions = this._menuService.getMenuActions(MenuId.SCMChangesContext, this.scopedContextKeyService, { arg: element.repository.provider, shouldForwardArgs: true }); - const actions = getFlatContextMenuActions(historyItemMenuActions); - if (element.historyItemViewModel.historyItem.references?.length) { - actions.push(new Separator()); - } - - const that = this; - for (const ref of element.historyItemViewModel.historyItem.references ?? []) { - const contextKeyService = this.scopedContextKeyService.createOverlay([ - ['scmHistoryItemRef', ref.id] - ]); - - const historyItemRefMenuActions = this._menuService.getMenuActions(MenuId.SCMHistoryItemRefContext, contextKeyService); - const historyItemRefSubMenuActions = getFlatContextMenuActions(historyItemRefMenuActions) - .map(action => new class extends MenuItemAction { - constructor() { - super( - { id: action.id, title: action.label }, undefined, - { arg: element!.repository.provider, shouldForwardArgs: true }, - undefined, undefined, contextKeyService, that._commandService); - } - - override run(): Promise { - return super.run(element.historyItemViewModel.historyItem, ref.id); - } - }); - - if (historyItemRefSubMenuActions.length > 0) { - actions.push(new SubmenuAction(`scm.historyItemRef.${ref.id}`, ref.name, historyItemRefSubMenuActions)); - } - } - this.contextMenuService.showContextMenu({ contextKeyService: this.scopedContextKeyService, getAnchor: () => e.anchor, - getActions: () => actions, + getActions: () => getFlatContextMenuActions(historyItemMenuActions), getActionsContext: () => element.historyItemViewModel.historyItem }); } @@ -1644,6 +1679,7 @@ export class SCMHistoryViewPane extends ViewPane { } override dispose(): void { + this._contextMenuDisposables.dispose(); this._visibilityDisposables.dispose(); super.dispose(); } diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 8a05bb6c06f..a786b73aab1 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -21,7 +21,7 @@ import { IContextKeyService, IContextKey, ContextKeyExpr, RawContextKey } from ' import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { MenuItemAction, IMenuService, registerAction2, MenuId, IAction2Options, MenuRegistry, Action2, IMenu } from '../../../../platform/actions/common/actions.js'; -import { IAction, ActionRunner, Action, Separator, IActionRunner } from '../../../../base/common/actions.js'; +import { IAction, ActionRunner, Action, Separator, IActionRunner, toAction } from '../../../../base/common/actions.js'; import { ActionBar, IActionViewItemProvider } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IThemeService, IFileIconTheme } from '../../../../platform/theme/common/themeService.js'; import { isSCMResource, isSCMResourceGroup, isSCMRepository, isSCMInput, collectContextMenuActions, getActionViewItemProvider, isSCMActionButton, isSCMViewService, isSCMResourceNode, connectPrimaryMenu } from './util.js'; @@ -2988,7 +2988,14 @@ export class SCMActionButton implements IDisposable { for (let index = 0; index < button.secondaryCommands.length; index++) { const commands = button.secondaryCommands[index]; for (const command of commands) { - actions.push(new Action(command.id, command.title, undefined, true, async () => await this.executeCommand(command.id, ...(command.arguments || [])))); + actions.push(toAction({ + id: command.id, + label: command.title, + enabled: true, + run: async () => { + await this.executeCommand(command.id, ...(command.arguments || [])); + } + })); } if (commands.length) { actions.push(new Separator()); diff --git a/src/vs/workbench/contrib/scm/common/history.ts b/src/vs/workbench/contrib/scm/common/history.ts index 2cdf25f2b99..e8a6cda495d 100644 --- a/src/vs/workbench/contrib/scm/common/history.ts +++ b/src/vs/workbench/contrib/scm/common/history.ts @@ -63,6 +63,7 @@ export interface ISCMHistoryItem { readonly message: string; readonly displayId?: string; readonly author?: string; + readonly authorEmail?: string; readonly timestamp?: number; readonly statistics?: ISCMHistoryItemStatistics; readonly references?: ISCMHistoryItemRef[]; diff --git a/src/vs/workbench/contrib/scm/common/scmService.ts b/src/vs/workbench/contrib/scm/common/scmService.ts index c9af734400a..14c69f73b0d 100644 --- a/src/vs/workbench/contrib/scm/common/scmService.ts +++ b/src/vs/workbench/contrib/scm/common/scmService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { ISCMService, ISCMProvider, ISCMInput, ISCMRepository, IInputValidator, ISCMInputChangeEvent, SCMInputChangeReason, InputValidationType, IInputValidation } from './scm.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -17,6 +17,7 @@ import { Iterable } from '../../../../base/common/iterator.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { Schemas } from '../../../../base/common/network.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { runOnChange } from '../../../../base/common/observable.js'; class SCMInput extends Disposable implements ISCMInput { @@ -188,7 +189,7 @@ class SCMRepository implements ISCMRepository { constructor( public readonly id: string, public readonly provider: ISCMProvider, - private disposable: IDisposable, + private readonly disposables: DisposableStore, inputHistory: SCMInputHistory ) { this.input = new SCMInput(this, inputHistory); @@ -204,7 +205,7 @@ class SCMRepository implements ISCMRepository { } dispose(): void { - this.disposable.dispose(); + this.disposables.dispose(); this.provider.dispose(); } } @@ -355,6 +356,7 @@ export class SCMService implements ISCMService { private inputHistory: SCMInputHistory; private providerCount: IContextKey; + private historyProviderCount: IContextKey; private readonly _onDidAddProvider = new Emitter(); readonly onDidAddRepository: Event = this._onDidAddProvider.event; @@ -370,7 +372,9 @@ export class SCMService implements ISCMService { @IUriIdentityService private readonly uriIdentityService: IUriIdentityService ) { this.inputHistory = new SCMInputHistory(storageService, workspaceContextService); + this.providerCount = contextKeyService.createKey('scm.providerCount', 0); + this.historyProviderCount = contextKeyService.createKey('scm.historyProviderCount', 0); } registerSCMProvider(provider: ISCMProvider): ISCMRepository { @@ -380,17 +384,33 @@ export class SCMService implements ISCMService { throw new Error(`SCM Provider ${provider.id} already exists.`); } - const disposable = toDisposable(() => { + const disposables = new DisposableStore(); + + const historyProviderCount = () => { + return Array.from(this._repositories.values()) + .filter(r => !!r.provider.historyProvider).length; + }; + + disposables.add(toDisposable(() => { this._repositories.delete(provider.id); this._onDidRemoveProvider.fire(repository); - this.providerCount.set(this._repositories.size); - }); - const repository = new SCMRepository(provider.id, provider, disposable, this.inputHistory); + this.providerCount.set(this._repositories.size); + this.historyProviderCount.set(historyProviderCount()); + })); + + const repository = new SCMRepository(provider.id, provider, disposables, this.inputHistory); this._repositories.set(provider.id, repository); - this._onDidAddProvider.fire(repository); + + disposables.add(runOnChange(provider.historyProvider, () => { + this.historyProviderCount.set(historyProviderCount()); + })); this.providerCount.set(this._repositories.size); + this.historyProviderCount.set(historyProviderCount()); + + this._onDidAddProvider.fire(repository); + return repository; } diff --git a/src/vs/workbench/contrib/share/browser/share.contribution.ts b/src/vs/workbench/contrib/share/browser/share.contribution.ts index cd1cfc176e0..3566792aa9d 100644 --- a/src/vs/workbench/contrib/share/browser/share.contribution.ts +++ b/src/vs/workbench/contrib/share/browser/share.contribution.ts @@ -32,7 +32,7 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; const targetMenus = [ MenuId.EditorContextShare, @@ -44,7 +44,7 @@ const targetMenus = [ MenuId.ExplorerContextShare ]; -class ShareWorkbenchContribution { +class ShareWorkbenchContribution extends Disposable { private static SHARE_ENABLED_SETTING = 'workbench.experimental.share.enabled'; private _disposables: DisposableStore | undefined; @@ -53,10 +53,12 @@ class ShareWorkbenchContribution { @IShareService private readonly shareService: IShareService, @IConfigurationService private readonly configurationService: IConfigurationService ) { + super(); + if (this.configurationService.getValue(ShareWorkbenchContribution.SHARE_ENABLED_SETTING)) { this.registerActions(); } - this.configurationService.onDidChangeConfiguration(e => { + this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ShareWorkbenchContribution.SHARE_ENABLED_SETTING)) { const settingValue = this.configurationService.getValue(ShareWorkbenchContribution.SHARE_ENABLED_SETTING); if (settingValue === true && this._disposables === undefined) { @@ -66,7 +68,12 @@ class ShareWorkbenchContribution { this._disposables = undefined; } } - }); + })); + } + + override dispose(): void { + super.dispose(); + this._disposables?.dispose(); } private registerActions() { diff --git a/src/vs/workbench/contrib/share/browser/shareService.ts b/src/vs/workbench/contrib/share/browser/shareService.ts index 0d684acf736..9a94bd370be 100644 --- a/src/vs/workbench/contrib/share/browser/shareService.ts +++ b/src/vs/workbench/contrib/share/browser/shareService.ts @@ -9,11 +9,13 @@ import { URI } from '../../../../base/common/uri.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { score } from '../../../../editor/common/languageSelector.js'; import { localize } from '../../../../nls.js'; -import { ISubmenuItem } from '../../../../platform/actions/common/actions.js'; -import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ISubmenuItem, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ToggleTitleBarConfigAction } from '../../../browser/parts/titlebar/titlebarActions.js'; +import { WorkspaceFolderCountContext } from '../../../common/contextkeys.js'; import { IShareProvider, IShareService, IShareableItem } from '../common/share.js'; export const ShareProviderCountContext = new RawContextKey('shareProviderCount', 0, localize('shareProviderCount', "The number of available share providers")); @@ -84,3 +86,9 @@ export class ShareService implements IShareService { return; } } + +registerAction2(class ToggleShareControl extends ToggleTitleBarConfigAction { + constructor() { + super('workbench.experimental.share.enabled', localize('toggle.share', 'Share'), localize('toggle.shareDescription', "Toggle visibility of the Share action in title bar"), 3, false, ContextKeyExpr.and(ContextKeyExpr.has('config.window.commandCenter'), ContextKeyExpr.and(ShareProviderCountContext.notEqualsTo(0), WorkspaceFolderCountContext.notEqualsTo(0)))); + } +}); diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 3b2c1e51c7f..e44eaa47cf9 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -1112,6 +1112,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { color: task.configurationProperties.icon?.color || undefined, waitOnExit }; + let shellSpecified: boolean = false; const shellOptions: IShellConfiguration | undefined = task.command.options && task.command.options.shell; if (shellOptions) { if (shellOptions.executable) { @@ -1120,12 +1121,12 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { shellLaunchConfig.args = undefined; } shellLaunchConfig.executable = await this._resolveVariable(variableResolver, shellOptions.executable); + shellSpecified = true; } if (shellOptions.args) { shellLaunchConfig.args = await this._resolveVariables(variableResolver, shellOptions.args.slice()); } } - const taskShellArgsSpecified = (defaultProfile.isAutomationShell ? shellLaunchConfig.args : shellOptions?.args) !== undefined; if (shellLaunchConfig.args === undefined) { shellLaunchConfig.args = []; } @@ -1138,34 +1139,29 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { windowsShellArgs = true; // If we don't have a cwd, then the terminal uses the home dir. const userHome = await this._pathService.userHome(); - if (basename === 'cmd.exe') { - if ((options.cwd && isUNC(options.cwd)) || (!options.cwd && isUNC(userHome.fsPath))) { - return undefined; - } - if (!taskShellArgsSpecified) { - toAdd.push('/d', '/c'); - } - } else if ((basename === 'powershell.exe') || (basename === 'pwsh.exe')) { - if (!taskShellArgsSpecified) { + if (basename === 'cmd.exe' && ((options.cwd && isUNC(options.cwd)) || (!options.cwd && isUNC(userHome.fsPath)))) { + return undefined; + } + if ((basename === 'powershell.exe') || (basename === 'pwsh.exe')) { + if (!shellSpecified) { toAdd.push('-Command'); } } else if ((basename === 'bash.exe') || (basename === 'zsh.exe')) { windowsShellArgs = false; - if (!taskShellArgsSpecified) { + if (!shellSpecified) { toAdd.push('-c'); } } else if (basename === 'wsl.exe') { - if (!taskShellArgsSpecified) { + if (!shellSpecified) { toAdd.push('-e'); } } else { - if (!taskShellArgsSpecified) { - // Push `-c` for unknown shells if the user didn't specify the args - toAdd.push('-c'); + if (!shellSpecified) { + toAdd.push('/d', '/c'); } } } else { - if (!taskShellArgsSpecified) { + if (!shellSpecified) { // Under Mac remove -l to not start it as a login shell. if (platform === Platform.Platform.Mac) { // Background on -l on osx https://github.com/microsoft/vscode/issues/107563 @@ -1272,12 +1268,11 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { const combinedShellArgs: string[] = Objects.deepClone(configuredShellArgs); shellCommandArgs.forEach(element => { const shouldAddShellCommandArg = configuredShellArgs.every((arg, index) => { - const isDuplicated = arg.toLowerCase() === element.toLowerCase(); - if (isDuplicated && (configuredShellArgs.length > index + 1)) { + if ((arg.toLowerCase() === element) && (configuredShellArgs.length > index + 1)) { // We can still add the argument, but only if not all of the following arguments begin with "-". return !configuredShellArgs.slice(index + 1).every(testArg => testArg.startsWith('-')); } else { - return !isDuplicated; + return arg.toLowerCase() !== element; } }); if (shouldAddShellCommandArg) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 2a56aeca979..a860d7ae974 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -31,6 +31,7 @@ import type { ICurrentPartialCommand } from '../../../../platform/terminal/commo import type { IXtermCore } from './xterm-private.js'; import type { IMenu } from '../../../../platform/actions/common/actions.js'; import type { Barrier } from '../../../../base/common/async.js'; +import type { IProgressState } from '@xterm/addon-progress'; export const ITerminalService = createDecorator('terminalService'); export const ITerminalConfigurationService = createDecorator('terminalConfigurationService'); @@ -610,6 +611,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance { readonly processName: string; readonly sequence?: string; readonly staticTitle?: string; + readonly progressState?: IProgressState; readonly workspaceFolder?: IWorkspaceFolder; readonly cwd?: string; readonly initialCwd?: string; @@ -1095,6 +1097,11 @@ export interface IXtermTerminal extends IDisposable { */ readonly isGpuAccelerated: boolean; + /** + * The last `onData` input event fired by {@link RawXtermTerminal.onData}. + */ + readonly lastInputEvent: string | undefined; + /** * Attached the terminal to the given element * @param container Container the terminal will be rendered in diff --git a/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts b/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts index ff4c4103eba..6f5040cd4d7 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts @@ -83,7 +83,7 @@ export class TerminalGroupService extends Disposable implements ITerminalGroupSe hidePanel(): void { // Hide the panel if the terminal is in the panel and it has no sibling views const panel = this._viewDescriptorService.getViewContainerByViewId(TERMINAL_VIEW_ID); - if (panel && this._viewDescriptorService.getViewContainerModel(panel).activeViewDescriptors.length === 1) { + if (panel && this._viewDescriptorService.getViewContainerModel(panel).visibleViewDescriptors.length === 1) { this._viewsService.closeView(TERMINAL_VIEW_ID); TerminalContextKeys.tabsMouse.bindTo(this._contextKeyService).set(false); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 0a1646792a0..15054267ff5 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -89,6 +89,7 @@ import { openContextMenu } from './terminalContextMenu.js'; import type { IMenu } from '../../../../platform/actions/common/actions.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { TerminalContribCommandId } from '../terminalContribExports.js'; +import type { IProgressState } from '@xterm/addon-progress'; const enum Constants { /** @@ -282,6 +283,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { get processName(): string { return this._processName; } get sequence(): string | undefined { return this._sequence; } get staticTitle(): string | undefined { return this._staticTitle; } + get progressState(): IProgressState | undefined { return this.xterm?.progressState; } get workspaceFolder(): IWorkspaceFolder | undefined { return this._workspaceFolder; } get cwd(): string | undefined { return this._cwd; } get initialCwd(): string | undefined { return this._initialCwd; } @@ -504,8 +506,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // Resolve the executable ahead of time if shell integration is enabled, this should not // be done for custom PTYs as that would cause extension Pseudoterminal-based terminals // to hang in resolver extensions + let os: OperatingSystem | undefined; if (!this.shellLaunchConfig.customPtyImplementation && this._terminalConfigurationService.config.shellIntegration?.enabled && !this.shellLaunchConfig.executable) { - const os = await this._processManager.getBackendOS(); + os = await this._processManager.getBackendOS(); const defaultProfile = (await this._terminalProfileResolverService.getDefaultProfile({ remoteAuthority: this.remoteAuthority, os })); this.shellLaunchConfig.executable = defaultProfile.path; this.shellLaunchConfig.args = defaultProfile.args; @@ -521,6 +524,12 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } } + // Resolve the shell type ahead of time to allow features that depend upon it to work + // before the process is actually created (like terminal suggest manual request) + if (os && this.shellLaunchConfig.executable) { + this.setShellType(guessShellTypeFromExecutable(os, this.shellLaunchConfig.executable)); + } + await this._createProcess(); // Re-establish the title after reconnect @@ -844,6 +853,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { xterm.refresh(); } })); + this._register(xterm.onDidChangeProgress(() => this._labelComputer?.refreshLabel(this))); // Set up updating of the process cwd on key press, this is only needed when the cwd // detection capability has not been registered @@ -1598,7 +1608,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } else { if (exitMessage) { const failedDuringLaunch = this._processManager.processState === ProcessState.KilledDuringLaunch; - if (failedDuringLaunch || this._terminalConfigurationService.config.showExitAlert) { + if (failedDuringLaunch || (this._terminalConfigurationService.config.showExitAlert && this.xterm?.lastInputEvent !== /*Ctrl+D*/'\x04')) { // Always show launch failures this._notificationService.notify({ message: exitMessage, @@ -2453,6 +2463,7 @@ interface ITerminalLabelTemplateProperties { local?: string | null | undefined; process?: string | null | undefined; sequence?: string | null | undefined; + progress?: string | null | undefined; task?: string | null | undefined; fixedDimensions?: string | null | undefined; separator?: string | ISeparator | null | undefined; @@ -2492,7 +2503,7 @@ export class TerminalLabelComputer extends Disposable { } computeLabel( - instance: Pick, + instance: Pick, labelTemplate: string, labelType: TerminalLabelType, reset?: boolean @@ -2523,6 +2534,7 @@ export class TerminalLabelComputer extends Disposable { shellPromptInput: commandDetection?.executingCommand && promptInputModel ? promptInputModel.getCombinedString(true) + nonTaskSpinner : promptInputModel?.getCombinedString(true), + progress: this._getProgressStateString(instance.progressState) }; templateProperties.workspaceFolderName = instance.workspaceFolder?.name ?? templateProperties.workspaceFolder; labelTemplate = labelTemplate.trim(); @@ -2560,6 +2572,19 @@ export class TerminalLabelComputer extends Disposable { const label = template(labelTemplate, (templateProperties as unknown) as { [key: string]: string | ISeparator | undefined | null }).replace(/[\n\r\t]/g, '').trim(); return label === '' && labelType === TerminalLabelType.Title ? (instance.processName || '') : label; } + + private _getProgressStateString(progressState?: IProgressState): string { + if (!progressState) { + return ''; + } + switch (progressState.state) { + case 0: return ''; + case 1: return `${Math.round(progressState.value)}%`; + case 2: return '$(error)'; + case 3: return '$(loading~spin)'; + case 4: return '$(alert)'; + } + } } export function parseExitResult( @@ -2656,3 +2681,46 @@ export class TerminalInstanceColorProvider implements IXtermColorProvider { return theme.getColor(SIDE_BAR_BACKGROUND); } } + +function guessShellTypeFromExecutable(os: OperatingSystem, executable: string): TerminalShellType | undefined { + const exeBasename = path.basename(executable); + const generalShellTypeMap: Map = new Map([ + [GeneralShellType.Julia, /^julia$/], + [GeneralShellType.NuShell, /^nu$/], + [GeneralShellType.PowerShell, /^pwsh(-preview)?|powershell$/], + [GeneralShellType.Python, /^py(?:thon)?$/] + ]); + for (const [shellType, pattern] of generalShellTypeMap) { + if (exeBasename.match(pattern)) { + return shellType; + } + } + + if (os === OperatingSystem.Windows) { + const windowsShellTypeMap: Map = new Map([ + [WindowsShellType.CommandPrompt, /^cmd$/], + [WindowsShellType.GitBash, /^bash$/], + [WindowsShellType.Wsl, /^wsl$/] + ]); + for (const [shellType, pattern] of windowsShellTypeMap) { + if (exeBasename.match(pattern)) { + return shellType; + } + } + } else { + const posixShellTypes: PosixShellType[] = [ + PosixShellType.Bash, + PosixShellType.Csh, + PosixShellType.Fish, + PosixShellType.Ksh, + PosixShellType.Sh, + PosixShellType.Zsh, + ]; + for (const type of posixShellTypes) { + if (exeBasename === type) { + return type; + } + } + } + return undefined; +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index ab4dab9a7ed..e8c35b335a6 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -94,6 +94,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce private _processListeners?: IDisposable[]; private _isDisconnected: boolean = false; + private _processTraits: IProcessReadyEvent | undefined; private _shellLaunchConfig?: IShellLaunchConfig; private _dimensions: ITerminalDimensions = { cols: 0, rows: 0 }; @@ -128,6 +129,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce get hasChildProcesses(): boolean { return this._hasChildProcesses; } get reconnectionProperties(): IReconnectionProperties | undefined { return this._shellLaunchConfig?.attachPersistentProcess?.reconnectionProperties || this._shellLaunchConfig?.reconnectionProperties || undefined; } get extEnvironmentVariableCollection(): IMergedEnvironmentVariableCollection | undefined { return this._extEnvironmentVariableCollection; } + get processTraits(): IProcessReadyEvent | undefined { return this._processTraits; } constructor( private readonly _instanceId: number, @@ -357,6 +359,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } this._processListeners = [ newProcess.onProcessReady((e: IProcessReadyEvent) => { + this._processTraits = e; this.shellProcessId = e.pid; this._initialCwd = e.cwd; this._onDidChangeProperty.fire({ type: ProcessPropertyType.InitialCwd, value: this._initialCwd }); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileResolverService.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileResolverService.ts index e54a12a1b44..6208cfc0413 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileResolverService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileResolverService.ts @@ -268,7 +268,6 @@ export abstract class BaseTerminalProfileResolverService extends Disposable impl const automationProfile = this._configurationService.getValue(`terminal.integrated.automationProfile.${this._getOsKey(options.os)}`); if (this._isValidAutomationProfile(automationProfile, options.os)) { automationProfile.icon = this._getCustomIcon(automationProfile.icon) || Codicon.tools; - automationProfile.isAutomationShell = true; return automationProfile; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 698559d3b2c..7fea79a51a7 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -208,7 +208,7 @@ export class TerminalService extends Disposable implements ITerminalService { // down. When shutting down the panel is locked in place so that it is restored upon next // launch. this._register(this._terminalGroupService.onDidChangeActiveInstance(instance => { - if (!instance && !this._isShuttingDown) { + if (!instance && !this._isShuttingDown && this._terminalConfigService.config.hideOnLastClosed) { this._terminalGroupService.hidePanel(); } if (instance?.shellType) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index b71064b87f0..38e6d342942 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -57,6 +57,11 @@ export class TerminalViewPane extends ViewPane { private _terminalTabbedView?: TerminalTabbedView; get terminalTabbedView(): TerminalTabbedView | undefined { return this._terminalTabbedView; } private _isInitialized: boolean = false; + /** + * Tracks an active promise of terminal creation requested by this component. This helps prevent + * double creation for example when toggling a terminal's visibility and focusing it. + */ + private _isTerminalBeingCreated: boolean = false; private readonly _newDropdown: MutableDisposable = this._register(new MutableDisposable()); private readonly _dropdownMenu: IMenu; private readonly _singleTabMenu: IMenu; @@ -164,7 +169,8 @@ export class TerminalViewPane extends ViewPane { if (!wasInitialized) { switch (hideOnStartup) { case 'never': - this._terminalService.createTerminal({ location: TerminalLocation.Panel }); + this._isTerminalBeingCreated = true; + this._terminalService.createTerminal({ location: TerminalLocation.Panel }).finally(() => this._isTerminalBeingCreated = false); break; case 'whenEmpty': if (this._terminalService.restoredGroupCount === 0) { @@ -175,7 +181,10 @@ export class TerminalViewPane extends ViewPane { return; } - this._terminalService.createTerminal({ location: TerminalLocation.Panel }); + if (!this._isTerminalBeingCreated) { + this._isTerminalBeingCreated = true; + this._terminalService.createTerminal({ location: TerminalLocation.Panel }).finally(() => this._isTerminalBeingCreated = false); + } } } @@ -320,6 +329,10 @@ export class TerminalViewPane extends ViewPane { override focus() { super.focus(); if (this._terminalService.connectionState === TerminalConnectionState.Connected) { + if (this._terminalGroupService.instances.length === 0 && !this._isTerminalBeingCreated) { + this._isTerminalBeingCreated = true; + this._terminalService.createTerminal({ location: TerminalLocation.Panel }).finally(() => this._isTerminalBeingCreated = false); + } this._terminalGroupService.showPanel(true); return; } diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermAddonImporter.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermAddonImporter.ts index 753ba6f9e79..30e6bd1905b 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermAddonImporter.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermAddonImporter.ts @@ -6,6 +6,7 @@ import type { ClipboardAddon as ClipboardAddonType } from '@xterm/addon-clipboard'; import type { ImageAddon as ImageAddonType } from '@xterm/addon-image'; import type { LigaturesAddon as LigaturesAddonType } from '@xterm/addon-ligatures'; +import type { ProgressAddon as ProgressAddonType } from '@xterm/addon-progress'; import type { SearchAddon as SearchAddonType } from '@xterm/addon-search'; import type { SerializeAddon as SerializeAddonType } from '@xterm/addon-serialize'; import type { Unicode11Addon as Unicode11AddonType } from '@xterm/addon-unicode11'; @@ -16,6 +17,7 @@ export interface IXtermAddonNameToCtor { clipboard: typeof ClipboardAddonType; image: typeof ImageAddonType; ligatures: typeof LigaturesAddonType; + progress: typeof ProgressAddonType; search: typeof SearchAddonType; serialize: typeof SerializeAddonType; unicode11: typeof Unicode11AddonType; @@ -42,6 +44,7 @@ export class XtermAddonImporter { case 'clipboard': addon = (await importAMDNodeModule('@xterm/addon-clipboard', 'lib/addon-clipboard.js')).ClipboardAddon as IXtermAddonNameToCtor[T]; break; case 'image': addon = (await importAMDNodeModule('@xterm/addon-image', 'lib/addon-image.js')).ImageAddon as IXtermAddonNameToCtor[T]; break; case 'ligatures': addon = (await importAMDNodeModule('@xterm/addon-ligatures', 'lib/addon-ligatures.js')).LigaturesAddon as IXtermAddonNameToCtor[T]; break; + case 'progress': addon = (await importAMDNodeModule('@xterm/addon-progress', 'lib/addon-progress.js')).ProgressAddon as IXtermAddonNameToCtor[T]; break; case 'search': addon = (await importAMDNodeModule('@xterm/addon-search', 'lib/addon-search.js')).SearchAddon as IXtermAddonNameToCtor[T]; break; case 'serialize': addon = (await importAMDNodeModule('@xterm/addon-serialize', 'lib/addon-serialize.js')).SerializeAddon as IXtermAddonNameToCtor[T]; break; case 'unicode11': addon = (await importAMDNodeModule('@xterm/addon-unicode11', 'lib/addon-unicode11.js')).Unicode11Addon as IXtermAddonNameToCtor[T]; break; diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 5ef6d497196..e753e86f97f 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -6,7 +6,7 @@ import type { IBuffer, ITerminalOptions, ITheme, Terminal as RawXtermTerminal, LogLevel as XtermLogLevel } from '@xterm/xterm'; import type { ISearchOptions, SearchAddon as SearchAddonType } from '@xterm/addon-search'; import type { Unicode11Addon as Unicode11AddonType } from '@xterm/addon-unicode11'; -import type { LigaturesAddon as LigaturesAddonType } from '@xterm/addon-ligatures'; +import type { ILigatureOptions, LigaturesAddon as LigaturesAddonType } from '@xterm/addon-ligatures'; import type { WebglAddon as WebglAddonType } from '@xterm/addon-webgl'; import type { SerializeAddon as SerializeAddonType } from '@xterm/addon-serialize'; import type { ImageAddon as ImageAddonType } from '@xterm/addon-image'; @@ -42,6 +42,9 @@ import { ILayoutService } from '../../../../../platform/layout/browser/layoutSer import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground } from '../../../../../platform/theme/common/colorRegistry.js'; import { XtermAddonImporter } from './xtermAddonImporter.js'; +import { equals } from '../../../../../base/common/objects.js'; +import type { IProgressState } from '@xterm/addon-progress'; +import type { CommandDetectionCapability } from '../../../../../platform/terminal/common/capabilities/commandDetectionCapability.js'; const enum RenderConstants { SmoothScrollDuration = 125 @@ -96,6 +99,10 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach private static _suggestedRendererType: 'dom' | undefined = undefined; private _attached?: { container: HTMLElement; options: IXtermAttachToElementOptions }; private _isPhysicalMouseWheel = MouseWheelClassifier.INSTANCE.isPhysicalMouseWheel(); + private _lastInputEvent: string | undefined; + get lastInputEvent(): string | undefined { return this._lastInputEvent; } + private _progressState: IProgressState = { state: 0, value: 0 }; + get progressState(): IProgressState { return this._progressState; } // Always on addons private _markNavigationAddon: MarkNavigationAddon; @@ -112,6 +119,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach private _serializeAddon?: SerializeAddonType; private _imageAddon?: ImageAddonType; private readonly _ligaturesAddon: MutableDisposable = this._register(new MutableDisposable()); + private readonly _ligaturesAddonConfig?: ILigatureOptions; private readonly _attachedDisposables = this._register(new DisposableStore()); private readonly _anyTerminalFocusContextKey: IContextKey; @@ -137,6 +145,8 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach readonly onDidChangeFocus = this._onDidChangeFocus.event; private readonly _onDidDispose = this._register(new Emitter()); readonly onDidDispose = this._onDidDispose.event; + private readonly _onDidChangeProgress = this._register(new Emitter()); + readonly onDidChangeProgress = this._onDidChangeProgress.event; get markTracker(): IMarkTracker { return this._markNavigationAddon; } get shellIntegration(): IShellIntegration { return this._shellIntegrationAddon; } @@ -256,6 +266,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._anyFocusedTerminalHasSelection.set(this.raw.hasSelection()); } })); + this._register(this.raw.onData(e => this._lastInputEvent = e)); // Load addons this._updateUnicodeVersion(); @@ -281,6 +292,33 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach }); this.raw.loadAddon(this._clipboardAddon); }); + this._xtermAddonLoader.importAddon('progress').then(ProgressAddon => { + if (this._store.isDisposed) { + return; + } + const progressAddon = this._instantiationService.createInstance(ProgressAddon); + this.raw.loadAddon(progressAddon); + const updateProgress = () => { + if (!equals(this._progressState, progressAddon.progress)) { + this._progressState = progressAddon.progress; + this._onDidChangeProgress.fire(this._progressState); + } + }; + this._register(progressAddon.onChange(() => updateProgress())); + updateProgress(); + const commandDetection = this._capabilities.get(TerminalCapability.CommandDetection); + if (commandDetection) { + commandDetection.onCommandFinished(() => progressAddon.progress = { state: 0, value: 0 }); + } else { + const disposable = this._capabilities.onDidAddCapability(e => { + if (e.id === TerminalCapability.CommandDetection) { + (e.capability as CommandDetectionCapability).onCommandFinished(() => progressAddon.progress = { state: 0, value: 0 }); + this._store.delete(disposable); + } + }); + this._store.add(disposable); + } + }); this._anyTerminalFocusContextKey = TerminalContextKeys.focusInAny.bindTo(contextKeyService); this._anyFocusedTerminalHasSelection = TerminalContextKeys.textSelectedInFocused.bindTo(contextKeyService); @@ -697,18 +735,37 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach if (!this.raw.element) { return; } - if (this._terminalConfigurationService.config.fontLigatures) { + const ligaturesConfig = this._terminalConfigurationService.config.fontLigatures; + let shouldRecreateWebglRenderer = false; + if (ligaturesConfig?.enabled) { + if (this._ligaturesAddon.value && !equals(ligaturesConfig, this._ligaturesAddonConfig)) { + this._ligaturesAddon.clear(); + } if (!this._ligaturesAddon.value) { - this._xtermAddonLoader.importAddon('ligatures').then(LigaturesAddon => { - if (this._store.isDisposed) { - return; - } - this._ligaturesAddon.value = this._instantiationService.createInstance(LigaturesAddon); - this.raw.loadAddon(this._ligaturesAddon.value); + const LigaturesAddon = await this._xtermAddonLoader.importAddon('ligatures'); + if (this._store.isDisposed) { + return; + } + this._ligaturesAddon.value = this._instantiationService.createInstance(LigaturesAddon, { + fontFeatureSettings: ligaturesConfig.featureSettings, + fallbackLigatures: ligaturesConfig.fallbackLigatures, }); + this.raw.loadAddon(this._ligaturesAddon.value); + shouldRecreateWebglRenderer = true; } } else { + if (!this._ligaturesAddon.value) { + return; + } this._ligaturesAddon.clear(); + shouldRecreateWebglRenderer = true; + } + + if (shouldRecreateWebglRenderer && this._webglAddon) { + // Re-create the webgl addon when ligatures state changes to so the texture atlas picks up + // styles from the DOM. + this._disposeOfWebglRenderer(); + await this._enableWebglRenderer(); } } diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 index 117a2285405..4c5a7059a51 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 @@ -88,6 +88,16 @@ function Global:Prompt() { # Current working directory # OSC 633 ; = ST $Result += if ($pwd.Provider.Name -eq 'FileSystem') { "$([char]0x1b)]633;P;Cwd=$(__VSCode-Escape-Value $pwd.ProviderPath)`a" } + + # Send current environment variables as JSON + # OSC 633 ; Env ; ; + if ($isStable -eq "0") { + $envMap = @{} + Get-ChildItem Env: | ForEach-Object { $envMap[$_.Name] = $_.Value } + $envJson = $envMap | ConvertTo-Json -Compress + $Result += "$([char]0x1b)]633;EnvJson;$(__VSCode-Escape-Value $envJson);$Nonce`a" + } + # Before running the original prompt, put $? back to what it was: if ($FakeCode -ne 0) { Write-Error "failure" -ea ignore diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index cf5c306bb70..f3ab7307d1a 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -207,10 +207,15 @@ export interface ITerminalConfiguration { smoothScrolling: boolean; ignoreBracketedPasteMode: boolean; rescaleOverlappingGlyphs: boolean; - fontLigatures?: boolean; + fontLigatures?: { + enabled: boolean; + featureSettings: string; + fallbackLigatures: string[]; + }; experimental?: { windowsUseConptyDll?: boolean; }; + hideOnLastClosed: boolean; } export interface ITerminalFont { @@ -273,6 +278,8 @@ export interface ITerminalProcessInfo { export const isTerminalProcessManager = (t: ITerminalProcessInfo | ITerminalProcessManager): t is ITerminalProcessManager => typeof (t as ITerminalProcessManager).write === 'function'; export interface ITerminalProcessManager extends IDisposable, ITerminalProcessInfo { + readonly processTraits: IProcessReadyEvent | undefined; + readonly onPtyDisconnect: Event; readonly onPtyReconnect: Event; @@ -566,6 +573,7 @@ export const DEFAULT_COMMANDS_TO_SKIP_SHELL: string[] = [ 'workbench.action.previousPanelView', 'workbench.action.nextSideBarView', 'workbench.action.previousSideBarView', + 'workbench.action.debug.disconnect', 'workbench.action.debug.start', 'workbench.action.debug.stop', 'workbench.action.debug.run', diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index b90a67cbbc6..663011266aa 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -21,6 +21,7 @@ const terminalDescriptors = '\n- ' + [ '`\${workspaceFolderName}`: ' + localize('workspaceFolderName', "the `name` of the workspace in which the terminal was launched"), '`\${local}`: ' + localize('local', "indicates a local terminal in a remote workspace"), '`\${process}`: ' + localize('process', "the name of the terminal process"), + '`\${progress}`: ' + localize('progress', "the progress state as reported by the `OSC 9;4` sequence"), '`\${separator}`: ' + localize('separator', "a conditional separator {0} that only shows when surrounded by variables with values or static text.", '(` - `)'), '`\${sequence}`: ' + localize('sequence', "the name provided to the terminal by the process"), '`\${task}`: ' + localize('task', "indicates this terminal is associated with a task"), @@ -174,12 +175,33 @@ const terminalConfiguration: IConfigurationNode = { markdownDescription: localize('terminal.integrated.fontFamily', "Controls the font family of the terminal. Defaults to {0}'s value.", '`#editor.fontFamily#`'), type: 'string' }, - [TerminalSettingId.FontLigatures]: { - markdownDescription: localize('terminal.integrated.fontLigatures', "Controls whether font ligatures are enabled in the terminal. Ligatures will only work if the configured {0} supports them.", `\`#${TerminalSettingId.FontFamily}#\``), + [TerminalSettingId.FontLigaturesEnabled]: { + markdownDescription: localize('terminal.integrated.fontLigatures.enabled', "Controls whether font ligatures are enabled in the terminal. Ligatures will only work if the configured {0} supports them.", `\`#${TerminalSettingId.FontFamily}#\``), type: 'boolean', - tags: ['preview'], default: false }, + [TerminalSettingId.FontLigaturesFeatureSettings]: { + markdownDescription: localize('terminal.integrated.fontLigatures.featureSettings', "Controls what font feature settings are used when ligatures are enabled, in the format of the `font-feature-settings` CSS property. Some examples which may be valid depending on the font:") + '\n\n- ' + [ + `\`"calt" off, "ss03"\``, + `\`"liga" on"\``, + `\`"calt" off, "dlig" on\`` + ].join('\n- '), + type: 'string', + default: '"calt" on' + }, + [TerminalSettingId.FontLigaturesFallbackLigatures]: { + markdownDescription: localize('terminal.integrated.fontLigatures.fallbackLigatures', "When {0} is enabled and the particular {1} cannot be parsed, this is the set of character sequences that will always be drawn together. This allows the use of a fixed set of ligatures even when the font isn't supported.", `\`#${TerminalSettingId.GpuAcceleration}#\``, `\`#${TerminalSettingId.FontFamily}#\``), + type: 'array', + items: [{ type: 'string' }], + default: [ + '<--', '<---', '<<-', '<-', '->', '->>', '-->', '--->', + '<==', '<===', '<<=', '<=', '=>', '=>>', '==>', '===>', '>=', '>>=', + '<->', '<-->', '<--->', '<---->', '<=>', '<==>', '<===>', '<====>', '::', ':::', + '<~~', '', '/>', '~~>', '==', '!=', '/=', '~=', '<>', '===', '!==', '!===', + '<:', ':=', '*=', '*+', '<*', '<*>', '*>', '<|', '<|>', '|>', '+*', '=*', '=:', ':>', + '/*', '*/', '+++', '