diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index 9907b0ccee876..0b082e81a24d9 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -24,12 +24,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: lfs: true - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 8d3f50df39b52..de462673a18b1 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -315,9 +315,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -329,9 +329,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -343,9 +343,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -357,9 +357,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -385,9 +385,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -399,9 +399,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -413,9 +413,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -427,9 +427,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -441,9 +441,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -455,9 +455,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -469,9 +469,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -483,9 +483,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -497,9 +497,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -511,9 +511,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -525,9 +525,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -539,9 +539,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -553,9 +553,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -567,9 +567,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -581,9 +581,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -595,9 +595,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -609,9 +609,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -623,9 +623,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -637,9 +637,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -651,9 +651,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1169,9 +1169,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1185,31 +1185,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/cglicenses.json b/cglicenses.json index 2b1bc6fece5b6..37bba3145ba11 100644 --- a/cglicenses.json +++ b/cglicenses.json @@ -707,64 +707,6 @@ "For more information, please refer to " ] }, - { - "name": "@isaacs/balanced-match", - "fullLicenseText": [ - "MIT License", - "", - "Copyright Isaac Z. Schlueter ", - "", - "Original code Copyright Julian Gruber ", - "", - "Port to TypeScript Copyright Isaac Z. Schlueter ", - "", - "Permission is hereby granted, free of charge, to any person obtaining a copy of", - "this software and associated documentation files (the \"Software\"), to deal in", - "the Software without restriction, including without limitation the rights to", - "use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies", - "of the Software, and to permit persons to whom the Software is furnished to do", - "so, subject to the following conditions:", - "", - "The above copyright notice and this permission notice shall be included in all", - "copies or substantial portions of the Software.", - "", - "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR", - "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,", - "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE", - "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER", - "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,", - "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE", - "SOFTWARE.", - "" - ] - }, - { - "name": "@isaacs/brace-expansion", - "fullLicenseText": [ - "MIT License", - "", - "Copyright (c) 2013 Julian Gruber ", - "", - "Permission is hereby granted, free of charge, to any person obtaining a copy", - "of this software and associated documentation files (the \"Software\"), to deal", - "in the Software without restriction, including without limitation the rights", - "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell", - "copies of the Software, and to permit persons to whom the Software is", - "furnished to do so, subject to the following conditions:", - "", - "The above copyright notice and this permission notice shall be included in all", - "copies or substantial portions of the Software.", - "", - "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR", - "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,", - "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE", - "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER", - "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,", - "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE", - "SOFTWARE.", - "" - ] - }, { // Reason: License file starts with (MIT) before the copyright, tool can't parse it "name": "balanced-match", diff --git a/cli/ThirdPartyNotices.txt b/cli/ThirdPartyNotices.txt index 61cacaea79978..9edb0ae9d2330 100644 --- a/cli/ThirdPartyNotices.txt +++ b/cli/ThirdPartyNotices.txt @@ -9189,6 +9189,32 @@ DEALINGS IN THE SOFTWARE. --------------------------------------------------------- +serde_spanned 1.0.4 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + serde_urlencoded 0.7.1 - MIT/Apache-2.0 https://github.com/nox/serde_urlencoded @@ -10517,7 +10543,34 @@ SOFTWARE. --------------------------------------------------------- +toml 0.9.12+spec-1.1.0 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + toml_datetime 0.6.11 - MIT OR Apache-2.0 +toml_datetime 0.7.5+spec-1.1.0 - MIT OR Apache-2.0 https://github.com/toml-rs/toml ../../LICENSE-MIT @@ -10533,6 +10586,58 @@ https://github.com/toml-rs/toml --------------------------------------------------------- +toml_parser 1.0.9+spec-1.1.0 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + +toml_writer 1.0.6+spec-1.1.0 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + tower-service 0.3.3 - MIT https://github.com/tower-rs/tower @@ -12700,6 +12805,7 @@ MIT License --------------------------------------------------------- winnow 0.5.40 - MIT +winnow 0.7.14 - MIT https://github.com/winnow-rs/winnow The MIT License (MIT) @@ -12755,6 +12861,40 @@ THE SOFTWARE. --------------------------------------------------------- +winresource 0.1.30 - MIT +https://github.com/BenjaminRi/winresource + +The MIT License (MIT) + +Copyright 2016 Max Resch + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + wit-bindgen 0.51.0 - Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT https://github.com/bytecodealliance/wit-bindgen diff --git a/extensions/github/package.json b/extensions/github/package.json index a9ba2e87d3087..bce90fe1812d5 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -22,7 +22,7 @@ "main": "./out/extension.js", "type": "module", "capabilities": { - "virtualWorkspaces": false, + "virtualWorkspaces": true, "untrustedWorkspaces": { "supported": true } @@ -74,6 +74,11 @@ "command": "github.createPullRequest", "title": "%command.createPullRequest%", "icon": "$(git-pull-request)" + }, + { + "command": "github.openPullRequest", + "title": "%command.openPullRequest%", + "icon": "$(git-pull-request)" } ], "continueEditSession": [ @@ -95,6 +100,10 @@ "command": "github.createPullRequest", "when": "false" }, + { + "command": "github.openPullRequest", + "when": "false" + }, { "command": "github.graph.openOnGitHub", "when": "false" @@ -179,7 +188,13 @@ "command": "github.createPullRequest", "group": "navigation", "order": 1, - "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli" + "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli && !github.hasOpenPullRequest" + }, + { + "command": "github.openPullRequest", + "group": "navigation", + "order": 1, + "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli && github.hasOpenPullRequest" } ] }, diff --git a/extensions/github/package.nls.json b/extensions/github/package.nls.json index ced536e4bd7c6..4acc8acabcbab 100644 --- a/extensions/github/package.nls.json +++ b/extensions/github/package.nls.json @@ -6,6 +6,7 @@ "command.openOnGitHub": "Open on GitHub", "command.openOnVscodeDev": "Open in vscode.dev", "command.createPullRequest": "Create Pull Request", + "command.openPullRequest": "Open Pull Request", "config.branchProtection": "Controls whether to query repository rules for GitHub repositories", "config.gitAuthentication": "Controls whether to enable automatic GitHub authentication for git commands within VS Code.", "config.gitProtocol": "Controls which protocol is used to clone a GitHub repository", diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 496772ededf82..33acf5a406b87 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -8,6 +8,7 @@ import { API as GitAPI, RefType, Repository } from './typings/git.js'; import { publishRepository } from './publish.js'; import { DisposableStore, getRepositoryFromUrl } from './util.js'; import { LinkContext, getCommitLink, getLink, getVscodeDevHost } from './links.js'; +import { getOctokit } from './auth.js'; async function copyVscodeDevLink(gitAPI: GitAPI, useSelection: boolean, context: LinkContext, includeRange = true) { try { @@ -34,46 +35,95 @@ async function openVscodeDevLink(gitAPI: GitAPI): Promise { - if (!sessionResource || !sessionMetadata?.worktreePath) { - return; +interface ResolvedSessionRepo { + repository: Repository; + remoteInfo: { owner: string; repo: string }; + gitRemote: { name: string; fetchUrl: string }; + head: { name: string; upstream?: { name: string; remote: string; commit: string } }; +} + +function resolveSessionRepo(gitAPI: GitAPI, sessionMetadata: { worktreePath?: string } | undefined, showErrors: boolean): ResolvedSessionRepo | undefined { + if (!sessionMetadata?.worktreePath) { + return undefined; } const worktreeUri = vscode.Uri.file(sessionMetadata.worktreePath); const repository = gitAPI.getRepository(worktreeUri); if (!repository) { - vscode.window.showErrorMessage(vscode.l10n.t('Could not find a git repository for the session worktree.')); - return; + if (showErrors) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not find a git repository for the session worktree.')); + } + return undefined; } - // Find the GitHub remote const remotes = repository.state.remotes .filter(remote => remote.fetchUrl && getRepositoryFromUrl(remote.fetchUrl)); if (remotes.length === 0) { - vscode.window.showErrorMessage(vscode.l10n.t('Could not find a GitHub remote for this repository.')); - return; + if (showErrors) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not find a GitHub remote for this repository.')); + } + return undefined; } - // Prefer upstream -> origin -> first const gitRemote = remotes.find(r => r.name === 'upstream') ?? remotes.find(r => r.name === 'origin') ?? remotes[0]; const remoteInfo = getRepositoryFromUrl(gitRemote.fetchUrl!); if (!remoteInfo) { - vscode.window.showErrorMessage(vscode.l10n.t('Could not parse GitHub remote URL.')); - return; + if (showErrors) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not parse GitHub remote URL.')); + } + return undefined; } - // Get the current branch (the worktree branch) const head = repository.state.HEAD; if (!head?.name) { - vscode.window.showErrorMessage(vscode.l10n.t('Could not determine the current branch.')); + if (showErrors) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not determine the current branch.')); + } + return undefined; + } + + return { repository, remoteInfo, gitRemote: { name: gitRemote.name, fetchUrl: gitRemote.fetchUrl! }, head: head as ResolvedSessionRepo['head'] }; +} + +async function checkOpenPullRequest(gitAPI: GitAPI, _sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { + const resolved = resolveSessionRepo(gitAPI, sessionMetadata, false); + if (!resolved) { + vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', false); + return; + } + + try { + const octokit = await getOctokit(); + const { data: openPRs } = await octokit.pulls.list({ + owner: resolved.remoteInfo.owner, + repo: resolved.remoteInfo.repo, + head: `${resolved.remoteInfo.owner}:${resolved.head.name}`, + state: 'all', + }); + + vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', openPRs.length > 0); + } catch { + vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', false); + } +} + +async function createPullRequest(gitAPI: GitAPI, sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { + if (!sessionResource) { + return; + } + + const resolved = resolveSessionRepo(gitAPI, sessionMetadata, true); + if (!resolved) { return; } + const { repository, remoteInfo, gitRemote, head } = resolved; + // Ensure the branch is published to the remote if (!head.upstream) { try { @@ -96,6 +146,34 @@ async function createPullRequest(gitAPI: GitAPI, sessionResource: vscode.Uri | u vscode.env.openExternal(vscode.Uri.parse(prUrl)); } +async function openPullRequest(gitAPI: GitAPI, _sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { + const resolved = resolveSessionRepo(gitAPI, sessionMetadata, true); + if (!resolved) { + return; + } + + try { + const octokit = await getOctokit(); + const { data: pullRequests } = await octokit.pulls.list({ + owner: resolved.remoteInfo.owner, + repo: resolved.remoteInfo.repo, + head: `${resolved.remoteInfo.owner}:${resolved.head.name}`, + state: 'all', + }); + + if (pullRequests.length > 0) { + vscode.env.openExternal(vscode.Uri.parse(pullRequests[0].html_url)); + return; + } + } catch { + // If the API call fails, fall through to open the repo page + } + + // Fallback: open the repository page + const { remoteInfo } = resolved; + vscode.env.openExternal(vscode.Uri.parse(`https://github.com/${remoteInfo.owner}/${remoteInfo.repo}`)); +} + async function openOnGitHub(repository: Repository, commit: string): Promise { // Get the unique remotes that contain the commit const branches = await repository.getBranches({ contains: commit, remote: true }); @@ -181,5 +259,13 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable { return createPullRequest(gitAPI, sessionResource, sessionMetadata); })); + disposables.add(vscode.commands.registerCommand('github.openPullRequest', async (sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined) => { + return openPullRequest(gitAPI, sessionResource, sessionMetadata); + })); + + disposables.add(vscode.commands.registerCommand('github.checkOpenPullRequest', async (sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined) => { + return checkOpenPullRequest(gitAPI, sessionResource, sessionMetadata); + })); + return disposables; } diff --git a/extensions/html-language-features/package-lock.json b/extensions/html-language-features/package-lock.json index 1e97c143679e7..ef75469b9646a 100644 --- a/extensions/html-language-features/package-lock.json +++ b/extensions/html-language-features/package-lock.json @@ -20,27 +20,6 @@ "vscode": "^1.77.0" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@microsoft/1ds-core-js": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-4.3.4.tgz", @@ -189,16 +168,37 @@ "vscode": "^1.75.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "license": "ISC", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" diff --git a/package.json b/package.json index a8b3853221492..67e53ae161026 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "11e5d56acfb25db3d8e0088b0150ce2488eddb53", + "distro": "3ddf7ca3e6b5b372de64cc436eb175522d30f8ab", "author": { "name": "Microsoft Corporation" }, diff --git a/product.json b/product.json index af053a48660fd..cbd7903d0b441 100644 --- a/product.json +++ b/product.json @@ -52,8 +52,8 @@ }, { "name": "ms-vscode.js-debug", - "version": "1.105.0", - "sha256": "0c45b90342e8aafd4ff2963b4006de64208ca58c2fd01fea7a710fe61dcfd12a", + "version": "1.110.0", + "sha256": "ad3f7d935b64f4ee123853c464b47b3cba13e5018edef831770ae9c3eb218f5b", "repo": "https://github.com/microsoft/vscode-js-debug", "metadata": { "id": "25629058-ddac-4e17-abba-74678e126c5d", diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index 4d3ae947ad6e5..8a276a599e410 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -1572,7 +1572,9 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati if (editingLinesCnt < deletingLinesCnt) { // Must delete some lines const spliceStartLineNumber = startLineNumber + editingLinesCnt; - rawContentChanges.push(new ModelRawLinesDeleted(spliceStartLineNumber + 1, endLineNumber)); + const cnt = insertingLinesCnt - deletingLinesCnt; + const lastUntouchedLinePostEdit = newLineCount - lineCount - cnt + spliceStartLineNumber; + rawContentChanges.push(new ModelRawLinesDeleted(spliceStartLineNumber + 1, endLineNumber, lastUntouchedLinePostEdit)); } if (editingLinesCnt < insertingLinesCnt) { diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index 4fa24afd2c1b0..ebbba09318334 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -397,10 +397,15 @@ export class ModelRawLinesDeleted { * At what line the deletion stopped (inclusive). */ public readonly toLineNumber: number; + /** + * The last unmodified line in the updated buffer after the deletion is made. + */ + public readonly lastUntouchedLinePostEdit: number; - constructor(fromLineNumber: number, toLineNumber: number) { + constructor(fromLineNumber: number, toLineNumber: number, lastUntouchedLinePostEdit: number) { this.fromLineNumber = fromLineNumber; this.toLineNumber = toLineNumber; + this.lastUntouchedLinePostEdit = lastUntouchedLinePostEdit; } } diff --git a/src/vs/editor/common/viewLayout/lineHeights.ts b/src/vs/editor/common/viewLayout/lineHeights.ts index 215d210f9fda1..94e81bf561d6c 100644 --- a/src/vs/editor/common/viewLayout/lineHeights.ts +++ b/src/vs/editor/common/viewLayout/lineHeights.ts @@ -21,7 +21,7 @@ type PendingChange = | { readonly kind: PendingChangeKind.InsertOrChange; readonly decorationId: string; readonly startLineNumber: number; readonly endLineNumber: number; readonly lineHeight: number } | { readonly kind: PendingChangeKind.Remove; readonly decorationId: string } | { readonly kind: PendingChangeKind.LinesDeleted; readonly fromLineNumber: number; readonly toLineNumber: number } - | { readonly kind: PendingChangeKind.LinesInserted; readonly fromLineNumber: number; readonly toLineNumber: number; readonly lineHeightsAdded: CustomLineHeightData[] }; + | { readonly kind: PendingChangeKind.LinesInserted; readonly fromLineNumber: number; readonly toLineNumber: number }; export class CustomLine { @@ -132,8 +132,8 @@ export class LineHeightsManager { this._hasPending = true; } - public onLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[]): void { - this._pendingChanges.push({ kind: PendingChangeKind.LinesInserted, fromLineNumber, toLineNumber, lineHeightsAdded }); + public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { + this._pendingChanges.push({ kind: PendingChangeKind.LinesInserted, fromLineNumber, toLineNumber }); this._hasPending = true; } @@ -160,7 +160,7 @@ export class LineHeightsManager { break; case PendingChangeKind.LinesInserted: this._flushStagedDecorationChanges(stagedInserts); - this._doLinesInserted(change.fromLineNumber, change.toLineNumber, change.lineHeightsAdded, stagedInserts); + this._doLinesInserted(change.fromLineNumber, change.toLineNumber, stagedInserts); break; } } @@ -358,7 +358,7 @@ export class LineHeightsManager { } } - private _doLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[], stagedInserts: CustomLine[]): void { + private _doLinesInserted(fromLineNumber: number, toLineNumber: number, stagedInserts: CustomLine[]): void { const insertCount = toLineNumber - fromLineNumber + 1; const candidateStartIndexOfInsertion = this._binarySearchOverOrderedCustomLinesArray(fromLineNumber); let startIndexOfInsertion: number; @@ -374,22 +374,6 @@ export class LineHeightsManager { } else { startIndexOfInsertion = -(candidateStartIndexOfInsertion + 1); } - const maxLineHeightPerLine = new Map(); - for (const lineHeightAdded of lineHeightsAdded) { - for (let lineNumber = lineHeightAdded.startLineNumber; lineNumber <= lineHeightAdded.endLineNumber; lineNumber++) { - if (lineNumber >= fromLineNumber && lineNumber <= toLineNumber) { - const currentMax = maxLineHeightPerLine.get(lineNumber) ?? this._defaultLineHeight; - maxLineHeightPerLine.set(lineNumber, Math.max(currentMax, lineHeightAdded.lineHeight)); - } - } - this._doInsertOrChangeCustomLineHeight( - lineHeightAdded.decorationId, - lineHeightAdded.startLineNumber, - lineHeightAdded.endLineNumber, - lineHeightAdded.lineHeight, - stagedInserts - ); - } const toReAdd: CustomLineHeightData[] = []; const decorationsImmediatelyAfter = new Set(); for (let i = startIndexOfInsertion; i < this._orderedCustomLines.length; i++) { @@ -404,9 +388,7 @@ export class LineHeightsManager { } } const decorationsWithGaps = intersection(decorationsImmediatelyBefore, decorationsImmediatelyAfter); - const specialHeightToAdd = Array.from(maxLineHeightPerLine.values()).reduce((acc, height) => acc + height, 0); - const defaultHeightToAdd = (insertCount - maxLineHeightPerLine.size) * this._defaultLineHeight; - const prefixSumToAdd = specialHeightToAdd + defaultHeightToAdd; + const prefixSumToAdd = insertCount * this._defaultLineHeight; for (let i = startIndexOfInsertion; i < this._orderedCustomLines.length; i++) { this._orderedCustomLines[i].lineNumber += insertCount; this._orderedCustomLines[i].prefixSum += prefixSumToAdd; diff --git a/src/vs/editor/common/viewLayout/linesLayout.ts b/src/vs/editor/common/viewLayout/linesLayout.ts index cd69d95877d8f..033b7423db4c2 100644 --- a/src/vs/editor/common/viewLayout/linesLayout.ts +++ b/src/vs/editor/common/viewLayout/linesLayout.ts @@ -349,9 +349,8 @@ export class LinesLayout { * * @param fromLineNumber The line number at which the insertion started, inclusive * @param toLineNumber The line number at which the insertion ended, inclusive. - * @param lineHeightsAdded The custom line height data for the inserted lines. */ - public onLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[]): void { + public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { fromLineNumber = fromLineNumber | 0; toLineNumber = toLineNumber | 0; @@ -363,7 +362,7 @@ export class LinesLayout { this._arr[i].afterLineNumber += (toLineNumber - fromLineNumber + 1); } } - this._lineHeightsManager.onLinesInserted(fromLineNumber, toLineNumber, lineHeightsAdded); + this._lineHeightsManager.onLinesInserted(fromLineNumber, toLineNumber); } /** diff --git a/src/vs/editor/common/viewLayout/viewLayout.ts b/src/vs/editor/common/viewLayout/viewLayout.ts index 048dc241eae7d..202187a4aadb2 100644 --- a/src/vs/editor/common/viewLayout/viewLayout.ts +++ b/src/vs/editor/common/viewLayout/viewLayout.ts @@ -243,8 +243,8 @@ export class ViewLayout extends Disposable implements IViewLayout { public onLinesDeleted(fromLineNumber: number, toLineNumber: number): void { this._linesLayout.onLinesDeleted(fromLineNumber, toLineNumber); } - public onLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[]): void { - this._linesLayout.onLinesInserted(fromLineNumber, toLineNumber, lineHeightsAdded); + public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { + this._linesLayout.onLinesInserted(fromLineNumber, toLineNumber); } // ---- end view event handlers diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 299fb151446f4..f632d6950338f 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -355,6 +355,11 @@ export class ViewModel extends Disposable implements IViewModel { const lineBreaks = lineBreaksComputer.finalize(); const lineBreakQueue = new ArrayQueue(lineBreaks); + // Collect model line ranges that need custom line height computation. + // We defer this until after the loop because the coordinatesConverter + // relies on projections that may not yet reflect all changes in the batch. + const customLineHeightRangesToInsert: { fromLineNumber: number; toLineNumber: number }[] = []; + for (const change of changes) { switch (change.changeType) { case textModelEvents.RawContentChangedType.Flush: { @@ -370,6 +375,7 @@ export class ViewModel extends Disposable implements IViewModel { if (linesDeletedEvent !== null) { eventsCollector.emitViewEvent(linesDeletedEvent); this.viewLayout.onLinesDeleted(linesDeletedEvent.fromLineNumber, linesDeletedEvent.toLineNumber); + customLineHeightRangesToInsert.push({ fromLineNumber: change.lastUntouchedLinePostEdit, toLineNumber: change.lastUntouchedLinePostEdit }); } hadOtherModelChange = true; break; @@ -379,7 +385,8 @@ export class ViewModel extends Disposable implements IViewModel { const linesInsertedEvent = this._lines.onModelLinesInserted(versionId, change.fromLineNumber, change.toLineNumber, insertedLineBreaks); if (linesInsertedEvent !== null) { eventsCollector.emitViewEvent(linesInsertedEvent); - this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber, this._getCustomLineHeightsForLines(change.fromLineNumberPostEdit, change.toLineNumberPostEdit)); + this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber); + customLineHeightRangesToInsert.push({ fromLineNumber: change.fromLineNumberPostEdit, toLineNumber: change.toLineNumberPostEdit }); } hadOtherModelChange = true; break; @@ -394,11 +401,13 @@ export class ViewModel extends Disposable implements IViewModel { } if (linesInsertedEvent) { eventsCollector.emitViewEvent(linesInsertedEvent); - this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber, this._getCustomLineHeightsForLines(change.lineNumberPostEdit, change.lineNumberPostEdit)); + this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber); + customLineHeightRangesToInsert.push({ fromLineNumber: change.lineNumberPostEdit, toLineNumber: change.lineNumberPostEdit }); } if (linesDeletedEvent) { eventsCollector.emitViewEvent(linesDeletedEvent); this.viewLayout.onLinesDeleted(linesDeletedEvent.fromLineNumber, linesDeletedEvent.toLineNumber); + customLineHeightRangesToInsert.push({ fromLineNumber: change.lineNumberPostEdit, toLineNumber: change.lineNumberPostEdit }); } break; } @@ -412,6 +421,19 @@ export class ViewModel extends Disposable implements IViewModel { if (versionId !== null) { this._lines.acceptVersionId(versionId); } + + // Apply deferred custom line heights now that projections are stable + if (customLineHeightRangesToInsert.length > 0) { + this.viewLayout.changeSpecialLineHeights((accessor: ILineHeightChangeAccessor) => { + for (const range of customLineHeightRangesToInsert) { + const customLineHeights = this._getCustomLineHeightsForLines(range.fromLineNumber, range.toLineNumber); + for (const data of customLineHeights) { + accessor.insertOrChangeCustomLineHeight(data.decorationId, data.startLineNumber, data.endLineNumber, data.lineHeight); + } + } + }); + } + this.viewLayout.onHeightMaybeChanged(); if (!hadOtherModelChange && hadModelLineChangeThatChangedLineMapping) { diff --git a/src/vs/editor/test/common/model/model.test.ts b/src/vs/editor/test/common/model/model.test.ts index 72140c2636000..c518ee861522f 100644 --- a/src/vs/editor/test/common/model/model.test.ts +++ b/src/vs/editor/test/common/model/model.test.ts @@ -245,7 +245,7 @@ suite('Editor Model - Model', () => { assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ new ModelRawLineChanged(1, 1, 'My Second Line', null), - new ModelRawLinesDeleted(2, 2), + new ModelRawLinesDeleted(2, 2, 1), ], 2, false, @@ -260,7 +260,7 @@ suite('Editor Model - Model', () => { assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ new ModelRawLineChanged(1, 1, 'My Third Line', null), - new ModelRawLinesDeleted(2, 3), + new ModelRawLinesDeleted(2, 3, 1), ], 2, false, diff --git a/src/vs/editor/test/common/viewLayout/lineHeights.test.ts b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts index 48b50f306162a..695650dd8a355 100644 --- a/src/vs/editor/test/common/viewLayout/lineHeights.test.ts +++ b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts @@ -182,7 +182,7 @@ suite('Editor ViewLayout - LineHeightsManager', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); - manager.onLinesInserted(3, 4, []); // Insert 2 lines at line 3 + manager.onLinesInserted(3, 4); // Insert 2 lines at line 3 assert.strictEqual(manager.heightForLineNumber(5), 10); assert.strictEqual(manager.heightForLineNumber(6), 10); @@ -195,7 +195,7 @@ suite('Editor ViewLayout - LineHeightsManager', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); - manager.onLinesInserted(6, 7, []); // Insert 2 lines at line 6 + manager.onLinesInserted(6, 7); // Insert 2 lines at line 6 assert.strictEqual(manager.heightForLineNumber(5), 20); assert.strictEqual(manager.heightForLineNumber(6), 20); @@ -267,9 +267,8 @@ suite('Editor ViewLayout - LineHeightsManager', () => { assert.strictEqual(manager.heightForLineNumber(2), 10); // Insert line 2 to line 2, with the same decoration ID 'decA' covering line 2 - manager.onLinesInserted(2, 2, [ - new CustomLineHeightData('decA', 2, 2, 30) - ]); + manager.onLinesInserted(2, 2); + manager.insertOrChangeCustomLineHeight('decA', 2, 2, 30); // After insertion, the decoration 'decA' now covers line 2 // Since insertOrChangeCustomLineHeight removes the old decoration first, @@ -349,7 +348,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { // Caller A removes its decoration before any flush occurs. manager.removeCustomLineHeight('decA'); // Caller B triggers a structural change that causes queue flush in the middle of commit. - manager.onLinesInserted(1, 1, []); + manager.onLinesInserted(1, 1); // decA must stay removed. If queued inserts are not canceled on remove, decA incorrectly survives. assert.strictEqual(manager.heightForLineNumber(4), 10); @@ -381,7 +380,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { manager.insertOrChangeCustomLineHeight('dec1', 2, 2, 20); manager.insertOrChangeCustomLineHeight('dec2', 5, 5, 30); // Step 3: insert 2 lines at line 3 (shifts dec2 from line 5 → 7) - manager.onLinesInserted(3, 4, []); + manager.onLinesInserted(3, 4); // Step 4: delete line 1 (shifts dec1 from line 2 → 1, dec2 from line 7 → 6) manager.onLinesDeleted(1, 1); // Step 5-6: remove the two decorations @@ -402,7 +401,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); // Insert 1 line at line 1 → dec1 shifts from 3 → 4 - manager.onLinesInserted(1, 1, []); + manager.onLinesInserted(1, 1); manager.removeCustomLineHeight('dec1'); // Read — no explicit commit assert.strictEqual(manager.heightForLineNumber(3), 10); @@ -442,7 +441,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); // Insert 2 lines at line 1 → dec1 moves from 3 → 5 - manager.onLinesInserted(1, 2, []); + manager.onLinesInserted(1, 2); // Delete line 1 → dec1 moves from 5 → 4 manager.onLinesDeleted(1, 1); // Read @@ -455,9 +454,9 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); // Insert 1 line at line 1 → dec1 at 3 → 4 - manager.onLinesInserted(1, 1, []); + manager.onLinesInserted(1, 1); // Insert 1 line at line 1 → dec1 at 4 → 5 - manager.onLinesInserted(1, 1, []); + manager.onLinesInserted(1, 1); // Read assert.strictEqual(manager.heightForLineNumber(5), 20); assert.strictEqual(manager.heightForLineNumber(3), 10); @@ -492,7 +491,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { // Insert a decoration at line 3 (pending, not committed) manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); // Insert 2 lines before it at line 1 → should shift dec1 from 3 → 5 - manager.onLinesInserted(1, 2, []); + manager.onLinesInserted(1, 2); // Read assert.strictEqual(manager.heightForLineNumber(3), 10); assert.strictEqual(manager.heightForLineNumber(5), 20); @@ -524,4 +523,13 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { assert.strictEqual(manager.heightForLineNumber(6), 30); assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(6), 110); }); + + test('deleting line 2 with lineHeightsRemoved re-adding at line 1 moves special line to line 1', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 2, 2, 20); + assert.strictEqual(manager.heightForLineNumber(2), 20); + manager.onLinesDeleted(2, 2); + manager.insertOrChangeCustomLineHeight('dec1', 1, 1, 20); + assert.strictEqual(manager.heightForLineNumber(1), 20); + }); }); diff --git a/src/vs/editor/test/common/viewLayout/linesLayout.test.ts b/src/vs/editor/test/common/viewLayout/linesLayout.test.ts index ceb624ac2740e..7bf20a78d8457 100644 --- a/src/vs/editor/test/common/viewLayout/linesLayout.test.ts +++ b/src/vs/editor/test/common/viewLayout/linesLayout.test.ts @@ -208,7 +208,7 @@ suite('Editor ViewLayout - LinesLayout', () => { // Insert two lines at the beginning // 10 lines // whitespace: - a(6,10) - linesLayout.onLinesInserted(1, 2, []); + linesLayout.onLinesInserted(1, 2); assert.strictEqual(linesLayout.getLinesTotalHeight(), 20); assert.strictEqual(linesLayout.getVerticalOffsetForLineNumber(1), 0); assert.strictEqual(linesLayout.getVerticalOffsetForLineNumber(2), 1); @@ -909,7 +909,7 @@ suite('Editor ViewLayout - LinesLayout', () => { assert.strictEqual(linesLayout.getWhitespaceAccumulatedHeightBeforeLineNumber(5), 50); // Insert a line before line 1 - linesLayout.onLinesInserted(1, 1, []); + linesLayout.onLinesInserted(1, 1); // whitespaces: d(3, 30), c(4, 20) assert.strictEqual(linesLayout.getWhitespacesCount(), 2); assert.strictEqual(linesLayout.getAfterLineNumberForWhitespaceIndex(0), 3); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index eb342a816ad34..a3309067c7f49 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -15,6 +15,7 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input ├── aiCustomizationListWidget.ts # Search + grouped list +├── aiCustomizationDebugPanel.ts # Debug diagnostics panel ├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl ├── customizationCreatorService.ts # AI-guided creation flow ├── mcpListWidget.ts # MCP servers section @@ -23,7 +24,7 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ └── aiCustomizationManagement.css src/vs/workbench/contrib/chat/common/ -└── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService interface +└── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter ``` The tree view and overview live in `vs/sessions` (sessions window only): @@ -42,9 +43,10 @@ Sessions-specific overrides: ``` src/vs/sessions/contrib/chat/browser/ -└── aiCustomizationWorkspaceService.ts # Sessions workspace service override +├── aiCustomizationWorkspaceService.ts # Sessions workspace service override +└── promptsService.ts # AgenticPromptsService (CLI user roots) src/vs/sessions/contrib/sessions/browser/ -├── customizationCounts.ts # Source count utilities +├── customizationCounts.ts # Source count utilities (type-aware) └── customizationsToolbar.contribution.ts # Sidebar customization links ``` @@ -52,13 +54,66 @@ src/vs/sessions/contrib/sessions/browser/ The `IAICustomizationWorkspaceService` interface controls per-window behavior: -| Property | Core VS Code | Sessions Window | +| Property / Method | Core VS Code | Sessions Window | |----------|-------------|----------| -| `managementSections` | All sections except Models | Same | -| `visibleStorageSources` | workspace, user, extension, plugin | workspace, user only | -| `preferManualCreation` | `false` (AI generation primary) | `true` (file creation primary) | +| `managementSections` | All sections except Models | Same minus MCP | +| `getStorageSourceFilter(type)` | All sources, no user root filter | Per-type (see below) | +| `isSessionsWindow` | `false` | `true` | | `activeProjectRoot` | First workspace folder | Active session worktree | +### IStorageSourceFilter + +A unified per-type filter controlling which storage sources and user file roots are visible. +Replaces the old `visibleStorageSources`, `getVisibleStorageSources(type)`, and `excludedUserFileRoots`. + +```typescript +interface IStorageSourceFilter { + sources: readonly PromptsStorage[]; // Which storage groups to display + includedUserFileRoots?: readonly URI[]; // Allowlist for user roots (undefined = all) +} +``` + +The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, storage}` array. + +**Sessions filter behavior by type:** + +| Type | sources | includedUserFileRoots | +|------|---------|----------------------| +| Hooks | `[local]` | N/A | +| Prompts | `[local, user]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user]` | `[~/.copilot, ~/.claude, ~/.agents]` | + +**Core VS Code:** All types use `[local, user, extension, plugin]` with no user root filter. + +### AgenticPromptsService (Sessions) + +Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): + +- **Discovery**: `AgenticPromptFilesLocator` scopes workspace folders to the active session's worktree +- **Creation targets**: `getSourceFolders()` override replaces VS Code profile user roots with `~/.copilot/{subfolder}` for CLI compatibility +- **Hook folders**: Falls back to `.github/hooks` in the active worktree + +### Count Consistency + +`customizationCounts.ts` uses the **same data sources** as the list widget's `loadItems()`: + +| Type | Data Source | Notes | +|------|-------------|-------| +| Agents | `getCustomAgents()` | Parsed agents, not raw files | +| Skills | `findAgentSkills()` | Parsed skills with frontmatter | +| Prompts | `getPromptSlashCommands()` | Filters out skill-type commands | +| Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. | +| Hooks | `listPromptFiles()` | Raw hook files | + +### Debug Panel + +Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a 4-stage pipeline view: + +1. **Raw PromptsService data** — per-storage file lists + type-specific extras +2. **After applyStorageSourceFilter** — what was removed and why +3. **Widget state** — allItems vs displayEntries with group counts +4. **Source/resolved folders** — creation targets and discovery order + ## Key Services - **Prompt discovery**: `IPromptsService` — parsing, lifecycle, storage enumeration diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 22273655103c7..6aab26d26318c 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -3,8 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left { + display: flex; + height: 100%; + align-items: center; + order: 0; + flex-grow: 2; + justify-content: flex-start; +} + /* Left Tool Bar Container */ -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { display: none; padding-left: 8px; flex-grow: 0; @@ -17,17 +26,17 @@ order: 2; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { display: flex; justify-content: center; align-items: center; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .codicon { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container .codicon { color: inherit; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item { display: flex; } @@ -35,7 +44,7 @@ * The contribution swaps the menu item synchronously, but the toolbar * re-render is async, causing a brief flash. Hide the container via * CSS when sidebar is visible (nosidebar class is removed synchronously). */ -.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container { +.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { display: none !important; } @@ -43,3 +52,7 @@ .agent-sessions-workbench.mac .part.titlebar .window-controls-container { -webkit-app-region: drag; } + +.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .sessions-titlebar-container > .titlebar-left .window-controls-container { + display: none !important; +} diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index 8eab246ab644e..fa99c2e21e3eb 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -18,7 +18,7 @@ import { WORKBENCH_BACKGROUND } from '../../../workbench/common/theme.js'; import { chatBarTitleBackground, chatBarTitleForeground } from '../../common/theme.js'; import { isMacintosh, isWeb, isNative, platformLocale } from '../../../base/common/platform.js'; import { Color } from '../../../base/common/color.js'; -import { EventType, EventHelper, Dimension, append, $, addDisposableListener, prepend, getWindow, getWindowId } from '../../../base/browser/dom.js'; +import { EventType, EventHelper, append, $, addDisposableListener, prepend, getWindow, getWindowId } from '../../../base/browser/dom.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; @@ -132,7 +132,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { protected override createContentArea(parent: HTMLElement): HTMLElement { this.element = parent; - this.rootContainer = append(parent, $('.titlebar-container.has-center')); + this.rootContainer = append(parent, $('.titlebar-container.sessions-titlebar-container.has-center')); // Draggable region prepend(this.rootContainer, $('div.titlebar-drag-region')); @@ -254,8 +254,6 @@ export class TitlebarPart extends Part implements ITitlebarPart { }); } - private lastLayoutDimension: Dimension | undefined; - get hasZoomableElements(): boolean { return true; // sessions titlebar always has command center and toolbar actions } @@ -268,7 +266,6 @@ export class TitlebarPart extends Part implements ITitlebarPart { } override layout(width: number, height: number): void { - this.lastLayoutDimension = new Dimension(width, height); this.updateLayout(); super.layoutContents(width, height); } @@ -281,24 +278,6 @@ export class TitlebarPart extends Part implements ITitlebarPart { const zoomFactor = getZoomFactor(getWindow(this.element)); this.element.style.setProperty('--zoom-factor', zoomFactor.toString()); this.rootContainer.classList.toggle('counter-zoom', this.preventZoom); - - this.updateCenterOffset(); - } - - private updateCenterOffset(): void { - if (!this.centerContent || !this.lastLayoutDimension) { - return; - } - - // Center the command center relative to the viewport. - // The titlebar only covers the right section (sidebar is to the left), - // so we shift the center content left by half the sidebar width - // using a negative margin. - const windowWidth = this.layoutService.mainContainerDimension.width; - const titlebarWidth = this.lastLayoutDimension.width; - const leftOffset = windowWidth - titlebarWidth; - this.centerContent.style.marginLeft = leftOffset > 0 ? `${-leftOffset / 2}px` : ''; - this.centerContent.style.marginRight = leftOffset > 0 ? `${leftOffset / 2}px` : ''; } focus(): void { diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts index f5ee5daff6440..9da044d818d4b 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts +++ b/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts @@ -8,9 +8,11 @@ import { localize2 } from '../../../../nls.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import './changesViewActions.js'; +import { ToggleChangesViewContribution } from './toggleChangesView.js'; const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); @@ -39,3 +41,5 @@ viewsRegistry.registerViews([{ order: 1, windowVisibility: WindowVisibility.Sessions }], changesViewContainer); + +registerWorkbenchContribution2(ToggleChangesViewContribution.ID, ToggleChangesViewContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts index 3e41846d313ef..2d29fbe475e0c 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changesView/browser/changesView.ts @@ -54,6 +54,7 @@ import { createFileIconThemableTreeContainerScope } from '../../../../workbench/ import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; import { IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; @@ -236,6 +237,7 @@ export class ChangesViewPane extends ViewPane { @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, @IStorageService private readonly storageService: IStorageService, + @ICommandService private readonly commandService: ICommandService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -542,13 +544,24 @@ export class ChangesViewPane extends ViewPane { return files > 0; })); + // Check if a PR exists when the active session changes + this.renderDisposables.add(autorun(reader => { + const sessionResource = activeSessionResource.read(reader); + if (sessionResource) { + const metadata = this.agentSessionsService.getSession(sessionResource)?.metadata; + this.commandService.executeCommand('github.checkOpenPullRequest', sessionResource, metadata).catch(() => { /* ignore */ }); + } + })); + this.renderDisposables.add(autorun(reader => { const { isSessionMenu, added, removed } = topLevelStats.read(reader); const sessionResource = activeSessionResource.read(reader); + const menuId = isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar; + reader.store.add(scopedInstantiationService.createInstance( MenuWorkbenchButtonBar, this.actionsContainer!, - isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar, + menuId, { telemetrySource: 'changesView', menuOptions: isSessionMenu && sessionResource @@ -562,7 +575,7 @@ export class ChangesViewPane extends ViewPane { ); return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel }; } - if (action.id === 'github.createPullRequest') { + if (action.id === 'github.createPullRequest' || action.id === 'github.openPullRequest') { return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'flex-grow' }; } if (action.id === 'chatEditing.applyToParentRepo') { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts b/src/vs/sessions/contrib/changesView/browser/toggleChangesView.ts similarity index 89% rename from src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts rename to src/vs/sessions/contrib/changesView/browser/toggleChangesView.ts index eb36b915ad62d..e7371a4f73d22 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts +++ b/src/vs/sessions/contrib/changesView/browser/toggleChangesView.ts @@ -14,16 +14,18 @@ import { IChatService } from '../../../../workbench/contrib/chat/common/chatServ import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { ISessionsManagementService } from './sessionsManagementService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { CHANGES_VIEW_ID } from './changesView.js'; interface IPendingTurnState { readonly hadChangesBeforeSend: boolean; readonly submittedAt: number; } -export class SessionsAuxiliaryBarContribution extends Disposable { +export class ToggleChangesViewContribution extends Disposable { - static readonly ID = 'workbench.contrib.sessionsAuxiliaryBarContribution'; + static readonly ID = 'workbench.contrib.toggleChangesView'; private readonly pendingTurnStateByResource = new ResourceMap(); @@ -33,6 +35,7 @@ export class SessionsAuxiliaryBarContribution extends Disposable { @IChatEditingService private readonly chatEditingService: IChatEditingService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatService private readonly chatService: IChatService, + @IViewsService private readonly viewsService: IViewsService, ) { super(); @@ -106,12 +109,10 @@ export class SessionsAuxiliaryBarContribution extends Disposable { } private syncAuxiliaryBarVisibility(hasChanges: boolean): void { - const shouldHideAuxiliaryBar = !hasChanges; - const isAuxiliaryBarVisible = this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); - if (shouldHideAuxiliaryBar === !isAuxiliaryBarVisible) { - return; + if (hasChanges) { + this.viewsService.openView(CHANGES_VIEW_ID, true); + } else { + this.layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); } - - this.layoutService.setPartHidden(shouldHideAuxiliaryBar, Parts.AUXILIARYBAR_PART); } } diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 2bb4fe99b64e6..79dc93837c9e5 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -4,14 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { derived, IObservable } from '../../../../base/common/observable.js'; +import { joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { CustomizationCreatorService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; /** * Agent Sessions override of IAICustomizationWorkspaceService. @@ -23,14 +24,32 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization readonly activeProjectRoot: IObservable; - readonly excludedUserFileRoots: readonly URI[]; + /** + * CLI-accessible user directories for customization file filtering and creation. + */ + private readonly _cliUserRoots: readonly URI[]; + + /** + * Pre-built filter for types that should only show CLI-accessible user roots. + */ + private readonly _cliUserFilter: IStorageSourceFilter; constructor( @ISessionsManagementService private readonly sessionsService: ISessionsManagementService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IUserDataProfilesService userDataProfilesService: IUserDataProfilesService, + @IPathService pathService: IPathService, ) { - this.excludedUserFileRoots = [userDataProfilesService.defaultProfile.promptsHome]; + const userHome = pathService.userHome({ preferLocal: true }); + this._cliUserRoots = [ + joinPath(userHome, '.copilot'), + joinPath(userHome, '.claude'), + joinPath(userHome, '.agents'), + ]; + this._cliUserFilter = { + sources: [PromptsStorage.local, PromptsStorage.user], + includedUserFileRoots: this._cliUserRoots, + }; + this.activeProjectRoot = derived(reader => { const session = this.sessionsService.activeSession.read(reader); return session?.worktree ?? session?.repository; @@ -52,19 +71,30 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization // AICustomizationManagementSection.McpServers, ]; - readonly visibleStorageSources: readonly PromptsStorage[] = [ - PromptsStorage.local, - PromptsStorage.user, - ]; + private static readonly _hooksFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local], + }; - getVisibleStorageSources(type: PromptsType): readonly PromptsStorage[] { + private static readonly _allUserRootsFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user], + }; + + getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { if (type === PromptsType.hook) { - return [PromptsStorage.local]; + return SessionsAICustomizationWorkspaceService._hooksFilter; + } + if (type === PromptsType.prompt) { + // Prompts are shown from all user roots (including VS Code profile) + return SessionsAICustomizationWorkspaceService._allUserRootsFilter; } - return this.visibleStorageSources; + // Other types only show user files from CLI-accessible roots (~/.copilot, ~/.claude, ~/.agents) + return this._cliUserFilter; } - readonly preferManualCreation = true; + /** + * Returns the CLI-accessible user directories (~/.copilot, ~/.claude, ~/.agents). + */ + readonly isSessionsWindow = true; async commitFiles(projectRoot: URI, fileUris: URI[]): Promise { const session = this.sessionsService.getActiveSession(); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index b23633e013fdb..247b4cdae062a 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -8,6 +8,7 @@ flex-direction: column; height: 100%; width: 100%; + position: relative; } /* Welcome container fills available space and centers content */ @@ -250,17 +251,37 @@ } /* Drag and drop */ -.sessions-chat-drop-overlay { - display: none; +.sessions-chat-dnd-overlay { position: absolute; top: 0; left: 0; - right: 0; - bottom: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + display: none; z-index: 10; + background-color: var(--vscode-sideBar-dropBackground, var(--vscode-list-dropBackground)); +} + +.sessions-chat-dnd-overlay.visible { + display: flex; + align-items: center; + justify-content: center; } -.sessions-chat-input-area.sessions-chat-drop-active { - border-color: var(--vscode-focusBorder); - background-color: var(--vscode-list-dropBackground); +.sessions-chat-dnd-overlay .attach-context-overlay-text { + padding: 0.6em; + margin: 0.2em; + line-height: 12px; + height: 12px; + display: flex; + align-items: center; + text-align: center; + background-color: var(--vscode-sideBar-background, var(--vscode-editor-background)); +} + +.sessions-chat-dnd-overlay .attach-context-overlay-text .codicon { + height: 12px; + font-size: 12px; + margin-right: 3px; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 7219eaeafd80f..a7440dfc69112 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; +import { DragAndDropObserver } from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { renderIcon, renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { registerOpenEditorListeners } from '../../../../platform/editor/browser/editor.js'; @@ -29,7 +30,8 @@ import { IChatRequestVariableEntry, OmittedState } from '../../../../workbench/c import { isLocation } from '../../../../editor/common/languages.js'; import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js'; import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; -import { getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; +import { CodeDataTransfers, containsDragType, extractEditorsDropData, getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; +import { DataTransfers } from '../../../../base/browser/dnd.js'; import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; /** @@ -54,6 +56,13 @@ export class NewChatContextAttachments extends Disposable { return this._attachedContext; } + setAttachments(entries: readonly IChatRequestVariableEntry[]): void { + this._attachedContext.length = 0; + this._attachedContext.push(...entries); + this._updateRendering(); + this._onDidChangeContext.fire(); + } + constructor( @IQuickInputService private readonly quickInputService: IQuickInputService, @ITextModelService private readonly textModelService: ITextModelService, @@ -94,7 +103,7 @@ export class NewChatContextAttachments extends Disposable { const pill = dom.append(this._container, dom.$('.sessions-chat-attachment-pill')); pill.tabIndex = 0; pill.role = 'button'; - const icon = entry.kind === 'image' ? Codicon.fileMedia : Codicon.file; + const icon = entry.kind === 'image' ? Codicon.fileMedia : entry.kind === 'directory' ? Codicon.folder : Codicon.file; dom.append(pill, renderIcon(icon)); dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); @@ -120,68 +129,85 @@ export class NewChatContextAttachments extends Disposable { // --- Drag and drop --- - registerDropTarget(element: HTMLElement): void { - // Use a transparent overlay during drag to capture events over the Monaco editor - const overlay = dom.append(element, dom.$('.sessions-chat-drop-overlay')); + registerDropTarget(dndContainer: HTMLElement): void { + const overlay = dom.append(dndContainer, dom.$('.sessions-chat-dnd-overlay')); + let overlayText: HTMLElement | undefined; - // Use capture phase to intercept drag events before Monaco editor handles them - this._register(dom.addDisposableListener(element, dom.EventType.DRAG_ENTER, (e: DragEvent) => { - if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - overlay.style.display = 'block'; - element.classList.add('sessions-chat-drop-active'); - } - }, true)); + const isDropSupported = (e: DragEvent): boolean => { + return containsDragType(e, DataTransfers.FILES, CodeDataTransfers.EDITORS, CodeDataTransfers.FILES, DataTransfers.RESOURCES, DataTransfers.INTERNAL_URI_LIST); + }; - this._register(dom.addDisposableListener(element, dom.EventType.DRAG_OVER, (e: DragEvent) => { - if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - if (overlay.style.display !== 'block') { - overlay.style.display = 'block'; - element.classList.add('sessions-chat-drop-active'); - } + const showOverlay = () => { + overlay.classList.add('visible'); + if (!overlayText) { + const label = localize('attachAsContext', "Attach as Context"); + const iconAndTextElements = renderLabelWithIcons(`$(${Codicon.attach.id}) ${label}`); + const htmlElements = iconAndTextElements.map(element => { + if (typeof element === 'string') { + return dom.$('span.overlay-text', undefined, element); + } + return element; + }); + overlayText = dom.$('span.attach-context-overlay-text', undefined, ...htmlElements); + overlay.appendChild(overlayText); } - }, true)); - - this._register(dom.addDisposableListener(overlay, dom.EventType.DRAG_OVER, (e) => { - e.preventDefault(); - e.dataTransfer!.dropEffect = 'copy'; - })); + }; - this._register(dom.addDisposableListener(overlay, dom.EventType.DRAG_LEAVE, (e) => { - if (e.relatedTarget && element.contains(e.relatedTarget as Node)) { - return; - } - overlay.style.display = 'none'; - element.classList.remove('sessions-chat-drop-active'); - })); + const hideOverlay = () => { + overlay.classList.remove('visible'); + overlayText?.remove(); + overlayText = undefined; + }; - this._register(dom.addDisposableListener(overlay, dom.EventType.DROP, async (e) => { - e.preventDefault(); - e.stopPropagation(); - overlay.style.display = 'none'; - element.classList.remove('sessions-chat-drop-active'); - - // Try items first (for URI-based drops from VS Code tree views) - const items = e.dataTransfer?.items; - if (items) { - for (const item of Array.from(items)) { - if (item.kind === 'file') { - const file = item.getAsFile(); - if (!file) { - continue; + this._register(new DragAndDropObserver(dndContainer, { + onDragOver: (e) => { + if (isDropSupported(e)) { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + showOverlay(); + } + }, + onDragLeave: () => { + hideOverlay(); + }, + onDrop: async (e) => { + e.preventDefault(); + e.stopPropagation(); + hideOverlay(); + + // Extract editor data from VS Code internal drags (e.g., explorer view) + const editorDropData = extractEditorsDropData(e); + if (editorDropData.length > 0) { + for (const editor of editorDropData) { + if (editor.resource) { + await this._attachFileUri(editor.resource, basename(editor.resource)); } - const filePath = getPathForFile(file); - if (!filePath) { - continue; + } + return; + } + + // Fallback: try native file items + const items = e.dataTransfer?.items; + if (items) { + for (const item of Array.from(items)) { + if (item.kind === 'file') { + const file = item.getAsFile(); + if (!file) { + continue; + } + const filePath = getPathForFile(file); + if (!filePath) { + continue; + } + const uri = URI.file(filePath); + await this._attachFileUri(uri, file.name); } - const uri = URI.file(filePath); - await this._attachFileUri(uri, file.name); } } - } + }, })); } @@ -439,6 +465,23 @@ export class NewChatContextAttachments extends Disposable { } private async _attachFileUri(uri: URI, name: string): Promise { + let stat; + try { + stat = await this.fileService.stat(uri); + } catch { + return; + } + + if (stat.isDirectory) { + this._addAttachments({ + kind: 'directory', + id: uri.toString(), + value: uri, + name, + }); + return; + } + if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(uri.path)) { const readFile = await this.fileService.readFile(uri); const resizedImage = await resizeImage(readFile.value.buffer); diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 489cceb9cce5a..a0d367ea964d6 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -23,6 +23,7 @@ import { SuggestController } from '../../../../editor/contrib/suggest/browser/su import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -35,6 +36,7 @@ import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hover import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; +import * as aria from '../../../../base/browser/ui/aria/aria.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; @@ -62,8 +64,14 @@ import { RepoPicker } from './repoPicker.js'; import { CloudModelPicker } from './modelPicker.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; import { SlashCommandHandler } from './slashCommands.js'; - -const STORAGE_KEY_LAST_MODEL = 'sessions.selectedModel'; +import { IChatModelInputState } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; +import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; +import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; + +const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; const MIN_EDITOR_HEIGHT = 50; const MAX_EDITOR_HEIGHT = 200; @@ -85,7 +93,7 @@ interface INewChatWidgetOptions { * This widget is shown only in the empty/welcome state. Once the user sends * a message, a session is created and the workbench ChatViewPane takes over. */ -class NewChatWidget extends Disposable { +class NewChatWidget extends Disposable implements IHistoryNavigationWidget { private readonly _targetPicker: SessionTargetPicker; private readonly _isolationModePicker: IsolationModePicker; @@ -93,6 +101,13 @@ class NewChatWidget extends Disposable { private readonly _syncIndicator: SyncIndicator; private readonly _options: INewChatWidgetOptions; + // IHistoryNavigationWidget + private readonly _onDidFocus = this._register(new Emitter()); + readonly onDidFocus = this._onDidFocus.event; + private readonly _onDidBlur = this._register(new Emitter()); + readonly onDidBlur = this._onDidBlur.event; + get element(): HTMLElement { return this._editorContainer; } + // Input private _editor!: CodeEditorWidget; private _editorContainer!: HTMLElement; @@ -137,6 +152,11 @@ class NewChatWidget extends Disposable { // Slash commands private _slashCommandHandler: SlashCommandHandler | undefined; + // Input history + private readonly _history: ChatHistoryNavigator; + private _historyNavigationBackwardsEnablement!: IHistoryNavigationContext['historyNavigationBackwardsEnablement']; + private _historyNavigationForwardsEnablement!: IHistoryNavigationContext['historyNavigationForwardsEnablement']; + constructor( options: INewChatWidgetOptions, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -144,7 +164,6 @@ class NewChatWidget extends Disposable { @IConfigurationService private readonly configurationService: IConfigurationService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IContextMenuService contextMenuService: IContextMenuService, @ILogService private readonly logService: ILogService, @IHoverService private readonly hoverService: IHoverService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @@ -153,11 +172,12 @@ class NewChatWidget extends Disposable { @IStorageService private readonly storageService: IStorageService, ) { super(); + this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); this._folderPicker = this._register(this.instantiationService.createInstance(FolderPicker)); this._repoPicker = this._register(this.instantiationService.createInstance(RepoPicker)); this._cloudModelPicker = this._register(this.instantiationService.createInstance(CloudModelPicker)); - this._targetPicker = this._register(new SessionTargetPicker(options.allowedTargets, options.defaultTarget)); + this._targetPicker = this._register(new SessionTargetPicker(options.allowedTargets, this._resolveDefaultTarget(options))); this._isolationModePicker = this._register(this.instantiationService.createInstance(IsolationModePicker)); this._branchPicker = this._register(this.instantiationService.createInstance(BranchPicker)); this._syncIndicator = this._register(this.instantiationService.createInstance(SyncIndicator)); @@ -221,7 +241,7 @@ class NewChatWidget extends Disposable { // Input area inside the input slot const inputArea = dom.$('.sessions-chat-input-area'); - this._contextAttachments.registerDropTarget(inputArea); + this._contextAttachments.registerDropTarget(wrapper); this._contextAttachments.registerPasteHandler(inputArea); // Attachments row (pills only) inside input area, above editor @@ -253,6 +273,9 @@ class NewChatWidget extends Disposable { // Initialize model picker this._initDefaultModel(); + // Restore draft input state from storage + this._restoreState(); + // Create initial session this._createNewSession(); @@ -385,6 +408,15 @@ class NewChatWidget extends Disposable { const editorContainer = this._editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); editorContainer.style.height = `${MIN_EDITOR_HEIGHT}px`; + // Create scoped context key service and register history navigation + // BEFORE creating the editor, so the editor's context key scope is a child + const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(container)); + const { historyNavigationBackwardsEnablement, historyNavigationForwardsEnablement } = this._register(registerAndCreateHistoryNavigationContext(inputScopedContextKeyService, this)); + this._historyNavigationBackwardsEnablement = historyNavigationBackwardsEnablement; + this._historyNavigationForwardsEnablement = historyNavigationForwardsEnablement; + + const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService]))); + const uri = URI.from({ scheme: 'sessions-chat', path: `input-${Date.now()}` }); const textModel = this._register(this.modelService.createModel('', null, uri, true)); @@ -419,7 +451,7 @@ class NewChatWidget extends Disposable { ]), }; - this._editor = this._register(this.instantiationService.createInstance( + this._editor = this._register(scopedInstantiationService.createInstance( CodeEditorWidget, editorContainer, editorOptions, widgetOptions, )); this._editor.setModel(textModel); @@ -427,6 +459,9 @@ class NewChatWidget extends Disposable { // Ensure suggest widget renders above the input (not clipped by container) SuggestController.get(this._editor)?.forceRenderingAbove(); + this._register(this._editor.onDidFocusEditorWidget(() => this._onDidFocus.fire())); + this._register(this._editor.onDidBlurEditorWidget(() => this._onDidBlur.fire())); + this._register(this._editor.onKeyDown(e => { if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && !e.altKey) { // Don't send if the suggest widget is visible (let it accept the completion) @@ -444,6 +479,19 @@ class NewChatWidget extends Disposable { } })); + // Update history navigation enablement based on cursor position + const updateHistoryNavigationEnablement = () => { + const model = this._editor.getModel(); + const position = this._editor.getPosition(); + if (!model || !position) { + return; + } + this._historyNavigationBackwardsEnablement.set(position.lineNumber === 1 && position.column === 1); + this._historyNavigationForwardsEnablement.set(position.lineNumber === model.getLineCount() && position.column === model.getLineMaxColumn(position.lineNumber)); + }; + this._register(this._editor.onDidChangeCursorPosition(() => updateHistoryNavigationEnablement())); + updateHistoryNavigationEnablement(); + let previousHeight = -1; this._register(this._editor.onDidContentSizeChange(e => { if (!e.contentHeightChanged) { @@ -557,7 +605,6 @@ class NewChatWidget extends Disposable { currentModel: this._currentLanguageModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { this._currentLanguageModel.set(model, undefined); - this.storageService.store(STORAGE_KEY_LAST_MODEL, model.identifier, StorageScope.PROFILE, StorageTarget.MACHINE); this._newSession.value?.setModelId(model.identifier); this._focusEditor(); }, @@ -581,13 +628,10 @@ class NewChatWidget extends Disposable { private _initDefaultModel(): void { const models = this._getAvailableModels(); - const lastModelId = this.storageService.get(STORAGE_KEY_LAST_MODEL, StorageScope.PROFILE); - const lastModel = lastModelId ? models.find(m => m.identifier === lastModelId) : undefined; - if (lastModel) { - this._currentLanguageModel.set(lastModel, undefined); - } else if (models.length > 0) { - this._currentLanguageModel.set(models[0], undefined); - } + const draft = this._getDraftState(); + const lastModelId = draft?.selectedModel?.identifier ?? this._history.values.at(-1)?.selectedModel?.identifier; + const defaultModel = (lastModelId ? models.find(m => m.identifier === lastModelId) : undefined) ?? models[0]; + this._currentLanguageModel.set(defaultModel, undefined); } private _getAvailableModels(): ILanguageModelChatMetadataAndIdentifier[] { @@ -792,6 +836,60 @@ class NewChatWidget extends Disposable { } } + // --- Input History (IHistoryNavigationWidget) --- + + showPreviousValue(): void { + if (this._history.isAtStart()) { + return; + } + const state = this._getInputState(); + if (state.inputText || state.attachments.length) { + this._history.overlay(state); + } + this._navigateHistory(true); + } + + showNextValue(): void { + if (this._history.isAtEnd()) { + return; + } + const state = this._getInputState(); + if (state.inputText || state.attachments.length) { + this._history.overlay(state); + } + this._navigateHistory(false); + } + + private _getInputState(): IChatModelInputState { + return { + inputText: this._editor?.getModel()?.getValue() ?? '', + attachments: [...this._contextAttachments.attachments], + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: this._currentLanguageModel.get(), + selections: this._editor?.getSelections() ?? [], + contrib: {}, + }; + } + + private _navigateHistory(previous: boolean): void { + const entry = previous ? this._history.previous() : this._history.next(); + const inputText = entry?.inputText ?? ''; + if (entry) { + this._editor?.getModel()?.setValue(inputText); + this._contextAttachments.setAttachments(entry.attachments); + } + aria.status(inputText); + if (previous) { + this._editor.setPosition({ lineNumber: 1, column: 1 }); + } else { + const model = this._editor.getModel(); + if (model) { + const lastLine = model.getLineCount(); + this._editor.setPosition({ lineNumber: lastLine, column: model.getLineMaxColumn(lastLine) }); + } + } + } + // --- Send --- private _updateSendButtonState(): void { @@ -828,6 +926,9 @@ class NewChatWidget extends Disposable { this._contextAttachments.attachments.length > 0 ? [...this._contextAttachments.attachments] : undefined ); + this._history.append(this._getInputState()); + this._clearDraftState(); + this._sending = true; this._editor.updateOptions({ readOnly: true }); this._updateSendButtonState(); @@ -872,6 +973,57 @@ class NewChatWidget extends Disposable { } + private _resolveDefaultTarget(options: INewChatWidgetOptions): AgentSessionProviders { + const draft = this._getDraftState(); + if (draft?.target && options.allowedTargets.includes(draft.target)) { + return draft.target; + } + return options.defaultTarget; + } + + private _restoreState(): void { + const draft = this._getDraftState(); + if (draft) { + this._editor?.getModel()?.setValue(draft.inputText); + if (draft.attachments?.length) { + this._contextAttachments.setAttachments(draft.attachments.map(IChatRequestVariableEntry.fromExport)); + } + if (draft.selectedModel) { + const models = this._getAvailableModels(); + const model = models.find(m => m.identifier === draft.selectedModel?.identifier); + if (model) { + this._currentLanguageModel.set(model, undefined); + } + } + } + } + + private _getDraftState(): (IChatModelInputState & { target?: AgentSessionProviders }) | undefined { + const raw = this.storageService.get(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE); + if (!raw) { + return undefined; + } + try { + return JSON.parse(raw); + } catch { + return undefined; + } + } + + private _clearDraftState(): void { + this.storageService.remove(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE); + } + + saveState(): void { + const inputState = this._getInputState(); + const state = { + ...inputState, + attachments: inputState.attachments.map(IChatRequestVariableEntry.toExport), + target: this._targetPicker.selectedTarget, + }; + this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + // --- Layout --- layout(_height: number, _width: number): void { @@ -956,6 +1108,10 @@ export class NewChatViewPane extends ViewPane { this._widget?.focusInput(); } } + + override saveState(): void { + this._widget?.saveState(); + } } // #endregion diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index 69c0e8e2497dd..96596f2de0c3f 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -13,6 +13,8 @@ import { IFileService } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; import { HOOKS_SOURCE_FOLDER } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptPath, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { ISearchService } from '../../../../workbench/services/search/common/search.js'; @@ -20,9 +22,38 @@ import { IUserDataProfileService } from '../../../../workbench/services/userData import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; export class AgenticPromptsService extends PromptsService { + private _copilotRoot: URI | undefined; + protected override createPromptFilesLocator(): PromptFilesLocator { return this.instantiationService.createInstance(AgenticPromptFilesLocator); } + + private getCopilotRoot(): URI { + if (!this._copilotRoot) { + const pathService = this.instantiationService.invokeFunction(accessor => accessor.get(IPathService)); + this._copilotRoot = joinPath(pathService.userHome({ preferLocal: true }), '.copilot'); + } + return this._copilotRoot; + } + + /** + * Override to use ~/.copilot as the user-level source folder for creation, + * instead of the VS Code profile's promptsHome. + */ + public override async getSourceFolders(type: PromptsType): Promise { + const folders = await super.getSourceFolders(type); + const copilotRoot = this.getCopilotRoot(); + // Replace any user-storage folders with the CLI-accessible ~/.copilot root + return folders.map(folder => { + if (folder.storage === PromptsStorage.user) { + const subfolder = getCliUserSubfolder(type); + return subfolder + ? { ...folder, uri: joinPath(copilotRoot, subfolder) } + : folder; + } + return folder; + }); + } } class AgenticPromptFilesLocator extends PromptFilesLocator { @@ -91,3 +122,17 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { } } +/** + * Returns the subfolder name under ~/.copilot/ for a given customization type. + * Used to determine the CLI-accessible user creation target. + */ +function getCliUserSubfolder(type: PromptsType): string | undefined { + switch (type) { + case PromptsType.instructions: return 'instructions'; + case PromptsType.skill: return 'skills'; + case PromptsType.agent: return 'agents'; + case PromptsType.prompt: return 'prompts'; + default: return undefined; + } +} + diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index b1730dd1897e3..467955a64bdc1 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -10,6 +10,7 @@ Registry.as(Extensions.Configuration).registerDefaultCon overrides: { 'chat.agentsControl.enabled': true, 'chat.agent.maxRequests': 1000, + 'chat.customizationsMenu.userStoragePath': '~/.copilot', 'chat.restoreLastPanelSession': true, 'chat.unifiedAgentsBar.enabled': true, 'chat.viewSessions.enabled': false, @@ -19,6 +20,8 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'diffEditor.renderSideBySide': false, 'diffEditor.hideUnchangedRegions.enabled': true, + 'extensions.ignoreRecommendations': true, + 'files.autoSave': 'afterDelay', 'git.autofetch': true, @@ -30,11 +33,11 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'github.copilot.chat.languageContext.typescript.enabled': true, 'github.copilot.chat.cli.mcp.enabled': true, - 'chat.customizationsMenu.userStoragePath': '~/.copilot', - 'inlineChat.affordance': 'editor', 'inlineChat.renderMode': 'hover', + 'terminal.integrated.initialHint': false, + 'workbench.editor.restoreEditors': false, 'workbench.editor.showTabs': 'single', 'workbench.startupEditor': 'none', @@ -42,10 +45,9 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'workbench.layoutControl.type': 'toggles', 'workbench.editor.useModal': 'all', 'workbench.panel.showLabels': false, + 'window.menuStyle': 'custom', 'window.dialogStyle': 'custom', - - 'terminal.integrated.initialHint': false }, donotCache: true, preventExperimentOverride: true, diff --git a/src/vs/sessions/contrib/files/browser/files.contribution.ts b/src/vs/sessions/contrib/files/browser/files.contribution.ts new file mode 100644 index 0000000000000..6d9f6fa696a4e --- /dev/null +++ b/src/vs/sessions/contrib/files/browser/files.contribution.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; +import { ExplorerView } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; + +const SESSIONS_FILES_CONTAINER_ID = 'workbench.sessions.auxiliaryBar.filesContainer'; +const SESSIONS_FILES_VIEW_ID = 'sessions.files.explorer'; + +const filesViewIcon = registerIcon('sessions-files-view-icon', Codicon.files, localize2('sessionsFilesViewIcon', 'View icon of the files view in the sessions window.').value); + +class RegisterFilesViewContribution implements IWorkbenchContribution { + + static readonly ID = 'sessions.registerFilesView'; + + constructor() { + const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); + const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); + + // Register a new Files view container in the auxiliary bar for the sessions window + const filesViewContainer = viewContainerRegistry.registerViewContainer({ + id: SESSIONS_FILES_CONTAINER_ID, + title: localize2('files', "Files"), + icon: filesViewIcon, + order: 11, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [SESSIONS_FILES_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), + storageId: SESSIONS_FILES_CONTAINER_ID, + hideIfEmpty: true, + windowVisibility: WindowVisibility.Sessions, + }, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true }); + + // Re-register the explorer view inside the new Files container + viewsRegistry.registerViews([{ + id: SESSIONS_FILES_VIEW_ID, + name: localize2('files', "Files"), + containerIcon: filesViewIcon, + ctorDescriptor: new SyncDescriptor(ExplorerView), + canToggleVisibility: true, + canMoveView: false, + when: WorkspaceFolderCountContext.notEqualsTo('0'), + windowVisibility: WindowVisibility.Sessions, + }], filesViewContainer); + } +} + +registerWorkbenchContribution2(RegisterFilesViewContribution.ID, RegisterFilesViewContribution, WorkbenchPhase.AfterRestored); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.files.action.collapseExplorerFolders', + title: localize2('collapseExplorerFolders', "Collapse Folders in Explorer"), + icon: Codicon.collapseAll, + menu: { + id: MenuId.ViewTitle, + group: 'navigation', + when: ContextKeyExpr.equals('view', SESSIONS_FILES_VIEW_ID), + }, + }); + } + + run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SESSIONS_FILES_VIEW_ID); + if (view !== null) { + (view as ExplorerView).collapseAll(); + } + } +}); diff --git a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts index e6de07b259404..b401f4e70ee4f 100644 --- a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts +++ b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts @@ -18,6 +18,7 @@ import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensi import { OutputViewPane } from '../../../../workbench/contrib/output/browser/outputView.js'; import { OUTPUT_VIEW_ID } from '../../../../workbench/services/output/common/output.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; const SESSIONS_LOGS_CONTAINER_ID = 'workbench.sessions.panel.logsContainer'; @@ -29,7 +30,11 @@ class RegisterLogsViewContainerContribution implements IWorkbenchContribution { static readonly ID = 'sessions.registerLogsViewContainer'; - constructor() { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IEnvironmentService environmentService: IEnvironmentService, + ) { + CONTEXT_SESSIONS_SHOW_LOGS.bindTo(contextKeyService).set(!environmentService.isBuilt); const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts index 98015af7a0ab0..fac485cf511c6 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { URI } from '../../../../base/common/uri.js'; import { isEqualOrParent } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; - -import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; export interface ISourceCounts { readonly workspace: number; @@ -24,9 +24,9 @@ const storageToCountKey: Partial> = [PromptsStorage.extension]: 'extension', }; -export function getSourceCountsTotal(counts: ISourceCounts, workspaceService: IAICustomizationWorkspaceService, type: PromptsType): number { +export function getSourceCountsTotal(counts: ISourceCounts, filter: IStorageSourceFilter): number { let total = 0; - for (const storage of workspaceService.getVisibleStorageSources(type)) { + for (const storage of filter.sources) { const key = storageToCountKey[storage]; if (key) { total += counts[key]; @@ -36,58 +36,86 @@ export function getSourceCountsTotal(counts: ISourceCounts, workspaceService: IA } /** - * Returns true if the URI should be excluded based on excluded user file roots. + * Gets source counts for a prompt type, using the SAME data sources as + * loadItems() in the list widget to avoid count mismatches. */ -function isExcludedUserFile(uri: URI, excludedRoots: readonly URI[]): boolean { - return excludedRoots.some(root => isEqualOrParent(uri, root)); -} +export async function getSourceCounts( + promptsService: IPromptsService, + promptType: PromptsType, + filter: IStorageSourceFilter, + workspaceContextService: IWorkspaceContextService, + workspaceService: IAICustomizationWorkspaceService, +): Promise { + const items: { storage: PromptsStorage; uri: URI }[] = []; -export async function getPromptSourceCounts(promptsService: IPromptsService, promptType: PromptsType, excludedUserFileRoots: readonly URI[] = []): Promise { - const [workspaceItems, userItems, extensionItems] = await Promise.all([ - promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), - promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), - promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), - ]); - const filteredUserItems = excludedUserFileRoots.length > 0 - ? userItems.filter(item => !isExcludedUserFile(item.uri, excludedUserFileRoots)) - : userItems; - return { - workspace: workspaceItems.length, - user: filteredUserItems.length, - extension: extensionItems.length, - }; -} - -export async function getSkillSourceCounts(promptsService: IPromptsService, excludedUserFileRoots: readonly URI[] = []): Promise { - const skills = await promptsService.findAgentSkills(CancellationToken.None); - if (!skills || skills.length === 0) { - return { workspace: 0, user: 0, extension: 0 }; + if (promptType === PromptsType.agent) { + // Must match loadItems: uses getCustomAgents() + const agents = await promptsService.getCustomAgents(CancellationToken.None); + for (const a of agents) { + items.push({ storage: a.source.storage, uri: a.uri }); + } + } else if (promptType === PromptsType.skill) { + // Must match loadItems: uses findAgentSkills() + const skills = await promptsService.findAgentSkills(CancellationToken.None); + for (const s of skills ?? []) { + items.push({ storage: s.storage, uri: s.uri }); + } + } else if (promptType === PromptsType.prompt) { + // Must match loadItems: uses getPromptSlashCommands() filtering out skills + const commands = await promptsService.getPromptSlashCommands(CancellationToken.None); + for (const c of commands) { + if (c.promptPath.type === PromptsType.skill) { + continue; + } + items.push({ storage: c.promptPath.storage, uri: c.promptPath.uri }); + } + } else if (promptType === PromptsType.instructions) { + // Must match loadItems: uses listPromptFiles + listAgentInstructions + const promptFiles = await promptsService.listPromptFiles(promptType, CancellationToken.None); + for (const f of promptFiles) { + items.push({ storage: f.storage, uri: f.uri }); + } + const agentInstructions = await promptsService.listAgentInstructions(CancellationToken.None, undefined); + const workspaceFolderUris = workspaceContextService.getWorkspace().folders.map(f => f.uri); + const activeRoot = workspaceService.getActiveProjectRoot(); + if (activeRoot) { + workspaceFolderUris.push(activeRoot); + } + for (const file of agentInstructions) { + const isWorkspaceFile = workspaceFolderUris.some(root => isEqualOrParent(file.uri, root)); + items.push({ + storage: isWorkspaceFile ? PromptsStorage.local : PromptsStorage.user, + uri: file.uri, + }); + } + } else { + // hooks and anything else: uses listPromptFiles + const files = await promptsService.listPromptFiles(promptType, CancellationToken.None); + for (const f of files) { + items.push({ storage: f.storage, uri: f.uri }); + } } - const userSkills = skills.filter(s => s.storage === PromptsStorage.user); - const filteredUserSkills = excludedUserFileRoots.length > 0 - ? userSkills.filter(s => !isExcludedUserFile(s.uri, excludedUserFileRoots)) - : userSkills; + + // Apply the same storage source filter as the list widget + const filtered = applyStorageSourceFilter(items, filter); return { - workspace: skills.filter(s => s.storage === PromptsStorage.local).length, - user: filteredUserSkills.length, - extension: skills.filter(s => s.storage === PromptsStorage.extension).length, + workspace: filtered.filter(i => i.storage === PromptsStorage.local).length, + user: filtered.filter(i => i.storage === PromptsStorage.user).length, + extension: filtered.filter(i => i.storage === PromptsStorage.extension).length, }; } -export async function getCustomizationTotalCount(promptsService: IPromptsService, mcpService: IMcpService, workspaceService: IAICustomizationWorkspaceService): Promise { - const excluded = workspaceService.excludedUserFileRoots; - const [agentCounts, skillCounts, instructionCounts, promptCounts, hookCounts] = await Promise.all([ - getPromptSourceCounts(promptsService, PromptsType.agent, excluded), - getSkillSourceCounts(promptsService, excluded), - getPromptSourceCounts(promptsService, PromptsType.instructions, excluded), - getPromptSourceCounts(promptsService, PromptsType.prompt, excluded), - getPromptSourceCounts(promptsService, PromptsType.hook, excluded), - ]); - - return getSourceCountsTotal(agentCounts, workspaceService, PromptsType.agent) - + getSourceCountsTotal(skillCounts, workspaceService, PromptsType.skill) - + getSourceCountsTotal(instructionCounts, workspaceService, PromptsType.instructions) - + getSourceCountsTotal(promptCounts, workspaceService, PromptsType.prompt) - + getSourceCountsTotal(hookCounts, workspaceService, PromptsType.hook) - + mcpService.servers.get().length; +export async function getCustomizationTotalCount( + promptsService: IPromptsService, + mcpService: IMcpService, + workspaceService: IAICustomizationWorkspaceService, + workspaceContextService: IWorkspaceContextService, +): Promise { + const types: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.prompt, PromptsType.hook]; + const results = await Promise.all(types.map(type => { + const filter = workspaceService.getStorageSourceFilter(type); + return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService) + .then(counts => getSourceCountsTotal(counts, filter)); + })); + return results.reduce((sum, n) => sum + n, 0) + mcpService.servers.get().length; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index 827d162f79470..c5c913e179726 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -29,19 +29,16 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { ISessionsManagementService } from './sessionsManagementService.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { getPromptSourceCounts, getSkillSourceCounts, getSourceCountsTotal, ISourceCounts } from './customizationCounts.js'; +import { getSourceCounts, getSourceCountsTotal, ISourceCounts } from './customizationCounts.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { URI } from '../../../../base/common/uri.js'; - interface ICustomizationItemConfig { readonly id: string; readonly label: string; readonly icon: ThemeIcon; readonly section: AICustomizationManagementSection; readonly promptType?: PromptsType; - readonly getSourceCounts?: (promptsService: IPromptsService, excludedUserFileRoots: readonly URI[]) => Promise; readonly getCount?: (languageModelsService: ILanguageModelsService, mcpService: IMcpService) => Promise; } @@ -52,7 +49,6 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ icon: agentIcon, section: AICustomizationManagementSection.Agents, promptType: PromptsType.agent, - getSourceCounts: (ps, ex) => getPromptSourceCounts(ps, PromptsType.agent, ex), }, { id: 'sessions.customization.skills', @@ -60,7 +56,6 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ icon: skillIcon, section: AICustomizationManagementSection.Skills, promptType: PromptsType.skill, - getSourceCounts: (ps, ex) => getSkillSourceCounts(ps, ex), }, { id: 'sessions.customization.instructions', @@ -68,7 +63,6 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ icon: instructionsIcon, section: AICustomizationManagementSection.Instructions, promptType: PromptsType.instructions, - getSourceCounts: (ps, ex) => getPromptSourceCounts(ps, PromptsType.instructions, ex), }, { id: 'sessions.customization.prompts', @@ -76,7 +70,6 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ icon: promptIcon, section: AICustomizationManagementSection.Prompts, promptType: PromptsType.prompt, - getSourceCounts: (ps, ex) => getPromptSourceCounts(ps, PromptsType.prompt, ex), }, { id: 'sessions.customization.hooks', @@ -84,7 +77,6 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ icon: hookIcon, section: AICustomizationManagementSection.Hooks, promptType: PromptsType.hook, - getSourceCounts: (ps, ex) => getPromptSourceCounts(ps, PromptsType.hook, ex), }, // TODO: Re-enable MCP Servers once CLI MCP configuration is unified with VS Code ]; @@ -167,8 +159,10 @@ class CustomizationLinkViewItem extends ActionViewItem { return; } - if (this._config.getSourceCounts) { - const counts = await this._config.getSourceCounts(this._promptsService, this._workspaceService.excludedUserFileRoots); + if (this._config.promptType) { + const type = this._config.promptType; + const filter = this._workspaceService.getStorageSourceFilter(type); + const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService); this._renderSourceCounts(this._countContainer, counts); } else if (this._config.getCount) { const count = await this._config.getCount(this._languageModelsService, this._mcpService); @@ -179,14 +173,14 @@ class CustomizationLinkViewItem extends ActionViewItem { private _renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { container.textContent = ''; const type = this._config.promptType; - const visibleSources = type ? this._workspaceService.getVisibleStorageSources(type) : this._workspaceService.visibleStorageSources; - const total = getSourceCountsTotal(counts, this._workspaceService, type ?? PromptsType.prompt); + const filter = type ? this._workspaceService.getStorageSourceFilter(type) : this._workspaceService.getStorageSourceFilter(PromptsType.prompt); + const total = getSourceCountsTotal(counts, filter); container.classList.toggle('hidden', total === 0); if (total === 0) { return; } - const visibleSourcesSet = new Set(visibleSources); + const visibleSourcesSet = new Set(filter.sources); const sources: { storage: PromptsStorage; count: number; icon: ThemeIcon; title: string }[] = [ { storage: PromptsStorage.local, count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, { storage: PromptsStorage.user, count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 3bd5ae6c0221c..577c5a482f621 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -20,16 +20,16 @@ overflow: hidden; color: var(--vscode-commandCenter-foreground); gap: 6px; + cursor: default; } /* Session pill - clickable area for session picker */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-pill { display: flex; align-items: center; - cursor: pointer; padding: 0 4px; border-radius: 4px; - overflow: hidden; + min-width: 0; } .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-pill:hover { @@ -62,8 +62,9 @@ display: flex; align-items: center; gap: 6px; - overflow: hidden; + min-width: 0; justify-content: center; + cursor: pointer; } /* Kind icon */ diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index b81e8fa62848f..52bd441711531 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -13,7 +13,6 @@ import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/vie import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; -import { SessionsAuxiliaryBarContribution } from './sessionsAuxiliaryBarContribution.js'; import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; import { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; @@ -47,6 +46,5 @@ const agentSessionsViewDescriptor: IViewDescriptor = { Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([agentSessionsViewDescriptor], agentSessionsViewContainer); registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(SessionsAuxiliaryBarContribution.ID, SessionsAuxiliaryBarContribution, WorkbenchPhase.AfterRestored); registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index b7a50d1210132..eb6848e9af8ae 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -5,7 +5,6 @@ import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { equals } from '../../../../base/common/objects.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -100,7 +99,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _activeSession = observableValue(this, undefined); readonly activeSession: IObservable = this._activeSession; - private readonly _activeSessionDisposables = this._register(new DisposableStore()); + private readonly _newActiveSessionDisposables = this._register(new DisposableStore()); private readonly _newSession = this._register(new MutableDisposable()); private lastSelectedSession: URI | undefined; @@ -401,7 +400,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } private setActiveSession(session: IAgentSession | INewSession | undefined): void { - this._activeSessionDisposables.clear(); let activeSessionItem: IActiveSessionItem | undefined; if (session) { if (isAgentSession(session)) { @@ -424,7 +422,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa worktree: undefined, providerType: session.target, }; - this._activeSessionDisposables.add(session.onDidChange(e => { + this._newActiveSessionDisposables.clear(); + this._newActiveSessionDisposables.add(session.onDidChange(e => { if (e === 'repoUri') { this.doSetActiveSession({ isUntitled: true, @@ -443,12 +442,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } private doSetActiveSession(activeSessionItem: IActiveSessionItem | undefined): void { - if (equals(this._activeSession.get(), activeSessionItem)) { + if (this.equalsSessionItem(this._activeSession.get(), activeSessionItem)) { return; } if (activeSessionItem) { - this.logService.info(`[ActiveSessionService] Active session changed: ${activeSessionItem.resource.toString()}, repository: ${activeSessionItem.repository?.toString() ?? 'none'}`); + this.logService.info(`[ActiveSessionService] Active session changed: ${activeSessionItem.resource.toString()}`); + this.logService.trace(`[ActiveSessionService] Active session details: ${JSON.stringify(activeSessionItem)}`); } else { this.logService.trace('[ActiveSessionService] Active session cleared'); } @@ -456,6 +456,21 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._activeSession.set(activeSessionItem, undefined); } + private equalsSessionItem(a: IActiveSessionItem | undefined, b: IActiveSessionItem | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return ( + a.label === b.label && + a.resource.toString() === b.resource.toString() && + a.repository?.toString() === b.repository?.toString() && + a.worktree?.toString() === b.worktree?.toString() + ); + } + async commitWorktreeFiles(session: IActiveSessionItem, fileUris: URI[]): Promise { const worktreeUri = session.worktree; if (!worktreeUri) { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index b8ec6ff4e1dad..d53188c77bd1a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -233,7 +233,7 @@ export class AgenticSessionsViewPane extends ViewPane { let updateCountRequestId = 0; const updateHeaderTotalCount = async () => { const requestId = ++updateCountRequestId; - const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService); + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService); if (requestId !== updateCountRequestId) { return; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts new file mode 100644 index 0000000000000..9a3ce3ee94220 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts @@ -0,0 +1,796 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage, IPromptPath, ILocalPromptPath, IUserPromptPath, IExtensionPromptPath, IResolvedAgentFile, AgentFileType } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; +import { getSourceCounts, getSourceCountsTotal, getCustomizationTotalCount } from '../../browser/customizationCounts.js'; +import { IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; + +function localFile(path: string): ILocalPromptPath { + return { uri: URI.file(path), storage: PromptsStorage.local, type: PromptsType.instructions }; +} + +function userFile(path: string): IUserPromptPath { + return { uri: URI.file(path), storage: PromptsStorage.user, type: PromptsType.instructions }; +} + +function extensionFile(path: string): IExtensionPromptPath { + return { + uri: URI.file(path), + storage: PromptsStorage.extension, + type: PromptsType.instructions, + extension: undefined!, + source: undefined!, + }; +} + +function agentInstructionFile(path: string): IResolvedAgentFile { + return { uri: URI.file(path), realPath: undefined, type: AgentFileType.agentsMd }; +} + +function makeWorkspaceFolder(path: string, name?: string): IWorkspaceFolder { + const uri = URI.file(path); + return { + uri, + name: name ?? path.split('/').pop()!, + index: 0, + toResource: (rel: string) => URI.joinPath(uri, rel), + }; +} + +function createMockPromptsService(opts: { + localFiles?: IPromptPath[]; + userFiles?: IPromptPath[]; + extensionFiles?: IPromptPath[]; + allFiles?: IPromptPath[]; + agentInstructions?: IResolvedAgentFile[]; + agents?: { name: string; uri: URI; storage: PromptsStorage }[]; + skills?: { name: string; uri: URI; storage: PromptsStorage }[]; + commands?: { name: string; uri: URI; storage: PromptsStorage; type: PromptsType }[]; +} = {}): IPromptsService { + return { + listPromptFilesForStorage: async (type: PromptsType, storage: PromptsStorage) => { + if (storage === PromptsStorage.local) { return opts.localFiles ?? []; } + if (storage === PromptsStorage.user) { return opts.userFiles ?? []; } + if (storage === PromptsStorage.extension) { return opts.extensionFiles ?? []; } + return []; + }, + listPromptFiles: async () => opts.allFiles ?? [...(opts.localFiles ?? []), ...(opts.userFiles ?? []), ...(opts.extensionFiles ?? [])], + listAgentInstructions: async () => opts.agentInstructions ?? [], + getCustomAgents: async () => (opts.agents ?? []).map(a => ({ + name: a.name, + uri: a.uri, + source: { storage: a.storage }, + })), + findAgentSkills: async () => (opts.skills ?? []).map(s => ({ + name: s.name, + uri: s.uri, + storage: s.storage, + })), + getPromptSlashCommands: async () => (opts.commands ?? []).map(c => ({ + name: c.name, + promptPath: { uri: c.uri, storage: c.storage, type: c.type }, + })), + getSourceFolders: async () => [], + getResolvedSourceFolders: async () => [], + onDidChangeCustomAgents: Event.None, + onDidChangeSlashCommands: Event.None, + } as unknown as IPromptsService; +} + +function createMockWorkspaceService(opts: { + activeRoot?: URI; + filter?: IStorageSourceFilter; +} = {}): IAICustomizationWorkspaceService { + const defaultFilter: IStorageSourceFilter = opts.filter ?? { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension], + }; + return { + _serviceBrand: undefined, + activeProjectRoot: observableValue('test', opts.activeRoot), + getActiveProjectRoot: () => opts.activeRoot, + managementSections: [], + getStorageSourceFilter: () => defaultFilter, + preferManualCreation: false, + commitFiles: async () => { }, + generateCustomization: async () => { }, + } as unknown as IAICustomizationWorkspaceService; +} + +function createMockWorkspaceContextService(folders: IWorkspaceFolder[]): IWorkspaceContextService { + return { + getWorkspace: () => ({ folders } as IWorkspace), + getWorkbenchState: () => WorkbenchState.FOLDER, + getWorkspaceFolder: () => folders[0], + onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, + onDidChangeWorkspaceName: Event.None, + isInsideWorkspace: () => true, + } as unknown as IWorkspaceContextService; +} + +suite('customizationCounts', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const workspaceRoot = URI.file('/workspace'); + const workspaceFolder = makeWorkspaceFolder('/workspace'); + + suite('getSourceCountsTotal', () => { + test('sums only visible sources', () => { + const counts = { workspace: 5, user: 3, extension: 2 }; + const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 8); + }); + + test('returns 0 for empty sources', () => { + const counts = { workspace: 5, user: 3, extension: 2 }; + const filter: IStorageSourceFilter = { sources: [] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 0); + }); + + test('sums all sources', () => { + const counts = { workspace: 5, user: 3, extension: 2 }; + const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 10); + }); + + test('handles single source', () => { + const counts = { workspace: 7, user: 0, extension: 0 }; + const filter: IStorageSourceFilter = { sources: [PromptsStorage.local] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 7); + }); + + test('ignores plugin storage in totals (not in ISourceCounts)', () => { + const counts = { workspace: 1, user: 1, extension: 1 }; + const filter: IStorageSourceFilter = { sources: [PromptsStorage.plugin] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 0); + }); + }); + + suite('getSourceCounts - instructions', () => { + test('includes agent instruction files in workspace count', async () => { + const promptsService = createMockPromptsService({ + localFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + ], + userFiles: [], + extensionFiles: [], + allFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + ], + agentInstructions: [ + agentInstructionFile('/workspace/AGENTS.md'), + agentInstructionFile('/workspace/.github/copilot-instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + // 1 .instructions.md + 2 agent instruction files = 3 workspace + assert.strictEqual(counts.workspace, 3); + assert.strictEqual(counts.user, 0); + }); + + test('classifies agent instructions outside workspace as user', async () => { + const promptsService = createMockPromptsService({ + localFiles: [], + userFiles: [], + extensionFiles: [], + allFiles: [], + agentInstructions: [ + agentInstructionFile('/home/user/.claude/CLAUDE.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 0); + assert.strictEqual(counts.user, 1); + }); + + test('agent instructions under active root classified as workspace', async () => { + // Active root might not be in getWorkspace().folders (e.g. sessions worktree), + // but should still count as workspace + const activeRoot = URI.file('/session/worktree'); + const promptsService = createMockPromptsService({ + allFiles: [], + agentInstructions: [ + agentInstructionFile('/session/worktree/AGENTS.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot }); + // No workspace folders match — but active root does + const contextService = createMockWorkspaceContextService([]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.user, 0); + }); + + test('no agent instructions returns only prompt file counts', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + localFile('/workspace/.github/instructions/b.instructions.md'), + ], + agentInstructions: [], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 2); + }); + + test('mixed agent instructions across workspace and user', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/instructions/rules.instructions.md'), + ], + agentInstructions: [ + agentInstructionFile('/workspace/AGENTS.md'), + agentInstructionFile('/workspace/CLAUDE.md'), + agentInstructionFile('/home/user/.claude/CLAUDE.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + // 1 .instructions.md + 2 workspace agent files = 3 + assert.strictEqual(counts.workspace, 3); + // 1 user-level CLAUDE.md + assert.strictEqual(counts.user, 1); + }); + }); + + suite('getSourceCounts - agents', () => { + test('uses getCustomAgents instead of listPromptFilesForStorage', async () => { + const promptsService = createMockPromptsService({ + // listPromptFilesForStorage would return these — but agents should use getCustomAgents + localFiles: [localFile('/workspace/.github/agents/a.agent.md')], + agents: [ + { name: 'agent-a', uri: URI.file('/workspace/.github/agents/a.agent.md'), storage: PromptsStorage.local }, + { name: 'agent-b', uri: URI.file('/workspace/.github/agents/b.agent.md'), storage: PromptsStorage.local }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.agent, + { sources: [PromptsStorage.local] }, + contextService, + workspaceService, + ); + + // Should use getCustomAgents (2), not listPromptFilesForStorage (1) + assert.strictEqual(counts.workspace, 2); + }); + + test('counts agents across storage types', async () => { + const promptsService = createMockPromptsService({ + agents: [ + { name: 'local-agent', uri: URI.file('/workspace/.github/agents/a.agent.md'), storage: PromptsStorage.local }, + { name: 'user-agent', uri: URI.file('/home/.claude/agents/b.agent.md'), storage: PromptsStorage.user }, + { name: 'ext-agent', uri: URI.file('/ext/agents/c.agent.md'), storage: PromptsStorage.extension }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.agent, + { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension] }, + contextService, + workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 1 }); + }); + + test('empty agents returns all zeros', async () => { + const promptsService = createMockPromptsService({ agents: [] }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.agent, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0 }); + }); + }); + + suite('getSourceCounts - skills', () => { + test('uses findAgentSkills', async () => { + const promptsService = createMockPromptsService({ + skills: [ + { name: 'skill-a', uri: URI.file('/workspace/.github/skills/a/SKILL.md'), storage: PromptsStorage.local }, + { name: 'skill-b', uri: URI.file('/home/user/.copilot/skills/b/SKILL.md'), storage: PromptsStorage.user }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.skill, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.user, 1); + }); + + test('empty skills returns zeros', async () => { + const promptsService = createMockPromptsService({ skills: [] }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.skill, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0 }); + }); + + test('skills filtered by storage source filter', async () => { + const promptsService = createMockPromptsService({ + skills: [ + { name: 'skill-a', uri: URI.file('/workspace/.github/skills/a/SKILL.md'), storage: PromptsStorage.local }, + { name: 'skill-b', uri: URI.file('/home/user/.copilot/skills/b/SKILL.md'), storage: PromptsStorage.user }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + // Only local sources visible + const counts = await getSourceCounts( + promptsService, PromptsType.skill, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.user, 0); + }); + }); + + suite('getSourceCounts - prompts', () => { + test('uses getPromptSlashCommands and filters out skills', async () => { + const promptsService = createMockPromptsService({ + commands: [ + { name: 'my-prompt', uri: URI.file('/workspace/.github/prompts/a.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + { name: 'my-skill', uri: URI.file('/workspace/.github/skills/b/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.prompt, + { sources: [PromptsStorage.local] }, + contextService, + workspaceService, + ); + + // Should exclude the skill command + assert.strictEqual(counts.workspace, 1); + }); + + test('counts prompts across storage types', async () => { + const promptsService = createMockPromptsService({ + commands: [ + { name: 'wp', uri: URI.file('/workspace/.github/prompts/a.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + { name: 'up', uri: URI.file('/home/user/prompts/b.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.prompt, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 0 }); + }); + + test('all skills are excluded from prompt counts', async () => { + const promptsService = createMockPromptsService({ + commands: [ + { name: 's1', uri: URI.file('/w/s1/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill }, + { name: 's2', uri: URI.file('/w/s2/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.prompt, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0 }); + }); + }); + + suite('getSourceCounts - hooks', () => { + test('uses listPromptFiles for hooks', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/hooks/pre-commit.json'), + localFile('/workspace/.claude/settings.json'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.hook, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + assert.strictEqual(counts.workspace, 2); + }); + + test('hooks with only local source excludes user hooks', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/hooks/pre-commit.json'), + userFile('/home/user/.claude/settings.json'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.hook, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.user, 0); + }); + }); + + suite('getSourceCounts - filter', () => { + test('applies includedUserFileRoots filter', async () => { + const copilotRoot = URI.file('/home/user/.copilot'); + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + userFile('/home/user/.copilot/instructions/b.instructions.md'), + userFile('/home/user/.vscode/instructions/c.instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { + sources: [PromptsStorage.local, PromptsStorage.user], + includedUserFileRoots: [copilotRoot], + }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + // Only the copilot file passes, not the vscode profile file + assert.strictEqual(counts.user, 1); + }); + + test('excludes storage types not in sources', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + extensionFile('/ext/instructions/b.instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.extension, 0); + }); + + test('includedUserFileRoots with multiple roots', async () => { + const copilotRoot = URI.file('/home/user/.copilot'); + const claudeRoot = URI.file('/home/user/.claude'); + const promptsService = createMockPromptsService({ + allFiles: [ + userFile('/home/user/.copilot/instructions/a.instructions.md'), + userFile('/home/user/.claude/rules/b.md'), + userFile('/home/user/.vscode/instructions/c.instructions.md'), + userFile('/home/user/.agents/instructions/d.instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.instructions, + { + sources: [PromptsStorage.local, PromptsStorage.user], + includedUserFileRoots: [copilotRoot, claudeRoot], + }, + contextService, workspaceService, + ); + + // copilot + claude pass, vscode + agents don't + assert.strictEqual(counts.user, 2); + }); + + test('undefined includedUserFileRoots shows all user files', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + userFile('/home/user/.copilot/instructions/a.instructions.md'), + userFile('/home/user/.vscode/instructions/b.instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.instructions, + { sources: [PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.strictEqual(counts.user, 2); + }); + }); + + suite('getCustomizationTotalCount', () => { + test('sums all sections', async () => { + const promptsService = createMockPromptsService({ + agents: [ + { name: 'a', uri: URI.file('/w/a.agent.md'), storage: PromptsStorage.local }, + ], + skills: [ + { name: 's', uri: URI.file('/w/s/SKILL.md'), storage: PromptsStorage.local }, + ], + commands: [ + { name: 'p', uri: URI.file('/w/p.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + ], + }); + const mcpService = { + servers: observableValue('test', [{ id: 'srv1' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ + activeRoot: URI.file('/w'), + filter: { sources: [PromptsStorage.local] }, + }); + const contextService = createMockWorkspaceContextService([makeWorkspaceFolder('/w')]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService); + + // 1 agent + 1 skill + 0 instructions + 1 prompt + 0 hooks + 1 mcp = 4 + assert.strictEqual(total, 4); + }); + + test('empty workspace returns only mcp count', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', [{ id: 's1' }, { id: 's2' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ + filter: { sources: [PromptsStorage.local] }, + }); + const contextService = createMockWorkspaceContextService([]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService); + + assert.strictEqual(total, 2); // just 2 mcp servers + }); + + test('includes instructions with agent files in count', async () => { + const instructionFiles = [ + localFile('/w/.github/instructions/a.instructions.md'), + ]; + const promptsService = createMockPromptsService({ + allFiles: instructionFiles, + agentInstructions: [ + agentInstructionFile('/w/AGENTS.md'), + ], + }); + // Override listPromptFiles to only return files for instructions type + promptsService.listPromptFiles = async (type: PromptsType) => { + return type === PromptsType.instructions ? instructionFiles : []; + }; + const mcpService = { + servers: observableValue('test', []), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ + activeRoot: URI.file('/w'), + filter: { sources: [PromptsStorage.local] }, + }); + const contextService = createMockWorkspaceContextService([makeWorkspaceFolder('/w')]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService); + + // 0 agents + 0 skills + 2 instructions (1 file + 1 AGENTS.md) + 0 prompts + 0 hooks + 0 mcp = 2 + assert.strictEqual(total, 2); + }); + }); + + suite('data source consistency', () => { + // These tests verify that getSourceCounts uses the same data sources + // as the list widget's loadItems() — the root cause of the count mismatch bug. + + test('instructions count matches widget: listPromptFiles + listAgentInstructions', async () => { + // Scenario: 13 .instructions.md files + 2 agent instruction files = 15 total + // The old bug: sidebar showed 13 (only listPromptFilesForStorage), + // editor showed 15 (listPromptFiles + listAgentInstructions) + const instructionFiles = Array.from({ length: 13 }, (_, i) => + localFile(`/workspace/.github/instructions/rule-${i}.instructions.md`) + ); + const promptsService = createMockPromptsService({ + localFiles: instructionFiles, + allFiles: instructionFiles, + agentInstructions: [ + agentInstructionFile('/workspace/AGENTS.md'), + agentInstructionFile('/workspace/.github/copilot-instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + // Must be 15, not 13 + assert.strictEqual(counts.workspace, 15); + }); + + test('agents count uses getCustomAgents not listPromptFilesForStorage', async () => { + // getCustomAgents parses frontmatter and may exclude invalid files + const promptsService = createMockPromptsService({ + // Raw file count would be 3 + localFiles: [ + localFile('/workspace/.github/agents/a.agent.md'), + localFile('/workspace/.github/agents/b.agent.md'), + localFile('/workspace/.github/agents/README.md'), // would be excluded by getCustomAgents + ], + // But parsed custom agents is only 2 + agents: [ + { name: 'agent-a', uri: URI.file('/workspace/.github/agents/a.agent.md'), storage: PromptsStorage.local }, + { name: 'agent-b', uri: URI.file('/workspace/.github/agents/b.agent.md'), storage: PromptsStorage.local }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.agent, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + // Must use getCustomAgents count (2), not raw file count (3) + assert.strictEqual(counts.workspace, 2); + }); + + test('prompts count excludes skills to match widget', async () => { + // The widget's loadItems filters out skill-type commands. + // Count must do the same. + const promptsService = createMockPromptsService({ + localFiles: [ + localFile('/workspace/.github/prompts/a.prompt.md'), + localFile('/workspace/.github/prompts/b.prompt.md'), + ], + commands: [ + { name: 'prompt-a', uri: URI.file('/workspace/.github/prompts/a.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + { name: 'prompt-b', uri: URI.file('/workspace/.github/prompts/b.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + { name: 'skill-x', uri: URI.file('/workspace/.github/skills/x/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.prompt, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + // Must be 2 (prompts only), not 3 (including skill) + assert.strictEqual(counts.workspace, 2); + }); + + test('no active root: agent instructions classified as user', async () => { + const promptsService = createMockPromptsService({ + allFiles: [], + agentInstructions: [ + agentInstructionFile('/somewhere/AGENTS.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: undefined }); + const contextService = createMockWorkspaceContextService([]); + + const counts = await getSourceCounts( + promptsService, PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + // No workspace context → classified as user + assert.strictEqual(counts.workspace, 0); + assert.strictEqual(counts.user, 1); + }); + }); +}); diff --git a/src/vs/sessions/contrib/workspace/browser/workspace.contribution.ts b/src/vs/sessions/contrib/workspace/browser/workspace.contribution.ts new file mode 100644 index 0000000000000..4351598efa6b2 --- /dev/null +++ b/src/vs/sessions/contrib/workspace/browser/workspace.contribution.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { WorkspaceFolderManagementContribution } from './workspaceFolderManagement.js'; + +registerWorkbenchContribution2(WorkspaceFolderManagementContribution.ID, WorkspaceFolderManagementContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts new file mode 100644 index 0000000000000..44fa452df3abe --- /dev/null +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { URI } from '../../../../base/common/uri.js'; +import { autorun } from '../../../../base/common/observable.js'; + +export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.workspaceFolderManagement'; + + constructor( + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + ) { + super(); + this._register(autorun(reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + this.updateWorkspaceFoldersForSession(activeSession); + })); + } + + private async updateWorkspaceFoldersForSession(session: IActiveSessionItem | undefined): Promise { + await this.manageTrustWorkspaceForSession(session); + const activeSessionRepo = session?.providerType === AgentSessionProviders.Background ? session.worktree ?? session.repository : undefined; + const currentRepo = this.workspaceContextService.getWorkspace().folders[0]?.uri; + + if (!activeSessionRepo) { + if (currentRepo) { + await this.workspaceEditingService.removeFolders([currentRepo], true); + } + return; + } + + if (!currentRepo) { + await this.workspaceEditingService.addFolders([{ uri: activeSessionRepo }], true); + return; + } + + if (this.uriIdentityService.extUri.isEqual(currentRepo, activeSessionRepo)) { + return; + } + + await this.workspaceEditingService.updateFolders(0, 1, [{ uri: activeSessionRepo }], true); + } + + private async manageTrustWorkspaceForSession(session: IActiveSessionItem | undefined): Promise { + if (session?.providerType !== AgentSessionProviders.Background) { + return; + } + + if (!session.repository || !session.worktree) { + return; + } + + if (!this.isUriTrusted(session.repository)) { + return; + } + + if (!this.isUriTrusted(session.worktree)) { + await this.workspaceTrustManagementService.setUrisTrust([session.worktree], true); + } + } + + private isUriTrusted(uri: URI): boolean { + return this.workspaceTrustManagementService.getTrustedUris().some(trustedUri => this.uriIdentityService.extUri.isEqual(trustedUri, uri)); + } +} diff --git a/src/vs/sessions/services/workspace/browser/workspaceContextService.ts b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts index 660d475ee8b94..b9ec9f571f9c5 100644 --- a/src/vs/sessions/services/workspace/browser/workspaceContextService.ts +++ b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../base/common/event.js'; +import { Queue } from '../../../../base/common/async.js'; import { removeTrailingPathSeparator } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; @@ -11,8 +12,9 @@ import { Workspace, WorkspaceFolder, IWorkspace, IWorkspaceContextService, IWork import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { getWorkspaceIdentifier } from '../../../../workbench/services/workspaces/browser/workspaces.js'; import { IDidEnterWorkspaceEvent, IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; -export class SessionsWorkspaceContextService implements IWorkspaceContextService, IWorkspaceEditingService { +export class SessionsWorkspaceContextService extends Disposable implements IWorkspaceContextService, IWorkspaceEditingService { declare readonly _serviceBrand: undefined; @@ -23,15 +25,17 @@ export class SessionsWorkspaceContextService implements IWorkspaceContextService private readonly _onWillChangeWorkspaceFolders = new Emitter(); readonly onWillChangeWorkspaceFolders = this._onWillChangeWorkspaceFolders.event; - private readonly _onDidChangeWorkspaceFolders = new Emitter(); + private readonly _onDidChangeWorkspaceFolders = this._register(new Emitter()); readonly onDidChangeWorkspaceFolders = this._onDidChangeWorkspaceFolders.event; private workspace: Workspace; + private readonly _updateFoldersQueue = this._register(new Queue()); constructor( sessionsWorkspaceUri: URI, private readonly uriIdentityService: IUriIdentityService ) { + super(); const workspaceIdentifier = getWorkspaceIdentifier(sessionsWorkspaceUri); this.workspace = new Workspace(workspaceIdentifier.id, [], false, workspaceIdentifier.configPath, uri => uriIdentityService.extUri.ignorePathCasing(uri)); } @@ -45,7 +49,7 @@ export class SessionsWorkspaceContextService implements IWorkspaceContextService } getWorkbenchState(): WorkbenchState { - return WorkbenchState.WORKSPACE; + return WorkbenchState.EMPTY; } getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { @@ -94,7 +98,11 @@ export class SessionsWorkspaceContextService implements IWorkspaceContextService async pickNewWorkspacePath(): Promise { return undefined; } - private async doUpdateFolders(foldersToAdd: IWorkspaceFolderCreationData[], foldersToRemove: URI[], index?: number): Promise { + private doUpdateFolders(foldersToAdd: IWorkspaceFolderCreationData[], foldersToRemove: URI[], index?: number): Promise { + return this._updateFoldersQueue.queue(() => this._doUpdateFolders(foldersToAdd, foldersToRemove, index)); + } + + private async _doUpdateFolders(foldersToAdd: IWorkspaceFolderCreationData[], foldersToRemove: URI[], index?: number): Promise { if (foldersToAdd.length === 0 && foldersToRemove.length === 0) { return; } diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 7089131ba679c..406ea03e50834 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -206,6 +206,7 @@ import './contrib/chat/browser/chat.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changesView/browser/changesView.contribution.js'; +import './contrib/files/browser/files.contribution.js'; import './contrib/gitSync/browser/gitSync.contribution.js'; import './contrib/applyToParentRepo/browser/applyToParentRepo.contribution.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed @@ -213,6 +214,7 @@ import './contrib/configuration/browser/configuration.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/logs/browser/logs.contribution.js'; +import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/welcome/browser/welcome.contribution.js'; //#endregion diff --git a/src/vs/sessions/test/ai-customizations.test.md b/src/vs/sessions/test/ai-customizations.test.md new file mode 100644 index 0000000000000..acc5ec8d364bf --- /dev/null +++ b/src/vs/sessions/test/ai-customizations.test.md @@ -0,0 +1,166 @@ +# AI Customizations Test Plan + +The following test plan outlines the scenarios and specifications for the AI Customizations feature, which includes a management editor and tree view for managing customization items. + +## SPECS + +- [`../AI_CUSTOMIZATIONS.md`](../AI_CUSTOMIZATIONS.md) + +## SCENARIOS + +### Scenario 1: Empty state — no session, no customizations + +#### Preconditions + +- On 'New Session' screen +- No folder selected +- No user customizations created (from this tool or others i.e. Copilot CLI) + +#### Actions + +1. Open the sidebar customizations section +2. Observe no sidebar counts are shown for any section (Agents, Skills, Instructions, Prompts, Hooks) +3. Open the management editor by clicking on one of the sections (e.g., "Instructions") +4. Observe the empty state message and +4. Click through each section in the sidebar + +#### Expected Results + +- All sidebar counts show 0 (no badges visible) +- Management editor shows empty state for each section with "No X yet" message and create +- "Add" button is disabled or hidden (no active workspace to create in) +- User storage group is empty (no `~/.copilot/` or `~/.claude/` files) +- Extension storage group may show built-in extension items if Copilot extension contributes any + +#### Notes + +- This tests the baseline empty state before any session or workspace is active +- The `isSessionsWindow` flag should be `true`, verified via Developer: Customizations Debug + +--- + +### Scenario 2: Active session with workspace customizations + +#### Preconditions + +- Active session with a repository that has customizations (e.g., the `vscode` repo which has `.github/agents/`, `.github/skills/`, `.github/instructions/`, `.github/prompts/`) +- Session has a worktree checked out + +#### Actions + +1. Observe sidebar counts update after session becomes active +2. Open management editor, select "Instructions" section +3. Verify items listed match the workspace files +4. Run Developer: Customizations Debug and compare Stage 1 (raw data) with Stage 3 (widget state) +5. Select "Agents" section and verify agent count +6. Select "Prompts" section and verify skill-type commands are excluded + +#### Expected Results + +- Sidebar counts match editor list counts for every section +- Instructions section includes AGENTS.md, CLAUDE.md, copilot-instructions.md (from `listAgentInstructions`) classified as "Workspace" +- Instructions section includes `.instructions.md` files from `.github/instructions/` +- Agents section uses `getCustomAgents()` — parsed names, not raw filenames (README.md excluded) +- Prompts section shows only prompt-type commands, not skill-type (skills have their own section) +- Hooks section shows only workspace-local hook files (user hooks filtered out by `sources: [local]`) +- Debug report shows `Window: Sessions`, `Active root: /path/to/worktree` +- Extension and plugin groups are not shown (sessions filter: `sources: [local, user]`) + +#### Notes + +- This is the core "happy path" for sessions — most customizations come from the workspace +- Count consistency between sidebar badges and editor item count is the key regression test + +--- + +### Scenario 3: User-level customizations from CLI paths + +#### Preconditions + +- Files exist in `~/.copilot/instructions/`, `~/.claude/rules/`, or `~/.claude/agents/` +- Active session with a repository open + +#### Actions + +1. Open management editor, select "Instructions" +2. Verify user-level instruction files appear under the "User" group +3. Select "Agents" section and check for `~/.claude/agents/` user agents +4. Run Developer: Customizations Debug and check the `includedUserFileRoots` filter +5. Verify that VS Code profile user files (e.g., `$PROFILE/instructions/`) are NOT shown + +#### Expected Results + +- User files from `~/.copilot/` and `~/.claude/` appear in the "User" group +- User files from the VS Code profile path do NOT appear (filtered by `includedUserFileRoots: [~/.copilot, ~/.claude, ~/.agents]`) +- Prompts section shows ALL user roots (filter has `includedUserFileRoots: undefined`) — including VS Code profile prompts +- Debug report Stage 2 shows "Removed" entries for any filtered-out user files +- Sidebar counts reflect the filtered user file counts + +#### Notes + +- This validates the `IStorageSourceFilter.includedUserFileRoots` allowlist +- Prompts are intentionally an exception — they show from all user roots since CLI now supports user prompts + +--- + +### Scenario 4: Creating new customization files + +#### Preconditions + +- Active session with a worktree + +#### Actions + +1. Open management editor, select "Instructions" +2. Click the "Add" button → "New Instructions (Workspace)" +3. Verify the file is created in `.github/instructions/` under the worktree +4. Click "Add" button dropdown → "New Instructions (User)" +5. Verify the file is created in `~/.copilot/instructions/` (not VS Code profile) +6. Select "Hooks" section +7. Click "Add" → verify a `hooks.json` is created in `.github/hooks/` +8. Verify the hooks.json has `"version": 1`, uses `"bash"` field, and contains all events from `COPILOT_CLI_HOOK_TYPE_MAP` + +#### Expected Results + +- Workspace files created under the active worktree's `.github/` folder +- User files created under `~/.copilot/{type}/` (from `AgenticPromptsService.getSourceFolders()` override) +- Hooks.json skeleton has correct Copilot CLI format: `version: 1`, `bash` (not `command`), all hook events derived from schema +- After creation, item count updates automatically +- Created files are editable in the embedded editor + +#### Notes + +- This tests that `AgenticPromptsService.getSourceFolders()` correctly redirects user creation to `~/.copilot/` +- Hooks creation derives events from `COPILOT_CLI_HOOK_TYPE_MAP` — adding new events to the schema auto-includes them + +--- + +### Scenario 5: Switching sessions updates customizations + +#### Preconditions + +- Two sessions active: one with a repo that has many customizations, another with none +- Or: one active session, then start a new session with a different repo + +#### Actions + +1. Note the sidebar customization counts for the first session +2. Switch to the second session (click it in the session list) +3. Observe sidebar counts update +4. Open the management editor and verify items reflect the new session's workspace +5. Switch back to the first session +6. Verify counts and items revert to the first session's state + +#### Expected Results + +- Sidebar counts update reactively when `activeSession` observable changes +- Management editor items refresh automatically (list widget subscribes to `activeProjectRoot`) +- Active root in the debug report changes to the new session's worktree +- No stale counts from the previous session persist +- If the new session has no worktree, counts show only user-level items (workspace = 0) +- "Add (Workspace)" button becomes disabled when no active root + +#### Notes + +- This tests the reactive wiring: `autorun` on `activeSession` triggers `_updateCounts()` in toolbar and `refresh()` in list widget +- Stale count bugs typically manifest when switching sessions — the count remains from the prior session diff --git a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts index a5b6b341f20ac..03bfed510efa8 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts @@ -48,8 +48,8 @@ const baseEntries: WatermarkEntry[] = [ const emptyWindowEntries: WatermarkEntry[] = coalesce([ ...baseEntries, - ...(isMacintosh && !isWeb ? [openFileOrFolder] : [openFile, openFolder]), openRecent, + ...(isMacintosh && !isWeb ? [openFileOrFolder] : [openFile, openFolder]), isMacintosh && !isWeb ? newUntitledFile : undefined, // fill in one more on macOS to get to 5 entries ]); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index f2cfdd54ec47d..d1232f91828ff 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -679,7 +679,7 @@ export function registerChatActions() { }, { id: MenuId.EditorTitle, group: 'navigation', - when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('copilot'), ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('sparkle')), + when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('copilot'), ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('new-session'), ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('comment')), order: 1 }], }); @@ -715,19 +715,43 @@ export function registerChatActions() { } }); - registerAction2(class NewChatEditorSparkleIconAction extends Action2 { + registerAction2(class NewChatEditorNewSessionIconAction extends Action2 { constructor() { super({ - id: ACTION_ID_OPEN_CHAT + '.sparkleIcon', + id: ACTION_ID_OPEN_CHAT + '.newSessionIcon', title: localize2('interactiveSession.open', "New Chat Editor"), - icon: Codicon.chatSparkle, + icon: Codicon.newSession, f1: false, category: CHAT_CATEGORY, precondition: ChatContextKeys.enabled, menu: [{ id: MenuId.EditorTitle, group: 'navigation', - when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), ChatContextKeys.newChatButtonExperimentIcon.isEqualTo('sparkle')), + when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), ChatContextKeys.newChatButtonExperimentIcon.isEqualTo('new-session')), + order: 1 + }], + }); + } + + async run(accessor: ServicesAccessor) { + const widgetService = accessor.get(IChatWidgetService); + await widgetService.openSession(LocalChatSessionUri.getNewSessionUri(), ACTIVE_GROUP, { pinned: true } satisfies IChatEditorOptions); + } + }); + + registerAction2(class NewChatEditorCommentIconAction extends Action2 { + constructor() { + super({ + id: ACTION_ID_OPEN_CHAT + '.commentIcon', + title: localize2('interactiveSession.open', "New Chat Editor"), + icon: Codicon.comment, + f1: false, + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.EditorTitle, + group: 'navigation', + when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), ChatContextKeys.newChatButtonExperimentIcon.isEqualTo('comment')), order: 1 }], }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index febdbc539f7fb..271e82b2baf41 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -108,6 +108,11 @@ export function registerNewChatActions() { id: MenuId.ChatNewMenu, group: '1_open', order: 1, + when: ContextKeyExpr.and( + ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('copilot'), + ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('new-session'), + ChatContextKeys.newChatButtonExperimentIcon.notEqualsTo('comment') + ) } ], keybinding: { @@ -132,6 +137,40 @@ export function registerNewChatActions() { } } ); + + const iconVariants = [ + { idSuffix: '.copilotIcon', iconValue: 'copilot', icon: Codicon.copilot }, + { idSuffix: '.newSessionIcon', iconValue: 'new-session', icon: Codicon.newSession }, + { idSuffix: '.commentIcon', iconValue: 'comment', icon: Codicon.comment }, + ] as const; + + for (const variant of iconVariants) { + registerAction2(class extends Action2 { + constructor() { + super({ + id: ACTION_ID_NEW_CHAT + variant.idSuffix, + title: localize2('chat.newEdits.label', "New Chat"), + category: CHAT_CATEGORY, + icon: variant.icon, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat)), + f1: false, + menu: [{ + id: MenuId.ChatNewMenu, + group: '1_open', + order: 1, + when: ChatContextKeys.newChatButtonExperimentIcon.isEqualTo(variant.iconValue) + }] + }); + } + + async run(accessor: ServicesAccessor, ...args: unknown[]) { + const executeCommandContext = isNewEditSessionActionContext(args[0]) ? args[0] : undefined; + const context = getEditingSessionContext(accessor, args); + await runNewChatAction(accessor, context, executeCommandContext); + } + }); + } + CommandsRegistry.registerCommandAlias(ACTION_ID_NEW_EDIT_SESSION, ACTION_ID_NEW_CHAT); registerAction2(class NewLocalChatAction extends Action2 { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts new file mode 100644 index 0000000000000..e4ed34928ff12 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection } from './aiCustomizationManagement.js'; + +/** + * Maps section ID to prompt type. Duplicated from aiCustomizationListWidget + * to avoid a circular dependency. + */ +function sectionToPromptType(section: AICustomizationManagementSection): PromptsType { + switch (section) { + case AICustomizationManagementSection.Agents: + return PromptsType.agent; + case AICustomizationManagementSection.Skills: + return PromptsType.skill; + case AICustomizationManagementSection.Instructions: + return PromptsType.instructions; + case AICustomizationManagementSection.Hooks: + return PromptsType.hook; + case AICustomizationManagementSection.Prompts: + default: + return PromptsType.prompt; + } +} + +/** + * Snapshot of the list widget's internal state, passed in to avoid coupling. + */ +export interface IDebugWidgetState { + readonly allItems: readonly { readonly storage: PromptsStorage }[]; + readonly displayEntries: readonly { type: string; label?: string; count?: number; collapsed?: boolean }[]; +} + +/** + * Generates a debug diagnostics report for the AI Customization list widget. + * Returns the report as a string suitable for opening in an editor. + */ +export async function generateCustomizationDebugReport( + section: AICustomizationManagementSection, + promptsService: IPromptsService, + workspaceService: IAICustomizationWorkspaceService, + widgetState: IDebugWidgetState, +): Promise { + const promptType = sectionToPromptType(section); + const filter = workspaceService.getStorageSourceFilter(promptType); + const lines: string[] = []; + + lines.push(`== Customization Debug: ${section} (${promptType}) ==`); + lines.push(`Window: ${workspaceService.isSessionsWindow ? 'Sessions' : 'Core VS Code'}`); + lines.push(`Active root: ${workspaceService.getActiveProjectRoot()?.fsPath ?? '(none)'}`); + lines.push(`Sections: [${workspaceService.managementSections.join(', ')}]`); + lines.push(`Filter sources: [${filter.sources.join(', ')}]`); + if (filter.includedUserFileRoots) { + lines.push(`Filter includedUserFileRoots:`); + for (const r of filter.includedUserFileRoots) { + lines.push(` ${r.fsPath}`); + } + } else { + lines.push(`Filter includedUserFileRoots: (all)`); + } + lines.push(''); + + await appendRawServiceData(lines, promptsService, promptType); + await appendFilteredData(lines, promptsService, promptType, filter); + appendWidgetState(lines, widgetState); + await appendSourceFolders(lines, promptsService, promptType); + + return lines.join('\n'); +} + +async function appendRawServiceData(lines: string[], promptsService: IPromptsService, promptType: PromptsType): Promise { + lines.push('--- Stage 1: Raw PromptsService Data ---'); + + const [localFiles, userFiles, extensionFiles] = await Promise.all([ + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), + ]); + + lines.push(` listPromptFilesForStorage(local): ${localFiles.length} files`); + appendFileList(lines, localFiles); + + lines.push(` listPromptFilesForStorage(user): ${userFiles.length} files`); + appendFileList(lines, userFiles); + + lines.push(` listPromptFilesForStorage(ext): ${extensionFiles.length} files`); + appendFileList(lines, extensionFiles); + + const allFiles = await promptsService.listPromptFiles(promptType, CancellationToken.None); + lines.push(` listPromptFiles (merged): ${allFiles.length} files`); + + if (promptType === PromptsType.instructions) { + const agentInstructions = await promptsService.listAgentInstructions(CancellationToken.None, undefined); + lines.push(` listAgentInstructions (extra): ${agentInstructions.length} files`); + appendFileList(lines, agentInstructions); + } + + if (promptType === PromptsType.skill) { + const skills = await promptsService.findAgentSkills(CancellationToken.None); + lines.push(` findAgentSkills: ${skills?.length ?? 0} skills`); + for (const s of skills ?? []) { + lines.push(` ${s.name ?? '?'} [${s.storage}] ${s.uri.fsPath}`); + } + } + + if (promptType === PromptsType.agent) { + const agents = await promptsService.getCustomAgents(CancellationToken.None); + lines.push(` getCustomAgents: ${agents.length} agents`); + for (const a of agents) { + lines.push(` ${a.name} [${a.source.storage}] ${a.uri.fsPath}`); + } + } + + if (promptType === PromptsType.prompt) { + const commands = await promptsService.getPromptSlashCommands(CancellationToken.None); + lines.push(` getPromptSlashCommands: ${commands.length} commands`); + for (const c of commands) { + lines.push(` /${c.name} [${c.promptPath.storage}] ${c.promptPath.uri.fsPath} (type=${c.promptPath.type})`); + } + } + + lines.push(''); +} + +async function appendFilteredData(lines: string[], promptsService: IPromptsService, promptType: PromptsType, filter: IStorageSourceFilter): Promise { + lines.push('--- Stage 2: After applyStorageSourceFilter ---'); + + const [localFiles, userFiles, extensionFiles] = await Promise.all([ + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), + ]); + + const all: IPromptPath[] = [...localFiles, ...userFiles, ...extensionFiles]; + const filtered = applyStorageSourceFilter(all, filter); + lines.push(` Input: ${all.length} → Filtered: ${filtered.length}`); + lines.push(` local: ${filtered.filter(f => f.storage === PromptsStorage.local).length}`); + lines.push(` user: ${filtered.filter(f => f.storage === PromptsStorage.user).length}`); + lines.push(` extension: ${filtered.filter(f => f.storage === PromptsStorage.extension).length}`); + + const removedCount = all.length - filtered.length; + if (removedCount > 0) { + const filteredUris = new Set(filtered.map(f => f.uri.toString())); + const removed = all.filter(f => !filteredUris.has(f.uri.toString())); + lines.push(` Removed (${removedCount}):`); + for (const f of removed) { + lines.push(` [${f.storage}] ${f.uri.fsPath}`); + } + } + + lines.push(''); +} + +function appendWidgetState(lines: string[], state: IDebugWidgetState): void { + lines.push('--- Stage 3: Widget State (loadItems → filterItems) ---'); + lines.push(` allItems (after loadItems): ${state.allItems.length}`); + lines.push(` local: ${state.allItems.filter(i => i.storage === PromptsStorage.local).length}`); + lines.push(` user: ${state.allItems.filter(i => i.storage === PromptsStorage.user).length}`); + lines.push(` extension: ${state.allItems.filter(i => i.storage === PromptsStorage.extension).length}`); + lines.push(` plugin: ${state.allItems.filter(i => i.storage === PromptsStorage.plugin).length}`); + + lines.push(` displayEntries (after filterItems): ${state.displayEntries.length}`); + const fileEntries = state.displayEntries.filter(e => e.type === 'file-item'); + lines.push(` file items shown: ${fileEntries.length}`); + const groupEntries = state.displayEntries.filter(e => e.type === 'group-header'); + for (const g of groupEntries) { + lines.push(` group "${g.label}": count=${g.count}, collapsed=${g.collapsed}`); + } + lines.push(''); +} + +async function appendSourceFolders(lines: string[], promptsService: IPromptsService, promptType: PromptsType): Promise { + lines.push('--- Source Folders (creation targets) ---'); + const sourceFolders = await promptsService.getSourceFolders(promptType); + for (const sf of sourceFolders) { + lines.push(` [${sf.storage}] ${sf.uri.fsPath}`); + } + + try { + const resolvedFolders = await promptsService.getResolvedSourceFolders(promptType); + lines.push(''); + lines.push('--- Resolved Source Folders (discovery order) ---'); + for (const rf of resolvedFolders) { + lines.push(` [${rf.storage}] ${rf.uri.fsPath} (source=${rf.source})`); + } + } catch { + // getResolvedSourceFolders may not exist for all types + } +} + +function appendFileList(lines: string[], files: readonly { uri: URI }[]): void { + for (const f of files) { + lines.push(` ${f.uri.fsPath}`); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 4d240a6a64a6e..a0c135aacc637 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -34,12 +34,12 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { Action, Separator } from '../../../../../base/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ISCMService } from '../../../scm/common/scm.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; const $ = DOM.$; @@ -364,7 +364,6 @@ export class AICustomizationListWidget extends Disposable { @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ILabelService private readonly labelService: ILabelService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, - @ILogService private readonly logService: ILogService, @IClipboardService private readonly clipboardService: IClipboardService, @ISCMService private readonly scmService: ISCMService, @IHoverService private readonly hoverService: IHoverService, @@ -621,7 +620,7 @@ export class AICustomizationListWidget extends Disposable { this.addButton.element.style.display = hasDropdown ? '' : 'none'; this.addButtonSimple.element.style.display = hasDropdown ? 'none' : ''; - if (this.workspaceService.preferManualCreation) { + if (this.workspaceService.isSessionsWindow) { // Sessions: primary is workspace creation const hasWorkspace = this.hasActiveWorkspace(); const label = `$(${Codicon.add.id}) New ${typeLabel} (Workspace)`; @@ -672,7 +671,7 @@ export class AICustomizationListWidget extends Disposable { // Hooks: no user-scoped creation if (promptType === PromptsType.hook) { - if (this.workspaceService.preferManualCreation) { + if (this.workspaceService.isSessionsWindow) { // Sessions: no dropdown for hooks } else { // Core: primary is generate, dropdown has configure quick pick @@ -685,7 +684,7 @@ export class AICustomizationListWidget extends Disposable { return actions; } - if (this.workspaceService.preferManualCreation) { + if (this.workspaceService.isSessionsWindow) { // Sessions: primary is workspace, dropdown has user actions.push(this.dropdownActionDisposables.add(new Action('createUser', `$(${Codicon.account.id}) New ${typeLabel} (User)`, undefined, true, () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); @@ -717,7 +716,7 @@ export class AICustomizationListWidget extends Disposable { */ private executePrimaryCreateAction(): void { const promptType = sectionToPromptType(this.currentSection); - if (this.workspaceService.preferManualCreation) { + if (this.workspaceService.isSessionsWindow) { // Sessions: primary creates in workspace if (!this.hasActiveWorkspace()) { return; @@ -763,10 +762,6 @@ export class AICustomizationListWidget extends Disposable { const promptType = sectionToPromptType(this.currentSection); const items: IAICustomizationListItem[] = []; - const folders = this.workspaceContextService.getWorkspace().folders; - const activeRepo = this.workspaceService.getActiveProjectRoot(); - this.logService.info(`[AICustomizationListWidget] loadItems: section=${this.currentSection}, promptType=${promptType}, workspaceFolders=[${folders.map(f => f.uri.toString()).join(', ')}], activeRepo=${activeRepo?.toString() ?? 'none'}`); - if (promptType === PromptsType.agent) { // Use getCustomAgents which has parsed name/description from frontmatter @@ -882,15 +877,11 @@ export class AICustomizationListWidget extends Disposable { items.push(...pluginItems.map(mapToListItem)); } - // Filter out files under excluded user roots - const excludedRoots = this.workspaceService.excludedUserFileRoots; - if (excludedRoots.length > 0) { - for (let i = items.length - 1; i >= 0; i--) { - if (items[i].storage === PromptsStorage.user && excludedRoots.some(root => isEqualOrParent(items[i].uri, root))) { - items.splice(i, 1); - } - } - } + // Apply storage source filter (removes items not in visible sources or excluded user roots) + const filter = this.workspaceService.getStorageSourceFilter(promptType); + const filteredItems = applyStorageSourceFilter(items, filter); + items.length = 0; + items.push(...filteredItems); // Sort items by name items.sort((a, b) => a.name.localeCompare(b.name)); @@ -898,8 +889,6 @@ export class AICustomizationListWidget extends Disposable { // Set git status for workspace (local) items this.updateGitStatus(items); - this.logService.info(`[AICustomizationListWidget] loadItems complete: ${items.length} items loaded [${items.map(i => `${i.name}(${i.storage}:${i.uri.toString()})`).join(', ')}]`); - this.allItems = items; this.filterItems(); this._onDidChangeItemCount.fire(items.length); @@ -973,12 +962,9 @@ export class AICustomizationListWidget extends Disposable { } } - const totalBeforeFilter = matchedItems.length; - this.logService.info(`[AICustomizationListWidget] filterItems: allItems=${this.allItems.length}, matched=${totalBeforeFilter}`); - // Group items by storage const promptType = sectionToPromptType(this.currentSection); - const visibleSources = new Set(this.workspaceService.getVisibleStorageSources(promptType)); + const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); const groups: { storage: PromptsStorage; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ { storage: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, { storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, @@ -1029,7 +1015,6 @@ export class AICustomizationListWidget extends Disposable { } this.list.splice(0, this.list.length, this.displayEntries); - this.logService.info(`[AICustomizationListWidget] filterItems complete: ${this.displayEntries.length} display entries spliced into list`); this.updateEmptyState(); } @@ -1170,4 +1155,16 @@ export class AICustomizationListWidget extends Disposable { get itemCount(): number { return this.allItems.length; } + + /** + * Generates a debug report for the current section. + */ + async generateDebugReport(): Promise { + return generateCustomizationDebugReport( + this.currentSection, + this.promptsService, + this.workspaceService, + { allItems: this.allItems, displayEntries: this.displayEntries }, + ); + } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 5e95b733bbbfb..0cd2c243b0f75 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -6,6 +6,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -270,8 +271,8 @@ class AICustomizationManagementActionsContribution extends Disposable implements constructor() { super({ id: AICustomizationManagementCommands.OpenEditor, - title: localize2('openAICustomizations', "Open Chat Customizations (Preview)"), - shortTitle: localize2('aiCustomizations', "Chat Customizations (Preview)"), + title: localize2('openAICustomizations', "Open Customizations (Preview)"), + shortTitle: localize2('aiCustomizations', "Customizations (Preview)"), category: CHAT_CATEGORY, precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), f1: true, @@ -287,6 +288,27 @@ class AICustomizationManagementActionsContribution extends Disposable implements } } })); + + // Toggle Debug Panel in AI Customizations Editor + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: AICustomizationManagementCommands.ToggleDebug, + title: localize2('toggleDebugPanel', "Customizations Debug"), + category: Categories.Developer, + f1: true, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const pane = editorService.activeEditorPane; + if (pane instanceof AICustomizationManagementEditor) { + const report = await (pane as AICustomizationManagementEditor).generateDebugReport(); + await editorService.openEditor({ resource: undefined, contents: report, options: { pinned: false } }); + } + } + })); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index 6aa4d4ba4d467..55da7d7eb798d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -26,6 +26,7 @@ export const AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID = 'workbench.input.aiCu */ export const AICustomizationManagementCommands = { OpenEditor: 'aiCustomization.openManagementEditor', + ToggleDebug: 'aiCustomization.toggleDebugPanel', CreateNewAgent: 'aiCustomization.createNewAgent', CreateNewSkill: 'aiCustomization.createNewSkill', CreateNewInstructions: 'aiCustomization.createNewInstructions', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 1aa934879ae94..8177097058f42 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -60,10 +60,10 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditorOptions.js'; import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; -import { IPathService } from '../../../../services/path/common/pathService.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { HOOKS_SOURCE_FOLDER } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { COPILOT_CLI_HOOK_TYPE_MAP } from '../../common/promptSyntax/hookSchema.js'; import { McpServerEditorInput } from '../../../mcp/browser/mcpServerEditorInput.js'; import { McpServerEditor } from '../../../mcp/browser/mcpServerEditor.js'; import { IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; @@ -185,7 +185,6 @@ export class AICustomizationManagementEditor extends EditorPane { @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, @ITextFileService private readonly textFileService: ITextFileService, - @IPathService private readonly pathService: IPathService, @IFileService private readonly fileService: IFileService, ) { super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); @@ -505,7 +504,7 @@ export class AICustomizationManagementEditor extends EditorPane { private async createNewItemManual(type: PromptsType, target: 'workspace' | 'user'): Promise { if (type === PromptsType.hook) { - if (this.workspaceService.preferManualCreation) { + if (this.workspaceService.isSessionsWindow) { // Sessions: directly create a Copilot CLI format hooks file await this.createCopilotCliHookFile(); } else { @@ -522,7 +521,7 @@ export class AICustomizationManagementEditor extends EditorPane { const targetDir = target === 'workspace' ? resolveWorkspaceTargetDirectory(this.workspaceService, type) - : await resolveUserTargetDirectory(this.promptsService, type, this.configurationService, this.pathService); + : await resolveUserTargetDirectory(this.promptsService, type); const options: INewPromptOptions = { targetFolder: targetDir, @@ -563,22 +562,12 @@ export class AICustomizationManagementEditor extends EditorPane { try { await this.fileService.stat(hookFileUri); } catch { - const hooksContent = { - hooks: { - sessionStart: [ - { type: 'command', command: '' } - ], - userPromptSubmitted: [ - { type: 'command', command: '' } - ], - preToolUse: [ - { type: 'command', command: '' } - ], - postToolUse: [ - { type: 'command', command: '' } - ], - } - }; + // Derive hook event names from the schema so new events are automatically included + const hooks: Record = {}; + for (const eventName of Object.keys(COPILOT_CLI_HOOK_TYPE_MAP)) { + hooks[eventName] = [{ type: 'command', bash: '' }]; + } + const hooksContent = { version: 1, hooks }; const jsonContent = JSON.stringify(hooksContent, null, '\t'); await this.fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); } @@ -658,6 +647,13 @@ export class AICustomizationManagementEditor extends EditorPane { void this.listWidget.refresh(); } + /** + * Generates a debug report for the current section. + */ + public async generateDebugReport(): Promise { + return this.listWidget.generateDebugReport(); + } + //#region Embedded Editor private createEmbeddedEditor(): void { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts index 50a5f6acc17c9..0f22c638e180b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts @@ -6,7 +6,7 @@ import { derived, IObservable, observableFromEventOpts } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -53,20 +53,15 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic AICustomizationManagementSection.McpServers, ]; - readonly visibleStorageSources: readonly PromptsStorage[] = [ - PromptsStorage.local, - PromptsStorage.user, - PromptsStorage.extension, - PromptsStorage.plugin, - ]; + private static readonly _defaultFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin], + }; - getVisibleStorageSources(_type: PromptsType): readonly PromptsStorage[] { - return this.visibleStorageSources; + getStorageSourceFilter(_type: PromptsType): IStorageSourceFilter { + return AICustomizationWorkspaceService._defaultFilter; } - readonly preferManualCreation = false; - - readonly excludedUserFileRoots: readonly URI[] = []; + readonly isSessionsWindow = false; async commitFiles(_projectRoot: URI, _fileUris: URI[]): Promise { // No-op in core VS Code. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts index 00d0721d35bcf..7058da4c373e9 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts @@ -6,15 +6,13 @@ import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { IChatWidgetService } from '../chat.js'; import { IChatService } from '../../common/chatService/chatService.js'; -import { ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { getPromptFileDefaultLocations } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { IPathService } from '../../../../services/path/common/pathService.js'; import { localize } from '../../../../../nls.js'; /** @@ -34,8 +32,6 @@ export class CustomizationCreatorService { @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IPromptsService private readonly promptsService: IPromptsService, @IQuickInputService private readonly quickInputService: IQuickInputService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IPathService private readonly pathService: IPathService, ) { } async createWithAI(type: PromptsType): Promise { @@ -103,7 +99,7 @@ export class CustomizationCreatorService { * Resolves the user-level directory for a new customization file. */ async resolveUserDirectory(type: PromptsType): Promise { - return resolveUserTargetDirectory(this.promptsService, type, this.configurationService, this.pathService); + return resolveUserTargetDirectory(this.promptsService, type); } } @@ -125,41 +121,18 @@ export function resolveWorkspaceTargetDirectory(workspaceService: IAICustomizati /** * Resolves the user-level directory for a new customization file. - * If chat.customizations.userStoragePath is set, uses that as the base - * with a type-appropriate subfolder. Otherwise falls back to the default - * source folders from IPromptsService. + * Delegates to IPromptsService.getSourceFolders() which returns the appropriate + * user root (VS Code profile in core, ~/.copilot in sessions). */ export async function resolveUserTargetDirectory( promptsService: IPromptsService, type: PromptsType, - configurationService?: IConfigurationService, - pathService?: IPathService, ): Promise { - const overridePath = configurationService?.getValue(ChatConfiguration.ChatCustomizationUserStoragePath); - const subfolder = getUserStorageSubfolder(type); - if (overridePath && pathService && subfolder) { - const userHome = await pathService.userHome(); - const resolved = overridePath.startsWith('~/') - ? URI.joinPath(userHome, overridePath.slice(2)) - : URI.file(overridePath); - return URI.joinPath(resolved, subfolder); - } const folders = await promptsService.getSourceFolders(type); const userFolder = folders.find(f => f.storage === PromptsStorage.user); return userFolder?.uri; } -/** - * Returns the subdirectory name for user-level storage of a given prompt type. - */ -function getUserStorageSubfolder(type: PromptsType): string | undefined { - switch (type) { - case PromptsType.instructions: return 'instructions'; - case PromptsType.skill: return 'skills'; - default: return undefined; - } -} - //#region Agent Instructions /** diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index aae88d01f5e86..d50fc45f037c1 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1139,7 +1139,7 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.SimpleTerminalCollapsible]: { type: 'boolean', - default: product.quality !== 'stable', + default: true, markdownDescription: nls.localize('chat.tools.terminal.simpleCollapsible', "When enabled, terminal tool calls are always displayed in a collapsible container with a simplified view."), tags: ['experimental'], }, @@ -1248,12 +1248,6 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is available in the Command Palette. When disabled, the Chat Customizations editor and related commands are hidden."), default: true, }, - [ChatConfiguration.ChatCustomizationUserStoragePath]: { - type: 'string', - tags: ['experimental'], - description: nls.localize('chat.customizationsMenu.userStoragePath', "Experimental: This setting is temporary and should not be relied on. Override the base directory for user-level customization files. When set, new user customizations are created here instead of the VS Code profile folder."), - default: '', - } } }); Registry.as(EditorExtensions.EditorPane).registerEditorPane( @@ -1463,7 +1457,8 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr private registerNewChatButtonIcon(): void { this.experimentService.getTreatment('chatNewButtonIcon').then((value) => { - if (value === 'copilot' || value === 'sparkle') { + const supportedValues = ['copilot', 'new-session', 'comment']; + if (typeof value === 'string' && supportedValues.includes(value)) { this.newChatButtonExperimentIcon.set(value); } else { this.newChatButtonExperimentIcon.reset(); diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 0ccf0f81f3ab1..9acdbe8df1642 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -27,6 +27,7 @@ import { CONFIGURE_PROMPTS_ACTION_ID } from './promptSyntax/runPromptAction.js'; import { CONFIGURE_SKILLS_ACTION_ID } from './promptSyntax/skillActions.js'; import { globalAutoApproveDescription } from './tools/languageModelToolsService.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; +import { Target } from '../common/promptSyntax/service/promptsService.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -81,7 +82,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z3_tools', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async () => { await commandService.executeCommand(ConfigureToolsAction.ID); })); @@ -91,7 +93,7 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z3_debug', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], }, async () => { await commandService.executeCommand('github.copilot.debug.showChatLogView'); })); @@ -141,7 +143,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z2_fork', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async (_prompt, _progress, _history, _location, sessionResource) => { await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource); })); @@ -151,7 +154,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z2_rename', executeImmediately: false, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async (prompt, _progress, _history, _location, sessionResource) => { const title = prompt.trim(); if (title) { @@ -216,7 +220,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_autoApprove', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, handleEnableAutoApprove)); this._store.add(slashCommandService.registerSlashCommand({ command: 'disableAutoApprove', @@ -224,7 +229,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_disableAutoApprove', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, handleDisableAutoApprove)); this._store.add(slashCommandService.registerSlashCommand({ command: 'yolo', @@ -232,7 +238,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_yolo', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, handleEnableAutoApprove)); this._store.add(slashCommandService.registerSlashCommand({ command: 'disableYolo', @@ -240,7 +247,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_disableYolo', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, handleDisableAutoApprove)); this._store.add(slashCommandService.registerSlashCommand({ command: 'help', @@ -248,7 +256,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_help', executeImmediately: true, locations: [ChatAgentLocation.Chat], - modes: [ChatModeKind.Ask] + modes: [ChatModeKind.Ask], + target: Target.VSCode }, async (prompt, progress, _history, _location, sessionResource) => { const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat); const agents = chatAgentService.getAgents(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts index 95b0edf8e267b..615ae0c63dcdf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -67,7 +67,8 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP alert(progress.content.value); } const isLoadingIcon = icon && ThemeIcon.isEqual(icon, ThemeIcon.modify(Codicon.loading, 'spin')); - const useShimmer = shimmer ?? ((!icon || isLoadingIcon) && this.showSpinner); + // Even if callers request shimmer, only the active (spinner-visible) progress row should animate. + const useShimmer = (shimmer ?? (!icon || isLoadingIcon)) && this.showSpinner; // if we have shimmer, don't show spinner const codicon = useShimmer ? Codicon.check : (icon ?? (this.showSpinner ? ThemeIcon.modify(Codicon.loading, 'spin') : Codicon.check)); const result = this.chatContentMarkdownRenderer.render(progress.content); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index f5865ba459266..b47f7312a4c76 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -66,7 +66,8 @@ export function getToolInvocationIcon(toolId: string): ThemeIcon { if ( lowerToolId.includes('edit') || - lowerToolId.includes('create') + lowerToolId.includes('create') || + lowerToolId.includes('replace') ) { return Codicon.pencil; } @@ -583,13 +584,41 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } + private setFinalizedTitle(title: string): void { + if (!this._collapseButton) { + return; + } + + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + + const firstSpaceIndex = title.indexOf(' '); + if (firstSpaceIndex === -1) { + // Single word title, no need to split + labelElement.textContent = title; + } else { + const verb = title.substring(0, firstSpaceIndex); + const rest = title.substring(firstSpaceIndex); + + const verbSpan = $('span'); + verbSpan.textContent = verb; + labelElement.appendChild(verbSpan); + + const restSpan = $('span.chat-thinking-title-detail-text'); + restSpan.textContent = rest; + labelElement.appendChild(restSpan); + } + + this._collapseButton.element.ariaLabel = title; + } + private setDropdownClickable(clickable: boolean): void { if (this._collapseButton) { this._collapseButton.element.style.pointerEvents = clickable ? 'auto' : 'none'; } if (!clickable && this.streamingCompleted) { - super.setTitle(this.lastExtractedTitle ?? this.currentTitle); + this.setFinalizedTitle(this.lastExtractedTitle ?? this.currentTitle); } } @@ -745,7 +774,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this.content.generatedTitle) { this.currentTitle = this.content.generatedTitle; - super.setTitle(this.content.generatedTitle); + this.setFinalizedTitle(this.content.generatedTitle); return; } @@ -755,7 +784,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.currentTitle = existingTitle; this.content.generatedTitle = existingTitle; this.setGeneratedTitleOnAllParts(existingTitle); - super.setTitle(existingTitle); + this.setFinalizedTitle(existingTitle); return; } @@ -787,7 +816,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.currentTitle = title; this.content.generatedTitle = title; this.setGeneratedTitleOnAllParts(title); - super.setTitle(title); + this.setFinalizedTitle(title); return; } @@ -837,6 +866,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen OUTPUT FORMAT: - MUST be a single sentence - MUST be under 10 words + - The FIRST word MUST be a past tense verb (e.g. "Updated", "Reviewed", "Created", "Searched", "Analyzed") - No quotes, no trailing punctuation GENERAL: @@ -962,9 +992,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): if (generatedTitle && !this._store.isDisposed) { this.currentTitle = generatedTitle; - if (this._collapseButton) { - this._collapseButton.label = generatedTitle; - } + this.setFinalizedTitle(generatedTitle); this.content.generatedTitle = generatedTitle; this.setGeneratedTitleOnAllParts(generatedTitle); return; @@ -1018,7 +1046,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): if (this._collapseButton) { this._collapseButton.icon = Codicon.check; - this._collapseButton.label = finalLabel; + this.setFinalizedTitle(finalLabel); } this.updateDropdownClickability(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 1702eaf25b327..31acb4c321b58 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -18,6 +18,8 @@ } > .chat-used-context-label .monaco-button.monaco-icon-button { + display: flex; + align-items: center; line-height: 1.5em; font-size: 13px; @@ -35,11 +37,18 @@ .rendered-markdown.chat-thinking-title-detail { display: inline; + font-size: inherit; + line-height: inherit; > p { display: inline; + margin: 0; } } + + .chat-thinking-title-detail-text { + opacity: 0.7; + } } &.chat-thinking-active > .chat-used-context-label .monaco-button.monaco-icon-button { @@ -160,7 +169,6 @@ padding: 6px 12px 6px 24px; position: relative; font-size: var(--vscode-chat-font-size-body-s); - .progress-container { margin-bottom: 0px; padding-top: 0px; @@ -176,7 +184,7 @@ } .chat-thinking-tool-wrapper .chat-markdown-part.rendered-markdown { - padding: 5px 12px 4px 20px; + padding: 5px 12px 4px 24px; .status-icon.codicon-check { display: none; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index e86cda4d36cda..a03b216363ceb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -54,11 +54,12 @@ import { IDynamicVariable } from '../../../../common/attachments/chatVariables.j import { ChatAgentLocation, ChatModeKind, isSupportedChatFileScheme } from '../../../../common/constants.js'; import { isToolSet } from '../../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../../common/chatSessionsService.js'; -import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { ChatSubmitAction, IChatExecuteActionContext } from '../../../actions/chatExecuteActions.js'; import { IChatWidget, IChatWidgetService } from '../../../chat.js'; import { resizeImage } from '../../../chatImageUtils.js'; import { ChatDynamicVariableModel } from '../../../attachments/chatDynamicVariables.js'; +import { IChatService } from '../../../../common/chatService/chatService.js'; /** * Regex matching a slash command word (e.g. `/foo`). Uses `\p{L}` for Unicode @@ -77,6 +78,8 @@ class SlashCommandCompletions extends Disposable { @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, @IPromptsService private readonly promptsService: IPromptsService, + @IChatService chatService: IChatService, + @IChatSessionsService chatSessionsService: IChatSessionsService, @IMcpService mcpService: IMcpService, ) { super(); @@ -90,8 +93,15 @@ class SlashCommandCompletions extends Disposable { return null; } - if (widget.lockedAgentId && !widget.attachmentCapabilities.supportsPromptAttachments) { - return null; + + let customAgentTarget: Target | undefined = undefined; + if (widget.lockedAgentId) { + if (!widget.attachmentCapabilities.supportsPromptAttachments) { + return null; + } + const sessionResource = widget.viewModel.model.sessionResource; + const ctx = sessionResource && chatService.getChatSessionFromInternalUri(sessionResource); + customAgentTarget = (ctx ? chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType) : undefined) ?? Target.Undefined; } const range = computeCompletionRanges(model, position, SlashCommandWord); @@ -117,18 +127,31 @@ class SlashCommandCompletions extends Disposable { } return { - suggestions: slashCommands.map((c, i): CompletionItem => { - const withSlash = `/${c.command}`; - return { - label: withSlash, - insertText: c.executeImmediately ? '' : `${withSlash} `, - documentation: c.detail, - range, - sortText: c.sortText ?? 'a'.repeat(i + 1), - kind: CompletionItemKind.Text, // The icons are disabled here anyway, - command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } : undefined, - }; - }) + suggestions: slashCommands + .filter(c => { + if (!widget.lockedAgentId) { + return true; + } + if (c.modes && c.modes.length && !c.modes.includes(ChatModeKind.Agent)) { + return false; + } + if (c.target && customAgentTarget && c.target !== customAgentTarget) { + return false; + } + return true; + }) + .map((c, i): CompletionItem => { + const withSlash = `/${c.command}`; + return { + label: withSlash, + insertText: c.executeImmediately ? '' : `${withSlash} `, + documentation: c.detail, + range, + sortText: c.sortText ?? 'a'.repeat(i + 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway, + command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } : undefined, + }; + }) }; } })); @@ -213,7 +236,15 @@ class SlashCommandCompletions extends Disposable { } // Filter out commands that are not user-invocable (hidden from / menu) - const userInvocableCommands = promptCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); + const userInvocableCommands = promptCommands + .filter(c => { + // Exclude extension-provided prompt files for locked agents. + if (widget.lockedAgentId && c.promptPath.extension) { + return false; + } + return true; + }) + .filter(c => c.parsedPromptFile?.header?.userInvocable !== false); if (userInvocableCommands.length === 0) { return null; } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index d7e4763aa4d76..8e703e221f7b6 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -141,7 +141,7 @@ export namespace ChatContextKeys { export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); - export const newChatButtonExperimentIcon = new RawContextKey('chatNewChatButtonExperimentIcon', '', { type: 'string', description: localize('chatNewChatButtonExperimentIcon', "The icon variant for the new chat button, controlled by experiment. Values: 'copilot', 'sparkle', or empty for default.") }); + export const newChatButtonExperimentIcon = new RawContextKey('chatNewChatButtonExperimentIcon', '', { type: 'string', description: localize('chatNewChatButtonExperimentIcon', "The icon variant for the new chat button, controlled by experiment. Values: 'copilot', 'new-session', 'comment', or empty for default.") }); } export namespace ChatContextKeyExprs { diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 93de4ba4d710d..28467684c8d93 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -5,6 +5,7 @@ import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; +import { isEqualOrParent } from '../../../../base/common/resources.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; import { PromptsStorage } from './promptSyntax/service/promptsService.js'; @@ -26,6 +27,41 @@ export const AICustomizationManagementSection = { export type AICustomizationManagementSection = typeof AICustomizationManagementSection[keyof typeof AICustomizationManagementSection]; +/** + * Per-type filter policy controlling which storage sources and user file + * roots are visible for a given customization type. + */ +export interface IStorageSourceFilter { + /** + * Which storage groups to display (e.g. workspace, user, extension). + */ + readonly sources: readonly PromptsStorage[]; + + /** + * If set, only user files under these roots are shown (allowlist). + * If `undefined`, all user file roots are included. + */ + readonly includedUserFileRoots?: readonly URI[]; +} + +/** + * Applies a storage source filter to an array of items that have uri and storage. + * Removes items whose storage is not in the filter's source list, + * and for user-storage items, removes those not under an allowed root. + */ +export function applyStorageSourceFilter(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { + const sourceSet = new Set(filter.sources); + return items.filter(item => { + if (!sourceSet.has(item.storage)) { + return false; + } + if (item.storage === PromptsStorage.user && filter.includedUserFileRoots) { + return filter.includedUserFileRoots.some(root => isEqualOrParent(item.uri, root)); + } + return true; + }); +} + /** * Provides workspace context for AI Customization views. */ @@ -48,26 +84,15 @@ export interface IAICustomizationWorkspaceService { readonly managementSections: readonly AICustomizationManagementSection[]; /** - * The storage sources to show as groups in the customization list. - */ - readonly visibleStorageSources: readonly PromptsStorage[]; - - /** - * Returns the visible storage sources for a specific customization type. - * Allows per-type overrides (e.g., hooks may only show workspace sources). - */ - getVisibleStorageSources(type: PromptsType): readonly PromptsStorage[]; - - /** - * URI roots to exclude from user-level file listings. - * Files under these roots are hidden from the customization list. + * Returns the storage source filter for a given customization type. + * Controls which storage groups and user file roots are visible. */ - readonly excludedUserFileRoots: readonly URI[]; + getStorageSourceFilter(type: PromptsType): IStorageSourceFilter; /** - * Whether the primary creation action should create a file directly + * Whether this is a sessions window (vs core VS Code). */ - readonly preferManualCreation: boolean; + readonly isSessionsWindow: boolean; /** * Commits files in the active project. diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index c2edad1d45c52..3d31f408577b1 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1489,6 +1489,8 @@ export type ChatStopCancellationNoopEvent = { reason: 'noWidget' | 'noViewModel' | 'noPendingRequest' | 'requestAlreadyCanceled' | 'requestIdUnavailable'; requestInProgress: 'true' | 'false' | 'unknown'; pendingRequests: number; + sessionScheme?: string; + lastRequestId?: string; }; export type ChatStopCancellationNoopClassification = { @@ -1496,6 +1498,8 @@ export type ChatStopCancellationNoopClassification = { reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The no-op reason when stop cancellation did not dispatch fully.' }; requestInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether request-in-progress was true, false, or unknown at no-op time.' }; pendingRequests: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of queued pending requests at no-op time when known.'; isMeasurement: true }; + sessionScheme?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The URI scheme of the session resource (e.g. vscodeLocalChatSession vs remote).' }; + lastRequestId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the last request in the session, for correlating with tool invocations.' }; owner: 'roblourens'; comment: 'Tracks possible no-op stop cancellation paths.'; }; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 57a993bb914d0..6b2d3a5aefcd4 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1441,11 +1441,14 @@ export class ChatService extends Disposable implements IChatService { const model = this._sessionModels.get(sessionResource); const requestInProgress = model?.requestInProgress.get(); const pendingRequestsCount = model?.getPendingRequests().length ?? 0; + const lastRequest = model?.lastRequest; this.telemetryService.publicLog2(ChatStopCancellationNoopEventName, { source: source ?? 'chatService', reason: 'noPendingRequest', requestInProgress: requestInProgress === undefined ? 'unknown' : requestInProgress ? 'true' : 'false', pendingRequests: pendingRequestsCount, + sessionScheme: sessionResource.scheme, + lastRequestId: lastRequest?.id, }); this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); return; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index aa9dcb530a70e..d7ce75b61bd31 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -56,7 +56,6 @@ export enum ChatConfiguration { ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', - ChatCustomizationUserStoragePath = 'chat.customizationsMenu.userStoragePath', } /** diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index 9964cfaa4d27e..50f145ccf1004 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -13,6 +13,7 @@ import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData } from import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { URI } from '../../../../../base/common/uri.js'; +import { Target } from '../promptSyntax/service/promptsService.js'; //#region slash service, commands etc @@ -37,6 +38,7 @@ export interface IChatSlashData { locations: ChatAgentLocation[]; modes?: ChatModeKind[]; + target?: Target; } export interface IChatSlashFragment { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 8da080588ad8b..8a2b48a7cf595 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; @@ -34,7 +33,6 @@ import { parseClaudeHooks } from '../promptSyntax/hookClaudeCompat.js'; import { parseCopilotHooks } from '../promptSyntax/hookCompatibility.js'; import { IHookCommand } from '../promptSyntax/hookSchema.js'; import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from './agentPluginService.js'; -import { IPluginInstallService } from './pluginInstallService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; const COMMAND_FILE_SUFFIX = '.md'; @@ -268,7 +266,6 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, - @IPluginInstallService private readonly _pluginInstallService: IPluginInstallService, @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IPathService private readonly _pathService: IPathService, @@ -302,8 +299,6 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent const plugins: IAgentPlugin[] = []; const seenPluginUris = new Set(); const config = this._pluginPathsConfig.get(); - // todo: temporary, we should have a dedicated discovery from the marketplace - const marketplacePluginsByInstallUri = await this._getMarketplacePluginsByInstallUri(); for (const [path, enabled] of Object.entries(config)) { if (!path.trim()) { @@ -328,8 +323,9 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent const key = stat.resource.toString(); if (!seenPluginUris.has(key)) { const adapter = await this._detectPluginFormatAdapter(stat.resource); + const fromMarketplace = await this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource); seenPluginUris.add(key); - plugins.push(this._toPlugin(stat.resource, path, enabled, adapter, marketplacePluginsByInstallUri.get(key))); + plugins.push(this._toPlugin(stat.resource, path, enabled, adapter, fromMarketplace)); } } } @@ -340,24 +336,6 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent return plugins; } - private async _getMarketplacePluginsByInstallUri(): Promise> { - const result = new Map(); - let marketplacePlugins: readonly IMarketplacePlugin[]; - try { - marketplacePlugins = await this._pluginMarketplaceService.fetchMarketplacePlugins(CancellationToken.None); - } catch (err) { - this._logService.debug('[ConfiguredAgentPluginDiscovery] Failed to fetch marketplace plugins for provenance mapping:', err); - return result; - } - - for (const marketplacePlugin of marketplacePlugins) { - const installUri = this._pluginInstallService.getPluginInstallUri(marketplacePlugin); - result.set(installUri.toString(), marketplacePlugin); - } - - return result; - } - /** * Resolves a plugin path to one or more resource URIs. Absolute paths are * used directly; relative paths are resolved against each workspace folder. diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 46cf8c7015530..e536a09d199e2 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -8,13 +8,13 @@ import { Event } from '../../../../../base/common/event.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Lazy } from '../../../../../base/common/lazy.js'; import { revive } from '../../../../../base/common/marshalling.js'; -import { joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js'; +import { isEqualOrParent, joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; -import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; import { ChatConfiguration } from '../constants.js'; @@ -75,6 +75,7 @@ export interface IPluginMarketplaceService { readonly _serviceBrand: undefined; readonly onDidChangeMarketplaces: Event; fetchMarketplacePlugins(token: CancellationToken): Promise; + getMarketplacePluginMetadata(pluginUri: URI): Promise; } /** @@ -294,6 +295,66 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { ); } + async getMarketplacePluginMetadata(pluginUri: URI): Promise { + const configuredRefs = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; + const refs = parseMarketplaceReferences(configuredRefs); + + for (const ref of refs) { + let repoDir: URI; + try { + repoDir = this._pluginRepositoryService.getRepositoryUri(ref); + } catch { + continue; + } + + if (!isEqualOrParent(pluginUri, repoDir)) { + continue; + } + + for (const def of MARKETPLACE_DEFINITIONS) { + const definitionUri = joinPath(repoDir, def.path); + let json: IMarketplaceJson | undefined; + try { + const contents = await this._fileService.readFile(definitionUri); + json = parseJSONC(contents.value.toString()) as IMarketplaceJson | undefined; + } catch { + continue; + } + + if (!json?.plugins || !Array.isArray(json.plugins)) { + continue; + } + + for (const p of json.plugins) { + if (typeof p.name !== 'string' || !p.name) { + continue; + } + + const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); + if (source === undefined) { + continue; + } + + const pluginSourceUri = normalizePath(joinPath(repoDir, source)); + if (isEqualOrParent(pluginUri, pluginSourceUri)) { + return { + name: p.name, + description: p.description ?? '', + version: p.version ?? '', + source, + marketplace: ref.displayLabel, + marketplaceReference: ref, + marketplaceType: def.type, + readmeUri: getMarketplaceReadmeFileUri(repoDir, source), + }; + } + } + } + } + + return undefined; + } + private async _fetchFromClonedRepo(reference: IMarketplaceReference, token: CancellationToken): Promise { let repoDir: URI; try { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts new file mode 100644 index 0000000000000..95988b49868b5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts @@ -0,0 +1,237 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { applyStorageSourceFilter, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; + +function item(path: string, storage: PromptsStorage): { uri: URI; storage: PromptsStorage } { + return { uri: URI.file(path), storage }; +} + +suite('applyStorageSourceFilter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('source filtering', () => { + test('keeps items matching sources', () => { + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/u/b.md', PromptsStorage.user), + item('/e/c.md', PromptsStorage.extension), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension], + }; + assert.strictEqual(applyStorageSourceFilter(items, filter).length, 3); + }); + + test('removes items not in sources', () => { + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/u/b.md', PromptsStorage.user), + item('/e/c.md', PromptsStorage.extension), + item('/p/d.md', PromptsStorage.plugin), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].uri.toString(), URI.file('/w/a.md').toString()); + }); + + test('empty sources removes everything', () => { + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/u/b.md', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { sources: [] }; + assert.strictEqual(applyStorageSourceFilter(items, filter).length, 0); + }); + + test('empty items returns empty', () => { + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user], + }; + assert.strictEqual(applyStorageSourceFilter([], filter).length, 0); + }); + }); + + suite('includedUserFileRoots filtering', () => { + test('undefined includedUserFileRoots keeps all user files', () => { + const items = [ + item('/home/.copilot/a.md', PromptsStorage.user), + item('/home/.vscode/b.md', PromptsStorage.user), + item('/home/.claude/c.md', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.user], + // includedUserFileRoots not set = allow all + }; + assert.strictEqual(applyStorageSourceFilter(items, filter).length, 3); + }); + + test('includedUserFileRoots filters user files by root', () => { + const items = [ + item('/home/.copilot/instructions/a.md', PromptsStorage.user), + item('/home/.vscode/instructions/b.md', PromptsStorage.user), + item('/home/.claude/rules/c.md', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.user], + includedUserFileRoots: [URI.file('/home/.copilot'), URI.file('/home/.claude')], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].uri.toString(), URI.file('/home/.copilot/instructions/a.md').toString()); + assert.strictEqual(result[1].uri.toString(), URI.file('/home/.claude/rules/c.md').toString()); + }); + + test('includedUserFileRoots does not affect non-user items', () => { + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/e/b.md', PromptsStorage.extension), + item('/home/.copilot/c.md', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.extension, PromptsStorage.user], + includedUserFileRoots: [URI.file('/home/.copilot')], + }; + const result = applyStorageSourceFilter(items, filter); + // local + extension kept (not affected by user root filter), user kept (matches root) + assert.strictEqual(result.length, 3); + }); + + test('empty includedUserFileRoots removes all user files', () => { + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/home/.copilot/b.md', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user], + includedUserFileRoots: [], // explicit empty = no user files allowed + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].storage, PromptsStorage.local); + }); + + test('user file at exact root is included', () => { + const items = [ + item('/home/.copilot', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.user], + includedUserFileRoots: [URI.file('/home/.copilot')], + }; + assert.strictEqual(applyStorageSourceFilter(items, filter).length, 1); + }); + + test('user file outside all roots is excluded', () => { + const items = [ + item('/other/path/a.md', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.user], + includedUserFileRoots: [URI.file('/home/.copilot'), URI.file('/home/.claude')], + }; + assert.strictEqual(applyStorageSourceFilter(items, filter).length, 0); + }); + + test('deeply nested user file under root is included', () => { + const items = [ + item('/home/.copilot/instructions/sub/deep/a.md', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.user], + includedUserFileRoots: [URI.file('/home/.copilot')], + }; + assert.strictEqual(applyStorageSourceFilter(items, filter).length, 1); + }); + }); + + suite('combined filtering', () => { + test('source filter + user root filter applied together', () => { + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/home/.copilot/b.md', PromptsStorage.user), + item('/home/.vscode/c.md', PromptsStorage.user), + item('/e/d.md', PromptsStorage.extension), + item('/p/e.md', PromptsStorage.plugin), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user], + includedUserFileRoots: [URI.file('/home/.copilot')], + }; + const result = applyStorageSourceFilter(items, filter); + // local (kept), .copilot user (kept), .vscode user (excluded by root), + // extension (excluded by source), plugin (excluded by source) + assert.strictEqual(result.length, 2); + }); + + test('sessions-like filter: hooks show only local', () => { + const items = [ + item('/w/.github/hooks/pre.json', PromptsStorage.local), + item('/home/.claude/settings.json', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].storage, PromptsStorage.local); + }); + + test('sessions-like filter: instructions show only CLI roots', () => { + const items = [ + item('/w/.github/instructions/a.md', PromptsStorage.local), + item('/home/.copilot/instructions/b.md', PromptsStorage.user), + item('/home/.claude/rules/c.md', PromptsStorage.user), + item('/home/.vscode-profile/instructions/d.md', PromptsStorage.user), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user], + includedUserFileRoots: [ + URI.file('/home/.copilot'), + URI.file('/home/.claude'), + URI.file('/home/.agents'), + ], + }; + const result = applyStorageSourceFilter(items, filter); + // local + .copilot + .claude pass; .vscode-profile excluded + assert.strictEqual(result.length, 3); + }); + + test('core-like filter: show everything', () => { + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/u/b.md', PromptsStorage.user), + item('/e/c.md', PromptsStorage.extension), + item('/p/d.md', PromptsStorage.plugin), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin], + }; + assert.strictEqual(applyStorageSourceFilter(items, filter).length, 4); + }); + }); + + suite('type safety', () => { + test('works with objects that have extra properties', () => { + const items = [ + { uri: URI.file('/w/a.md'), storage: PromptsStorage.local, name: 'A', extra: true }, + { uri: URI.file('/u/b.md'), storage: PromptsStorage.user, name: 'B', extra: false }, + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 1); + assert.strictEqual((result[0] as typeof items[0]).name, 'A'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts index 5e27539c76710..0b5d81754fb01 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts @@ -9,37 +9,10 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { resolveUserTargetDirectory } from '../../../browser/aiCustomization/customizationCreatorService.js'; -import { ChatConfiguration } from '../../../common/constants.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IPathService } from '../../../../../services/path/common/pathService.js'; suite('customizationCreatorService', () => { ensureNoDisposablesAreLeakedInTestSuite(); - const userHome = URI.file('/home/user'); - - function createMockPathService(): Pick { - return { - userHome: ((options?: { preferLocal: boolean }) => { - if (options?.preferLocal) { - return userHome; - } - return Promise.resolve(userHome); - }) as IPathService['userHome'], - }; - } - - function createMockConfigService(overridePath: string): Pick { - return { - getValue: (key: string) => { - if (key === ChatConfiguration.ChatCustomizationUserStoragePath) { - return overridePath; - } - return undefined; - }, - } as Pick; - } - function createMockPromptsService(userFolderUri?: URI): Pick { return { getSourceFolders: () => Promise.resolve( @@ -52,89 +25,21 @@ suite('customizationCreatorService', () => { suite('resolveUserTargetDirectory', () => { - test('with override path and tilde for instructions', async () => { + test('returns user folder from getSourceFolders', async () => { + const userFolder = URI.file('/home/user/.copilot/instructions'); const result = await resolveUserTargetDirectory( - createMockPromptsService() as IPromptsService, + createMockPromptsService(userFolder) as IPromptsService, PromptsType.instructions, - createMockConfigService('~/.copilot') as IConfigurationService, - createMockPathService() as IPathService, ); assert.strictEqual(result?.path, '/home/user/.copilot/instructions'); }); - test('with override path and tilde for skills', async () => { - const result = await resolveUserTargetDirectory( - createMockPromptsService() as IPromptsService, - PromptsType.skill, - createMockConfigService('~/.copilot') as IConfigurationService, - createMockPathService() as IPathService, - ); - assert.strictEqual(result?.path, '/home/user/.copilot/skills'); - }); - - test('override path is ignored for prompts (no CLI discovery path)', async () => { - const fallbackUri = URI.file('/home/user/.vscode/prompts'); - const result = await resolveUserTargetDirectory( - createMockPromptsService(fallbackUri) as IPromptsService, - PromptsType.prompt, - createMockConfigService('~/.copilot') as IConfigurationService, - createMockPathService() as IPathService, - ); - // Should fall through to getSourceFolders, not use the override - assert.strictEqual(result?.path, '/home/user/.vscode/prompts'); - }); - - test('override path is ignored for agents (no CLI convention)', async () => { - const fallbackUri = URI.file('/home/user/.vscode/prompts'); - const result = await resolveUserTargetDirectory( - createMockPromptsService(fallbackUri) as IPromptsService, - PromptsType.agent, - createMockConfigService('~/.copilot') as IConfigurationService, - createMockPathService() as IPathService, - ); - // Should fall through to getSourceFolders, not use the override - assert.strictEqual(result?.path, '/home/user/.vscode/prompts'); - }); - - test('override path is ignored for hooks (no CLI convention)', async () => { + test('returns undefined when no user folder exists', async () => { const result = await resolveUserTargetDirectory( createMockPromptsService() as IPromptsService, PromptsType.hook, - createMockConfigService('~/.copilot') as IConfigurationService, - createMockPathService() as IPathService, ); - // No user folder for hooks, should return undefined assert.strictEqual(result, undefined); }); - - test('falls back to getSourceFolders when no override is set', async () => { - const fallbackUri = URI.file('/home/user/.vscode/prompts'); - const result = await resolveUserTargetDirectory( - createMockPromptsService(fallbackUri) as IPromptsService, - PromptsType.instructions, - createMockConfigService('') as IConfigurationService, - createMockPathService() as IPathService, - ); - assert.strictEqual(result?.path, '/home/user/.vscode/prompts'); - }); - - test('falls back to getSourceFolders when no config service provided', async () => { - const fallbackUri = URI.file('/home/user/.vscode/prompts'); - const result = await resolveUserTargetDirectory( - createMockPromptsService(fallbackUri) as IPromptsService, - PromptsType.instructions, - ); - assert.strictEqual(result?.path, '/home/user/.vscode/prompts'); - }); - - test('with absolute override path (no tilde)', async () => { - const result = await resolveUserTargetDirectory( - createMockPromptsService() as IPromptsService, - PromptsType.instructions, - createMockConfigService('/custom/path') as IConfigurationService, - createMockPathService() as IPathService, - ); - assert.strictEqual(result?.path, '/custom/path/instructions'); - }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index 4db8ae689147f..4b42ab0ea040c 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -4,8 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { joinPath } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IRequestService } from '../../../../../../platform/request/common/request.js'; +import { IStorageService, InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, parseMarketplaceReference, parseMarketplaceReferences } from '../../../common/plugins/pluginMarketplaceService.js'; suite('PluginMarketplaceService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -102,3 +114,209 @@ suite('PluginMarketplaceService', () => { assert.strictEqual(parsed[0].canonicalId, 'github:microsoft/vscode'); }); }); + +suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const repoDir = URI.file('/cache/agentPlugins/github.com/microsoft/plugins'); + const marketplaceRef = parseMarketplaceReference('microsoft/plugins')!; + + function createMarketplaceJson(plugins: object[], metadata?: object): string { + return JSON.stringify({ metadata, plugins }); + } + + function createService(fileContents: Map): PluginMarketplaceService { + const instantiationService = store.add(new TestInstantiationService()); + + const configService = new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: ['microsoft/plugins'], + [ChatConfiguration.PluginsEnabled]: true, + }); + + const fileService = { + readFile: async (uri: URI) => { + const content = fileContents.get(uri.path); + if (content !== undefined) { + return { value: VSBuffer.fromString(content) }; + } + throw new Error('File not found'); + }, + } as unknown as IFileService; + + const repositoryService = { + getRepositoryUri: () => repoDir, + getPluginInstallUri: (plugin: { source: string }) => joinPath(repoDir, plugin.source), + } as unknown as IAgentPluginRepositoryService; + + instantiationService.stub(IConfigurationService, configService); + instantiationService.stub(IFileService, fileService); + instantiationService.stub(IAgentPluginRepositoryService, repositoryService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRequestService, {} as unknown as IRequestService); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + + return instantiationService.createInstance(PluginMarketplaceService); + } + + test('returns metadata for a plugin that matches by source', async () => { + const files = new Map(); + files.set( + joinPath(repoDir, '.github/plugin/marketplace.json').path, + createMarketplaceJson([ + { name: 'my-plugin', description: 'A test plugin', version: '2.0.0', source: 'plugins/my-plugin' }, + ]), + ); + + const service = createService(files); + const pluginUri = joinPath(repoDir, 'plugins/my-plugin'); + + const result = await service.getMarketplacePluginMetadata(pluginUri); + + assert.deepStrictEqual(result && { + name: result.name, + description: result.description, + version: result.version, + source: result.source, + marketplace: result.marketplace, + marketplaceType: result.marketplaceType, + }, { + name: 'my-plugin', + description: 'A test plugin', + version: '2.0.0', + source: 'plugins/my-plugin', + marketplace: marketplaceRef.displayLabel, + marketplaceType: MarketplaceType.Copilot, + }); + }); + + test('returns undefined for a URI outside all marketplace repos', async () => { + const files = new Map(); + files.set( + joinPath(repoDir, '.github/plugin/marketplace.json').path, + createMarketplaceJson([ + { name: 'my-plugin', version: '1.0.0', source: 'plugins/my-plugin' }, + ]), + ); + + const service = createService(files); + const unrelatedUri = URI.file('/some/other/path'); + + const result = await service.getMarketplacePluginMetadata(unrelatedUri); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when plugin URI is in repo but no source matches', async () => { + const files = new Map(); + files.set( + joinPath(repoDir, '.github/plugin/marketplace.json').path, + createMarketplaceJson([ + { name: 'my-plugin', version: '1.0.0', source: 'plugins/my-plugin' }, + ]), + ); + + const service = createService(files); + const noMatchUri = joinPath(repoDir, 'plugins/other-plugin'); + + const result = await service.getMarketplacePluginMetadata(noMatchUri); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when no marketplace.json files exist', async () => { + const service = createService(new Map()); + const pluginUri = joinPath(repoDir, 'plugins/my-plugin'); + + const result = await service.getMarketplacePluginMetadata(pluginUri); + assert.strictEqual(result, undefined); + }); + + test('falls back to Claude marketplace.json when Copilot one is missing', async () => { + const files = new Map(); + files.set( + joinPath(repoDir, '.claude-plugin/marketplace.json').path, + createMarketplaceJson([ + { name: 'claude-plugin', version: '3.0.0', source: 'src/claude-plugin' }, + ]), + ); + + const service = createService(files); + const pluginUri = joinPath(repoDir, 'src/claude-plugin'); + + const result = await service.getMarketplacePluginMetadata(pluginUri); + assert.ok(result); + assert.strictEqual(result!.name, 'claude-plugin'); + assert.strictEqual(result!.marketplaceType, MarketplaceType.Claude); + }); + + test('resolves source relative to pluginRoot metadata', async () => { + const files = new Map(); + files.set( + joinPath(repoDir, '.github/plugin/marketplace.json').path, + createMarketplaceJson( + [{ name: 'nested', version: '1.0.0', source: 'my-plugin' }], + { pluginRoot: 'packages' }, + ), + ); + + const service = createService(files); + const pluginUri = joinPath(repoDir, 'packages/my-plugin'); + + const result = await service.getMarketplacePluginMetadata(pluginUri); + assert.ok(result); + assert.strictEqual(result!.name, 'nested'); + assert.strictEqual(result!.source, 'packages/my-plugin'); + }); + + test('selects the correct plugin among multiple entries', async () => { + const files = new Map(); + files.set( + joinPath(repoDir, '.github/plugin/marketplace.json').path, + createMarketplaceJson([ + { name: 'alpha', version: '1.0.0', source: 'plugins/alpha' }, + { name: 'beta', version: '2.0.0', source: 'plugins/beta' }, + { name: 'gamma', version: '3.0.0', source: 'plugins/gamma' }, + ]), + ); + + const service = createService(files); + const pluginUri = joinPath(repoDir, 'plugins/beta'); + + const result = await service.getMarketplacePluginMetadata(pluginUri); + assert.ok(result); + assert.strictEqual(result!.name, 'beta'); + assert.strictEqual(result!.version, '2.0.0'); + }); + + test('returns undefined when no marketplaces are configured', async () => { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: [], + [ChatConfiguration.PluginsEnabled]: true, + })); + instantiationService.stub(IFileService, {} as unknown as IFileService); + instantiationService.stub(IAgentPluginRepositoryService, {} as unknown as IAgentPluginRepositoryService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRequestService, {} as unknown as IRequestService); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + + const service = instantiationService.createInstance(PluginMarketplaceService); + const result = await service.getMarketplacePluginMetadata(URI.file('/any/path')); + assert.strictEqual(result, undefined); + }); + + test('matches when pluginUri is a subdirectory inside a plugin source', async () => { + const files = new Map(); + files.set( + joinPath(repoDir, '.github/plugin/marketplace.json').path, + createMarketplaceJson([ + { name: 'my-plugin', version: '1.0.0', source: 'plugins/my-plugin' }, + ]), + ); + + const service = createService(files); + const nestedUri = joinPath(repoDir, 'plugins/my-plugin/src/tool.ts'); + + const result = await service.getMarketplacePluginMetadata(nestedUri); + assert.ok(result); + assert.strictEqual(result!.name, 'my-plugin'); + }); +}); diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index e74c8948c3cc2..fce88654401e4 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -185,20 +185,23 @@ export const mcpServerSchema: IJSONSchema = { additionalProperties: false, properties: { sandbox: { - description: localize('app.mcp.json.sandbox', "Default sandbox settings for running servers."), + description: localize('app.mcp.json.sandbox', "Sandbox config that determines file system and network access. Sandboxing is enabled when sandboxEnabled property is set at the server level on Mac OS and Linux only."), type: 'object', additionalProperties: false, properties: { network: { + description: localize('app.mcp.json.sandbox.network', "Network access settings for the sandboxed server."), type: 'object', additionalProperties: false, properties: { allowedDomains: { + description: localize('app.mcp.json.sandbox.network.allowedDomains', "List of domains that the server is allowed to access. Wildcards are supported, e.g. `*.example.com`."), type: 'array', items: { type: 'string' }, default: [] }, deniedDomains: { + description: localize('app.mcp.json.sandbox.network.deniedDomains', "List of domains that the server is not allowed to access. e.g. `invalid.example.com`."), type: 'array', items: { type: 'string' }, default: [] @@ -206,20 +209,24 @@ export const mcpServerSchema: IJSONSchema = { } }, filesystem: { + description: localize('app.mcp.json.sandbox.filesystem', "Filesystem access settings for the sandboxed server. Glob patterns are supported for Mac OS only."), type: 'object', additionalProperties: false, properties: { denyRead: { + description: localize('app.mcp.json.sandbox.filesystem.denyRead', "List of file paths that the server is not allowed to read. By default, all files are allowed to be read. e.g. `~/src/secrets`."), type: 'array', items: { type: 'string' }, default: [] }, allowWrite: { + description: localize('app.mcp.json.sandbox.filesystem.allowWrite', "List of file paths that the server is allowed to write to. e.g. `~/src/`."), type: 'array', items: { type: 'string' }, default: [] }, denyWrite: { + description: localize('app.mcp.json.sandbox.filesystem.denyWrite', "List of file paths that the server is not allowed to write to. e.g. `~/src/auth/`."), type: 'array', items: { type: 'string' }, default: [] diff --git a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts index a0e599cd4b4b1..16ace8d597d8b 100644 --- a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts +++ b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts @@ -1024,9 +1024,13 @@ export abstract class AbstractExtensionService extends Disposable implements IEx let managers: IExtensionHostManager[]; if (activationKind === ActivationKind.Immediate) { // For immediate activation, only activate on local extension hosts - // and defer remote activation until the remote host is ready + // and on remote extension hosts that are already ready. + // Defer activation for remote hosts that are not yet ready to avoid + // blocking (e.g. during remote authority resolution). managers = this._extensionHostManagers.filter( - extHostManager => extHostManager.kind === ExtensionHostKind.LocalProcess || extHostManager.kind === ExtensionHostKind.LocalWebWorker + extHostManager => extHostManager.kind === ExtensionHostKind.LocalProcess + || extHostManager.kind === ExtensionHostKind.LocalWebWorker + || extHostManager.isReady ); this._pendingRemoteActivationEvents.add(activationEvent); } else { diff --git a/src/vs/workbench/services/extensions/common/extensionHostManager.ts b/src/vs/workbench/services/extensions/common/extensionHostManager.ts index 88e8fd1d2571f..c7e1f8b626194 100644 --- a/src/vs/workbench/services/extensions/common/extensionHostManager.ts +++ b/src/vs/workbench/services/extensions/common/extensionHostManager.ts @@ -71,6 +71,7 @@ export class ExtensionHostManager extends Disposable implements IExtensionHostMa private readonly _customers: IDisposable[]; private readonly _extensionHost: IExtensionHost; private _proxy: Promise | null; + private _hasStarted: boolean = false; public get pid(): number | null { return this._extensionHost.pid; @@ -152,6 +153,7 @@ export class ExtensionHostManager extends Disposable implements IExtensionHostMa } ); this._proxy.then(() => { + this._hasStarted = true; initialActivationEvents.forEach((activationEvent) => this.activateByEvent(activationEvent, ActivationKind.Normal)); this._register(registerLatencyTestProvider({ measure: () => this.measure() @@ -196,6 +198,10 @@ export class ExtensionHostManager extends Disposable implements IExtensionHostMa }; } + public get isReady(): boolean { + return this._hasStarted; + } + public async ready(): Promise { await this._proxy; } diff --git a/src/vs/workbench/services/extensions/common/extensionHostManagers.ts b/src/vs/workbench/services/extensions/common/extensionHostManagers.ts index 6c0947ddb9d0c..8b534771e44c1 100644 --- a/src/vs/workbench/services/extensions/common/extensionHostManagers.ts +++ b/src/vs/workbench/services/extensions/common/extensionHostManagers.ts @@ -22,6 +22,7 @@ export interface IExtensionHostManager { readonly onDidChangeResponsiveState: Event; disconnect(): Promise; dispose(): void; + readonly isReady: boolean; ready(): Promise; representsRunningLocation(runningLocation: ExtensionRunningLocation): boolean; deltaExtensions(extensionsDelta: IExtensionDescriptionDelta): Promise; diff --git a/src/vs/workbench/services/extensions/common/lazyCreateExtensionHostManager.ts b/src/vs/workbench/services/extensions/common/lazyCreateExtensionHostManager.ts index f9acb2a03c971..8662618b1b94b 100644 --- a/src/vs/workbench/services/extensions/common/lazyCreateExtensionHostManager.ts +++ b/src/vs/workbench/services/extensions/common/lazyCreateExtensionHostManager.ts @@ -90,6 +90,10 @@ export class LazyCreateExtensionHostManager extends Disposable implements IExten return actual; } + public get isReady(): boolean { + return this._startCalled.isOpen() && (this._actual?.isReady ?? false); + } + public async ready(): Promise { await this._startCalled.wait(); if (this._actual) { diff --git a/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts b/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts index fa3b8370531ed..86c11e9e1bf61 100644 --- a/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts +++ b/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts @@ -192,12 +192,15 @@ suite('ExtensionService', () => { private _extHostId = 0; public readonly order: string[] = []; public readonly activationEvents: { event: string; activationKind: ActivationKind; kind: ExtensionHostKind }[] = []; + public remoteExtHostIsReady = true; + public localExtHostIsReady = true; protected _pickExtensionHostKind(extensionId: ExtensionIdentifier, extensionKinds: ExtensionKind[], isInstalledLocally: boolean, isInstalledRemotely: boolean, preference: ExtensionRunningPreference): ExtensionHostKind | null { throw new Error('Method not implemented.'); } protected override _doCreateExtensionHostManager(extensionHost: IExtensionHost, initialActivationEvents: string[]): IExtensionHostManager { const order = this.order; const activationEvents = this.activationEvents; + const extService = this; const extensionHostId = ++this._extHostId; const extHostKind = extensionHost.runningLocation.kind; order.push(`create ${extensionHostId}`); @@ -205,6 +208,7 @@ suite('ExtensionService', () => { override onDidExit = Event.None; override onDidChangeResponsiveState = Event.None; override kind = extHostKind; + override get isReady() { return extHostKind === ExtensionHostKind.Remote ? extService.remoteExtHostIsReady : extService.localExtHostIsReady; } override disconnect() { return Promise.resolve(); } @@ -334,16 +338,41 @@ suite('ExtensionService', () => { assert.strictEqual(events[0].activationKind, ActivationKind.Immediate); }); - test('Immediate activation only activates local extension hosts', async () => { + test('Immediate activation includes ready remote extension hosts (issue #297019)', async () => { extService.activationEvents.length = 0; // Clear any initial activations await extService.activateByEvent('onTest', ActivationKind.Immediate); - // Should only activate on local hosts (LocalProcess and LocalWebWorker), not Remote + // When remote host is ready, Immediate activation should include it const activatedKinds = extService.activationEvents.map(e => e.kind); assert.ok(activatedKinds.includes(ExtensionHostKind.LocalProcess), 'Should activate on LocalProcess'); assert.ok(activatedKinds.includes(ExtensionHostKind.LocalWebWorker), 'Should activate on LocalWebWorker'); - assert.ok(!activatedKinds.includes(ExtensionHostKind.Remote), 'Should NOT activate on Remote'); + assert.ok(activatedKinds.includes(ExtensionHostKind.Remote), 'Should activate on ready Remote host'); + }); + + test('Immediate activation excludes not-ready remote extension hosts', async () => { + extService.remoteExtHostIsReady = false; + extService.activationEvents.length = 0; // Clear any initial activations + + await extService.activateByEvent('onTest', ActivationKind.Immediate); + + // When remote host is not ready, Immediate activation should skip it + const activatedKinds = extService.activationEvents.map(e => e.kind); + assert.ok(activatedKinds.includes(ExtensionHostKind.LocalProcess), 'Should activate on LocalProcess'); + assert.ok(activatedKinds.includes(ExtensionHostKind.LocalWebWorker), 'Should activate on LocalWebWorker'); + assert.ok(!activatedKinds.includes(ExtensionHostKind.Remote), 'Should NOT activate on not-ready Remote host'); + }); + + test('Immediate activation still activates local extension hosts even when not ready', async () => { + extService.localExtHostIsReady = false; + extService.activationEvents.length = 0; // Clear any initial activations + + await extService.activateByEvent('onTest', ActivationKind.Immediate); + + // Local hosts should always be activated for Immediate, regardless of isReady + const activatedKinds = extService.activationEvents.map(e => e.kind); + assert.ok(activatedKinds.includes(ExtensionHostKind.LocalProcess), 'Should activate on LocalProcess even when not ready'); + assert.ok(activatedKinds.includes(ExtensionHostKind.LocalWebWorker), 'Should activate on LocalWebWorker even when not ready'); }); test('Normal activation activates all extension hosts', async () => {